From 5a3890c55e6162b27206ab17a86dea0ee218f68f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 29 May 2026 13:52:13 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=B0=D0=B1=D1=83=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D0=B4=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 14 ++ lib/core/storage/token_storage.dart | 5 + lib/frontend/screens/auth/login_screen.dart | 30 ++- .../screens/chats/chat_list_screen.dart | 58 +---- lib/frontend/screens/chats/chat_screen.dart | 19 +- .../screens/profile/debug_menu_screen.dart | 68 ++++++ .../widgets/login_success_screen.dart | 199 ++++++++++++++---- lib/frontend/widgets/message_bubble.dart | 64 ++++-- lib/main.dart | 20 +- 9 files changed, 369 insertions(+), 108 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 75449dd..74d6a64 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -937,6 +937,20 @@ class AccountModule { _checkPacketError(packet, 'authorizeWebQrLogin'); } + Future beginAddAccount() async { + try { + await _api.disconnect(); + } catch (_) {} + + await TokenStorage.clearActiveAccount(); + + ContactCache.clear(); + TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); + + logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index b04ed77..2d4f600 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -24,6 +24,11 @@ class TokenStorage { await prefs.setString(_activeAccountKey, accountId.toString()); } + static Future clearActiveAccount() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeAccountKey); + } + static Future getActiveAccountId() async { final prefs = await SharedPreferences.getInstance(); final val = prefs.getString(_activeAccountKey); diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 037a5c8..aca0e81 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -14,10 +14,14 @@ import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import '../profile/spoof_screen.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/adaptive_shell.dart'; +import '../../../backend/api.dart'; import '../../../main.dart'; class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); + final int? returnToAccountId; + + const LoginScreen({super.key, this.returnToAccountId}); @override State createState() => _LoginScreenState(); @@ -34,11 +38,30 @@ class _LoginScreenState extends State { @override void initState() { super.initState(); + if (api.state == SessionState.disconnected) { + unawaited(api.connect()); + } _selectedCountry = countriesByCode['RU'] ?? allCountries.first; _clampCountryToAllowed(); _checkTOS(); } + Future _onBackPressed() async { + final returnId = widget.returnToAccountId; + if (returnId != null) { + try { + await accountModule.switchAccount(returnId); + } catch (_) {} + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + (route) => false, + ); + return; + } + if (Navigator.canPop(context)) Navigator.pop(context); + } + void _clampCountryToAllowed() { final allowed = api.registrationCountries; if (allowed.any((c) => c.code == _selectedCountry.code)) return; @@ -666,9 +689,10 @@ class _LoginScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (Navigator.canPop(context)) + if (Navigator.canPop(context) || + widget.returnToAccountId != null) IconButton( - onPressed: () => Navigator.pop(context), + onPressed: _onBackPressed, icon: Icon( Symbols.arrow_back, color: cs.onSurfaceVariant, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index eb5b624..4a3e97a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -25,6 +25,7 @@ import '../../../backend/modules/chats.dart'; import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; import '../../../main.dart' show accountModule, api, messagesModule; class _StoriesScrollPhysics extends BouncingScrollPhysics { @@ -100,7 +101,6 @@ class _ChatListScreenState extends State bool _navDragging = false; bool _isFabOpen = false; - bool _showCacheWarning = false; bool _storiesAnimClosing = false; Timer? _contactRebuildTimer; bool get _isSelectionMode => _selectedChats.isNotEmpty; @@ -140,7 +140,6 @@ class _ChatListScreenState extends State _selectedFolderId, _isInitialLoading, _foldersListKnown, - _showCacheWarning, _isSelectionMode, _shouldCollapseSearch, _selectedChats.length, @@ -446,12 +445,6 @@ class _ChatListScreenState extends State if (mounted) { setState(() { _sessionState = state; - if (state == SessionState.disconnected && _chats.isNotEmpty) { - _showCacheWarning = true; - } - if (state == SessionState.online) { - _showCacheWarning = false; - } }); if (state == SessionState.online) { _reloadChatsAndFolders(); @@ -1145,42 +1138,6 @@ class _ChatListScreenState extends State ), ), ), - if (_showCacheWarning) - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: cs.errorContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: cs.error.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Icon( - Symbols.cloud_off, - size: 18, - color: cs.error, - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Ошибка соединения, сейчас вы смотрите КЕШ', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), Padding( padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), child: Container( @@ -2393,9 +2350,16 @@ class _ChatListScreenState extends State controller.dispose(); if (!mounted) return; if (accountId == null) { - await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const LoginScreen()), + final previousId = await TokenStorage.getActiveAccountId(); + try { + await accountModule.beginAddAccount(); + } catch (_) {} + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => LoginScreen(returnToAccountId: previousId), + ), + (route) => false, ); return; } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index b7c54d7..31ddbef 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -114,6 +114,7 @@ class _ChatScreenState extends State final Map _typingTimers = {}; int _otherStatus = 0; int? _otherSeenTime; + int? _participantsCount; final ValueNotifier _headerStatusNotifier = ValueNotifier(''); final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; @@ -172,9 +173,21 @@ class _ChatScreenState extends State ); unawaited(_fastPreloadCache()); + unawaited(_loadParticipantsCount()); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } + Future _loadParticipantsCount() async { + if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; + final info = await ChatsModule.getChatInfo(api, widget.chatId); + if (!mounted) return; + final count = info?['participantsCount'] as int?; + if (count != null && count != _participantsCount) { + _participantsCount = count; + _recomputeHeaderStatus(); + } + } + Future _fastPreloadCache() async { final p = await AppDatabase.loadActiveProfile(); if (!mounted) return; @@ -521,10 +534,12 @@ class _ChatScreenState extends State String _headerStatus() { if (_typingUserIds.isNotEmpty) return 'Печатает...'; if (widget.chatType == 'CHAT') { - return '${chat?.participants.length ?? 0} участников'; + final count = _participantsCount ?? chat?.participants.length ?? 0; + return '$count участников'; } if (widget.chatType == 'CHANNEL') { - return '${chat?.participants.length ?? 0} подписчиков'; + final count = _participantsCount ?? chat?.participants.length ?? 0; + return '$count подписчиков'; } if (_otherStatus == 1) return 'В сети'; if (_otherStatus == 3) return 'Был(-а) недавно'; diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index b607079..3d409a7 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -9,6 +9,7 @@ import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -397,6 +398,73 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const LoginSuccessScreen(preview: true), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.celebration, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'test hello', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показать приветственную анимацию входа', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 9617e37..022a8bb 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -23,7 +23,9 @@ Future precacheLoginAvatar( class LoginSuccessScreen extends StatefulWidget { final ImageProvider? avatar; - const LoginSuccessScreen({super.key, this.avatar}); + final bool preview; + + const LoginSuccessScreen({super.key, this.avatar, this.preview = false}); @override State createState() => _LoginSuccessScreenState(); @@ -54,6 +56,8 @@ class _LoginSuccessScreenState extends State late final Animation _fadeOut; bool _navigated = false; + bool _handedOff = false; + final GlobalKey _subtitleKey = GlobalKey(); @override void initState() { @@ -114,26 +118,66 @@ class _LoginSuccessScreenState extends State void _onStatus(AnimationStatus status) { if (status == AnimationStatus.completed && !_navigated && mounted) { _navigated = true; - Navigator.of(context).pushAndRemoveUntil( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 360), - reverseTransitionDuration: const Duration(milliseconds: 200), - pageBuilder: (_, __, ___) => const AdaptiveShell(), - transitionsBuilder: (_, animation, __, child) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - child: child, - ); - }, - ), - (route) => false, - ); + + final navigator = Navigator.of(context); + final overlay = navigator.overlay; + final entry = _buildGreetingEntry(); + if (entry != null) { + setState(() => _handedOff = true); + } + + if (widget.preview) { + navigator.pop(); + } else { + navigator.pushAndRemoveUntil( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 360), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (_, _, _) => const AdaptiveShell(), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + child: child, + ); + }, + ), + (route) => false, + ); + } + + if (entry != null && overlay != null) { + overlay.insert(entry); + } } } + OverlayEntry? _buildGreetingEntry() { + final box = _subtitleKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.attached) return null; + + final topLeft = box.localToGlobal(Offset.zero); + final size = box.size; + final color = Theme.of(context).colorScheme.onSurfaceVariant; + + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => Positioned( + left: topLeft.dx, + top: topLeft.dy, + width: size.width, + child: _GreetingLinger( + greeting: _greeting, + color: color, + onDone: () => entry.remove(), + ), + ), + ); + return entry; + } + @override void dispose() { _controller.removeStatusListener(_onStatus); @@ -149,11 +193,12 @@ class _LoginSuccessScreenState extends State body: AnimatedBuilder( animation: _controller, builder: (context, _) { - return Opacity( - opacity: 1.0 - _fadeOut.value, - child: Stack( - children: [ - Positioned.fill( + final celebrationOpacity = 1.0 - _fadeOut.value; + return Stack( + children: [ + Positioned.fill( + child: Opacity( + opacity: celebrationOpacity, child: DecoratedBox( decoration: BoxDecoration( gradient: RadialGradient( @@ -169,11 +214,14 @@ class _LoginSuccessScreenState extends State ), ), ), - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: celebrationOpacity, + child: SizedBox( width: 220, height: 220, child: Stack( @@ -186,15 +234,21 @@ class _LoginSuccessScreenState extends State ], ), ), - const SizedBox(height: 28), - _buildTitle(cs), - const SizedBox(height: 8), - _buildSubtitle(cs), - ], - ), + ), + const SizedBox(height: 28), + Opacity( + opacity: celebrationOpacity, + child: _buildTitle(cs), + ), + const SizedBox(height: 8), + Opacity( + opacity: _handedOff ? 0.0 : 1.0, + child: _buildSubtitle(cs), + ), + ], ), - ], - ), + ), + ], ); }, ), @@ -315,6 +369,7 @@ class _LoginSuccessScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 40), child: Text( _greeting, + key: _subtitleKey, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurfaceVariant, @@ -329,6 +384,78 @@ class _LoginSuccessScreenState extends State } } +class _GreetingLinger extends StatefulWidget { + final String greeting; + final Color color; + final VoidCallback onDone; + + const _GreetingLinger({ + required this.greeting, + required this.color, + required this.onDone, + }); + + @override + State<_GreetingLinger> createState() => _GreetingLingerState(); +} + +class _GreetingLingerState extends State<_GreetingLinger> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _opacity = TweenSequence([ + TweenSequenceItem(tween: ConstantTween(1.0), weight: 55), + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.0) + .chain(CurveTween(curve: Curves.easeInCubic)), + weight: 45, + ), + ]).animate(_controller); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) widget.onDone(); + }); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedBuilder( + animation: _opacity, + builder: (context, _) { + return Opacity( + opacity: _opacity.value, + child: Text( + widget.greeting, + textAlign: TextAlign.center, + style: TextStyle( + color: widget.color, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.3, + ), + ), + ); + }, + ), + ); + } +} + class _CheckPainter extends CustomPainter { final double progress; final Color color; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e3c4478..3efeaae 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -300,6 +300,10 @@ class MessageBubble extends StatelessWidget { ) : _buildContent(makeCtx()); + final reactionsUnder = _reactionsUnderBubble(contentType); + final reactionsInside = + contentType != MessageType.text && !reactionsUnder; + return GestureDetector( onTap: Haptics.tap, child: Padding( @@ -349,16 +353,15 @@ class MessageBubble extends StatelessWidget { padding: padding, child: child, ), - child: bubbleContent, - ), - if (contentType != MessageType.text) - reactionsListenable != null - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, info, _) => - _buildReactionsBarFor(cs, info), + child: reactionsInside + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [bubbleContent, _reactionsBar(cs)], ) - : _buildReactionsBar(cs), + : bubbleContent, + ), + if (reactionsUnder) _reactionsBar(cs), ], ), ], @@ -378,6 +381,27 @@ class MessageBubble extends StatelessWidget { return null; } + bool _reactionsUnderBubble(MessageType contentType) { + if (contentType != MessageType.attachment) return false; + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return false; + if (attachments.first is ForwardedMessageAttachment) return false; + if (attachments.any((a) => a is ContactAttachment)) return false; + if (attachments.whereType().length >= 2) return false; + return true; + } + + Widget _reactionsBar(ColorScheme cs) { + final listenable = reactionsListenable; + if (listenable != null) { + return ValueListenableBuilder?>( + valueListenable: listenable, + builder: (context, info, _) => _buildReactionsBarFor(cs, info), + ); + } + return _buildReactionsBar(cs); + } + Widget _buildContent(_BubbleCtx ctx) { switch (ctx.contentType) { case MessageType.control: @@ -1752,10 +1776,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { @override void initState() { super.initState(); - if (widget.preloadedText != null) { - _transcriptionText = widget.preloadedText; - _transcriptionVisible = true; - } + _transcriptionText = widget.preloadedText; } String _formatDuration(int seconds) { @@ -1917,14 +1938,19 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _formatDuration(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.7), - fontSize: 11, + SizedBox( + width: 32, + child: Center( + child: Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), Expanded( child: AnimatedSize( duration: const Duration(milliseconds: 200), diff --git a/lib/main.dart b/lib/main.dart index b007b7e..529d616 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -700,8 +700,13 @@ class _StartupScreenState extends State<_StartupScreen> { } Future _tryAutoLogin() async { - final accountId = await TokenStorage.getActiveAccountId(); + int? accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null || await TokenStorage.readToken(accountId) == null) { + accountId = await _recoverActiveAccount(); + } + + if (accountId == null) { _goToLogin(); return; } @@ -718,6 +723,19 @@ class _StartupScreenState extends State<_StartupScreen> { } } + Future _recoverActiveAccount() async { + final profiles = await AppDatabase.loadAllProfiles(); + for (final profile in profiles) { + if (await TokenStorage.readToken(profile.id) != null) { + await TokenStorage.setActiveAccount(profile.id); + await AppDatabase.setActiveAccount(profile.id); + await ContactsModule.primeCacheFromDb(profile.id); + return profile.id; + } + } + return null; + } + void _goToLogin() { if (mounted) { Navigator.pushReplacement(