From 221487ca28f62c8f9fe52392f257582dc5df1dd7 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 05:41:54 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=D0=BA=D1=80=D0=B0=D1=81=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=83=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=88=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/code_confirmation_screen.dart | 11 +- .../screens/auth/password_2fa_screen.dart | 11 +- .../widgets/login_success_screen.dart | 402 ++++++++++++++++++ 3 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 lib/frontend/widgets/login_success_screen.dart diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 512a938..1f776aa 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; -import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; @@ -173,7 +173,14 @@ class _CodeConfirmationScreenState extends State Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (context) => const ChatListScreen()), + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), (route) => false, ); } catch (e) { diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index b38171e..fae5643 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import '../chats/chat_list_screen.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; class Password2FAScreen extends StatefulWidget { final String trackId; @@ -46,7 +46,14 @@ class _Password2FAScreenState extends State { Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (context) => const ChatListScreen()), + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), (route) => false, ); } catch (e) { diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart new file mode 100644 index 0000000..74aa9e9 --- /dev/null +++ b/lib/frontend/widgets/login_success_screen.dart @@ -0,0 +1,402 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../core/utils/haptics.dart'; +import '../screens/chats/chat_list_screen.dart'; + +class LoginSuccessScreen extends StatefulWidget { + const LoginSuccessScreen({super.key}); + + @override + State createState() => _LoginSuccessScreenState(); +} + +class _LoginSuccessScreenState extends State + with SingleTickerProviderStateMixin { + static const Duration _duration = Duration(milliseconds: 1900); + + late final AnimationController _controller; + late final Animation _circleScale; + late final Animation _ringSweep; + late final Animation _checkProgress; + late final Animation _particles; + late final Animation _haloOpacity; + late final Animation _haloScale; + late final Animation _titleOpacity; + late final Animation _titleOffset; + late final Animation _subtitleOpacity; + late final Animation _subtitleOffset; + late final Animation _fadeOut; + + bool _navigated = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _duration); + + _circleScale = CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.22, curve: Curves.easeOutBack), + ); + _ringSweep = CurvedAnimation( + parent: _controller, + curve: const Interval(0.08, 0.45, curve: Curves.easeOutCubic), + ); + _checkProgress = CurvedAnimation( + parent: _controller, + curve: const Interval(0.22, 0.48, curve: Curves.easeOutCubic), + ); + _haloOpacity = CurvedAnimation( + parent: _controller, + curve: const Interval(0.12, 0.65, curve: Curves.easeOut), + ); + _haloScale = CurvedAnimation( + parent: _controller, + curve: const Interval(0.12, 0.85, curve: Curves.easeOutCubic), + ); + _particles = CurvedAnimation( + parent: _controller, + curve: const Interval(0.32, 0.78, curve: Curves.easeOutCubic), + ); + _titleOpacity = CurvedAnimation( + parent: _controller, + curve: const Interval(0.42, 0.62, curve: Curves.easeOut), + ); + _titleOffset = CurvedAnimation( + parent: _controller, + curve: const Interval(0.42, 0.7, curve: Curves.easeOutCubic), + ); + _subtitleOpacity = CurvedAnimation( + parent: _controller, + curve: const Interval(0.5, 0.72, curve: Curves.easeOut), + ); + _subtitleOffset = CurvedAnimation( + parent: _controller, + curve: const Interval(0.5, 0.78, curve: Curves.easeOutCubic), + ); + _fadeOut = CurvedAnimation( + parent: _controller, + curve: const Interval(0.88, 1.0, curve: Curves.easeInCubic), + ); + + _controller.addStatusListener(_onStatus); + _controller.forward(); + Haptics.success(); + } + + 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 ChatListScreen(), + transitionsBuilder: (_, animation, __, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + child: child, + ); + }, + ), + (route) => false, + ); + } + } + + @override + void dispose() { + _controller.removeStatusListener(_onStatus); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Opacity( + opacity: 1.0 - _fadeOut.value, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment.center, + radius: 0.9, + colors: [ + cs.primaryContainer.withValues( + alpha: 0.35 * _haloOpacity.value, + ), + cs.surface.withValues(alpha: 0), + ], + ), + ), + ), + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 220, + height: 220, + child: Stack( + alignment: Alignment.center, + children: [ + _buildHalo(cs), + _buildParticles(cs), + _buildRing(cs), + _buildCircle(cs), + ], + ), + ), + const SizedBox(height: 28), + _buildTitle(cs), + const SizedBox(height: 8), + _buildSubtitle(cs), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildHalo(ColorScheme cs) { + final scale = 0.8 + _haloScale.value * 0.6; + final opacity = (1.0 - _haloScale.value) * 0.6 * _haloOpacity.value; + return IgnorePointer( + child: Transform.scale( + scale: scale, + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary.withValues(alpha: opacity * 0.25), + boxShadow: [ + BoxShadow( + color: cs.primary.withValues(alpha: opacity * 0.4), + blurRadius: 60, + spreadRadius: 8, + ), + ], + ), + ), + ), + ); + } + + Widget _buildParticles(ColorScheme cs) { + return IgnorePointer( + child: CustomPaint( + size: const Size(220, 220), + painter: _ParticlesPainter( + progress: _particles.value, + color: cs.primary, + ), + ), + ); + } + + Widget _buildRing(ColorScheme cs) { + return IgnorePointer( + child: CustomPaint( + size: const Size(140, 140), + painter: _RingPainter( + progress: _ringSweep.value, + color: cs.primary, + ), + ), + ); + } + + Widget _buildCircle(ColorScheme cs) { + final scale = _circleScale.value.clamp(0.0, 1.0); + return Transform.scale( + scale: scale, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary, + boxShadow: [ + BoxShadow( + color: cs.primary.withValues(alpha: 0.4), + blurRadius: 24, + spreadRadius: 2, + ), + ], + ), + child: CustomPaint( + painter: _CheckPainter( + progress: _checkProgress.value, + color: cs.onPrimary, + ), + ), + ), + ); + } + + Widget _buildTitle(ColorScheme cs) { + return Opacity( + opacity: _titleOpacity.value, + child: Transform.translate( + offset: Offset(0, 18 * (1 - _titleOffset.value)), + child: Text( + 'Готово!', + style: TextStyle( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + ), + ); + } + + Widget _buildSubtitle(ColorScheme cs) { + return Opacity( + opacity: _subtitleOpacity.value, + child: Transform.translate( + offset: Offset(0, 14 * (1 - _subtitleOffset.value)), + child: Text( + 'Добро пожаловать в Komet', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +class _CheckPainter extends CustomPainter { + final double progress; + final Color color; + + _CheckPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0) return; + + final w = size.width; + final h = size.height; + final p1 = Offset(w * 0.30, h * 0.52); + final p2 = Offset(w * 0.45, h * 0.67); + final p3 = Offset(w * 0.72, h * 0.38); + + final firstLen = (p2 - p1).distance; + final secondLen = (p3 - p2).distance; + final total = firstLen + secondLen; + final drawn = total * progress; + + final path = Path()..moveTo(p1.dx, p1.dy); + if (drawn <= firstLen) { + final t = drawn / firstLen; + final mid = Offset.lerp(p1, p2, t)!; + path.lineTo(mid.dx, mid.dy); + } else { + path.lineTo(p2.dx, p2.dy); + final t = ((drawn - firstLen) / secondLen).clamp(0.0, 1.0); + final end = Offset.lerp(p2, p3, t)!; + path.lineTo(end.dx, end.dy); + } + + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 8 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _CheckPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} + +class _RingPainter extends CustomPainter { + final double progress; + final Color color; + + _RingPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0) return; + + final rect = Offset.zero & size; + final paint = Paint() + ..color = color.withValues(alpha: 0.55) + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + rect.deflate(4), + -math.pi / 2, + 2 * math.pi * progress, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant _RingPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} + +class _ParticlesPainter extends CustomPainter { + final double progress; + final Color color; + + static const int _count = 10; + static const double _startRadius = 60; + static const double _endRadius = 104; + + _ParticlesPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0 || progress >= 1) return; + + final center = Offset(size.width / 2, size.height / 2); + final paint = Paint()..style = PaintingStyle.fill; + + for (int i = 0; i < _count; i++) { + final angle = (i / _count) * 2 * math.pi + (math.pi / 2); + final radius = _startRadius + (_endRadius - _startRadius) * progress; + final pos = center + Offset(math.cos(angle), math.sin(angle)) * radius; + final fade = 1.0 - progress; + final dotSize = 4.5 * fade + 1.0; + paint.color = color.withValues(alpha: fade * 0.9); + canvas.drawCircle(pos, dotSize, paint); + } + } + + @override + bool shouldRepaint(covariant _ParticlesPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} From a6aab0a7e3487bd858b36e1e5ae69320291d5ef2 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 17:13:26 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D1=80=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0,=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=D0=BE=D0=B4=D0=B0=20=D0=B2=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 8 ++ .../auth/code_confirmation_screen.dart | 11 ++- .../screens/auth/password_2fa_screen.dart | 11 ++- .../screens/profile/settings_tab.dart | 95 ++++++++++--------- .../widgets/login_success_screen.dart | 66 ++++++++++--- 5 files changed, 127 insertions(+), 64 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e64996e..2255a74 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -24,6 +24,12 @@ class ContactCache { static String? getAvatar(int id) => _avatarCache[id]; static Set? getOptions(int id) => _optionsCache[id]; static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; + + static void clear() { + _nameCache.clear(); + _avatarCache.clear(); + _optionsCache.clear(); + } } class TranscriptionResult { @@ -52,6 +58,8 @@ class TranscriptionCache { static TranscriptionResult? get(String messageId) => _cache[messageId]; static bool has(String messageId) => _cache.containsKey(messageId); + + static void clear() => _cache.clear(); } class FileHistoryEntry { diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 1f776aa..5a52c01 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -167,7 +167,14 @@ class _CodeConfirmationScreenState extends State return; } - await accountModule.login(); + final loginResult = await accountModule.login(); + + if (!mounted) return; + + final avatar = await precacheLoginAvatar( + context, + loginResult.profile.baseUrl, + ); if (!mounted) return; @@ -175,7 +182,7 @@ class _CodeConfirmationScreenState extends State context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), transitionsBuilder: (_, animation, __, child) => FadeTransition( opacity: animation, child: child, diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index fae5643..a6716b0 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -40,7 +40,14 @@ class _Password2FAScreenState extends State { if (!mounted) return; - await accountModule.login(token: result.loginToken); + final loginResult = await accountModule.login(token: result.loginToken); + + if (!mounted) return; + + final avatar = await precacheLoginAvatar( + context, + loginResult.profile.baseUrl, + ); if (!mounted) return; @@ -48,7 +55,7 @@ class _Password2FAScreenState extends State { context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), transitionsBuilder: (_, animation, __, child) => FadeTransition( opacity: animation, child: child, diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 5f9c7d5..dd40169 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/utils/haptics.dart'; @@ -30,8 +31,6 @@ class SettingsTab extends StatefulWidget { } class _SettingsTabState extends State { - static const bool _showLogoutButton = false; - ProfileData? _profile; bool _isPhoneVisible = false; String? _appVersionLabel; @@ -164,7 +163,7 @@ class _SettingsTabState extends State { ), const SizedBox(height: 8), Text( - 'Сессия будет сброшена. Локальный кеш сохранится — войдёшь снова в этот же аккаунт.', + 'Данные аккаунта будут удалены с этого устройства.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 20), @@ -202,8 +201,11 @@ class _SettingsTabState extends State { } catch (_) {} final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { - await TokenStorage.deleteToken(accountId); + await TokenStorage.deleteAccount(accountId); + await AppDatabase.deleteAccount(accountId); } + ContactCache.clear(); + TranscriptionCache.clear(); try { await api.connect(); } catch (_) {} @@ -442,6 +444,23 @@ child: _buildSection( ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + cs, + items: [ + _SettingsItem( + icon: Symbols.logout, + label: 'Выйти из аккаунта', + tintColor: cs.error, + onTap: _confirmLogout, + ), + ], + ), + ), + ), if (_appVersionLabel != null) SliverToBoxAdapter( child: Padding( @@ -552,51 +571,31 @@ child: _buildSection( ), ), const SizedBox(height: 4), - Stack( + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: _PhoneSpoiler( - text: phone, - isVisible: _isPhoneVisible, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - letterSpacing: 0.5, - ), - ), - ), - ), - const SizedBox(width: 4), - Icon( - _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, - size: 14, - color: cs.onSurfaceVariant.withValues(alpha: 0.6), - ), - ], - ), - if (_showLogoutButton) - Positioned.fill( - child: Align( - alignment: Alignment.centerRight, - child: IconButton( - tooltip: 'Выйти', - icon: Icon( - Symbols.logout, - color: cs.error, - size: 22, - weight: 400, - ), - onPressed: _confirmLogout, + GestureDetector( + onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _PhoneSpoiler( + text: phone, + isVisible: _isPhoneVisible, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, ), ), ), + ), + const SizedBox(width: 4), + Icon( + _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, + size: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + ), ], ), ], @@ -662,7 +661,7 @@ child: _buildSection( children: [ Icon( item.icon, - color: cs.onSurfaceVariant, + color: item.tintColor ?? cs.onSurfaceVariant, size: 22, weight: 400, ), @@ -671,7 +670,7 @@ child: _buildSection( child: Text( item.label, style: TextStyle( - color: cs.onSurface, + color: item.tintColor ?? cs.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), @@ -712,6 +711,7 @@ class _SettingsItem { final IconData icon; final String label; final VoidCallback? onTap; + final Color? tintColor; /// When [onToggle] is set the row renders a trailing switch instead of a /// chevron, and [toggleValue] reflects its current state. @@ -722,6 +722,7 @@ class _SettingsItem { required this.icon, required this.label, this.onTap, + this.tintColor, this.toggleValue, this.onToggle, }); diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 74aa9e9..fa35339 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -1,12 +1,29 @@ import 'dart:math' as math; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; import '../screens/chats/chat_list_screen.dart'; +Future precacheLoginAvatar( + BuildContext context, + String? url, +) async { + if (url == null || url.isEmpty) return null; + final provider = CachedNetworkImageProvider(url); + try { + await precacheImage(provider, context); + return provider; + } catch (_) { + return null; + } +} + class LoginSuccessScreen extends StatefulWidget { - const LoginSuccessScreen({super.key}); + final ImageProvider? avatar; + + const LoginSuccessScreen({super.key, this.avatar}); @override State createState() => _LoginSuccessScreenState(); @@ -16,6 +33,13 @@ class _LoginSuccessScreenState extends State with SingleTickerProviderStateMixin { static const Duration _duration = Duration(milliseconds: 1900); + static const List _greetings = [ + 'С большой силой приходит большая ответственность', + 'All in your hands', + 'Иногда забавные вещи могут быть уголовно наказуемы', + ]; + + late final String _greeting; late final AnimationController _controller; late final Animation _circleScale; late final Animation _ringSweep; @@ -34,6 +58,7 @@ class _LoginSuccessScreenState extends State @override void initState() { super.initState(); + _greeting = _greetings[math.Random().nextInt(_greetings.length)]; _controller = AnimationController(vsync: this, duration: _duration); _circleScale = CurvedAnimation( @@ -227,6 +252,7 @@ class _LoginSuccessScreenState extends State Widget _buildCircle(ColorScheme cs) { final scale = _circleScale.value.clamp(0.0, 1.0); + final avatar = widget.avatar; return Transform.scale( scale: scale, child: Container( @@ -243,12 +269,21 @@ class _LoginSuccessScreenState extends State ), ], ), - child: CustomPaint( - painter: _CheckPainter( - progress: _checkProgress.value, - color: cs.onPrimary, - ), - ), + child: avatar != null + ? ClipOval( + child: Image( + image: avatar, + fit: BoxFit.cover, + width: 120, + height: 120, + ), + ) + : CustomPaint( + painter: _CheckPainter( + progress: _checkProgress.value, + color: cs.onPrimary, + ), + ), ), ); } @@ -276,12 +311,17 @@ class _LoginSuccessScreenState extends State opacity: _subtitleOpacity.value, child: Transform.translate( offset: Offset(0, 14 * (1 - _subtitleOffset.value)), - child: Text( - 'Добро пожаловать в Komet', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 15, - fontWeight: FontWeight.w500, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + _greeting, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.3, + ), ), ), ), From 80706daf45612a5207cc30d4ec24c46c6de88040 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 17:32:29 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=D0=B4=D0=B5=D1=81=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=BF-=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=E2=80=94=202-pan?= =?UTF-8?q?e=20=D1=81=20=D1=80=D0=B5=D1=81=D0=B0=D0=B9=D0=B7-=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=88=D0=B8=D1=80=D0=B8=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BD=D0=B0=20=E2=89=A5=20900px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 32 ++- lib/frontend/screens/chats/chat_screen.dart | 17 +- lib/frontend/widgets/adaptive_shell.dart | 220 ++++++++++++++++++ .../widgets/login_success_screen.dart | 4 +- lib/main.dart | 4 +- 5 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 lib/frontend/widgets/adaptive_shell.dart diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 60b4d53..ec16901 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,6 +8,7 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; import 'create_group_flow.dart'; +import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../calls/calls_tab.dart'; @@ -56,7 +57,9 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { } class ChatListScreen extends StatefulWidget { - const ChatListScreen({super.key}); + final ValueChanged? onChatSelected; + + const ChatListScreen({super.key, this.onChatSelected}); @override State createState() => _ChatListScreenState(); @@ -2050,18 +2053,25 @@ class _ChatListScreenState extends State onTap: () { if (_isSelectionMode) { _toggleSelection(id); + } else if (widget.onChatSelected != null) { + widget.onChatSelected!(DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + )); } else { -Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), - ); + ), + ); } }, onLongPress: () => _toggleSelection(id), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 4b9fa90..4f26dce 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -58,6 +58,8 @@ class ChatScreen extends StatefulWidget { final String name; final String imageUrl; final String chatType; + final bool embedded; + final VoidCallback? onClose; const ChatScreen({ super.key, @@ -65,6 +67,8 @@ class ChatScreen extends StatefulWidget { required this.name, required this.imageUrl, required this.chatType, + this.embedded = false, + this.onClose, }); @override @@ -712,8 +716,17 @@ class _ChatScreenState extends State surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), leading: IconButton( - icon: const Icon(Symbols.arrow_back, weight: 400), - onPressed: () => Navigator.pop(context), + icon: Icon( + widget.embedded ? Symbols.close : Symbols.arrow_back, + weight: 400, + ), + onPressed: () { + if (widget.embedded) { + widget.onClose?.call(); + } else { + Navigator.pop(context); + } + }, ), titleSpacing: 0, title: Row( diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart new file mode 100644 index 0000000..c708845 --- /dev/null +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../screens/chats/chat_list_screen.dart'; +import '../screens/chats/chat_screen.dart'; + +class AdaptiveShell extends StatefulWidget { + const AdaptiveShell({super.key}); + + @override + State createState() => _AdaptiveShellState(); +} + +class DesktopChatSelection { + final int chatId; + final String name; + final String imageUrl; + final String chatType; + + const DesktopChatSelection({ + required this.chatId, + required this.name, + required this.imageUrl, + required this.chatType, + }); +} + +class _AdaptiveShellState extends State { + static const double _breakpoint = 900; + static const double _defaultListWidth = 380; + static const double _minListWidth = 280; + static const double _maxListWidth = 560; + static const double _minChatPaneWidth = 360; + static const double _dividerHitWidth = 10; + static const double _dividerLineWidth = 1; + static const String _prefsKey = 'desktop_list_width'; + + double _listWidth = _defaultListWidth; + DesktopChatSelection? _selected; + + @override + void initState() { + super.initState(); + _loadListWidth(); + } + + Future _loadListWidth() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getDouble(_prefsKey); + if (saved == null || !mounted) return; + setState(() { + _listWidth = saved.clamp(_minListWidth, _maxListWidth); + }); + } + + Future _persistListWidth() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_prefsKey, _listWidth); + } + + void _onChatSelected(DesktopChatSelection chat) { + setState(() => _selected = chat); + } + + void _closeChat() { + setState(() => _selected = null); + } + + void _onDrag(double dx, double totalWidth) { + final maxAllowedByPane = + totalWidth - _minChatPaneWidth - _dividerHitWidth; + final upperBound = maxAllowedByPane < _maxListWidth + ? maxAllowedByPane + : _maxListWidth; + final lower = _minListWidth; + final next = (_listWidth + dx).clamp(lower, upperBound); + if (next == _listWidth) return; + setState(() => _listWidth = next); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < _breakpoint) { + return ChatListScreen(onChatSelected: _onChatSelected); + } + final totalWidth = constraints.maxWidth; + final effectiveListWidth = _listWidth.clamp( + _minListWidth, + (totalWidth - _minChatPaneWidth - _dividerHitWidth) + .clamp(_minListWidth, _maxListWidth), + ); + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: Row( + children: [ + SizedBox( + width: effectiveListWidth, + child: ChatListScreen(onChatSelected: _onChatSelected), + ), + _ResizeDivider( + hitWidth: _dividerHitWidth, + lineWidth: _dividerLineWidth, + color: cs.outlineVariant.withValues(alpha: 0.35), + onDrag: (dx) => _onDrag(dx, totalWidth), + onDragEnd: _persistListWidth, + ), + Expanded( + child: _selected == null + ? _EmptyChatPane(colorScheme: cs) + : ChatScreen( + key: ValueKey(_selected!.chatId), + chatId: _selected!.chatId, + name: _selected!.name, + imageUrl: _selected!.imageUrl, + chatType: _selected!.chatType, + embedded: true, + onClose: _closeChat, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ResizeDivider extends StatefulWidget { + final double hitWidth; + final double lineWidth; + final Color color; + final ValueChanged onDrag; + final Future Function() onDragEnd; + + const _ResizeDivider({ + required this.hitWidth, + required this.lineWidth, + required this.color, + required this.onDrag, + required this.onDragEnd, + }); + + @override + State<_ResizeDivider> createState() => _ResizeDividerState(); +} + +class _ResizeDividerState extends State<_ResizeDivider> { + bool _hovering = false; + bool _dragging = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final highlight = _dragging || _hovering; + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) => setState(() => _dragging = true), + onHorizontalDragUpdate: (d) => widget.onDrag(d.delta.dx), + onHorizontalDragEnd: (_) async { + setState(() => _dragging = false); + await widget.onDragEnd(); + }, + onHorizontalDragCancel: () => setState(() => _dragging = false), + child: SizedBox( + width: widget.hitWidth, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: widget.lineWidth, + color: highlight ? cs.primary.withValues(alpha: 0.6) : widget.color, + ), + ), + ), + ), + ); + } +} + +class _EmptyChatPane extends StatelessWidget { + final ColorScheme colorScheme; + + const _EmptyChatPane({required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: colorScheme.surfaceContainerLow, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.chat_bubble, + size: 56, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + weight: 300, + ), + const SizedBox(height: 14), + Text( + 'Выберите чат', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index fa35339..9617e37 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -4,7 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; -import '../screens/chats/chat_list_screen.dart'; +import 'adaptive_shell.dart'; Future precacheLoginAvatar( BuildContext context, @@ -118,7 +118,7 @@ class _LoginSuccessScreenState extends State PageRouteBuilder( transitionDuration: const Duration(milliseconds: 360), reverseTransitionDuration: const Duration(milliseconds: 200), - pageBuilder: (_, __, ___) => const ChatListScreen(), + pageBuilder: (_, __, ___) => const AdaptiveShell(), transitionsBuilder: (_, animation, __, child) { return FadeTransition( opacity: CurvedAnimation( diff --git a/lib/main.dart b/lib/main.dart index cbf9d76..74485e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,7 +33,7 @@ import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; -import 'frontend/screens/chats/chat_list_screen.dart'; +import 'frontend/widgets/adaptive_shell.dart'; import 'frontend/widgets/custom_notification.dart'; import 'frontend/widgets/theme_reveal.dart'; @@ -694,7 +694,7 @@ class _StartupScreenState extends State<_StartupScreen> { if (mounted) { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (_) => const ChatListScreen()), + MaterialPageRoute(builder: (_) => const AdaptiveShell()), ); } } From f781a5f3d03172e73fac8ada19fbba396eb643ef Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 18:09:29 +0300 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B8=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=BD=D0=B0=20=C2=AB=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 21 + lib/frontend/screens/auth/login_screen.dart | 48 +- .../screens/chats/chat_list_screen.dart | 39 ++ .../widgets/account_switcher_overlay.dart | 490 ++++++++++++++++++ 4 files changed, 582 insertions(+), 16 deletions(-) create mode 100644 lib/frontend/widgets/account_switcher_overlay.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 29c5661..67573bb 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -9,6 +9,7 @@ import '../../core/utils/logger.dart'; import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; +import 'messages.dart'; String _normalizeAuthPhone(String phone) { final digits = phone.replaceAll(RegExp(r'\D'), ''); @@ -925,14 +926,34 @@ class AccountModule { if (profile == null) { throw StateError('switchAccount: аккаунт $accountId не найден в базе'); } + final token = await TokenStorage.readToken(accountId); + if (token == null) { + throw StateError('switchAccount: нет токена для аккаунта $accountId'); + } + + try { + await _api.disconnect(); + } catch (_) {} await AppDatabase.setActiveAccount(accountId); await TokenStorage.setActiveAccount(accountId); + ContactCache.clear(); + TranscriptionCache.clear(); + await ContactsModule.primeCacheFromDb(accountId); + + try { + await _api.connect(); + } catch (_) {} + logger.i('Активный аккаунт переключён на $accountId'); return profile; } + Future> listAccounts() async { + return AppDatabase.loadAllProfiles(); + } + Future removeAccount(int accountId) async { await AppDatabase.deleteAccount(accountId); await TokenStorage.deleteAccount(accountId); diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index c64d751..037a5c8 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -664,23 +664,39 @@ class _LoginScreenState extends State { children: [ const SizedBox(height: 44), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - onPressed: () => _showSecurityOptions(context), - icon: Icon( - Symbols.admin_panel_settings, - color: cs.onSurfaceVariant, - weight: 400, - ), - ), - IconButton( - onPressed: _showLanguagePicker, - icon: Icon( - Symbols.language, - color: cs.onSurfaceVariant, - weight: 400, - ), + if (Navigator.canPop(context)) + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon( + Symbols.arrow_back, + color: cs.onSurfaceVariant, + weight: 400, + ), + ) + else + const SizedBox.shrink(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => _showSecurityOptions(context), + icon: Icon( + Symbols.admin_panel_settings, + color: cs.onSurfaceVariant, + weight: 400, + ), + ), + IconButton( + onPressed: _showLanguagePicker, + icon: Icon( + Symbols.language, + color: cs.onSurfaceVariant, + weight: 400, + ), + ), + ], ), ], ), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ec16901..d4f1347 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -14,6 +14,8 @@ import '../../widgets/custom_notification.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; +import '../auth/login_screen.dart'; +import '../../widgets/account_switcher_overlay.dart'; import '../../../backend/api.dart'; import '../../../core/utils/haptics.dart'; import '../../../backend/models/chat_folder.dart'; @@ -2285,8 +2287,12 @@ class _ChatListScreenState extends State final Duration opacityDur = instant ? Duration.zero : const Duration(milliseconds: 200); + final bool isSettings = index == 3; return GestureDetector( onTap: () => _onNavTabSelected(index), + onLongPressStart: isSettings + ? (details) => _openAccountSwitcher(details.globalPosition) + : null, behavior: HitTestBehavior.opaque, child: Center( child: FittedBox( @@ -2330,6 +2336,39 @@ class _ChatListScreenState extends State ); } + void _openAccountSwitcher(Offset point) { + Haptics.medium(); + final controller = AccountSwitcherController()..attach(point); + showAccountSwitcher( + context: context, + tapPoint: point, + controller: controller, + onSelected: (accountId) async { + controller.dispose(); + if (!mounted) return; + if (accountId == null) { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + return; + } + try { + await accountModule.switchAccount(accountId); + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось переключить аккаунт'); + return; + } + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + (route) => false, + ); + }, + ); + } + Widget _buildFabMenu() { return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/frontend/widgets/account_switcher_overlay.dart b/lib/frontend/widgets/account_switcher_overlay.dart new file mode 100644 index 0000000..983692c --- /dev/null +++ b/lib/frontend/widgets/account_switcher_overlay.dart @@ -0,0 +1,490 @@ +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/haptics.dart'; + +class AccountSwitcherController extends ChangeNotifier { + Offset? pointer; + Offset? initialPointer; + bool committed = false; + bool movedSignificantly = false; + bool _attached = false; + + void attach(Offset initial) { + if (_attached) return; + _attached = true; + initialPointer = initial; + pointer = initial; + GestureBinding.instance.pointerRouter.addGlobalRoute(_onPointerEvent); + } + + void _onPointerEvent(PointerEvent event) { + if (committed) return; + if (event is PointerMoveEvent) { + pointer = event.position; + if (initialPointer != null && + !movedSignificantly && + (event.position - initialPointer!).distance > 12) { + movedSignificantly = true; + } + notifyListeners(); + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + commit(); + } + } + + void commit() { + if (committed) return; + committed = true; + notifyListeners(); + } + + @override + void dispose() { + if (_attached) { + GestureBinding.instance.pointerRouter.removeGlobalRoute(_onPointerEvent); + _attached = false; + } + super.dispose(); + } +} + +typedef AccountSwitcherCallback = void Function(int? accountId); + +void showAccountSwitcher({ + required BuildContext context, + required Offset tapPoint, + required AccountSwitcherController controller, + required AccountSwitcherCallback onSelected, +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => _AccountSwitcherLayer( + tapPoint: tapPoint, + controller: controller, + onSelected: onSelected, + onDismiss: () { + if (entry.mounted) entry.remove(); + }, + ), + ); + overlay.insert(entry); +} + +class _AccountSwitcherLayer extends StatefulWidget { + final Offset tapPoint; + final AccountSwitcherController controller; + final AccountSwitcherCallback onSelected; + final VoidCallback onDismiss; + + const _AccountSwitcherLayer({ + required this.tapPoint, + required this.controller, + required this.onSelected, + required this.onDismiss, + }); + + @override + State<_AccountSwitcherLayer> createState() => _AccountSwitcherLayerState(); +} + +class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> + with SingleTickerProviderStateMixin { + static const double _menuWidth = 280.0; + static const double _itemHeight = 60.0; + static const double _addItemHeight = 54.0; + static const double _vPad = 8.0; + static const double _hMargin = 12.0; + + late final AnimationController _animController; + late final Animation _animation; + bool _closing = false; + + List _accounts = const []; + int? _activeId; + bool _loaded = false; + + int _hoveredIndex = -1; + Rect _menuRect = Rect.zero; + List _itemHitRects = const []; + bool _committedFired = false; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + reverseDuration: const Duration(milliseconds: 180), + ); + _animation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _animController.forward(); + widget.controller.addListener(_onControllerUpdate); + _loadAccounts(); + } + + Future _loadAccounts() async { + final accounts = await AppDatabase.loadAllProfiles(); + final activeId = await TokenStorage.getActiveAccountId(); + if (!mounted) return; + setState(() { + _accounts = accounts; + _activeId = activeId; + _loaded = true; + _computeGeometry(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); + } + + void _computeGeometry() { + final screen = MediaQuery.sizeOf(context); + final totalItems = _accounts.length; + final height = _vPad * 2 + totalItems * _itemHeight + _addItemHeight; + + double menuX = widget.tapPoint.dx - _menuWidth / 2; + menuX = menuX.clamp(_hMargin, screen.width - _menuWidth - _hMargin); + + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final maxBottom = screen.height - bottomInset - 88; + double menuBottom = maxBottom; + if (widget.tapPoint.dy - 20 < menuBottom) { + menuBottom = widget.tapPoint.dy - 20; + } + double menuY = menuBottom - height; + if (menuY < 24) menuY = 24; + + _menuRect = Rect.fromLTWH(menuX, menuY, _menuWidth, height); + _itemHitRects = [ + for (int i = 0; i < totalItems; i++) + Rect.fromLTWH( + menuX, + menuY + _vPad + i * _itemHeight, + _menuWidth, + _itemHeight, + ), + Rect.fromLTWH( + menuX, + menuY + _vPad + totalItems * _itemHeight, + _menuWidth, + _addItemHeight, + ), + ]; + } + + void _onControllerUpdate() { + if (!mounted || !_loaded) return; + final p = widget.controller.pointer; + if (p != null) { + final newHovered = _findItemAt(p); + if (newHovered != _hoveredIndex) { + if (newHovered != -1) Haptics.selection(); + setState(() => _hoveredIndex = newHovered); + } + } + if (widget.controller.committed && !_committedFired) { + _committedFired = true; + _onCommit(); + } + } + + int _findItemAt(Offset p) { + for (int i = 0; i < _itemHitRects.length; i++) { + if (_itemHitRects[i].contains(p)) return i; + } + return -1; + } + + void _onCommit() { + if (_hoveredIndex == -1) { + _close(); + return; + } + Haptics.medium(); + final isAddItem = _hoveredIndex == _accounts.length; + final id = isAddItem ? null : _accounts[_hoveredIndex].id; + if (!isAddItem && id == _activeId) { + _close(); + return; + } + final selected = id; + _close().then((_) => widget.onSelected(selected)); + } + + Future _close() async { + if (!mounted || _closing) return; + _closing = true; + try { + await _animController.reverse(); + } catch (_) {} + if (!mounted) return; + widget.onDismiss(); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerUpdate); + _animController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (ctx, _) { + final t = _animation.value.clamp(0.0, 1.0); + final blurSigma = 14.0 * t; + return GestureDetector( + onTap: _close, + behavior: HitTestBehavior.opaque, + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.22 * t), + ), + ), + ), + if (_loaded) _buildMenu(t), + ], + ), + ); + }, + ); + } + + Widget _buildMenu(double t) { + final cs = Theme.of(context).colorScheme; + final eased = Curves.easeOutCubic.transform(t); + final scale = 0.88 + 0.12 * eased; + return Positioned( + left: _menuRect.left, + top: _menuRect.top, + width: _menuRect.width, + height: _menuRect.height, + child: Opacity( + opacity: eased, + child: Transform.scale( + scale: scale, + alignment: Alignment.bottomCenter, + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + clipBehavior: Clip.antiAlias, + elevation: 10, + shadowColor: Colors.black.withValues(alpha: 0.4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: _vPad), + for (int i = 0; i < _accounts.length; i++) + _AccountRow( + profile: _accounts[i], + highlighted: _hoveredIndex == i, + active: _accounts[i].id == _activeId, + ), + _AddAccountRow( + highlighted: _hoveredIndex == _accounts.length, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _AccountRow extends StatelessWidget { + final ProfileData profile; + final bool highlighted; + final bool active; + + const _AccountRow({ + required this.profile, + required this.highlighted, + required this.active, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pillBg = cs.primary; + final onPill = cs.onPrimary; + final fg = highlighted ? onPill : cs.onSurface; + final subFg = highlighted + ? onPill.withValues(alpha: 0.8) + : cs.onSurfaceVariant; + final fullName = (profile.lastName != null && profile.lastName!.isNotEmpty) + ? '${profile.firstName} ${profile.lastName}' + : profile.firstName; + final phone = profile.phone == 0 ? '' : '+${profile.phone}'; + return SizedBox( + height: 60, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: highlighted ? pillBg : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: active + ? Border.all( + color: highlighted ? onPill : cs.primary, + width: 2, + ) + : null, + ), + child: ClipOval( + child: profile.baseUrl != null && profile.baseUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: profile.baseUrl!, + fit: BoxFit.cover, + memCacheWidth: 96, + memCacheHeight: 96, + errorWidget: (_, __, ___) => + _initialAvatar(cs, fullName, highlighted), + ) + : _initialAvatar(cs, fullName, highlighted), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + fullName.isNotEmpty ? fullName : 'Без имени', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (phone.isNotEmpty) + Text( + phone, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: subFg, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + if (active) + Icon( + Symbols.check_circle, + color: highlighted ? onPill : cs.primary, + size: 20, + ), + ], + ), + ), + ), + ); + } + + Widget _initialAvatar(ColorScheme cs, String name, bool highlighted) { + return Container( + color: highlighted ? cs.primaryContainer : cs.surfaceContainerHighest, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _AddAccountRow extends StatelessWidget { + final bool highlighted; + + const _AddAccountRow({required this.highlighted}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pillBg = cs.primary; + final onPill = cs.onPrimary; + final fg = highlighted ? onPill : cs.primary; + return SizedBox( + height: 54, + child: Padding( + padding: const EdgeInsets.fromLTRB(6, 0, 6, 6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: highlighted ? pillBg : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: highlighted + ? onPill.withValues(alpha: 0.18) + : cs.primary.withValues(alpha: 0.12), + ), + alignment: Alignment.center, + child: Icon(Symbols.add, color: fg, size: 20, weight: 500), + ), + const SizedBox(width: 14), + Text( + 'Добавить аккаунт', + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +}