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/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 6fb91eb..f74c9e0 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -25,6 +25,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 { @@ -53,6 +59,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 512a938..5a52c01 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; @@ -167,13 +167,27 @@ 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; Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (context) => const ChatListScreen()), + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), (route) => false, ); } catch (e) { 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/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index b38171e..a6716b0 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; @@ -40,13 +40,27 @@ 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; Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (context) => const ChatListScreen()), + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), (route) => false, ); } catch (e) { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 08ce89e..38fb4b9 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,11 +8,14 @@ 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'; 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'; @@ -57,7 +60,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(); @@ -2051,18 +2056,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), @@ -2276,8 +2288,12 @@ Navigator.push( 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( @@ -2321,6 +2337,39 @@ Navigator.push( ); } + 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/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index bfdefd3..77c61b9 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -59,6 +59,8 @@ class ChatScreen extends StatefulWidget { final String name; final String imageUrl; final String chatType; + final bool embedded; + final VoidCallback? onClose; const ChatScreen({ super.key, @@ -66,6 +68,8 @@ class ChatScreen extends StatefulWidget { required this.name, required this.imageUrl, required this.chatType, + this.embedded = false, + this.onClose, }); @override @@ -713,8 +717,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/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/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, + ), + ), + ], + ), + ), + ), + ); + } +} 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 new file mode 100644 index 0000000..9617e37 --- /dev/null +++ b/lib/frontend/widgets/login_success_screen.dart @@ -0,0 +1,442 @@ +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 'adaptive_shell.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 { + final ImageProvider? avatar; + + const LoginSuccessScreen({super.key, this.avatar}); + + @override + State createState() => _LoginSuccessScreenState(); +} + +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; + 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(); + _greeting = _greetings[math.Random().nextInt(_greetings.length)]; + _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 AdaptiveShell(), + 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); + final avatar = widget.avatar; + 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: avatar != null + ? ClipOval( + child: Image( + image: avatar, + fit: BoxFit.cover, + width: 120, + height: 120, + ), + ) + : 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: 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, + ), + ), + ), + ), + ); + } +} + +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; +} 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()), ); } }