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, + ), + ), + ], + ), + ), + ), + ); + } +}