diff --git a/lib/backend/api.dart b/lib/backend/api.dart index aedf00d..72f987c 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -30,6 +30,8 @@ class Api { SessionState _sessionState = SessionState.disconnected; final _stateController = StreamController.broadcast(); + final _sessionExpiredController = + StreamController.broadcast(); Map? _userAgent; Map? get userAgent => _userAgent; @@ -40,6 +42,8 @@ class Api { _registrationCountries ?? allCountries; Stream get stateStream => _stateController.stream; + Stream get sessionExpiredStream => + _sessionExpiredController.stream; SessionState get state => _sessionState; StreamSubscription? _dataSubscription; @@ -195,6 +199,7 @@ class Api { _dispatcher.dispose(); _connection.dispose(); _stateController.close(); + _sessionExpiredController.close(); } // Внутрянка @@ -208,6 +213,13 @@ class Api { Future _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); } } diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 82a6505..ec64490 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -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() : 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 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.broadcast(); AccountModule(this._api); + Stream get loginStatusStream => _loginStatusController.stream; + Future 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(), + resolvedAccountId, + ); + _loginStatusController.add(LoginStatus.success); + return result; + } catch (e) { + _loginStatusController.add(LoginStatus.error); + rethrow; } - - return _processLoginResponse( - data.cast(), - resolvedAccountId, - ); } Future> getSessions() async { @@ -278,13 +306,6 @@ class AccountModule { logger.i('Аккаунт $accountId удалён локально'); } - /// Проверяет 2FA-пароль (opcode 115). - /// - /// [trackId] — из [VerifyCodeResult.challengeTrackId]. - /// [accountId] — из [VerifyCodeResult.accountId]. - /// - /// При неверном пароле бросает [Exception]. - /// При успехе сохраняет токен и устанавливает аккаунт активным. Future 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)); } } } diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 33413d3..f8c81d6 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -137,8 +137,6 @@ class ChatsModule { static Future clearCache(int accountId) => AppDatabase.clearChatsCache(accountId); - // internal - static Map> _buildContactsMap(dynamic contacts) { if (contacts is! List) return {}; final result = >{}; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 6970165..8bd0bc4 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -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']) { diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index bb22136..a7ff78e 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -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); } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 78d1aaf..1bf2d97 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -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 _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 } 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 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 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 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 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 } 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 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 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 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 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 }, 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 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 bubbleLeftForIndex: bubbleLeftForPageT, ); - final showChatsFab = !_isSelectionMode && + final showChatsFab = + !_isSelectionMode && (_navDragging || _navPageAnimController.isAnimating ? pageDisplayT < 1.0 : _currentNavIndex == 0); @@ -950,10 +1004,10 @@ class _ChatListScreenState extends State 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 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 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 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, diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index c31090b..c400d60 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -252,7 +252,7 @@ class _DevicesScreenState extends State (session) => _buildDeviceItem( context, cs, - id: session.id ?? 0, + id: session.uniqueId, title: session.client + (session.current ? ' (текущая)' : ''), platform: session.info, location: session.location, diff --git a/lib/main.dart b/lib/main.dart index 1e20ee2..a210687 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); static KometAppState? stateOf(BuildContext context) { return context.findAncestorStateOfType(); @@ -53,11 +56,35 @@ class KometAppState extends State { 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 applyLocale(Locale locale) async { @@ -114,6 +141,7 @@ class KometAppState extends State { 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() { diff --git a/pubspec.lock b/pubspec.lock index e8fa11e..ca2281c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: