нахуевёртил

This commit is contained in:
Ваше Имя
2026-04-04 13:31:13 +07:00
parent 54c2c90964
commit 3f99bda6d2
9 changed files with 236 additions and 112 deletions
+12
View File
@@ -30,6 +30,8 @@ class Api {
SessionState _sessionState = SessionState.disconnected;
final _stateController = StreamController<SessionState>.broadcast();
final _sessionExpiredController =
StreamController<SessionExpiredException>.broadcast();
Map<dynamic, dynamic>? _userAgent;
Map<dynamic, dynamic>? get userAgent => _userAgent;
@@ -40,6 +42,8 @@ class Api {
_registrationCountries ?? allCountries;
Stream<SessionState> get stateStream => _stateController.stream;
Stream<SessionExpiredException> get sessionExpiredStream =>
_sessionExpiredController.stream;
SessionState get state => _sessionState;
StreamSubscription<Uint8List>? _dataSubscription;
@@ -195,6 +199,7 @@ class Api {
_dispatcher.dispose();
_connection.dispose();
_stateController.close();
_sessionExpiredController.close();
}
// Внутрянка
@@ -208,6 +213,13 @@ class Api {
Future<void> _onDataReceived(Uint8List data) async {
await for (final packet in _receiver.feed(data)) {
if (packet.isError &&
packet.payload is Map &&
packet.payload['message'] == 'FAIL_LOGIN_TOKEN') {
_sessionExpiredController.add(
SessionExpiredException(messageFromErrorPayload(packet.payload)),
);
}
_dispatcher.dispatch(packet);
}
}
+49 -24
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
@@ -17,6 +18,8 @@ enum AuthRequestType {
final String value;
}
enum LoginStatus { idle, loading, success, error }
class RequestCodeResult {
final String token;
@@ -39,10 +42,8 @@ class VerifyCodeResult {
return c is Map ? c.cast<dynamic, dynamic>() : null;
}
/// trackId из passwordChallenge — передаётся в [AccountModule.checkPassword].
String? get challengeTrackId => passwordChallenge?['trackId'] as String?;
/// Подсказка к паролю из passwordChallenge.
String? get challengeHint => passwordChallenge?['hint'] as String?;
int? get accountId {
@@ -68,8 +69,6 @@ class TwoFactorResult {
const TwoFactorResult({required this.loginToken});
}
/// При отсутствии [LoginSyncParams] в [AccountModule.login] сервер вернёт
/// полный снимок данных (cold start), иначе только дельту (warm start).
class LoginSyncParams {
final int chatsSync;
final int contactsSync;
@@ -131,7 +130,9 @@ class SessionInfo {
factory SessionInfo.fromMap(Map<dynamic, dynamic> map) {
return SessionInfo(
id: map['id'],
id: map['id'] is int
? map['id']
: (int.tryParse(map['id']?.toString() ?? '')),
client: map['client'] ?? '',
location: map['location'] ?? '',
current: map['current'] ?? false,
@@ -139,6 +140,23 @@ class SessionInfo {
info: map['info'] ?? '',
);
}
int get uniqueId => Object.hash(id, client, time, info);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SessionInfo &&
runtimeType == other.runtimeType &&
id == other.id &&
client == other.client &&
location == other.location &&
current == other.current &&
time == other.time &&
info == other.info;
@override
int get hashCode => Object.hash(id, client, location, current, time, info);
}
class LoginResult {
@@ -157,9 +175,12 @@ class LoginResult {
class AccountModule {
final Api _api;
final _loginStatusController = StreamController<LoginStatus>.broadcast();
AccountModule(this._api);
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
Future<RequestCodeResult> requestCode(
String phone, {
String language = 'ru',
@@ -200,7 +221,6 @@ class AccountModule {
if (sessionToken != null && accountId != null) {
await TokenStorage.saveToken(sessionToken, accountId);
await TokenStorage.setActiveAccount(accountId);
logger.i('Токен аккаунта $accountId сохранён, установлен активным');
}
return result;
@@ -226,19 +246,27 @@ class AccountModule {
final requestPayload = _buildLoginPayload(authToken, syncParams);
final packet = await _api.sendRequest(Opcode.login, requestPayload);
_loginStatusController.add(LoginStatus.loading);
try {
final packet = await _api.sendRequest(Opcode.login, requestPayload);
_checkPacketError(packet, 'login');
_checkPacketError(packet, 'login');
final data = packet.payload;
if (data is! Map) {
throw Exception('login: неожиданный тип payload: ${data.runtimeType}');
final data = packet.payload;
if (data is! Map) {
throw Exception('login: неожиданный тип payload: ${data.runtimeType}');
}
final result = await _processLoginResponse(
data.cast<dynamic, dynamic>(),
resolvedAccountId,
);
_loginStatusController.add(LoginStatus.success);
return result;
} catch (e) {
_loginStatusController.add(LoginStatus.error);
rethrow;
}
return _processLoginResponse(
data.cast<dynamic, dynamic>(),
resolvedAccountId,
);
}
Future<List<SessionInfo>> getSessions() async {
@@ -278,13 +306,6 @@ class AccountModule {
logger.i('Аккаунт $accountId удалён локально');
}
/// Проверяет 2FA-пароль (opcode 115).
///
/// [trackId] — из [VerifyCodeResult.challengeTrackId].
/// [accountId] — из [VerifyCodeResult.accountId].
///
/// При неверном пароле бросает [Exception].
/// При успехе сохраняет токен и устанавливает аккаунт активным.
Future<TwoFactorResult> checkPassword({
required String password,
required String trackId,
@@ -487,7 +508,11 @@ class AccountModule {
void _checkPacketError(Packet packet, String method) {
if (packet.isError) {
throw PacketError(messageFromErrorPayload(packet.payload));
final payload = packet.payload;
if (payload is Map && payload['message'] == 'FAIL_LOGIN_TOKEN') {
throw SessionExpiredException(messageFromErrorPayload(payload));
}
throw PacketError(messageFromErrorPayload(payload));
}
}
}
-2
View File
@@ -137,8 +137,6 @@ class ChatsModule {
static Future<void> clearCache(int accountId) =>
AppDatabase.clearChatsCache(accountId);
// internal
static Map<int, Map<dynamic, dynamic>> _buildContactsMap(dynamic contacts) {
if (contacts is! List) return {};
final result = <int, Map<dynamic, dynamic>>{};
+4
View File
@@ -59,6 +59,10 @@ class PacketError implements Exception {
String toString() => message;
}
class SessionExpiredException extends PacketError {
const SessionExpiredException(super.message);
}
String messageFromErrorPayload(dynamic payload) {
if (payload is Map) {
for (final key in ['localizedMessage', 'message', 'title']) {
+7 -3
View File
@@ -68,9 +68,13 @@ class PacketDispatcher {
}
if (packet.isError) {
completer.completeError(
PacketError(messageFromErrorPayload(packet.payload)),
);
final message = messageFromErrorPayload(packet.payload);
if (packet.payload is Map &&
packet.payload['message'] == 'FAIL_LOGIN_TOKEN') {
completer.completeError(SessionExpiredException(message));
} else {
completer.completeError(PacketError(message));
}
} else {
completer.complete(packet);
}
+126 -69
View File
@@ -15,6 +15,31 @@ import '../../../backend/modules/chats.dart';
import '../../../core/storage/app_database.dart';
import '../../../main.dart' show api;
class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive;
const _StoriesScrollPhysics({
required this.blockPositive,
ScrollPhysics? parent,
}) : super(parent: parent);
@override
_StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _StoriesScrollPhysics(
blockPositive: blockPositive,
parent: buildParent(ancestor),
);
}
@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
if (blockPositive() && value > 0.0) {
return value - max(0.0, position.pixels);
}
return super.applyBoundaryConditions(position, value);
}
}
class ChatListScreen extends StatefulWidget {
const ChatListScreen({super.key});
@@ -62,13 +87,7 @@ class _ChatListScreenState extends State<ChatListScreen>
_selectedChats.add(chatId);
}
if (_isSelectionMode) {
if (_scrollController.hasClients && _scrollController.offset < 132) {
_shouldCollapseSearch = true;
}
} else {
_shouldCollapseSearch = false;
}
_shouldCollapseSearch = _isSelectionMode;
});
}
@@ -80,6 +99,20 @@ class _ChatListScreenState extends State<ChatListScreen>
}
bool _isInitialLoading = true;
DateTime _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0);
bool _shouldBlockPositiveScroll() {
if (_pullRatio > 0 ||
_storiesDockedOpen ||
_storiesRevealController.isAnimating) {
return true;
}
if (DateTime.now().isBefore(_storiesLockdownUntil)) {
return true;
}
return false;
}
late AnimationController _shimmerController;
@override
@@ -89,13 +122,14 @@ class _ChatListScreenState extends State<ChatListScreen>
vsync: this,
duration: const Duration(milliseconds: 350),
);
_navPageAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
value: 1.0,
)..addListener(() {
if (mounted) setState(() {});
});
_navPageAnimController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
value: 1.0,
)..addListener(() {
if (mounted) setState(() {});
});
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
@@ -272,6 +306,9 @@ class _ChatListScreenState extends State<ChatListScreen>
return;
}
if (_storiesAnimClosing && _storiesRevealController.isAnimating) return;
_storiesLockdownUntil = DateTime.now().add(
const Duration(milliseconds: 800),
);
_storiesRevealController.stop();
_storiesAnimClosing = true;
final from = _pullRatio.clamp(0.0, 1.0);
@@ -293,8 +330,20 @@ class _ChatListScreenState extends State<ChatListScreen>
bool _onStoriesScrollNotification(ScrollNotification n) {
if (_currentNavIndex != 0) return false;
if (n is! ScrollUpdateNotification) return false;
if (!_scrollController.hasClients) return false;
if (n is OverscrollNotification && n.overscroll > 0) {
if ((_storiesDockedOpen ||
_storiesRevealController.isAnimating ||
_pullRatio > 0) &&
!_storiesAnimClosing &&
DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) {
_startStoriesAutoClose();
}
return false;
}
if (n is! ScrollUpdateNotification) return false;
if (!_storiesDockedOpen || _storiesRevealController.isAnimating) {
return false;
}
@@ -372,13 +421,14 @@ class _ChatListScreenState extends State<ChatListScreen>
required double Function(int index) bubbleLeftForIndex,
}) {
if (_navDragging) {
final left = (_navDragBaseLeft + _navDragDx)
.clamp(bubbleLeftForIndex(0), bubbleLeftForIndex(3));
final left = (_navDragBaseLeft + _navDragDx).clamp(
bubbleLeftForIndex(0),
bubbleLeftForIndex(3),
);
return ((left - 4) / inactiveWidth).clamp(0.0, 3.0);
}
if (_navPageAnimController.isAnimating) {
final t =
Curves.easeOutCubic.transform(_navPageAnimController.value);
final t = Curves.easeOutCubic.transform(_navPageAnimController.value);
return ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!;
}
return _currentNavIndex.toDouble();
@@ -390,8 +440,7 @@ class _ChatListScreenState extends State<ChatListScreen>
}
double fromT;
if (_navPageAnimController.isAnimating) {
final t =
Curves.easeOutCubic.transform(_navPageAnimController.value);
final t = Curves.easeOutCubic.transform(_navPageAnimController.value);
fromT = ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!;
} else {
fromT = _currentNavIndex.toDouble();
@@ -428,7 +477,7 @@ class _ChatListScreenState extends State<ChatListScreen>
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _shouldCollapseSearch
? const SizedBox(width: double.infinity, height: 0)
? const SizedBox(width: double.infinity, height: 52)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -667,8 +716,17 @@ class _ChatListScreenState extends State<ChatListScreen>
Widget _buildChatsTabBody() {
return Listener(
onPointerDown: (_) {
_storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0);
},
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
if (_shouldBlockPositiveScroll() &&
pointerSignal.scrollDelta.dy > 0) {
_storiesLockdownUntil = DateTime.now().add(
const Duration(milliseconds: 300),
);
}
if (_scrollController.hasClients && _scrollController.offset <= 0) {
if (pointerSignal.scrollDelta.dy < 0) {
_startStoriesAutoReveal(max(_pullRatio, 0.18));
@@ -687,37 +745,32 @@ class _ChatListScreenState extends State<ChatListScreen>
Expanded(
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
physics: _StoriesScrollPhysics(
blockPositive: _shouldBlockPositiveScroll,
parent: const AlwaysScrollableScrollPhysics(),
),
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (_isInitialLoading) {
return _buildChatShimmer();
}
final chat = _chats[index];
return _buildChatItem(
chat.id.toString(),
chat.title ?? 'Чат',
chat.lastMsgText ?? '',
_formatTime(chat.lastMsgTime),
(chat.iconUrl != null && chat.iconUrl!.isNotEmpty)
? chat.iconUrl!
: '',
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
);
},
childCount:
_isInitialLoading ? 10 : _chats.length,
),
),
const SliverPadding(
padding: EdgeInsets.only(bottom: 100),
delegate: SliverChildBuilderDelegate((context, index) {
if (_isInitialLoading) {
return _buildChatShimmer();
}
final chat = _chats[index];
return _buildChatItem(
chat.id.toString(),
chat.title ?? 'Чат',
chat.lastMsgText ?? '',
_formatTime(chat.lastMsgTime),
(chat.iconUrl != null && chat.iconUrl!.isNotEmpty)
? chat.iconUrl!
: '',
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
);
}, childCount: _isInitialLoading ? 10 : _chats.length),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 100)),
],
),
),
@@ -747,12 +800,10 @@ class _ChatListScreenState extends State<ChatListScreen>
final maxBubbleLeft = bubbleLeftForIndex(3);
final bubbleLeft = _navDragging
? (_navDragBaseLeft + _navDragDx)
.clamp(minBubbleLeft, maxBubbleLeft)
? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft)
: leftOffset;
final navRowT =
((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
double navInterpolatedWidth(int tabIndex, double rowT) {
final rt = rowT.clamp(0.0, 3.0);
@@ -820,8 +871,10 @@ class _ChatListScreenState extends State<ChatListScreen>
},
onHorizontalDragEnd: (_) {
if (!_navDragging) return;
final left = (_navDragBaseLeft + _navDragDx)
.clamp(minBubbleLeft, maxBubbleLeft);
final left = (_navDragBaseLeft + _navDragDx).clamp(
minBubbleLeft,
maxBubbleLeft,
);
final next = indexForBubbleLeft(left);
setState(() {
_currentNavIndex = next;
@@ -886,8 +939,8 @@ class _ChatListScreenState extends State<ChatListScreen>
width: _navDragging
? navInterpolatedWidth(index, navRowT)
: (isSelected
? (activeWidth - 0.5)
: (inactiveWidth - 0.5)),
? (activeWidth - 0.5)
: (inactiveWidth - 0.5)),
child: ClipRRect(
borderRadius: BorderRadius.circular(26),
child: _buildNavItem(
@@ -939,7 +992,8 @@ class _ChatListScreenState extends State<ChatListScreen>
bubbleLeftForIndex: bubbleLeftForPageT,
);
final showChatsFab = !_isSelectionMode &&
final showChatsFab =
!_isSelectionMode &&
(_navDragging || _navPageAnimController.isAnimating
? pageDisplayT < 1.0
: _currentNavIndex == 0);
@@ -950,10 +1004,10 @@ class _ChatListScreenState extends State<ChatListScreen>
child: SizedBox(
width: pageW,
height: pageH,
child: UnconstrainedBox(
constrainedAxis: Axis.vertical,
child: OverflowBox(
alignment: Alignment.topLeft,
clipBehavior: Clip.hardEdge,
maxWidth: pageW * 4,
maxHeight: pageH,
child: SizedBox(
width: pageW * 4,
height: pageH,
@@ -1013,7 +1067,9 @@ class _ChatListScreenState extends State<ChatListScreen>
onTap: _toggleFab,
behavior: HitTestBehavior.opaque,
child: Container(
color: Colors.black.withValues(alpha: val * 0.2),
color: Colors.black.withValues(
alpha: val * 0.2,
),
),
),
),
@@ -1066,7 +1122,7 @@ class _ChatListScreenState extends State<ChatListScreen>
left: 0,
right: 0,
child: Container(
height: 64,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: cs.surface,
@@ -1360,12 +1416,13 @@ class _ChatListScreenState extends State<ChatListScreen>
bool instant = false,
}) {
final cs = Theme.of(context).colorScheme;
final bool isSelected =
selectedOverride ?? (_currentNavIndex == index);
final Duration animDur =
instant ? Duration.zero : const Duration(milliseconds: 350);
final Duration opacityDur =
instant ? Duration.zero : const Duration(milliseconds: 200);
final bool isSelected = selectedOverride ?? (_currentNavIndex == index);
final Duration animDur = instant
? Duration.zero
: const Duration(milliseconds: 350);
final Duration opacityDur = instant
? Duration.zero
: const Duration(milliseconds: 200);
return GestureDetector(
onTap: () => _onNavTabSelected(index),
behavior: HitTestBehavior.opaque,
@@ -252,7 +252,7 @@ class _DevicesScreenState extends State<DevicesScreen>
(session) => _buildDeviceItem(
context,
cs,
id: session.id ?? 0,
id: session.uniqueId,
title: session.client + (session.current ? ' (текущая)' : ''),
platform: session.info,
location: session.location,
+29 -5
View File
@@ -8,8 +8,10 @@ import 'backend/modules/account.dart';
import 'backend/modules/messages.dart';
import 'core/storage/app_database.dart';
import 'core/storage/token_storage.dart';
import 'core/protocol/packet.dart';
import 'frontend/screens/auth/login_screen.dart';
import 'frontend/screens/chats/chat_list_screen.dart';
import 'frontend/widgets/custom_notification.dart';
final api = Api();
final accountModule = AccountModule(api);
@@ -40,6 +42,7 @@ class KometApp extends StatefulWidget {
const KometApp({super.key, required this.initialLocale});
final Locale initialLocale;
static final navigatorKey = GlobalKey<NavigatorState>();
static KometAppState? stateOf(BuildContext context) {
return context.findAncestorStateOfType<KometAppState>();
@@ -53,11 +56,35 @@ class KometAppState extends State<KometApp> {
static const _fallbackSeed = Color(0xFFC1C4FF);
late Locale _locale;
bool _isLoggingOut = false;
@override
void initState() {
super.initState();
_locale = widget.initialLocale;
api.sessionExpiredStream.listen((SessionExpiredException e) async {
if (_isLoggingOut) return;
_isLoggingOut = true;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await accountModule.removeAccount(accountId);
}
final navState = KometApp.navigatorKey.currentState;
if (navState != null) {
final overlayContext = navState.overlay?.context;
if (overlayContext != null) {
showCustomNotification(overlayContext, e.message);
}
await navState.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
}
_isLoggingOut = false;
});
}
Future<void> applyLocale(Locale locale) async {
@@ -114,6 +141,7 @@ class KometAppState extends State<KometApp> {
colorScheme: darkScheme,
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme),
),
navigatorKey: KometApp.navigatorKey,
home: const _StartupScreen(),
);
},
@@ -151,11 +179,7 @@ class _StartupScreenState extends State<_StartupScreen> {
try {
await accountModule.login(accountId: accountId);
} catch (e) {
debugPrint(
'Background auto-login failed (safe to ignore if offline): $e',
);
}
} catch (_) {}
}
void _goToLogin() {
+8 -8
View File
@@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
clock:
dependency: transitive
description:
@@ -313,18 +313,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
material_symbols_icons:
dependency: "direct main"
description:
@@ -614,10 +614,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.10"
timezone:
dependency: "direct main"
description: