From a2851b3681f04050892a95ab2561b77c451384c6 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 5 Jun 2026 13:11:28 +0700 Subject: [PATCH 1/8] =?UTF-8?q?=D0=92=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=D0=BE=D0=B9=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F.=20=D0=92=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B8=D0=BA=D1=81=20issue=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/transport/connection.dart | 24 ++++++++++++--------- lib/main.dart | 34 +++++++++++++++--------------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 9aa8869..afb9dd9 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -13,6 +13,8 @@ enum SocketState { disconnected, connecting, connected } /// Обёртка над TCP + TLS сокетом. /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { + static const Duration _defaultConnectTimeout = Duration(seconds: 15); + SecureSocket? _socket; StreamSubscription? _subscription; SocketState _state = SocketState.disconnected; @@ -86,28 +88,30 @@ class Connection { ProxySettings proxySettings, { Duration? timeout, }) async { + final connectTimeout = timeout ?? _defaultConnectTimeout; Socket socket; if (proxySettings.isEnabled) { final connector = ProxyConnector(proxySettings); - socket = await connector.connect(host, port); + socket = await connector.connect(host, port).timeout(connectTimeout); logger.i('Подключено через прокси ${proxySettings.type.name}'); } else { - socket = timeout == null - ? await Socket.connect(host, port) - : await Socket.connect(host, port, timeout: timeout); + socket = await Socket.connect(host, port, timeout: connectTimeout); } final allowInsecure = await TlsConfig.isInsecureAllowed(); if (allowInsecure) { logger.w( 'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM', ); - return SecureSocket.secure( - socket, - host: host, - onBadCertificate: (_) => true, - ); } - return SecureSocket.secure(socket, host: host); + final secured = allowInsecure + ? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true) + : SecureSocket.secure(socket, host: host); + try { + return await secured.timeout(connectTimeout); + } on TimeoutException { + socket.destroy(); + rethrow; + } } void write(Uint8List data) { diff --git a/lib/main.dart b/lib/main.dart index 89b0cdf..88a8c36 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,13 +91,6 @@ void main() async { final storiesFuture = AppStories.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); - await api.connect(); - - final packageInfo = await packageInfoFuture; - if (packageInfo.packageName == 'ru.oneme.app') { - await PushService.instance.init(api: api, account: accountModule); - } - final initialLocale = await localeFuture; await hapticsFuture; @@ -135,6 +128,15 @@ void main() async { initialAccentSeed: initialAccentSeed, ), ); + + unawaited(_initPushIfNeeded(packageInfoFuture)); +} + +Future _initPushIfNeeded(Future packageInfoFuture) async { + final packageInfo = await packageInfoFuture; + if (packageInfo.packageName == 'ru.oneme.app') { + await PushService.instance.init(api: api, account: accountModule); + } } class KometApp extends StatefulWidget { @@ -714,27 +716,25 @@ class _StartupScreenState extends State<_StartupScreen> { } Future _tryAutoLogin() async { + unawaited(api.connect()); + int? accountId = await TokenStorage.getActiveAccountId(); if (accountId == null || await TokenStorage.readToken(accountId) == null) { accountId = await _recoverActiveAccount(); } + if (!mounted) return; + if (accountId == null) { _goToLogin(); return; } - try { - await accountModule.login(accountId: accountId); - } catch (_) {} - - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const AdaptiveShell()), - ); - } + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + ); } Future _recoverActiveAccount() async { From e954e0bc3bec341c49ac643ba341c08024ee67f4 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 5 Jun 2026 14:11:41 +0700 Subject: [PATCH 2/8] =?UTF-8?q?=D1=8F=20=D0=B1=D0=B0=D0=BB=D0=B1=D0=B5?= =?UTF-8?q?=D1=81,=20=D1=84=D0=B8=D0=BA=D1=81=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 88a8c36..a1b476e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,6 +91,11 @@ void main() async { final storiesFuture = AppStories.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); + final packageInfo = await packageInfoFuture; + if (packageInfo.packageName == 'ru.oneme.app') { + await PushService.instance.init(api: api, account: accountModule); + } + final initialLocale = await localeFuture; await hapticsFuture; @@ -128,15 +133,6 @@ void main() async { initialAccentSeed: initialAccentSeed, ), ); - - unawaited(_initPushIfNeeded(packageInfoFuture)); -} - -Future _initPushIfNeeded(Future packageInfoFuture) async { - final packageInfo = await packageInfoFuture; - if (packageInfo.packageName == 'ru.oneme.app') { - await PushService.instance.init(api: api, account: accountModule); - } } class KometApp extends StatefulWidget { From 2daf384dadd11669300477c706099490be7ff843 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 6 Jun 2026 19:36:24 +0300 Subject: [PATCH 3/8] fix(spoof): persist and apply all spoofing fields --- lib/backend/api.dart | 21 ++++++-- lib/core/storage/spoofing_service.dart | 8 ++- .../screens/profile/spoof_screen.dart | 49 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 4221c4f..a3fb613 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -179,6 +179,9 @@ class Api { String locale = 'ru'; String deviceLocale = Platform.localeName.substring(0, 2); String deviceId = await DeviceIdentity.deviceId(); + String pushDeviceType = 'GCM'; + String instanceId = await DeviceIdentity.instanceId(); + int clientSessionId = DeviceIdentity.clientSessionId; if (Platform.isLinux) { final linuxInfo = await deviceInfo.linuxInfo; @@ -223,6 +226,10 @@ class Api { locale = sLocale; deviceLocale = sLocale.split(RegExp(r'[-_]')).first; } + final sDeviceLocale = spoofed['device_locale'] as String?; + if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) { + deviceLocale = sDeviceLocale; + } final sDeviceId = spoofed['device_id'] as String?; if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId; appVersion = (spoofed['app_version'] as String?) ?? appVersion; @@ -233,6 +240,14 @@ class Api { } else if (sBuild is String) { buildNumber = int.tryParse(sBuild) ?? buildNumber; } + final sPushType = spoofed['push_device_type'] as String?; + if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType; + final sInstanceId = spoofed['instance_id'] as String?; + if (sInstanceId != null && sInstanceId.isNotEmpty) { + instanceId = sInstanceId; + } + final sClientSession = spoofed['client_session_id']; + if (sClientSession is int) clientSessionId = sClientSession; } _userAgent = { @@ -241,7 +256,7 @@ class Api { 'osVersion': osVersion, 'timezone': timezone, 'screen': screen, - 'pushDeviceType': 'GCM', + 'pushDeviceType': pushDeviceType, 'arch': architecture, 'locale': locale, 'buildNumber': buildNumber, @@ -252,9 +267,9 @@ class Api { _deviceId = deviceId; final payload = { - 'mt_instanceid': await DeviceIdentity.instanceId(), + 'mt_instanceid': instanceId, 'userAgent': _userAgent, - 'clientSessionId': DeviceIdentity.clientSessionId, + 'clientSessionId': clientSessionId, 'deviceId': deviceId, }; diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index b5e8c57..a51890b 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -16,11 +16,15 @@ class SpoofingService { 'screen': prefs.getString('spoof_screen'), 'timezone': prefs.getString('spoof_timezone'), 'locale': prefs.getString('spoof_locale'), + 'device_locale': prefs.getString('spoof_devicelocale'), 'device_id': prefs.getString('spoof_deviceid'), 'device_type': prefs.getString('spoof_devicetype'), - 'app_version': hardcodedAppVersion, + 'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion, 'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a', - 'build_number': hardcodedBuildNumber, + 'build_number': prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber, + 'instance_id': prefs.getString('spoof_instanceid'), + 'client_session_id': prefs.getInt('spoof_clientsessionid'), + 'push_device_type': prefs.getString('spoof_pushdevicetype'), }; } } diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index 9eef76d..48434c0 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -103,6 +103,21 @@ class _SpoofScreenState extends State { _buildNumberController.text = prefs.getInt('spoof_buildnumber')?.toString() ?? '$_hardcodedBuildNumber'; + _pushDeviceTypeController.text = + prefs.getString('spoof_pushdevicetype') ?? 'GCM'; + + final savedDeviceLocale = prefs.getString('spoof_devicelocale'); + if (savedDeviceLocale != null && savedDeviceLocale.isNotEmpty) { + _deviceLocaleController.text = savedDeviceLocale; + } + final savedInstanceId = prefs.getString('spoof_instanceid'); + if (savedInstanceId != null && savedInstanceId.isNotEmpty) { + _instanceIdController.text = savedInstanceId; + } + final savedClientSessionId = prefs.getInt('spoof_clientsessionid'); + if (savedClientSessionId != null) { + _clientSessionIdController.text = '$savedClientSessionId'; + } String savedType = prefs.getString('spoof_devicetype') ?? 'ANDROID'; if (savedType == 'WEB') savedType = 'ANDROID'; @@ -235,6 +250,13 @@ class _SpoofScreenState extends State { 'device_id': prefs.getString('spoof_deviceid') ?? '', 'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID', 'arch': prefs.getString('spoof_arch') ?? '', + 'device_locale': prefs.getString('spoof_devicelocale') ?? '', + 'app_version': prefs.getString('spoof_appversion') ?? '', + 'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '', + 'push_device_type': prefs.getString('spoof_pushdevicetype') ?? '', + 'instance_id': prefs.getString('spoof_instanceid') ?? '', + 'client_session_id': + prefs.getInt('spoof_clientsessionid')?.toString() ?? '', }; final newValues = { @@ -246,6 +268,12 @@ class _SpoofScreenState extends State { 'device_id': _deviceIdController.text, 'device_type': _selectedDeviceType, 'arch': _selectedArch, + 'device_locale': _deviceLocaleController.text, + 'app_version': _appVersionController.text, + 'build_number': _buildNumberController.text, + 'push_device_type': _pushDeviceTypeController.text, + 'instance_id': _instanceIdController.text, + 'client_session_id': _clientSessionIdController.text, }; bool otherDataChanged = false; @@ -358,6 +386,27 @@ class _SpoofScreenState extends State { await prefs.setString('spoof_deviceid', _deviceIdController.text); await prefs.setString('spoof_devicetype', _selectedDeviceType); await prefs.setString('spoof_arch', _selectedArch); + await prefs.setString('spoof_devicelocale', _deviceLocaleController.text); + await prefs.setString('spoof_appversion', _appVersionController.text); + await prefs.setString( + 'spoof_pushdevicetype', + _pushDeviceTypeController.text, + ); + await prefs.setString('spoof_instanceid', _instanceIdController.text); + + final buildNumber = int.tryParse(_buildNumberController.text); + if (buildNumber != null) { + await prefs.setInt('spoof_buildnumber', buildNumber); + } else { + await prefs.remove('spoof_buildnumber'); + } + + final clientSessionId = int.tryParse(_clientSessionIdController.text); + if (clientSessionId != null) { + await prefs.setInt('spoof_clientsessionid', clientSessionId); + } else { + await prefs.remove('spoof_clientsessionid'); + } } void _generateNewDeviceId() { From 5b9aaf7d7eb344cf7d3125d4620104e0513bbd42 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 7 Jun 2026 13:07:47 +0700 Subject: [PATCH 4/8] =?UTF-8?q?=D1=86=D1=8D=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=D1=85=D1=83=D0=B9=D0=BD=D1=8E=D1=88=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20=D0=B3=D0=B4=D0=B5?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 4 + lib/core/media/gallery_source.dart | 162 ++++ .../screens/chats/chat_list_screen.dart | 780 ++++++++---------- lib/frontend/screens/chats/chat_screen.dart | 20 +- .../widgets/attachment/attachment_sheet.dart | 701 ++++++++++++++++ lib/frontend/widgets/sliding_pill_nav.dart | 209 +++++ pubspec.lock | 16 +- pubspec.yaml | 1 + 8 files changed, 1445 insertions(+), 448 deletions(-) create mode 100644 lib/core/media/gallery_source.dart create mode 100644 lib/frontend/widgets/attachment/attachment_sheet.dart create mode 100644 lib/frontend/widgets/sliding_pill_nav.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2735665..4ab092f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + thumbnail(int size); + Future originFile(); +} + +abstract class GallerySource { + Future ensurePermission(); + Future> load({int limit}); + Future openSettings(); + Future manageAccess(); + + factory GallerySource.create() { + if (Platform.isAndroid || Platform.isIOS) { + return _PhotoManagerSource(); + } + return _DesktopGallerySource(); + } +} + +class _PhotoManagerSource implements GallerySource { + @override + Future ensurePermission() async { + final state = await PhotoManager.requestPermissionExtend(); + if (state.isAuth) return GalleryPermission.granted; + if (state.hasAccess) return GalleryPermission.limited; + return GalleryPermission.denied; + } + + @override + Future> load({int limit = 120}) async { + final paths = await PhotoManager.getAssetPathList( + type: RequestType.common, + onlyAll: true, + filterOption: FilterOptionGroup( + orders: const [ + OrderOption(type: OrderOptionType.createDate, asc: false), + ], + ), + ); + if (paths.isEmpty) return const []; + final assets = await paths.first.getAssetListRange(start: 0, end: limit); + return assets.map((a) => _AssetGalleryItem(a)).toList(); + } + + @override + Future openSettings() => PhotoManager.openSetting(); + + @override + Future manageAccess() => PhotoManager.presentLimited(); +} + +class _AssetGalleryItem implements GalleryItem { + final AssetEntity asset; + + _AssetGalleryItem(this.asset); + + @override + String get id => asset.id; + + @override + bool get isVideo => asset.type == AssetType.video; + + @override + Duration? get duration => isVideo ? Duration(seconds: asset.duration) : null; + + @override + File? get localFile => null; + + @override + Future thumbnail(int size) => + asset.thumbnailDataWithSize(ThumbnailSize.square(size)); + + @override + Future originFile() => asset.file; +} + +class _DesktopGallerySource implements GallerySource { + static const _imageExtensions = { + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif', + }; + + @override + Future ensurePermission() async => + GalleryPermission.granted; + + @override + Future> load({int limit = 120}) async { + final entries = <({File file, DateTime modified})>[]; + for (final dir in _candidateDirs()) { + if (!dir.existsSync()) continue; + try { + for (final entity in dir.listSync(followLinks: false)) { + if (entity is! File || !_isImage(entity.path)) continue; + entries.add((file: entity, modified: entity.statSync().modified)); + } + } catch (_) {} + } + entries.sort((a, b) => b.modified.compareTo(a.modified)); + return entries + .take(limit) + .map((e) => _FileGalleryItem(e.file)) + .toList(); + } + + @override + Future openSettings() async {} + + @override + Future manageAccess() async {} + + List _candidateDirs() { + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null || home.isEmpty) return const []; + return [ + Directory('$home/Pictures'), + Directory('$home/Изображения'), + Directory('$home/Images'), + ]; + } + + bool _isImage(String path) { + final dot = path.lastIndexOf('.'); + if (dot < 0) return false; + return _imageExtensions.contains(path.substring(dot).toLowerCase()); + } +} + +class _FileGalleryItem implements GalleryItem { + final File file; + + _FileGalleryItem(this.file); + + @override + String get id => file.path; + + @override + bool get isVideo => false; + + @override + Duration? get duration => null; + + @override + File? get localFile => file; + + @override + Future thumbnail(int size) async => null; + + @override + Future originFile() async => file; +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ddd75c1..659fb9a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -11,6 +11,7 @@ import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; +import '../../widgets/sliding_pill_nav.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -82,6 +83,17 @@ class _ChatListScreenState extends State int _currentNavIndex = 0; + static const List _chatsNavItems = [ + PillNavItem(icon: Symbols.chat_bubble, label: 'Чаты'), + PillNavItem(icon: Symbols.call, label: 'Звонки'), + PillNavItem(icon: Symbols.person_pin, label: 'Контакты'), + PillNavItem( + icon: Symbols.settings, + label: 'Настройки', + longPressable: true, + ), + ]; + double _navPageAnimStart = 0; double _navPageAnimEnd = 0; final ValueNotifier _navDragDx = ValueNotifier(0); @@ -255,14 +267,20 @@ class _ChatListScreenState extends State final myId = _profile?.id; if (myId == null) return; - await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList()); + await ChatsModule.refreshChats( + api, + selectedBefore.map((c) => c.id).toList(), + ); if (!mounted) return; final selectedAfter = _selectedChatObjects(); if (selectedAfter.isEmpty) return; final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet(); if (cats.contains(_DeleteKind.blocked) || cats.length > 1) { - showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз'); + showCustomNotification( + context, + 'Статус чатов изменился, попробуйте ещё раз', + ); return; } final kind = cats.single; @@ -561,7 +579,9 @@ class _ChatListScreenState extends State if (mounted) { setState(() { _profile = p; - _chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList(); + _chats = chats + .where((c) => !CloudStorageModule.isCloudStorageGroup(c)) + .toList(); _folders = folders; _foldersListKnown = foldersKnown; if (_selectedFolderId != null && @@ -674,8 +694,10 @@ class _ChatListScreenState extends State final Map> _pageChatsCache = {}; List _chatsForPageIndex(int pageIndex) { - final baseKey = - Object.hash(identityHashCode(_chats), identityHashCode(_folders)); + final baseKey = Object.hash( + identityHashCode(_chats), + identityHashCode(_folders), + ); if (_pageChatsBaseKey != baseKey) { _pageChatsBaseKey = baseKey; _pageChatsCache.clear(); @@ -692,7 +714,9 @@ class _ChatListScreenState extends State final folder = _folders[pageIndex]; base = FoldersModule.isAllChatsFolder(folder) ? _chats - : _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList(); + : _chats + .where((c) => FoldersModule.chatMatchesFolder(c, folder)) + .toList(); } final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); @@ -1087,163 +1111,167 @@ class _ChatListScreenState extends State child: _shouldCollapseSearch ? const SizedBox(width: double.infinity, height: 52) : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (AppStories.current.value && - _pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only(right: 8), - child: Stack( - children: [ - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (AppStories.current.value && + _pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: Stack( + children: [ + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), ), ), - ), - Text( - _sessionState == SessionState.online - ? (_profile?.firstName ?? 'Чат') - : 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( - 2, - 'Кнопка 2', - Symbols.notifications, - ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, - ), - ], - ), - ], - ), - ), - if (AppStories.current.value) - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, + Text( + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], ), ], ), ), - ), - ], - ), + if (AppStories.current.value) + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + children: [ + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + ], + ), ), ), ), @@ -1388,11 +1416,15 @@ class _ChatListScreenState extends State ); } - final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index; + final chatIndex = hasSeparator && index > pinnedCount + ? index - 1 + : index; final chat = chats[chatIndex]; final isPinned = (chat.favIndex ?? 0) > 0; - if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { + if (chat.type.isNotEmpty && + chat.type == "DIALOG" && + chat.id != 0) { int secondId = _profile?.id ?? 0; for (final entry in chat.participants.entries) { if (entry.key != _profile?.id) { @@ -1404,7 +1436,8 @@ class _ChatListScreenState extends State final avatar = ContactCache.getAvatar(secondId); // ContactCache.isOfficial covers contacts loaded via opcode 32; // chat.isOfficial covers contacts from the login payload. - final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; + final isVerified = + ContactCache.isOfficial(secondId) || chat.isOfficial; final isPlaceholder = chat.lastMsgText == ChatsModule.lastMsgPlaceholder; @@ -1531,34 +1564,15 @@ class _ChatListScreenState extends State double navInnerW, double bottomInset, ) { - final totalWeight = 5.2; - final unitWidth = navInnerW / totalWeight; - final activeWidth = unitWidth * 2.2; - final inactiveWidth = unitWidth * 1.0; + final geometry = PillNavGeometry.fromInnerWidth(navInnerW, 4); + final inactiveWidth = geometry.inactiveWidth; + final bubbleW = geometry.activeWidth - 8; - double bubbleLeftForIndex(int index) { - double lo = 0; - for (int i = 0; i < index; i++) { - lo += inactiveWidth; - } - return lo + 4; - } + double bubbleLeftForIndex(int index) => index * inactiveWidth + 4; - final leftOffset = bubbleLeftForIndex(_currentNavIndex); - final bubbleW = activeWidth - 8; final minBubbleLeft = bubbleLeftForIndex(0); final maxBubbleLeft = bubbleLeftForIndex(3); - double navInterpolatedWidth(int tabIndex, double rowT) { - final rt = rowT.clamp(0.0, 3.0); - final i0 = rt.floor().clamp(0, 3); - final i1 = rt.ceil().clamp(0, 3); - final frac = i0 == i1 ? 0.0 : (rt - i0); - double at(int sel, int tab) => - (tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5); - return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac; - } - int indexForBubbleLeft(double left) { final cx = left + bubbleW / 2; var best = 0; @@ -1581,140 +1595,68 @@ class _ChatListScreenState extends State right: 8, bottom: _isSelectionMode ? -100 : bottomInset + 10.0, child: RepaintBoundary( - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (_) { - if (_isSelectionMode) return; - _navPageAnimController.stop(); - _navPageAnimController.value = 1.0; - _navDragDx.value = 0; - setState(() { - _navDragging = true; - _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); - }); - }, - onHorizontalDragUpdate: (details) { - if (!_navDragging) return; - _navDragDx.value += details.delta.dx; - }, - onHorizontalDragEnd: (_) { - if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx.value).clamp( - minBubbleLeft, - maxBubbleLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + if (_isSelectionMode) return; + _navPageAnimController.stop(); + _navPageAnimController.value = 1.0; + _navDragDx.value = 0; + setState(() { + _navDragging = true; + _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); + }); + }, + onHorizontalDragUpdate: (details) { + if (!_navDragging) return; + _navDragDx.value += details.delta.dx; + }, + onHorizontalDragEnd: (_) { + if (!_navDragging) return; + final left = (_navDragBaseLeft + _navDragDx.value).clamp( + minBubbleLeft, + maxBubbleLeft, + ); + final next = indexForBubbleLeft(left); + _navDragDx.value = 0; + setState(() { + _currentNavIndex = next; + _navDragging = false; + }); + }, + onHorizontalDragCancel: () { + if (!_navDragging) return; + _navDragDx.value = 0; + setState(() { + _navDragging = false; + }); + }, + child: ValueListenableBuilder( + valueListenable: _navDragDx, + builder: (context, navDragDx, _) { + final position = _navDragging + ? ((_navDragBaseLeft + navDragDx).clamp( + minBubbleLeft, + maxBubbleLeft, + ) - + 4) / + inactiveWidth + : _currentNavIndex.toDouble(); + return SlidingPillNav( + items: _chatsNavItems, + position: position, + animationDuration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + geometry: geometry, + iconSize: 20, + labelGap: 4, + onTap: _onNavTabSelected, + onItemLongPress: (index, pos) { + if (index == 3) _openAccountSwitcher(pos); + }, ); - final next = indexForBubbleLeft(left); - _navDragDx.value = 0; - setState(() { - _currentNavIndex = next; - _navDragging = false; - }); }, - onHorizontalDragCancel: () { - if (!_navDragging) return; - _navDragDx.value = 0; - setState(() { - _navDragging = false; - }); - }, - child: ValueListenableBuilder( - valueListenable: _navDragDx, - builder: (context, navDragDx, _) { - final bubbleLeft = _navDragging - ? (_navDragBaseLeft + navDragDx) - .clamp(minBubbleLeft, maxBubbleLeft) - : leftOffset; - final navRowT = - ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); - return Stack( - clipBehavior: Clip.hardEdge, - children: [ - AnimatedPositioned( - duration: _navDragging - ? Duration.zero - : const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: bubbleLeft, - top: 8, - bottom: 8, - width: bubbleW, - child: Container( - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(26), - ), - ), - ), - SizedBox( - width: navInnerW, - child: Row( - children: List.generate(4, (index) { - IconData icon; - String label; - switch (index) { - case 0: - icon = Symbols.chat_bubble; - label = 'Чаты'; - break; - case 1: - icon = Symbols.call; - label = 'Звонки'; - break; - case 2: - icon = Symbols.person_pin; - label = 'Контакты'; - break; - default: - icon = Symbols.settings; - label = 'Настройки'; - } - - final isSelected = _currentNavIndex == index; - final visualSel = navRowT.round().clamp(0, 3); - return AnimatedContainer( - duration: _navDragging - ? Duration.zero - : const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - width: _navDragging - ? navInterpolatedWidth(index, navRowT) - : (isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5)), - child: ClipRRect( - borderRadius: BorderRadius.circular(26), - child: _buildNavItem( - index, - icon, - label, - selectedOverride: _navDragging - ? (index == visualSel) - : null, - instant: _navDragging, - ), - ), - ); - }), - ), - ), - ], - ); - }, - ), ), ), ), @@ -1759,8 +1701,10 @@ class _ChatListScreenState extends State width: pageW * 4, height: pageH, child: AnimatedBuilder( - animation: Listenable.merge( - [_navPageAnimController, _navDragDx]), + animation: Listenable.merge([ + _navPageAnimController, + _navDragDx, + ]), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1902,53 +1846,64 @@ class _ChatListScreenState extends State ), ], ), - child: Builder(builder: (_) { - final selected = _selectedChatObjects(); - final deleteCategory = _selectionDeleteCategoryFor(selected); - final anyMuted = selected.any((c) => c.isMuted); - final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0); - return Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - if (deleteCategory != null) + child: Builder( + builder: (_) { + final selected = _selectedChatObjects(); + final deleteCategory = _selectionDeleteCategoryFor( + selected, + ); + final anyMuted = selected.any((c) => c.isMuted); + final anyPinned = selected.any( + (c) => (c.favIndex ?? 0) > 0, + ); + return Row( + children: [ IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: _onDeleteTap, + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + ), + onPressed: _clearSelection, ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon( - anyPinned ? Symbols.keep_off : Symbols.keep, - color: cs.onSurface, + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), - onPressed: selected.isEmpty ? null : _onPinTap, - ), - IconButton( - icon: Icon( - anyMuted ? Symbols.volume_up : Symbols.volume_off, - color: cs.onSurface, + const Spacer(), + if (deleteCategory != null) + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: _onDeleteTap, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, ), - onPressed: selected.isEmpty ? null : _onMuteTap, - ), - ], - ); - }), + IconButton( + icon: Icon( + anyPinned ? Symbols.keep_off : Symbols.keep, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onPinTap, + ), + IconButton( + icon: Icon( + anyMuted + ? Symbols.volume_up + : Symbols.volume_off, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onMuteTap, + ), + ], + ); + }, + ), ), ), ], @@ -1981,7 +1936,11 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 26, - backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), ), ), const SizedBox(height: 6), @@ -2114,18 +2073,26 @@ class _ChatListScreenState extends State return; } if (imageUrl.isNotEmpty) { - unawaited(precacheImage( - CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), - context, - )); + unawaited( + precacheImage( + CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + context, + ), + ); } if (widget.onChatSelected != null) { - widget.onChatSelected!(DesktopChatSelection( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - )); + widget.onChatSelected!( + DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + ), + ); } else { pushSwipeable( context, @@ -2155,7 +2122,11 @@ class _ChatListScreenState extends State radius: 24, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty - ? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144) + ? CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ) : null, child: imageUrl.isEmpty ? Text( @@ -2337,71 +2308,6 @@ class _ChatListScreenState extends State ); } - Widget _buildNavItem( - int index, - IconData icon, - String label, { - bool? selectedOverride, - bool instant = false, - }) { - final cs = Theme.of(context).colorScheme; - final bool isSelected = selectedOverride ?? (_currentNavIndex == index); - final Duration animDur = instant - ? Duration.zero - : const Duration(milliseconds: 350); - final Duration opacityDur = instant - ? Duration.zero - : const Duration(milliseconds: 200); - final bool isSettings = index == 3; - return GestureDetector( - onTap: () => _onNavTabSelected(index), - onLongPressStart: isSettings - ? (details) => _openAccountSwitcher(details.globalPosition) - : null, - behavior: HitTestBehavior.opaque, - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - color: isSelected ? cs.onPrimary : cs.onSurface, - size: 20, - fill: 1, - ), - AnimatedContainer( - duration: animDur, - curve: Curves.easeOutCubic, - width: isSelected ? null : 0, - child: AnimatedOpacity( - duration: opacityDur, - opacity: isSelected ? 1.0 : 0.0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - color: cs.onPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } - void _openAccountSwitcher(Offset point) { Haptics.medium(); final controller = AccountSwitcherController()..attach(point); @@ -2537,7 +2443,11 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 12, - backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), ), ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 35981c2..c3fa1f5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -29,6 +29,7 @@ import '../../widgets/message_bubble.dart'; import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; +import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/swipe_to_pop.dart'; class _UploadStatus { @@ -1526,7 +1527,7 @@ class _ChatScreenState extends State ), _AttachButton( hasText: _hasText, - panelOpen: _showAttachmentPanel, + onOpen: _openAttachmentSheet, uploadStatus: _uploadStatus, mutedIcon: mutedIcon, cs: cs, @@ -1712,6 +1713,10 @@ class _ChatScreenState extends State } } + void _openAttachmentSheet() { + showAttachmentSheet(context); + } + Future _pickAndUploadFile() async { final result = await FilePicker.platform.pickFiles(); if (result == null || result.files.isEmpty) return; @@ -1820,14 +1825,14 @@ class _ChatScreenState extends State class _AttachButton extends StatelessWidget { final ValueNotifier hasText; - final ValueNotifier panelOpen; + final VoidCallback onOpen; final ValueNotifier<_UploadStatus> uploadStatus; final Color mutedIcon; final ColorScheme cs; const _AttachButton({ required this.hasText, - required this.panelOpen, + required this.onOpen, required this.uploadStatus, required this.mutedIcon, required this.cs, @@ -1836,19 +1841,16 @@ class _AttachButton extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: Listenable.merge([hasText, panelOpen, uploadStatus]), + listenable: Listenable.merge([hasText, uploadStatus]), builder: (context, _) { final isText = hasText.value; - final open = panelOpen.value; final status = uploadStatus.value; final iconColor = status.awaitingResponse ? cs.primary - : (status.active || open + : (status.active ? cs.onSurfaceVariant.withValues(alpha: 0.5) : mutedIcon); - final onTap = (isText || status.active || open) - ? null - : () => panelOpen.value = true; + final onTap = (isText || status.active) ? null : onOpen; return AnimatedContainer( duration: const Duration(milliseconds: 200), width: isText ? 0 : 36, diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart new file mode 100644 index 0000000..81d5bd6 --- /dev/null +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -0,0 +1,701 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/core/media/gallery_source.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; + +const List _navItems = [ + PillNavItem(icon: Symbols.image, label: 'Галерея'), + PillNavItem(icon: Symbols.description, label: 'Файл'), + PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'), + PillNavItem(icon: Symbols.person, label: 'Контакт'), +]; + +Future showAttachmentSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.45), + builder: (_) => const AttachmentSheet(), + ); +} + +class AttachmentSheet extends StatefulWidget { + const AttachmentSheet({super.key}); + + @override + State createState() => _AttachmentSheetState(); +} + +class _AttachmentSheetState extends State { + final GallerySource _source = GallerySource.create(); + final ValueNotifier> _selected = ValueNotifier({}); + final PageController _pageController = PageController(); + + bool _navDragging = false; + double _navDragBasePageT = 0; + double _navDragAccumDx = 0; + + bool _loading = true; + GalleryPermission _permission = GalleryPermission.granted; + List _items = const []; + + @override + void initState() { + super.initState(); + _loadGallery(); + } + + @override + void dispose() { + _pageController.dispose(); + _selected.dispose(); + super.dispose(); + } + + Future _loadGallery() async { + setState(() => _loading = true); + final permission = await _source.ensurePermission(); + if (!mounted) return; + if (permission == GalleryPermission.denied) { + setState(() { + _permission = permission; + _items = const []; + _loading = false; + }); + return; + } + final items = await _source.load(limit: 120); + if (!mounted) return; + setState(() { + _permission = permission; + _items = items; + _loading = false; + }); + } + + void _toggleSelection(GalleryItem item) { + final next = Set.from(_selected.value); + if (!next.remove(item.id)) next.add(item.id); + _selected.value = next; + } + + void _onSectionTap(int index) { + _pageController.animateToPage( + index, + duration: _navAnim, + curve: Curves.easeOutCubic, + ); + } + + void _onCameraTap() { + showCustomNotification(context, 'Камера скоро появится'); + } + + void _onSend() { + final count = _selected.value.length; + final overlay = Overlay.of(context, rootOverlay: true); + Navigator.of(context).pop(); + showCustomNotificationOnOverlay( + overlay, + 'Отправка $count выбранных скоро появится', + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return DraggableScrollableSheet( + initialChildSize: 0.62, + minChildSize: 0.4, + maxChildSize: 0.94, + expand: false, + snap: true, + snapSizes: const [0.62, 0.94], + builder: (context, scrollController) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final barReserve = _barHeight + bottomInset; + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _buildHandle(cs), + Expanded( + child: Stack( + children: [ + _buildPages(scrollController, cs, barReserve), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomBar(), + ), + Positioned( + right: 16, + bottom: barReserve + 8, + child: AnimatedBuilder( + animation: Listenable.merge([ + _selected, + _pageController, + ]), + builder: (context, _) { + final count = _selected.value.length; + final galleryT = (1 - _currentPageT()).clamp( + 0.0, + 1.0, + ); + if (count == 0 || galleryT == 0) { + return const SizedBox.shrink(); + } + return Opacity( + opacity: galleryT, + child: IgnorePointer( + ignoring: galleryT < 0.5, + child: _buildSendButton(cs, count), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + static const double _pillMargin = 10; + static const double _barHeight = SlidingPillNav.height + _pillMargin; + static const Duration _navAnim = Duration(milliseconds: 300); + + Widget _buildHandle(ColorScheme cs) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 10), + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ); + } + + Widget _buildPages( + ScrollController scrollController, + ColorScheme cs, + double bottomReserve, + ) { + return PageView( + controller: _pageController, + children: [ + _KeepAlivePage( + child: _buildGalleryPage(scrollController, cs, bottomReserve), + ), + _buildPlaceholderPage(cs, bottomReserve), + _buildPlaceholderPage(cs, bottomReserve), + _buildPlaceholderPage(cs, bottomReserve), + ], + ); + } + + Widget _buildGalleryPage( + ScrollController scrollController, + ColorScheme cs, + double bottomReserve, + ) { + if (_loading) { + return Center(child: CircularProgressIndicator(color: cs.primary)); + } + if (_permission == GalleryPermission.denied) { + return _buildDenied(scrollController, cs, bottomReserve); + } + if (_items.isEmpty) { + return _buildMessage( + scrollController, + cs, + 'Изображений не найдено', + bottomReserve, + ); + } + + return CustomScrollView( + controller: scrollController, + slivers: [ + if (_permission == GalleryPermission.limited) + SliverToBoxAdapter(child: _buildLimitedBanner(cs)), + SliverPadding( + padding: EdgeInsets.fromLTRB(2, 2, 2, bottomReserve + 6), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + ), + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) return _CameraTile(onTap: _onCameraTap, cs: cs); + final item = _items[index - 1]; + return _GalleryTile( + key: ValueKey(item.id), + item: item, + selectedIds: _selected, + onTap: () => _toggleSelection(item), + cs: cs, + ); + }, childCount: _items.length + 1), + ), + ), + ], + ); + } + + Widget _buildLimitedBanner(ColorScheme cs) { + return InkWell( + onTap: () => _source.manageAccess().then((_) => _loadGallery()), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: cs.surfaceContainerHighest, + child: Row( + children: [ + Icon(Symbols.info, size: 18, color: cs.onSurfaceVariant), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Доступны не все фото', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + Text( + 'Изменить', + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } + + Widget _buildPlaceholderPage(ColorScheme cs, double bottomReserve) { + return Padding( + padding: EdgeInsets.only(bottom: bottomReserve), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.construction, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + 'Раздел в разработке', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + ], + ), + ), + ); + } + + Widget _buildDenied( + ScrollController scrollController, + ColorScheme cs, + double bottomReserve, + ) { + return _scrollableCenter( + scrollController, + bottomReserve, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.no_photography, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + 'Нет доступа к галерее', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + 'Разрешите доступ к фото, чтобы выбрать их отсюда', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: _loadGallery, + child: const Text('Разрешить'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => _source.openSettings(), + child: const Text('Настройки'), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildMessage( + ScrollController scrollController, + ColorScheme cs, + String text, + double bottomReserve, + ) { + return _scrollableCenter( + scrollController, + bottomReserve, + Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), + ); + } + + Widget _scrollableCenter( + ScrollController scrollController, + double bottomReserve, + Widget child, + ) { + return CustomScrollView( + controller: scrollController, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.only(bottom: bottomReserve), + child: Center(child: child), + ), + ), + ], + ); + } + + Widget _buildSendButton(ColorScheme cs, int count) { + return Material( + color: cs.primary, + shape: const StadiumBorder(), + elevation: 3, + child: InkWell( + customBorder: const StadiumBorder(), + onTap: _onSend, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.send, color: cs.onPrimary, size: 22, weight: 500), + const SizedBox(width: 8), + Text( + '$count', + style: TextStyle( + color: cs.onPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } + + double _currentPageT() { + if (!_pageController.hasClients) return 0; + return _pageController.page ?? 0; + } + + void _onPillDragStart() { + _navDragging = true; + _navDragBasePageT = _currentPageT(); + _navDragAccumDx = 0; + } + + void _onPillDragUpdate(double dx, double inactiveWidth) { + if (!_navDragging || !_pageController.hasClients) return; + _navDragAccumDx += dx; + final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp( + 0.0, + 3.0, + ); + _pageController.jumpTo(pageT * _pageController.position.viewportDimension); + } + + void _onPillDragEnd() { + if (!_navDragging) return; + _navDragging = false; + final target = _currentPageT().round().clamp(0, 3); + _pageController.animateToPage( + target, + duration: _navAnim, + curve: Curves.easeOutCubic, + ); + } + + Widget _buildBottomBar() { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin), + child: LayoutBuilder( + builder: (context, constraints) { + final geometry = PillNavGeometry.fromInnerWidth( + constraints.maxWidth - 4, + _navItems.length, + ); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) => _onPillDragStart(), + onHorizontalDragUpdate: (d) => + _onPillDragUpdate(d.delta.dx, geometry.inactiveWidth), + onHorizontalDragEnd: (_) => _onPillDragEnd(), + onHorizontalDragCancel: _onPillDragEnd, + child: AnimatedBuilder( + animation: _pageController, + builder: (context, _) { + return SlidingPillNav( + items: _navItems, + position: _currentPageT(), + geometry: geometry, + onTap: _onSectionTap, + ); + }, + ), + ); + }, + ), + ), + ); + } +} + +class _KeepAlivePage extends StatefulWidget { + final Widget child; + + const _KeepAlivePage({required this.child}); + + @override + State<_KeepAlivePage> createState() => _KeepAlivePageState(); +} + +class _KeepAlivePageState extends State<_KeepAlivePage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + +class _CameraTile extends StatelessWidget { + final VoidCallback onTap; + final ColorScheme cs; + + const _CameraTile({required this.onTap, required this.cs}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + color: cs.surfaceContainerHighest, + alignment: Alignment.center, + child: Icon( + Symbols.photo_camera, + size: 34, + color: cs.onSurface, + weight: 400, + ), + ), + ); + } +} + +class _GalleryTile extends StatefulWidget { + final GalleryItem item; + final ValueListenable> selectedIds; + final VoidCallback onTap; + final ColorScheme cs; + + const _GalleryTile({ + super.key, + required this.item, + required this.selectedIds, + required this.onTap, + required this.cs, + }); + + @override + State<_GalleryTile> createState() => _GalleryTileState(); +} + +class _GalleryTileState extends State<_GalleryTile> { + late bool _selected; + + @override + void initState() { + super.initState(); + _selected = widget.selectedIds.value.contains(widget.item.id); + widget.selectedIds.addListener(_onSelectionChanged); + } + + @override + void dispose() { + widget.selectedIds.removeListener(_onSelectionChanged); + super.dispose(); + } + + void _onSelectionChanged() { + final selected = widget.selectedIds.value.contains(widget.item.id); + if (selected != _selected) setState(() => _selected = selected); + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + return GestureDetector( + onTap: widget.onTap, + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedScale( + scale: _selected ? 0.86 : 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: _Thumbnail(item: item, cs: widget.cs), + ), + if (item.isVideo) + Positioned( + left: 6, + bottom: 6, + child: Row( + children: [ + Icon( + Symbols.play_arrow, + size: 16, + color: Colors.white, + fill: 1, + ), + if (item.duration != null) + Text( + _formatDuration(item.duration!), + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 3, color: Colors.black54)], + ), + ), + ], + ), + ), + Positioned( + top: 6, + right: 6, + child: _SelectionCheck(selected: _selected, cs: widget.cs), + ), + ], + ), + ); + } + + String _formatDuration(Duration d) { + final m = d.inMinutes; + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } +} + +class _SelectionCheck extends StatelessWidget { + final bool selected; + final ColorScheme cs; + + const _SelectionCheck({required this.selected, required this.cs}); + + @override + Widget build(BuildContext context) { + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25), + border: Border.all(color: Colors.white, width: 2), + ), + child: selected + ? Icon(Symbols.check, size: 16, color: cs.onPrimary, weight: 700) + : null, + ); + } +} + +class _Thumbnail extends StatefulWidget { + final GalleryItem item; + final ColorScheme cs; + + const _Thumbnail({required this.item, required this.cs}); + + @override + State<_Thumbnail> createState() => _ThumbnailState(); +} + +class _ThumbnailState extends State<_Thumbnail> { + static const int _pixelSize = 320; + Future? _future; + + @override + void initState() { + super.initState(); + if (widget.item.localFile == null) { + _future = widget.item.thumbnail(_pixelSize); + } + } + + @override + Widget build(BuildContext context) { + final file = widget.item.localFile; + if (file != null) { + return Image.file( + file, + fit: BoxFit.cover, + cacheWidth: _pixelSize, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) return _placeholder(); + return Image.memory( + data, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _placeholder(), + ); + }, + ); + } + + Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest); +} diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart new file mode 100644 index 0000000..def6650 --- /dev/null +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; + +class PillNavItem { + final IconData icon; + final String label; + final bool longPressable; + + const PillNavItem({ + required this.icon, + required this.label, + this.longPressable = false, + }); +} + +class PillNavGeometry { + final double navInnerW; + final double activeWidth; + final double inactiveWidth; + + const PillNavGeometry(this.navInnerW, this.activeWidth, this.inactiveWidth); + + factory PillNavGeometry.fromInnerWidth(double navInnerW, int itemCount) { + final totalWeight = (itemCount - 1) + _activeWeight; + final unit = navInnerW / totalWeight; + return PillNavGeometry(navInnerW, unit * _activeWeight, unit); + } + + static const double _activeWeight = 2.2; +} + +class SlidingPillNav extends StatelessWidget { + final List items; + final double position; + final Duration animationDuration; + final PillNavGeometry geometry; + final ValueChanged onTap; + final void Function(int index, Offset globalPosition)? onItemLongPress; + final double iconSize; + final double labelGap; + + const SlidingPillNav({ + super.key, + required this.items, + required this.position, + required this.geometry, + required this.onTap, + this.animationDuration = Duration.zero, + this.onItemLongPress, + this.iconSize = 22, + this.labelGap = 6, + }); + + static const double height = 68; + + double _interpWidth(int tab) { + final maxIndex = items.length - 1; + final rt = position.clamp(0.0, maxIndex.toDouble()); + final i0 = rt.floor(); + final i1 = rt.ceil(); + final frac = i0 == i1 ? 0.0 : rt - i0; + double at(int sel) => + (tab == sel ? geometry.activeWidth : geometry.inactiveWidth) - 0.5; + return at(i0) + (at(i1) - at(i0)) * frac; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final visualSel = position.round().clamp(0, items.length - 1); + return Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + AnimatedPositioned( + duration: animationDuration, + curve: Curves.easeOutCubic, + left: position * geometry.inactiveWidth + 4, + top: 8, + bottom: 8, + width: geometry.activeWidth - 8, + child: DecoratedBox( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(26), + ), + ), + ), + SizedBox( + width: geometry.navInnerW, + child: Row( + children: List.generate(items.length, (i) { + return AnimatedContainer( + duration: animationDuration, + curve: Curves.easeOutCubic, + width: _interpWidth(i), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _PillNavCell( + item: items[i], + selected: i == visualSel, + cs: cs, + animationDuration: animationDuration, + iconSize: iconSize, + labelGap: labelGap, + onTap: () => onTap(i), + onLongPress: + (onItemLongPress == null || !items[i].longPressable) + ? null + : (pos) => onItemLongPress!(i, pos), + ), + ), + ); + }), + ), + ), + ], + ), + ); + } +} + +class _PillNavCell extends StatelessWidget { + final PillNavItem item; + final bool selected; + final ColorScheme cs; + final Duration animationDuration; + final double iconSize; + final double labelGap; + final VoidCallback onTap; + final void Function(Offset globalPosition)? onLongPress; + + const _PillNavCell({ + required this.item, + required this.selected, + required this.cs, + required this.animationDuration, + required this.iconSize, + required this.labelGap, + required this.onTap, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final opacityDuration = animationDuration == Duration.zero + ? Duration.zero + : const Duration(milliseconds: 200); + return GestureDetector( + onTap: onTap, + onLongPressStart: onLongPress == null + ? null + : (d) => onLongPress!(d.globalPosition), + behavior: HitTestBehavior.opaque, + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + item.icon, + color: selected ? cs.onPrimary : cs.onSurface, + size: iconSize, + fill: 1, + ), + AnimatedContainer( + duration: animationDuration, + curve: Curves.easeOutCubic, + width: selected ? null : 0, + child: AnimatedOpacity( + duration: opacityDuration, + opacity: selected ? 1.0 : 0.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: labelGap), + Text( + item.label, + style: TextStyle( + color: cs.onPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9d4e8db..9bf17e2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -601,10 +601,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -749,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + photo_manager: + dependency: "direct main" + description: + name: photo_manager + sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2 + url: "https://pub.dev" + source: hosted + version: "3.9.0" platform: dependency: transitive description: @@ -982,10 +990,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ecc0898..3b6d708 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + photo_manager: ^3.0.0 image: ^4.3.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 From 628c809fd2179f460662a149220845a286b183ea Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 7 Jun 2026 14:06:35 +0700 Subject: [PATCH 5/8] =?UTF-8?q?=D0=93=D0=9E=D0=92=D0=9D=D0=9E=D0=A7=D0=98?= =?UTF-8?q?=D0=A1=D0=A2,=20=D0=93=D0=9E=D0=92=D0=9D=D0=9E=D0=A7=D0=98?= =?UTF-8?q?=D0=A1=D0=A2=20=D0=93=D0=9E=D0=92=D0=9D=D0=9E=D0=A7=D0=98=D0=A1?= =?UTF-8?q?=D0=A2.=20=D0=9E=D0=A5=20=D0=93=D0=9E=D0=92=D0=9D=D0=90=20?= =?UTF-8?q?=D0=AF=20=D0=9A=D0=9E=D0=9D=D0=95=D0=A7=D0=9D=D0=9E=20=D0=A3?= =?UTF-8?q?=D0=9D=D0=95=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 70 +- lib/core/utils/format.dart | 95 ++ lib/core/utils/image_utils.dart | 3 + lib/frontend/screens/auth/login_screen.dart | 48 +- .../screens/auth/proxy_settings_sheet.dart | 38 +- .../screens/auth/server_settings_sheet.dart | 44 +- lib/frontend/screens/calls/call_screen.dart | 24 +- lib/frontend/screens/calls/calls_tab.dart | 50 +- .../screens/chats/chat_info_screen.dart | 305 +++--- .../screens/chats/chat_list_screen.dart | 11 +- lib/frontend/screens/chats/chat_screen.dart | 943 ++++++++++-------- .../screens/chats/create_group_flow.dart | 79 +- .../contacts/contact_profile_screen.dart | 151 +-- .../screens/contacts/contacts_tab.dart | 69 +- .../screens/profile/cloud_storage_screen.dart | 250 +++-- .../screens/profile/debug_menu_screen.dart | 52 +- .../screens/profile/devices_screen.dart | 43 +- .../screens/profile/edit_profile_screen.dart | 45 +- lib/frontend/screens/profile/info_screen.dart | 82 +- .../screens/profile/notifications_screen.dart | 55 +- .../profile/password_entry_screen.dart | 60 +- .../screens/profile/performance_screen.dart | 43 +- .../screens/profile/security_screen.dart | 92 +- .../screens/profile/settings_tab.dart | 66 +- .../screens/profile/spoof_screen.dart | 32 +- .../widgets/account_switcher_overlay.dart | 41 +- .../widgets/attachment/attachment_sheet.dart | 24 +- lib/frontend/widgets/confirm_dialog.dart | 44 + lib/frontend/widgets/komet_avatar.dart | 58 ++ lib/frontend/widgets/message_bubble.dart | 453 +++++---- lib/frontend/widgets/section_header.dart | 32 + lib/frontend/widgets/sheet_helpers.dart | 30 + 32 files changed, 1827 insertions(+), 1605 deletions(-) create mode 100644 lib/core/utils/format.dart create mode 100644 lib/frontend/widgets/confirm_dialog.dart create mode 100644 lib/frontend/widgets/komet_avatar.dart create mode 100644 lib/frontend/widgets/section_header.dart create mode 100644 lib/frontend/widgets/sheet_helpers.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 6108dcb..e893153 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -5,6 +5,7 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; import '../../models/attachment.dart'; import 'chats.dart' show ChatsModule; @@ -24,7 +25,8 @@ class ContactCache { static String? get(int id) => _nameCache[id]; 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 bool isOfficial(int id) => + _optionsCache[id]?.contains('OFFICIAL') ?? false; static void clear() { _nameCache.clear(); @@ -81,13 +83,13 @@ class FileHistoryEntry { }); Map toJson() => { - 'fileId': fileId, - if (url != null) 'url': url, - if (token != null) 'token': token, - if (filename != null) 'filename': filename, - if (size != null) 'size': size, - 'sentAt': sentAt.millisecondsSinceEpoch, - }; + 'fileId': fileId, + if (url != null) 'url': url, + if (token != null) 'token': token, + if (filename != null) 'filename': filename, + if (size != null) 'size': size, + 'sentAt': sentAt.millisecondsSinceEpoch, + }; static FileHistoryEntry? fromJson(Map j) { final id = j['fileId']; @@ -108,8 +110,9 @@ class FileHistoryCache { static const _prefKey = 'file_history_v1'; static const _maxEntries = 50; - static final ValueNotifier> notifier = - ValueNotifier(const []); + static final ValueNotifier> notifier = ValueNotifier( + const [], + ); static List get history => notifier.value; static bool get isEmpty => notifier.value.isEmpty; @@ -135,7 +138,10 @@ class FileHistoryCache { } static void add(FileHistoryEntry entry) { - final next = [entry, ...notifier.value.where((e) => e.fileId != entry.fileId)]; + final next = [ + entry, + ...notifier.value.where((e) => e.fileId != entry.fileId), + ]; if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length); notifier.value = next; _persist(); @@ -223,15 +229,24 @@ class CachedMessage { return CachedMessage( id: row['id']?.toString() ?? '', - accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, - chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, - senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, + accountId: row['account_id'] is int + ? row['account_id'] as int + : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, + chatId: row['chat_id'] is int + ? row['chat_id'] as int + : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, + senderId: row['sender_id'] is int + ? row['sender_id'] as int + : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, text: row['text']?.toString(), - time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0, + time: row['time'] is int + ? row['time'] as int + : int.tryParse(row['time']?.toString() ?? '') ?? 0, status: row['status']?.toString(), payload: payload, attachments: attachments, - isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false, + isControl: + attachments?.any((a) => a.type == AttachmentType.control) ?? false, ); } @@ -252,8 +267,7 @@ class CachedMessage { if (attaches is List && attaches.isNotEmpty) { attachments = attaches .whereType() - .map((a) => - MessageAttachment.fromMap(Map.from(a))) + .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } return CachedMessage( @@ -327,7 +341,7 @@ class MessagesModule { if (rows.isNotEmpty) { AppDatabase.saveMessages(rows).catchError((e) { - debugPrint('saveMessages error: $e'); + logger.e('saveMessages error: $e'); }); } @@ -424,7 +438,9 @@ class MessagesModule { final response = await _api.sendRequest(Opcode.msgSend, payload); if (!response.isOk) { final msg = (response.payload is Map) - ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки') + ? (response.payload['localizedMessage'] ?? + response.payload['message'] ?? + 'Ошибка отправки') : 'Ошибка отправки'; throw Exception(msg.toString()); } @@ -460,7 +476,10 @@ class MessagesModule { if (transcriptionStatus == 1) { final text = data['transcription'] as String? ?? ''; if (text.isEmpty) { - return TranscriptionResult(status: 1, text: 'не удалось распознать текст'); + return TranscriptionResult( + status: 1, + text: 'не удалось распознать текст', + ); } return TranscriptionResult(status: 1, text: text); } @@ -509,7 +528,7 @@ class MessagesModule { if (token != null) {'_type': 'FILE', 'token': token} else - {'_type': 'FILE', 'fileId': fileId} + {'_type': 'FILE', 'fileId': fileId}, ], }, 'notify': notify, @@ -706,7 +725,10 @@ class MessagesModule { final rawOpts = contact['options']; if (rawOpts is List) { - ContactCache.putOptions(contactId, rawOpts.whereType().toSet()); + ContactCache.putOptions( + contactId, + rawOpts.whereType().toSet(), + ); } ChatsModule.applyContactUpdate(contactId); @@ -716,7 +738,7 @@ class MessagesModule { } } } catch (e) { - debugPrint('searchContactById error: $e'); + logger.e('searchContactById error: $e'); } return null; } diff --git a/lib/core/utils/format.dart b/lib/core/utils/format.dart new file mode 100644 index 0000000..0987c72 --- /dev/null +++ b/lib/core/utils/format.dart @@ -0,0 +1,95 @@ +/// Shared formatting helpers (dates, durations, sizes, phone, gender). +library; + +const List kRuMonthsShort = [ + 'янв', + 'фев', + 'мар', + 'апр', + 'мая', + 'июн', + 'июл', + 'авг', + 'сен', + 'окт', + 'ноя', + 'дек', +]; + +String _two(int n) => n.toString().padLeft(2, '0'); + +/// "512 Б" / "1.5 КБ" / "3.2 МБ" / "1.1 ГБ" — Cyrillic units, 1 decimal. +String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes Б'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} ГБ'; +} + +/// "m:ss" (e.g. "3:07"); with [padMinutes] the minutes are zero-padded ("03:07"). +String formatDurationMmSs(Duration d, {bool padMinutes = false}) { + final m = d.inMinutes; + return '${padMinutes ? _two(m) : m}:${_two(d.inSeconds % 60)}'; +} + +/// "m:ss" from a raw seconds count. +String formatSecondsMmSs(int seconds, {bool padMinutes = false}) => + formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes); + +/// "HH:mm". +String formatClock(DateTime dt) => '${_two(dt.hour)}:${_two(dt.minute)}'; + +/// "5 мая 2024". +String formatDateWords(DateTime dt) => + '${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}'; + +/// "05.04.2024". +String formatDateNumeric(DateTime dt) => + '${_two(dt.day)}.${_two(dt.month)}.${dt.year}'; + +/// "05.04.2024 14:30". +String formatDateTimeNumeric(DateTime dt) => + '${formatDateNumeric(dt)} ${formatClock(dt)}'; + +/// "5 мая 2024, 14:30". +String formatDateTimeWords(DateTime dt) => + '${formatDateWords(dt)}, ${formatClock(dt)}'; + +/// "Был(-а) только что / N мин назад / N ч назад / N дн назад / 5 мая 2024". +String formatLastSeen(int secondsSinceEpoch) { + final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 2) return 'Был(-а) только что'; + if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад'; + if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад'; + if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад'; + return 'Был(-а) ${formatDateWords(dt)}'; +} + +/// "+7 (912) 345-67-89" for RU numbers, "+digits" otherwise. +/// Accepts an int phone or a string; returns null if there is no usable number. +String? formatPhone(dynamic raw) { + String? digits; + if (raw is int && raw > 0) { + digits = raw.toString(); + } else if (raw is String && raw.isNotEmpty && raw != '***') { + digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.isEmpty) return null; + } + if (digits == null) return null; + if (digits.length == 11 && digits.startsWith('7')) { + return '+${digits[0]} (${digits.substring(1, 4)}) ' + '${digits.substring(4, 7)}-${digits.substring(7, 9)}-${digits.substring(9)}'; + } + return '+$digits'; +} + +/// 1 → "Мужской", 2 → "Женский", anything else → null. +String? formatGender(dynamic raw) { + if (raw is! int) return null; + if (raw == 1) return 'Мужской'; + if (raw == 2) return 'Женский'; + return null; +} diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart index a7b99a7..413f6f1 100644 --- a/lib/core/utils/image_utils.dart +++ b/lib/core/utils/image_utils.dart @@ -4,6 +4,9 @@ import 'package:image/image.dart' as img; const int _avatarMaxDimension = 1024; const int _avatarTargetBytes = 900 * 1024; +/// Maximum accepted size for a user-picked avatar before compression. +const int kMaxAvatarBytes = 8 * 1024 * 1024; + Future compressAvatar(Uint8List input) => compute(_encodeAvatar, input); Uint8List? _encodeAvatar(Uint8List input) { diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 40fb36b..5b5dd59 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -16,6 +16,7 @@ import '../profile/spoof_screen.dart'; import '../profile/debug_menu_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/adaptive_shell.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../../backend/api.dart'; import '../../../main.dart'; @@ -152,9 +153,7 @@ class _LoginScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) { return SafeArea( child: Padding( @@ -188,7 +187,9 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(sheetContext); - KometApp.stateOf(appContext)?.applyLocale(const Locale('ru')); + KometApp.stateOf( + appContext, + )?.applyLocale(const Locale('ru')); }, ), ListTile( @@ -202,7 +203,9 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(sheetContext); - KometApp.stateOf(appContext)?.applyLocale(const Locale('en')); + KometApp.stateOf( + appContext, + )?.applyLocale(const Locale('en')); }, ), ], @@ -220,9 +223,7 @@ class _LoginScreenState extends State { context: context, backgroundColor: cs.surfaceContainerHigh, isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (context) { double progress = _isTOSRead ? 1.0 : 0.0; return StatefulBuilder( @@ -503,13 +504,9 @@ class _LoginScreenState extends State { context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) { - return SafeArea( - child: const ServerSettingsSheet(), - ); + return SafeArea(child: const ServerSettingsSheet()); }, ); } @@ -520,13 +517,9 @@ class _LoginScreenState extends State { context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) { - return SafeArea( - child: const ProxySettingsSheet(), - ); + return SafeArea(child: const ProxySettingsSheet()); }, ); } @@ -537,9 +530,7 @@ class _LoginScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) { return SafeArea( child: Padding( @@ -614,9 +605,7 @@ class _LoginScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (context) { return SafeArea( child: Padding( @@ -725,7 +714,8 @@ class _LoginScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () => _showSecurityOptions(context), + onPressed: () => + _showSecurityOptions(context), icon: Icon( Symbols.admin_panel_settings, color: cs.onSurfaceVariant, @@ -840,7 +830,9 @@ class _LoginScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: _phoneMaskHint(_selectedCountry), + hintText: _phoneMaskHint( + _selectedCountry, + ), hintStyle: TextStyle( color: cs.outline, fontSize: 15, diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart index 8553ab1..2c20811 100644 --- a/lib/frontend/screens/auth/proxy_settings_sheet.dart +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -7,6 +7,7 @@ import 'package:komet/l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; class ProxySettingsSheet extends StatefulWidget { const ProxySettingsSheet({super.key}); @@ -57,13 +58,15 @@ class _ProxySettingsSheetState extends State { try { final username = _usernameController.text.trim(); final password = _passwordController.text.trim(); - await ProxyConfig.save(ProxySettings( - type: _selectedType, - host: host, - port: port, - username: username.isNotEmpty ? username : null, - password: password.isNotEmpty ? password : null, - )); + await ProxyConfig.save( + ProxySettings( + type: _selectedType, + host: host, + port: port, + username: username.isNotEmpty ? username : null, + password: password.isNotEmpty ? password : null, + ), + ); await api.disconnect(); await api.connect(); if (!mounted) return; @@ -120,16 +123,8 @@ class _ProxySettingsSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(2), - ), - ), + const Center( + child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)), ), Text( l10n.proxySettingsTitle, @@ -197,9 +192,7 @@ class _ProxySettingsSheetState extends State { const SizedBox(height: 16), FilledButton( onPressed: _busy ? null : () => _apply(l10n), - child: Text( - isActive ? l10n.proxyApply : l10n.proxyDisable, - ), + child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable), ), ], ), @@ -278,10 +271,7 @@ class _ProxySettingsSheetState extends State { inputFormatters: inputFormatters, enabled: !_busy, obscureText: obscureText, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 15, - ), + style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15), decoration: InputDecoration( hintText: hintText, hintStyle: GoogleFonts.inter( diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index e458642..f071f3b 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; class ServerSettingsSheet extends StatefulWidget { const ServerSettingsSheet({super.key}); @@ -57,10 +58,13 @@ class _ServerSettingsSheetState extends State { await api.disconnect(); unawaited(api.connect()); final online = await api.stateStream - .firstWhere((s) => - s == SessionState.online || s == SessionState.disconnected) - .timeout(const Duration(seconds: 15), - onTimeout: () => SessionState.disconnected); + .firstWhere( + (s) => s == SessionState.online || s == SessionState.disconnected, + ) + .timeout( + const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected, + ); if (!mounted) return; if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); @@ -83,10 +87,13 @@ class _ServerSettingsSheetState extends State { await api.disconnect(); api.connect(); final online = await api.stateStream - .firstWhere((s) => - s == SessionState.online || s == SessionState.disconnected) - .timeout(const Duration(seconds: 15), - onTimeout: () => SessionState.disconnected); + .firstWhere( + (s) => s == SessionState.online || s == SessionState.disconnected, + ) + .timeout( + const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected, + ); if (!mounted) return; if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); @@ -119,16 +126,8 @@ class _ServerSettingsSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(2), - ), - ), + const Center( + child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)), ), Text( l10n.serverSettingsTitle, @@ -153,9 +152,7 @@ class _ServerSettingsSheetState extends State { hintText: '${ServerConfig.defaultPort}', cs: cs, keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), const SizedBox(height: 24), FilledButton( @@ -199,10 +196,7 @@ class _ServerSettingsSheetState extends State { keyboardType: keyboardType, inputFormatters: inputFormatters, enabled: !_busy, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 15, - ), + style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15), decoration: InputDecoration( hintText: hintText, hintStyle: GoogleFonts.inter( diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 4ca0beb..43f0ec1 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -4,6 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/utils/format.dart'; + enum CallScreenState { incoming, outgoing, active } class CallScreen extends StatefulWidget { @@ -68,12 +70,6 @@ class _CallScreenState extends State }); } - String get _timerText { - final m = (_seconds ~/ 60).toString().padLeft(2, '0'); - final s = (_seconds % 60).toString().padLeft(2, '0'); - return '$m:$s'; - } - void _accept() { setState(() { _state = CallScreenState.active; @@ -127,13 +123,8 @@ class _CallScreenState extends State return AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { - final scale = (isRinging || isOutgoing) - ? _pulseAnimation.value - : 1.0; - return Transform.scale( - scale: scale, - child: child, - ); + final scale = (isRinging || isOutgoing) ? _pulseAnimation.value : 1.0; + return Transform.scale(scale: scale, child: child); }, child: Container( width: size, @@ -204,7 +195,7 @@ class _CallScreenState extends State case CallScreenState.outgoing: text = 'Вызов...'; case CallScreenState.active: - text = _timerText; + text = formatSecondsMmSs(_seconds, padMinutes: true); } return Text( text, @@ -322,10 +313,7 @@ class _ActionButton extends StatelessWidget { Container( width: 64, height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - ), + decoration: BoxDecoration(shape: BoxShape.circle, color: color), alignment: Alignment.center, child: Icon(icon, color: Colors.white, size: 28, fill: 1), ), diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index e5f920b..d278eb9 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,9 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show api; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/format.dart'; import '../../../backend/modules/calls.dart'; +import '../../widgets/komet_avatar.dart'; class CallsTab extends StatefulWidget { const CallsTab({super.key}); @@ -81,36 +82,7 @@ class _CallsTabState extends State { String _formatDate(int timestamp) { if (timestamp == 0) return ''; final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final months = [ - 'янв.', - 'фев.', - 'мар.', - 'апр.', - 'мая', - 'июн.', - 'июл.', - 'авг.', - 'сен.', - 'окт.', - 'ноя.', - 'дек.', - ]; - return '${dt.day} ${months[dt.month - 1]}'; - } - - Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { - return Container( - color: cs.primaryContainer, - alignment: Alignment.center, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ); + return '${dt.day} ${kRuMonthsShort[dt.month - 1]}'; } Widget _buildCallItem( @@ -165,18 +137,10 @@ class _CallsTabState extends State { width: 1, ), ), - child: ClipOval( - child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: call.avatarUrl!, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (context, url, error) => - _buildPlaceholderAvatar(cs, call.name), - ) - : _buildPlaceholderAvatar(cs, call.name), + child: KometAvatar( + name: call.name, + imageUrl: call.avatarUrl, + size: 48, ), ), const SizedBox(width: 16), diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 8965c7a..360a519 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -5,6 +5,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/format.dart'; +import '../../widgets/komet_avatar.dart'; class _MemberInfo { final int id; @@ -223,7 +225,12 @@ class _ChatInfoScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(height: 4), - _buildAvatar(cs), + KometAvatar( + name: widget.name, + imageUrl: widget.imageUrl, + size: 96, + fontSize: 36, + ), const SizedBox(height: 14), Text( widget.name, @@ -253,39 +260,6 @@ class _ChatInfoScreenState extends State { ); } - // ─── AVATAR ────────────────────────────────────────────────────────────── - - Widget _buildAvatar(ColorScheme cs) { - return Container( - width: 96, - height: 96, - decoration: BoxDecoration( - shape: BoxShape.circle, color: cs.primaryContainer), - child: widget.imageUrl.isNotEmpty - ? ClipOval( - child: CachedNetworkImage( - imageUrl: widget.imageUrl, - fit: BoxFit.cover, - memCacheWidth: 360, - memCacheHeight: 360, - errorWidget: (context, error, stack) => _avatarLetters(cs), - ), - ) - : _avatarLetters(cs), - ); - } - - Widget _avatarLetters(ColorScheme cs) => Center( - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 36, - fontWeight: FontWeight.bold, - ), - ), - ); - // ─── SUBTITLE ──────────────────────────────────────────────────────────── String _subtitle() { @@ -392,10 +366,13 @@ class _ChatInfoScreenState extends State { } } else { final phone = _contactData?['phone']; - final phoneInt = - phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + final phoneInt = phone is int + ? phone + : int.tryParse(phone?.toString() ?? ''); if (phoneInt != null && phoneInt > 0) { - items.add(_simpleInfoCard(cs, 'Номер телефона', _formatPhone(phoneInt))); + items.add( + _simpleInfoCard(cs, 'Номер телефона', formatPhone(phoneInt)!), + ); } } } else if (widget.chatType == 'CHANNEL') { @@ -417,8 +394,12 @@ class _ChatInfoScreenState extends State { ); } - Widget _simpleInfoCard(ColorScheme cs, String label, String value, - {bool isLink = false}) { + Widget _simpleInfoCard( + ColorScheme cs, + String label, + String value, { + bool isLink = false, + }) { return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), @@ -429,8 +410,10 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), Text( value, @@ -458,19 +441,27 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Ссылка-приглашение', - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + 'Ссылка-приглашение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), - Text(link, - style: const TextStyle( - color: Color(0xFF007AFF), fontSize: 15)), + Text( + link, + style: const TextStyle( + color: Color(0xFF007AFF), + fontSize: 15, + ), + ), ], ), ), IconButton( - icon: const Icon(Icons.qr_code_2, - color: Color(0xFF007AFF), size: 22), + icon: const Icon( + Icons.qr_code_2, + color: Color(0xFF007AFF), + size: 22, + ), onPressed: () {}, ), ], @@ -492,16 +483,16 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Описание', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + 'Описание', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), Text( desc, - style: - TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), maxLines: (_descExpanded || !isLong) ? null : collapsedLines, - overflow: - (_descExpanded || !isLong) ? null : TextOverflow.ellipsis, + overflow: (_descExpanded || !isLong) ? null : TextOverflow.ellipsis, ), if (isLong) ...[ const SizedBox(height: 6), @@ -509,8 +500,7 @@ class _ChatInfoScreenState extends State { onTap: () => setState(() => _descExpanded = !_descExpanded), child: Text( _descExpanded ? 'Свернуть' : 'Ещё', - style: const TextStyle( - color: Color(0xFF007AFF), fontSize: 13), + style: const TextStyle(color: Color(0xFF007AFF), fontSize: 13), ), ), ], @@ -598,10 +588,7 @@ class _ChatInfoScreenState extends State { if (_selectedTab.isEmpty) return const SizedBox.shrink(); return AnimatedSwitcher( duration: const Duration(milliseconds: 180), - child: KeyedSubtree( - key: ValueKey(_selectedTab), - child: _tabBody(cs), - ), + child: KeyedSubtree(key: ValueKey(_selectedTab), child: _tabBody(cs)), ); } @@ -632,11 +619,16 @@ class _ChatInfoScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, - color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48), + Icon( + icon, + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + size: 48, + ), const SizedBox(height: 12), - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), ], ), ); @@ -648,7 +640,8 @@ class _ChatInfoScreenState extends State { final items = []; if (widget.chatType == 'DIALOG' && !_isBot) { - final bio = (_contactData?['description'] as String?) ?? + final bio = + (_contactData?['description'] as String?) ?? (_contactData?['about'] as String?); if (bio != null && bio.isNotEmpty) { items @@ -696,14 +689,19 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), - Text(value, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500)), + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), ], ), ); @@ -727,7 +725,11 @@ class _ChatInfoScreenState extends State { } Widget _memberAction( - ColorScheme cs, IconData icon, String label, VoidCallback onTap) { + ColorScheme cs, + IconData icon, + String label, + VoidCallback onTap, + ) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), @@ -737,8 +739,7 @@ class _ChatInfoScreenState extends State { children: [ Icon(icon, color: const Color(0xFF007AFF), size: 26), const SizedBox(width: 14), - Text(label, - style: TextStyle(color: cs.onSurface, fontSize: 16)), + Text(label, style: TextStyle(color: cs.onSurface, fontSize: 16)), ], ), ), @@ -746,11 +747,11 @@ class _ChatInfoScreenState extends State { } Widget _listDivider(ColorScheme cs) => Divider( - height: 1, - indent: 56, - endIndent: 0, - color: cs.outlineVariant.withValues(alpha: 0.3), - ); + height: 1, + indent: 56, + endIndent: 0, + color: cs.outlineVariant.withValues(alpha: 0.3), + ); Widget _memberTile(ColorScheme cs, _MemberInfo member) { final name = @@ -768,8 +769,9 @@ class _ChatInfoScreenState extends State { sublabel = 'Был(-а) недавно'; } - final String? roleLabel = - member.isOwner ? 'владелец' : (member.isAdmin ? 'Адмін' : null); + final String? roleLabel = member.isOwner + ? 'владелец' + : (member.isAdmin ? 'Адмін' : null); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), @@ -778,7 +780,11 @@ class _ChatInfoScreenState extends State { (avatar != null && avatar.isNotEmpty) ? CircleAvatar( radius: 22, - backgroundImage: CachedNetworkImageProvider(avatar, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + avatar, + maxWidth: 144, + maxHeight: 144, + ), backgroundColor: cs.primaryContainer, ) : CircleAvatar( @@ -787,7 +793,9 @@ class _ChatInfoScreenState extends State { child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( - color: cs.onPrimaryContainer, fontSize: 16), + color: cs.onPrimaryContainer, + fontSize: 16, + ), ), ), const SizedBox(width: 14), @@ -795,21 +803,26 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(name, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500)), - Text(sublabel, - style: TextStyle( - color: cs.onSurfaceVariant, fontSize: 13)), + Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + Text( + sublabel, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), ], ), ), if (roleLabel != null) - Text(roleLabel, - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + roleLabel, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), ], ), ); @@ -821,8 +834,10 @@ class _ChatInfoScreenState extends State { final rows = <({String label, String value})>[]; final chat = _chatData; if (chat == null) { - return Text('Нет данных', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + return Text( + 'Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ); } void add(String label, dynamic val, {bool tsFormat = false}) { @@ -830,7 +845,7 @@ class _ChatInfoScreenState extends State { if (val is bool && !val) return; String str; if (tsFormat && val is int && val > 1) { - str = _formatTs(val); + str = formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(val)); } else if (val is bool) { str = 'да'; } else { @@ -887,8 +902,10 @@ class _ChatInfoScreenState extends State { } if (rows.isEmpty) { - return Text('Нет данных', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + return Text( + 'Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ); } final extraRows = _buildExtraContactRows(); @@ -908,18 +925,21 @@ class _ChatInfoScreenState extends State { rows[i].value, trailing: _trailingFor(rows[i].label, cs), ), - if (i < rows.length - 1 || (_extraContactExpanded && extraRows.isNotEmpty)) + if (i < rows.length - 1 || + (_extraContactExpanded && extraRows.isNotEmpty)) Divider( - height: 10, - color: cs.outlineVariant.withValues(alpha: 0.25)), + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25), + ), ], if (_extraContactExpanded) for (int i = 0; i < extraRows.length; i++) ...[ _infoRow(cs, extraRows[i].label, extraRows[i].value), if (i < extraRows.length - 1) Divider( - height: 10, - color: cs.outlineVariant.withValues(alpha: 0.25)), + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25), + ), ], ], ), @@ -932,11 +952,17 @@ class _ChatInfoScreenState extends State { final rows = <({String label, String value})>[]; final reg = c['registrationTime']; if (reg is int && reg > 0) { - rows.add((label: 'Регистрация', value: _formatTs(reg))); + rows.add(( + label: 'Регистрация', + value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(reg)), + )); } final upd = c['updateTime']; if (upd is int && upd > 0) { - rows.add((label: 'Обновлён', value: _formatTs(upd))); + rows.add(( + label: 'Обновлён', + value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(upd)), + )); } final country = c['country']; if (country is String && country.isNotEmpty) { @@ -944,7 +970,7 @@ class _ChatInfoScreenState extends State { } final gender = c['gender']; if (gender is int) { - final g = gender == 1 ? 'Мужской' : (gender == 2 ? 'Женский' : null); + final g = formatGender(gender); if (g != null) rows.add((label: 'Пол', value: g)); } final phone = c['phone']; @@ -981,11 +1007,17 @@ class _ChatInfoScreenState extends State { ), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () => setState(() => _extraContactExpanded = !_extraContactExpanded), + onPressed: () => + setState(() => _extraContactExpanded = !_extraContactExpanded), ); } - Widget _infoRow(ColorScheme cs, String label, String value, {Widget? trailing}) { + Widget _infoRow( + ColorScheme cs, + String label, + String value, { + Widget? trailing, + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -995,13 +1027,18 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)), - Text(value, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontWeight: FontWeight.w500)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), + ), + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), ], ), ), @@ -1015,13 +1052,13 @@ class _ChatInfoScreenState extends State { Widget _buildShimmer(ColorScheme cs) { Widget block(double w, double h, {double r = 8}) => Container( - width: w, - height: h, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(r), - ), - ); + width: w, + height: h, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(r), + ), + ); return ListView( padding: const EdgeInsets.fromLTRB(16, 60, 16, 0), @@ -1044,7 +1081,8 @@ class _ChatInfoScreenState extends State { // ─── HELPERS ───────────────────────────────────────────────────────────── String _formatLastSeen(int secondsSinceEpoch) { - final diff = DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000; + final diff = + DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000; if (diff < 60000) return 'только что'; if (diff < 3600000) return '${diff ~/ 60000} мин назад'; if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; @@ -1052,21 +1090,6 @@ class _ChatInfoScreenState extends State { return 'давно'; } - String _formatPhone(int phone) { - final s = phone.toString(); - if (s.length == 11 && s.startsWith('7')) { - return '+7 ${s.substring(1, 4)} ${s.substring(4, 7)}-' - '${s.substring(7, 9)}-${s.substring(9, 11)}'; - } - return '+$s'; - } - - String _formatTs(int ts) { - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - } - String _pluralCount(int n, String one, String few, String many) { final mod100 = n % 100; final mod10 = n % 10; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 659fb9a..c5117cd 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -10,8 +10,10 @@ import 'chat_screen.dart'; import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; +import '../../../core/utils/format.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -342,9 +344,7 @@ class _ChatListScreenState extends State return showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (ctx) { return SafeArea( child: Padding( @@ -832,10 +832,7 @@ class _ChatListScreenState extends State String _formatTime(int? timestamp) { if (timestamp == null || timestamp == 0) return ''; - final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final h = dt.hour.toString().padLeft(2, '0'); - final m = dt.minute.toString().padLeft(2, '0'); - return '$h:$m'; + return formatClock(DateTime.fromMillisecondsSinceEpoch(timestamp)); } Widget _buildChatShimmer() { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c3fa1f5..55cbdcc 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -10,6 +10,8 @@ import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; +import 'package:komet/core/utils/format.dart'; +import 'package:komet/core/utils/logger.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -37,11 +39,7 @@ class _UploadStatus { final int sent; final int total; - const _UploadStatus({ - this.active = false, - this.sent = 0, - this.total = 0, - }); + const _UploadStatus({this.active = false, this.sent = 0, this.total = 0}); bool get awaitingResponse => active && total > 0 && sent >= total; double? get progressValue => @@ -82,19 +80,21 @@ class ChatScreen extends StatefulWidget { State createState() => _ChatScreenState(); } -class _ChatScreenState extends State - with TickerProviderStateMixin { +class _ChatScreenState extends State with TickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final GlobalKey _listKey = GlobalKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); - final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); + final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier( + const _UploadStatus(), + ); StreamSubscription? _uploadSub; StreamSubscription? _pushSub; StreamSubscription? _messageEventSub; - final Map?>> _reactionNotifiers = {}; + final Map?>> _reactionNotifiers = + {}; ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; @@ -109,11 +109,14 @@ class _ChatScreenState extends State void _pruneReactionNotifiers() { final liveIds = _messages.map((m) => m.id).toSet(); - final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList(); + final dead = _reactionNotifiers.keys + .where((id) => !liveIds.contains(id)) + .toList(); for (final id in dead) { _reactionNotifiers.remove(id)?.dispose(); } } + final Set _typingUserIds = {}; final Map _typingTimers = {}; int _otherStatus = 0; @@ -132,7 +135,8 @@ class _ChatScreenState extends State int _tempIdCounter = 0; late final AnimationController _attachAnim; - String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; + String _nextTempId() => + 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; late AnimationController _shimmerController; Timer? _shimmerStartTimer; bool _historyKickedOff = false; @@ -150,7 +154,7 @@ class _ChatScreenState extends State late final CurvedAnimation _floatingDateCurved; final Map _separatorKeys = {}; String? _lastSentId; - + @override void initState() { super.initState(); @@ -167,9 +171,9 @@ class _ChatScreenState extends State ); _showAttachmentPanel.addListener(_onAttachPanelToggle); _pushSub = api.pushStream - .where((p) => - p.opcode == Opcode.notifMark || - p.opcode == Opcode.notifTyping) + .where( + (p) => p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping, + ) .listen(_onIncomingPush); _messageEventSub = ChatsModule.messageEvents .where((e) => e.chatId == widget.chatId) @@ -206,15 +210,17 @@ class _ChatScreenState extends State if (!mounted) return; _myId = p?.id ?? 0; - ChatsModule.getChat(_myId, widget.chatId).then((value) { - if (mounted && value.isNotEmpty) { - setState(() { - chat = value.first; - }); - _recomputeHeaderStatus(); - _syncOtherReadTime(); - } - }).catchError((_) {}); + ChatsModule.getChat(_myId, widget.chatId) + .then((value) { + if (mounted && value.isNotEmpty) { + setState(() { + chat = value.first; + }); + _recomputeHeaderStatus(); + _syncOtherReadTime(); + } + }) + .catchError((_) {}); final firstRows = await AppDatabase.loadMessages( _myId, @@ -254,6 +260,7 @@ class _ChatScreenState extends State if (!mounted) return; _kickoffHistory(); } + anim.addStatusListener(onStatus); safety = Timer(const Duration(milliseconds: 400), () { anim.removeStatusListener(onStatus); @@ -322,10 +329,12 @@ class _ChatScreenState extends State if (mounted) { _applyMergedMessages(updatedRows, markLoaded: true); } - unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId)); + unawaited( + ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId), + ); _loadForwardedSenderNames(); } catch (e) { - debugPrint('Error fetching history: $e'); + logger.e('Error fetching history: $e'); if (mounted) { setState(() { _isLoading = false; @@ -339,9 +348,7 @@ class _ChatScreenState extends State List> rowsDesc, { bool markLoaded = false, }) { - final byId = { - for (final m in _messages) m.id: m, - }; + final byId = {for (final m in _messages) m.id: m}; final merged = []; for (final row in rowsDesc.reversed) { final fresh = CachedMessage.fromDbRow(row); @@ -635,20 +642,6 @@ class _ChatScreenState extends State } catch (_) {} } - String _formatLastSeen(int secondsSinceEpoch) { - final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); - final diff = DateTime.now().difference(dt); - if (diff.inMinutes < 2) return 'Был(-а) только что'; - if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад'; - if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад'; - if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад'; - const months = [ - 'янв', 'фев', 'мар', 'апр', 'мая', 'июн', - 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', - ]; - return 'Был(-а) ${dt.day} ${months[dt.month - 1]} ${dt.year}'; - } - void _recomputeHeaderStatus() { _headerStatusNotifier.value = _headerStatus(); } @@ -666,7 +659,7 @@ class _ChatScreenState extends State if (_otherStatus == 1) return 'В сети'; if (_otherStatus == 3) return 'Был(-а) недавно'; final s = _otherSeenTime; - if (s != null && s > 0) return _formatLastSeen(s); + if (s != null && s > 0) return formatLastSeen(s); return ''; } @@ -720,7 +713,6 @@ class _ChatScreenState extends State final now = DateTime.now().millisecondsSinceEpoch; try { - final tempMessage = CachedMessage( id: tempId, accountId: _myId, @@ -747,7 +739,11 @@ class _ChatScreenState extends State _scrollToBottom(); _checkPrankTrigger(tempMessage); - final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); + final actualId = await messagesModule.sendMessage( + _myId, + widget.chatId, + text, + ); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { @@ -902,26 +898,34 @@ class _ChatScreenState extends State for (int i = 0; i < _messages.length; i++) { final msg = _messages[i]; final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time); - final dayMillis = DateTime(msgDate.year, msgDate.month, msgDate.day) - .millisecondsSinceEpoch; + final dayMillis = DateTime( + msgDate.year, + msgDate.month, + msgDate.day, + ).millisecondsSinceEpoch; bool needSeparator = i == 0; if (!needSeparator) { - final prevDate = - DateTime.fromMillisecondsSinceEpoch(_messages[i - 1].time); - final prevDayMillis = - DateTime(prevDate.year, prevDate.month, prevDate.day) - .millisecondsSinceEpoch; + final prevDate = DateTime.fromMillisecondsSinceEpoch( + _messages[i - 1].time, + ); + final prevDayMillis = DateTime( + prevDate.year, + prevDate.month, + prevDate.day, + ).millisecondsSinceEpoch; needSeparator = dayMillis != prevDayMillis; } if (needSeparator) { _separatorKeys.putIfAbsent(dayMillis, () => GlobalKey()); usedDates.add(dayMillis); - items.add(_DateSeparatorItem( - DateTime.fromMillisecondsSinceEpoch(dayMillis), - _separatorKeys[dayMillis]!, - )); + items.add( + _DateSeparatorItem( + DateTime.fromMillisecondsSinceEpoch(dayMillis), + _separatorKeys[dayMillis]!, + ), + ); } items.add(_MessageItem(msg, i)); @@ -992,8 +996,18 @@ class _ChatScreenState extends State if (d == yesterday) return 'Вчера'; const months = [ - 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', - 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', + 'января', + 'февраля', + 'марта', + 'апреля', + 'мая', + 'июня', + 'июля', + 'августа', + 'сентября', + 'октября', + 'ноября', + 'декабря', ]; if (date.year == now.year) { return '${date.day} ${months[date.month - 1]}'; @@ -1001,8 +1015,12 @@ class _ChatScreenState extends State return '${date.day} ${months[date.month - 1]} ${date.year}'; } - Widget _buildDateSeparatorWidget(BuildContext context, DateTime date, - {Key? key, bool floating = false}) { + Widget _buildDateSeparatorWidget( + BuildContext context, + DateTime date, { + Key? key, + bool floating = false, + }) { final cs = Theme.of(context).colorScheme; return Padding( key: key, @@ -1029,8 +1047,9 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { - final theme = - _prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context); + final theme = _prankActive + ? _prankPinkTheme(Theme.of(context)) + : Theme.of(context); final cs = theme.colorScheme; // TODO: Локализация @@ -1040,161 +1059,173 @@ class _ChatScreenState extends State child: RepaintBoundary( key: _prankCaptureKey, child: ValueListenableBuilder( - valueListenable: AppSwipeBackDesktop.current, - builder: (context, desktopSwipe, child) => SwipeToPop( - enabled: widget.embedded && desktopSwipe, - onPop: widget.onClose, - child: child!, - ), - child: Scaffold( - backgroundColor: cs.surface, - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType) - ) + valueListenable: AppSwipeBackDesktop.current, + builder: (context, desktopSwipe, child) => SwipeToPop( + enabled: widget.embedded && desktopSwipe, + onPop: widget.onClose, + child: child!, ), - child: AppBar( - backgroundColor: cs.surfaceContainerHigh, - foregroundColor: cs.onSurface, - elevation: 0, - surfaceTintColor: Colors.transparent, - iconTheme: IconThemeData(color: cs.onSurface), - leading: IconButton( - 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( - children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 18, - backgroundImage: CachedNetworkImageProvider(widget.imageUrl, maxWidth: 144, maxHeight: 144), - ) - else - CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), + child: Scaffold( + backgroundColor: cs.surface, + appBar: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + child: AppBar( + backgroundColor: cs.surfaceContainerHigh, + foregroundColor: cs.onSurface, + elevation: 0, + surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: cs.onSurface), + leading: IconButton( + 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( children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 18, + backgroundImage: CachedNetworkImageProvider( + widget.imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + else + CircleAvatar( + radius: 18, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 12, ), ), - if (chat?.isOfficial ?? false) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (chat?.isOfficial ?? false) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], + ), + ValueListenableBuilder( + valueListenable: _headerStatusNotifier, + builder: (context, status, _) => Text( + status, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), ), ], - ], - ), - ValueListenableBuilder( - valueListenable: _headerStatusNotifier, - builder: (context, status, _) => Text( - status, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, - ), ), ), ], ), + actions: [ + IconButton( + icon: const Icon(Symbols.call, weight: 400), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Symbols.more_vert, weight: 400), + onPressed: () {}, + ), + ], ), + ), + ), + body: Column( + children: [ + Expanded( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + AnimatedBuilder( + animation: _attachAnim, + builder: (context, _) { + if (_attachAnim.value == 0) return const SizedBox.shrink(); + final curve = _attachAnim.status == AnimationStatus.reverse + ? Curves.easeIn + : Curves.easeOut; + final t = curve.transform(_attachAnim.value); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: t, + child: Opacity( + opacity: t, + child: AttachmentPanel( + onClose: () => _showAttachmentPanel.value = false, + onPickFile: _pickAndUploadFile, + onSendById: _sendFileById, + ), + ), + ), + ), + ); + }, + ), + _buildInputArea(context), ], ), - actions: [ - IconButton( - icon: const Icon(Symbols.call, weight: 400), - onPressed: () {}, - ), - IconButton( - icon: const Icon(Symbols.more_vert, weight: 400), - onPressed: () {}, - ), - ], ), - )), - body: Column( - children: [ - Expanded( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), - ), - AnimatedBuilder( - animation: _attachAnim, - builder: (context, _) { - if (_attachAnim.value == 0) return const SizedBox.shrink(); - final curve = _attachAnim.status == AnimationStatus.reverse - ? Curves.easeIn - : Curves.easeOut; - final t = curve.transform(_attachAnim.value); - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: ClipRect( - child: Align( - alignment: Alignment.bottomCenter, - heightFactor: t, - child: Opacity( - opacity: t, - child: AttachmentPanel( - onClose: () => _showAttachmentPanel.value = false, - onPickFile: _pickAndUploadFile, - onSendById: _sendFileById, - ), - ), - ), - ), - ); - }, - ), - _buildInputArea(context), - ], - ), - ), ), ), ); @@ -1220,68 +1251,72 @@ class _ChatScreenState extends State ValueListenableBuilder( valueListenable: _otherReadTime, builder: (context, _, _) => ValueListenableBuilder( - valueListenable: AppCacheExtent.current, - builder: (context, cacheExtent, _) => ListView.builder( - controller: _scrollController, - reverse: true, - padding: const EdgeInsets.symmetric(vertical: 8), - cacheExtent: cacheExtent, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[items.length - 1 - index]; + valueListenable: AppCacheExtent.current, + builder: (context, cacheExtent, _) => ListView.builder( + controller: _scrollController, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + cacheExtent: cacheExtent, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[items.length - 1 - index]; - if (item is _DateSeparatorItem) { - return _buildDateSeparatorWidget(context, item.date, - key: item.key); - } + if (item is _DateSeparatorItem) { + return _buildDateSeparatorWidget( + context, + item.date, + key: item.key, + ); + } - final msgItem = item as _MessageItem; - final message = msgItem.message; - final msgIndex = msgItem.index; - final isMe = message.senderId == _myId; - final prevMessage = - msgIndex > 0 ? _messages[msgIndex - 1] : null; - final nextMessage = msgIndex < _messages.length - 1 - ? _messages[msgIndex + 1] - : null; + final msgItem = item as _MessageItem; + final message = msgItem.message; + final msgIndex = msgItem.index; + final isMe = message.senderId == _myId; + final prevMessage = msgIndex > 0 + ? _messages[msgIndex - 1] + : null; + final nextMessage = msgIndex < _messages.length - 1 + ? _messages[msgIndex + 1] + : null; - final bubble = MessageBubble( - message: message, - isMe: isMe, - myId: _myId, - prevMessage: prevMessage, - nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', - overrideStatus: _effectiveStatus(message), - reactionsListenable: _reactionNotifierFor(message), - ); + final bubble = MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: chat?.type ?? 'CHAT', + overrideStatus: _effectiveStatus(message), + reactionsListenable: _reactionNotifierFor(message), + ); - final pressable = _LongPressBubble( - message: message, - isMe: isMe, - child: bubble, - ); + final pressable = _LongPressBubble( + message: message, + isMe: isMe, + child: bubble, + ); - final Widget child = message.id == _lastSentId - ? _SentMessageAnimation( - key: ValueKey('anim_${message.id}'), - onComplete: () { - if (mounted) setState(() => _lastSentId = null); - }, - child: pressable, - ) - : pressable; + final Widget child = message.id == _lastSentId + ? _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) setState(() => _lastSentId = null); + }, + child: pressable, + ) + : pressable; - final builtItem = RepaintBoundary( - key: ValueKey('msg_${message.id}'), - child: child, - ); - return message.id == _prankBubbleId - ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) - : builtItem; - }, - ), - ), + final builtItem = RepaintBoundary( + key: ValueKey('msg_${message.id}'), + child: child, + ); + return message.id == _prankBubbleId + ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) + : builtItem; + }, + ), + ), ), Positioned( top: 8, @@ -1304,7 +1339,11 @@ class _ChatScreenState extends State ), ); }, - child: _buildDateSeparatorWidget(context, date, floating: true), + child: _buildDateSeparatorWidget( + context, + date, + floating: true, + ), ); }, ), @@ -1483,7 +1522,10 @@ class _ChatScreenState extends State final t = _attachAnim.value; return IgnorePointer( ignoring: t > 0.5, - child: Opacity(opacity: (1 - t).clamp(0.0, 1.0), child: child), + child: Opacity( + opacity: (1 - t).clamp(0.0, 1.0), + child: child, + ), ); }, child: Padding( @@ -1491,14 +1533,22 @@ class _ChatScreenState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), + Icon( + Symbols.face, + color: mutedIcon, + size: 24, + weight: 400, + ), const SizedBox(width: 12), Expanded( child: Focus( onKeyEvent: (node, event) { if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.enter && - !HardwareKeyboard.instance.isShiftPressed) { + event.logicalKey == + LogicalKeyboardKey.enter && + !HardwareKeyboard + .instance + .isShiftPressed) { if (_hasText.value) _sendMessage(); return KeyEventResult.handled; } @@ -1506,7 +1556,10 @@ class _ChatScreenState extends State }, child: TextField( controller: _messageController, - style: TextStyle(color: cs.onSurface, fontSize: 16), + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, @@ -1548,7 +1601,10 @@ class _ChatScreenState extends State final t = _attachAnim.value; return IgnorePointer( ignoring: t < 0.5, - child: Opacity(opacity: t.clamp(0.0, 1.0), child: child), + child: Opacity( + opacity: t.clamp(0.0, 1.0), + child: child, + ), ); }, child: _HistoryStrip( @@ -1598,7 +1654,9 @@ class _ChatScreenState extends State height: 54, alignment: Alignment.center, decoration: BoxDecoration( - color: hasText ? cs.primary : cs.surfaceContainerHighest, + color: hasText + ? cs.primary + : cs.surfaceContainerHighest, shape: BoxShape.circle, ), child: GestureDetector( @@ -1670,12 +1728,14 @@ class _ChatScreenState extends State } Future _sendHistoryFile(FileHistoryEntry entry) async { - final tempId = _addOptimisticFileMessage(FileAttachment( - fileId: entry.fileId, - fileToken: entry.token, - name: entry.filename, - size: entry.size, - )); + final tempId = _addOptimisticFileMessage( + FileAttachment( + fileId: entry.fileId, + fileToken: entry.token, + name: entry.filename, + size: entry.size, + ), + ); _showAttachmentPanel.value = false; try { final ok = await messagesModule.sendFileMessage( @@ -1695,10 +1755,9 @@ class _ChatScreenState extends State final ok = await messagesModule.sendFileMessage(widget.chatId, fileId); if (!mounted) return ok; if (ok) { - FileHistoryCache.add(FileHistoryEntry( - fileId: fileId, - sentAt: DateTime.now(), - )); + FileHistoryCache.add( + FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()), + ); _updateFileMessageStatus(tempId, 'sent'); _showAttachmentPanel.value = false; } else { @@ -1726,10 +1785,9 @@ class _ChatScreenState extends State _showAttachmentPanel.value = false; _uploadStatus.value = _UploadStatus(active: true, total: file.size); - final tempId = _addOptimisticFileMessage(FileAttachment( - name: file.name, - size: file.size, - )); + final tempId = _addOptimisticFileMessage( + FileAttachment(name: file.name, size: file.size), + ); UploadNotificationService.start(file.name); @@ -1743,83 +1801,94 @@ class _ChatScreenState extends State _uploadSub?.cancel(); _uploadSub = fileUploader .upload( - chatId: widget.chatId, - file: File(file.path!), - filename: file.name, - totalSize: file.size, - ) + chatId: widget.chatId, + file: File(file.path!), + filename: file.name, + totalSize: file.size, + ) .listen( - (event) { - if (!mounted) return; - switch (event) { - case UploadProgress(:final sent, :final total): - _uploadStatus.value = _UploadStatus(active: true, sent: sent, total: total); - final nowMs = DateTime.now().millisecondsSinceEpoch; - final elapsed = nowMs - notifLastMs; - if (elapsed >= 500) { - notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed).round(); - notifLastSent = sent; - notifLastMs = nowMs; + (event) { + if (!mounted) return; + switch (event) { + case UploadProgress(:final sent, :final total): + _uploadStatus.value = _UploadStatus( + active: true, + sent: sent, + total: total, + ); + final nowMs = DateTime.now().millisecondsSinceEpoch; + final elapsed = nowMs - notifLastMs; + if (elapsed >= 500) { + notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed) + .round(); + notifLastSent = sent; + notifLastMs = nowMs; + } + final percent = total > 0 ? (sent * 100 ~/ total) : 0; + if (percent != notifLastPercent) { + notifLastPercent = percent; + UploadNotificationService.update( + filename: file.name, + progressPercent: percent, + speedBps: notifSpeedBps, + ); + } + case UploadDone(:final fileId, :final token, :final url): + stopNotif(); + FileHistoryCache.add( + FileHistoryEntry( + fileId: fileId, + url: url, + token: token, + filename: file.name, + size: file.size, + sentAt: DateTime.now(), + ), + ); + _updateFileMessageStatus( + tempId, + 'sent', + attachment: FileAttachment( + fileId: fileId, + fileToken: token, + name: file.name, + size: file.size, + ), + ); + case UploadError(:final message): + stopNotif(); + showCustomNotification(context, 'Ошибка: $message'); + _updateFileMessageStatus(tempId, 'error'); } - final percent = total > 0 ? (sent * 100 ~/ total) : 0; - if (percent != notifLastPercent) { - notifLastPercent = percent; - UploadNotificationService.update( - filename: file.name, - progressPercent: percent, - speedBps: notifSpeedBps, - ); - } - case UploadDone(:final fileId, :final token, :final url): + }, + onDone: () { + if (!mounted) return; stopNotif(); - FileHistoryCache.add(FileHistoryEntry( - fileId: fileId, - url: url, - token: token, - filename: file.name, - size: file.size, - sentAt: DateTime.now(), - )); - _updateFileMessageStatus( - tempId, - 'sent', - attachment: FileAttachment( - fileId: fileId, - fileToken: token, - name: file.name, - size: file.size, + final inFlight = _messages.firstWhere( + (m) => m.id == tempId, + orElse: () => CachedMessage( + id: '', + accountId: 0, + chatId: 0, + senderId: 0, + time: 0, ), ); - case UploadError(:final message): + if (inFlight.id == tempId && inFlight.status == 'sending') { + _updateFileMessageStatus(tempId, 'error'); + } + _uploadStatus.value = const _UploadStatus(); + _uploadSub = null; + }, + onError: (Object e) { + if (!mounted) return; stopNotif(); - showCustomNotification(context, 'Ошибка: $message'); + showCustomNotification(context, 'Ошибка: $e'); _updateFileMessageStatus(tempId, 'error'); - } - }, - onDone: () { - if (!mounted) return; - stopNotif(); - final inFlight = _messages.firstWhere( - (m) => m.id == tempId, - orElse: () => CachedMessage( - id: '', accountId: 0, chatId: 0, senderId: 0, time: 0, - ), + _uploadStatus.value = const _UploadStatus(); + _uploadSub = null; + }, ); - if (inFlight.id == tempId && inFlight.status == 'sending') { - _updateFileMessageStatus(tempId, 'error'); - } - _uploadStatus.value = const _UploadStatus(); - _uploadSub = null; - }, - onError: (Object e) { - if (!mounted) return; - stopNotif(); - showCustomNotification(context, 'Ошибка: $e'); - _updateFileMessageStatus(tempId, 'error'); - _uploadStatus.value = const _UploadStatus(); - _uploadSub = null; - }, - ); } } @@ -1848,8 +1917,8 @@ class _AttachButton extends StatelessWidget { final iconColor = status.awaitingResponse ? cs.primary : (status.active - ? cs.onSurfaceVariant.withValues(alpha: 0.5) - : mutedIcon); + ? cs.onSurfaceVariant.withValues(alpha: 0.5) + : mutedIcon); final onTap = (isText || status.active) ? null : onOpen; return AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -1933,86 +2002,98 @@ class _HistoryStrip extends StatelessWidget { itemCount: history.length, itemBuilder: (ctx, idx) { final e = history[idx]; - final startInterval = (idx * 0.05).clamp(0.0, 0.45); - return AnimatedBuilder( - animation: anim, - builder: (context, child) { - final raw = ((anim.value - startInterval) / 0.45).clamp(0.0, 1.0); - final v = Curves.easeOutCubic.transform(raw); - return Opacity( - opacity: v, - child: Transform.translate( - offset: Offset(-14 * (1 - v), 0), - child: child, - ), - ); - }, - child: Container( - width: 54, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - color: cs.surfaceContainerLow, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), - ), - child: Stack(children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onTapEntry(e), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _iconForFilename(e.filename), - color: cs.onSurfaceVariant, - size: 22, - ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 3), - child: Text( - _labelForEntry(e), - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, + final startInterval = (idx * 0.05).clamp(0.0, 0.45); + return AnimatedBuilder( + animation: anim, + builder: (context, child) { + final raw = ((anim.value - startInterval) / 0.45).clamp( + 0.0, + 1.0, + ); + final v = Curves.easeOutCubic.transform(raw); + return Opacity( + opacity: v, + child: Transform.translate( + offset: Offset(-14 * (1 - v), 0), + child: child, + ), + ); + }, + child: Container( + width: 54, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTapEntry(e), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _iconForFilename(e.filename), + color: cs.onSurfaceVariant, + size: 22, + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 3, + ), + child: Text( + _labelForEntry(e), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 9, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], ), ), - ], - ), - ), - ), - Positioned( - top: -2, - right: -2, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => FileHistoryCache.remove(e.fileId), - child: Container( - width: 18, - height: 18, - alignment: Alignment.center, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - border: Border.all( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, + ), + Positioned( + top: -2, + right: -2, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FileHistoryCache.remove(e.fileId), + child: Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Icon( + Symbols.close, + size: 12, + color: cs.onSurfaceVariant, + ), + ), ), ), - child: Icon( - Symbols.close, - size: 12, - color: cs.onSurfaceVariant, - ), - ), + ], ), ), - ]), - ), - ); + ); }, ); }, @@ -2215,10 +2296,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { _controller?.updatePointer(d.globalPosition), onLongPressEnd: (_) => _controller?.commit(), onSecondaryTapDown: _onSecondaryTapDown, - child: RepaintBoundary( - key: _boundaryKey, - child: widget.child, - ), + child: RepaintBoundary(key: _boundaryKey, child: widget.child), ), ); } @@ -2252,9 +2330,10 @@ class _SentMessageAnimationState extends State<_SentMessageAnimation> duration: const Duration(milliseconds: 220), ); _opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); - _slide = Tween(begin: 16, end: 0).animate( - CurvedAnimation(parent: _ctrl, curve: Curves.easeOut), - ); + _slide = Tween( + begin: 16, + end: 0, + ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); _ctrl.forward().whenComplete(widget.onComplete); } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index c35dca9..faef130 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -11,20 +11,17 @@ import '../../../core/storage/token_storage.dart'; import '../../../core/utils/image_utils.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import 'chat_screen.dart'; -const int _maxAvatarBytes = 8 * 1024 * 1024; - Future showCreateGroupFlow(BuildContext context) async { final cs = Theme.of(context).colorScheme; await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) => const _CreateGroupFlow(), ); } @@ -70,7 +67,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { } final list = await ContactsModule.getContacts(myId); list.removeWhere((c) => c.id == myId); - list.sort((a, b) => _displayName(a).toLowerCase().compareTo(_displayName(b).toLowerCase())); + list.sort( + (a, b) => _displayName( + a, + ).toLowerCase().compareTo(_displayName(b).toLowerCase()), + ); if (!mounted) return; setState(() { _all = list; @@ -102,7 +103,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (path == null) return; final file = File(path); final size = await file.length(); - if (size > _maxAvatarBytes) { + if (size > kMaxAvatarBytes) { if (!mounted) return; showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); return; @@ -134,7 +135,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (url != null) { final bytes = await compressAvatar(await _avatar!.readAsBytes()); if (bytes == null) { - if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку'); + if (mounted) { + showCustomNotification(context, 'Не удалось обработать аватарку'); + } } else { final token = await fileUploader.uploadImage( Uri.parse(url), @@ -142,7 +145,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { filename: 'avatar.jpg', ); if (token != null) { - await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); + await ChatsModule.setChatPhoto( + api, + chatId: chat.id, + photoToken: token, + ); } else if (mounted) { showCustomNotification(context, 'Не удалось загрузить аватарку'); } @@ -183,7 +190,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { padding: EdgeInsets.only(bottom: viewInsets.bottom), child: SafeArea( child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, @@ -193,7 +202,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { ? Offset(-0.05, 0) : Offset(0.05, 0); return SlideTransition( - position: Tween(begin: offset, end: Offset.zero).animate(anim), + position: Tween( + begin: offset, + end: Offset.zero, + ).animate(anim), child: FadeTransition(opacity: anim, child: child), ); }, @@ -217,7 +229,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { final query = _search.text.trim().toLowerCase(); final filtered = query.isEmpty ? _all - : _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList(); + : _all + .where((c) => _displayName(c).toLowerCase().contains(query)) + .toList(); return Column( mainAxisSize: MainAxisSize.min, @@ -269,7 +283,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { decoration: InputDecoration( hintText: 'Найти по имени', hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - prefixIcon: Icon(Symbols.search, color: cs.onSurfaceVariant, size: 20), + prefixIcon: Icon( + Symbols.search, + color: cs.onSurfaceVariant, + size: 20, + ), isDense: true, border: InputBorder.none, ), @@ -291,7 +309,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { return InkWell( onTap: () => _toggle(c), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Row( children: [ _Avatar(contact: c, size: 40, cs: cs), @@ -316,7 +337,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { Text( _statusText(c), style: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.8), + color: cs.onSurfaceVariant.withValues( + alpha: 0.8, + ), fontSize: 12, ), maxLines: 1, @@ -333,7 +356,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { color: cs.primary, shape: BoxShape.circle, ), - child: Icon(Symbols.check, color: cs.onPrimary, size: 16), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 16, + ), ), ], ), @@ -419,7 +446,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { clipBehavior: Clip.antiAlias, child: _avatar != null ? Image.file(_avatar!, fit: BoxFit.cover) - : Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20), + : Icon( + Symbols.add_a_photo, + color: cs.onSurfaceVariant, + size: 20, + ), ), ), const SizedBox(width: 12), @@ -431,7 +462,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { style: TextStyle(color: cs.onSurface, fontSize: 16), decoration: InputDecoration( hintText: 'Название группы', - hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), border: InputBorder.none, isDense: true, ), @@ -500,7 +534,10 @@ class _Avatar extends StatelessWidget { return Container( width: size, height: size, - decoration: BoxDecoration(color: cs.primaryContainer, shape: BoxShape.circle), + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), alignment: Alignment.center, child: Text( initial, @@ -589,8 +626,8 @@ class _SheetButton extends StatelessWidget { color: filled ? cs.onPrimary : (disabled - ? cs.onSurface.withValues(alpha: 0.4) - : cs.onSurface), + ? cs.onSurface.withValues(alpha: 0.4) + : cs.onSurface), fontSize: 14, fontWeight: FontWeight.w600, ), diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index b433648..6a89c0b 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -1,11 +1,12 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/format.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; @@ -96,64 +97,10 @@ class _ContactProfileScreenState extends State { if (_isBot) return 'Бот'; if (_presenceStatus == 1) return 'В сети'; if (_presenceStatus == 3) return 'Был(-а) недавно'; - if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!); + if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!); return ''; } - String _formatLastSeen(int secondsSinceEpoch) { - final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); - final now = DateTime.now(); - final diff = now.difference(dt); - if (diff.inMinutes < 2) return 'Был(-а) только что'; - if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад'; - if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад'; - if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад'; - return 'Был(-а) ${_formatDate(dt)}'; - } - - String _formatDate(DateTime dt) { - const months = [ - 'янв', 'фев', 'мар', 'апр', 'мая', 'июн', - 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', - ]; - return '${dt.day} ${months[dt.month - 1]} ${dt.year}'; - } - - String _formatDateTime(int msSinceEpoch) { - final dt = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch); - final hh = dt.hour.toString().padLeft(2, '0'); - final mm = dt.minute.toString().padLeft(2, '0'); - return '${_formatDate(dt)}, $hh:$mm'; - } - - String? _formatPhone(dynamic raw) { - String? digits; - if (raw is int && raw > 0) { - digits = raw.toString(); - } else if (raw is String && raw.isNotEmpty && raw != '***') { - digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); - if (digits.isEmpty) return null; - } - if (digits == null) return null; - if (digits.length == 11 && digits.startsWith('7')) { - final p = digits; - return '+${p[0]} (${p.substring(1, 4)}) ${p.substring(4, 7)}-${p.substring(7, 9)}-${p.substring(9)}'; - } - return '+$digits'; - } - - String? _formatGender(dynamic raw) { - if (raw is! int) return null; - switch (raw) { - case 1: - return 'Мужской'; - case 2: - return 'Женский'; - default: - return null; - } - } - Future _openChat() async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; @@ -204,7 +151,12 @@ class _ContactProfileScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ - _buildAvatar(cs), + KometAvatar( + name: _displayName(), + imageUrl: _avatarUrl(), + size: 96, + fontSize: 36, + ), const SizedBox(height: 14), _buildNameRow(cs), const SizedBox(height: 4), @@ -225,41 +177,6 @@ class _ContactProfileScreenState extends State { ); } - Widget _buildAvatar(ColorScheme cs) { - final url = _avatarUrl(); - return Container( - width: 96, - height: 96, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.primaryContainer, - ), - child: (url != null && url.isNotEmpty) - ? ClipOval( - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (_, _, _) => _avatarLetters(cs), - ), - ) - : _avatarLetters(cs), - ); - } - - Widget _avatarLetters(ColorScheme cs) { - final name = _displayName(); - return Center( - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 36, - fontWeight: FontWeight.bold, - ), - ), - ); - } - Widget _buildNameRow(ColorScheme cs) { return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -280,12 +197,7 @@ class _ContactProfileScreenState extends State { ), if (_isVerified) ...[ const SizedBox(width: 6), - Icon( - Symbols.verified, - color: cs.primary, - size: 20, - fill: 1, - ), + Icon(Symbols.verified, color: cs.primary, size: 20, fill: 1), ], ], ); @@ -295,8 +207,7 @@ class _ContactProfileScreenState extends State { final actions = <({IconData icon, String label, VoidCallback? onTap})>[ (icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat), (icon: Symbols.notifications, label: 'Звук', onTap: null), - if (!_isBot) - (icon: Symbols.call, label: 'Звонок', onTap: null), + if (!_isBot) (icon: Symbols.call, label: 'Звонок', onTap: null), ]; return Row( children: [ @@ -336,7 +247,7 @@ class _ContactProfileScreenState extends State { final rows = []; - final phoneStr = _formatPhone(c['phone']); + final phoneStr = formatPhone(c['phone']); if (phoneStr != null) { rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr)); } @@ -346,24 +257,45 @@ class _ContactProfileScreenState extends State { rows.add(_infoRow(cs, Symbols.public, 'Страна', country)); } - final genderStr = _formatGender(c['gender']); + final genderStr = formatGender(c['gender']); if (genderStr != null) { rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr)); } final regTime = c['registrationTime'] as int?; if (regTime != null && regTime > 0) { - rows.add(_infoRow(cs, Symbols.event, 'Регистрация', _formatDateTime(regTime))); + rows.add( + _infoRow( + cs, + Symbols.event, + 'Регистрация', + formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)), + ), + ); } final updateTime = c['updateTime'] as int?; if (updateTime != null && updateTime > 0) { - rows.add(_infoRow(cs, Symbols.update, 'Обновлён', _formatDateTime(updateTime))); + rows.add( + _infoRow( + cs, + Symbols.update, + 'Обновлён', + formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)), + ), + ); } final accountStatus = c['accountStatus']; if (accountStatus is int && accountStatus != 0) { - rows.add(_infoRow(cs, Symbols.account_circle, 'Статус аккаунта', accountStatus.toString())); + rows.add( + _infoRow( + cs, + Symbols.account_circle, + 'Статус аккаунта', + accountStatus.toString(), + ), + ); } final desc = (c['description'] as String?)?.trim(); @@ -383,7 +315,9 @@ class _ContactProfileScreenState extends State { final opts = _options(); if (opts.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true)); + rows.add( + _infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true), + ); } rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString())); @@ -401,7 +335,10 @@ class _ContactProfileScreenState extends State { children: [ for (var i = 0; i < rows.length; i++) ...[ if (i > 0) - Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), rows[i], ], ], diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 3e4abdc..595a3d7 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/protocol/opcode_map.dart'; @@ -6,6 +5,8 @@ import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../backend/modules/contacts.dart'; import '../../../main.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/sheet_helpers.dart'; import 'contact_profile_screen.dart'; class ContactsTab extends StatefulWidget { @@ -31,9 +32,7 @@ class _ContactsTabState extends State { context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) => const _SearchContactSheet(), ); } @@ -55,21 +54,6 @@ class _ContactsTabState extends State { } } - Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { - return Container( - color: cs.primaryContainer, - alignment: Alignment.center, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ); - } - Widget _buildContactItem( BuildContext context, ColorScheme cs, @@ -109,18 +93,10 @@ class _ContactsTabState extends State { width: 1, ), ), - child: ClipOval( - child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: contact.baseUrl!, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (context, url, error) => - _buildPlaceholderAvatar(cs, nameToDisplay), - ) - : _buildPlaceholderAvatar(cs, nameToDisplay), + child: KometAvatar( + name: nameToDisplay, + imageUrl: contact.baseUrl, + size: 48, ), ), const SizedBox(width: 16), @@ -366,8 +342,15 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { style: TextStyle(color: cs.onSurface, fontSize: 16), decoration: InputDecoration( hintText: 'Введите ID контакта', - hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), - prefixIcon: Icon(Symbols.tag, color: cs.onSurfaceVariant, size: 20), + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + prefixIcon: Icon( + Symbols.tag, + color: cs.onSurfaceVariant, + size: 20, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), @@ -380,19 +363,29 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { if (_error != null) ...[ const SizedBox(height: 10), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), decoration: BoxDecoration( color: cs.errorContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(Symbols.error_outline, size: 18, color: cs.onErrorContainer), + Icon( + Symbols.error_outline, + size: 18, + color: cs.onErrorContainer, + ), const SizedBox(width: 8), Expanded( child: Text( _error!, - style: TextStyle(color: cs.onErrorContainer, fontSize: 13), + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 13, + ), ), ), ], @@ -403,7 +396,9 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { FilledButton( onPressed: _loading ? null : _submit, style: FilledButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), padding: const EdgeInsets.symmetric(vertical: 14), ), child: _loading diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index a08b595..851db05 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -11,8 +11,10 @@ import '../../../backend/modules/chats.dart'; import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/upload_manager.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/format.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; enum _EnvState { loading, notConfigured, ready } @@ -108,7 +110,8 @@ class _CloudStorageScreenState extends State final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id); if (cachedId != null) { final rows = await ChatsModule.getChat(profile.id, cachedId); - if (rows.isNotEmpty && CloudStorageModule.isCloudStorageGroup(rows.first)) { + if (rows.isNotEmpty && + CloudStorageModule.isCloudStorageGroup(rows.first)) { if (!mounted) return; setState(() { _envState = _EnvState.ready; @@ -127,7 +130,10 @@ class _CloudStorageScreenState extends State final orphans = CloudStorageModule.findOrphanGroups(chats); if (envGroup == null && orphans.isNotEmpty) { - final repaired = await CloudStorageModule.repairOrphan(api, orphans.first); + final repaired = await CloudStorageModule.repairOrphan( + api, + orphans.first, + ); if (repaired != null) { envGroup = repaired; await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id); @@ -161,14 +167,23 @@ class _CloudStorageScreenState extends State void _deleteOrLeave(int accountId, CachedChat chat) async { final isAdmin = chat.owner == accountId || chat.admins.contains(accountId); if (isAdmin) { - await ChatsModule.deleteChat(api, chatId: chat.id, lastEventTime: chat.lastEventTime, forAll: true); + await ChatsModule.deleteChat( + api, + chatId: chat.id, + lastEventTime: chat.lastEventTime, + forAll: true, + ); } else { await ChatsModule.leaveChat(api, chatId: chat.id); } } Future _loadFiles(int accountId, int chatId) async { - final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId); + final files = await CloudStorageModule.fetchFiles( + messagesModule, + accountId, + chatId, + ); if (!mounted) return; setState(() => _files = files.reversed.toList()); } @@ -179,8 +194,11 @@ class _CloudStorageScreenState extends State _animateNewCard = true; }); if (_pageController.hasClients) { - _pageController.animateToPage(0, - duration: const Duration(milliseconds: 350), curve: Curves.easeOut); + _pageController.animateToPage( + 0, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + ); } Future.delayed(const Duration(milliseconds: 800), () { if (mounted) setState(() => _animateNewCard = false); @@ -248,7 +266,10 @@ class _CloudStorageScreenState extends State final ok = await messagesModule.sendFileMessage(chatId, id); if (!ok) return false; final newest = await CloudStorageModule.fetchLatestFile( - messagesModule, accountId, chatId, expectedFileId: id, + messagesModule, + accountId, + chatId, + expectedFileId: id, ); if (mounted) { if (newest != null) { @@ -316,23 +337,45 @@ class _CloudStorageScreenState extends State Text( 'Среда для облачного хранилища не настроена', textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600), + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 6), - Text('Начнем? Это быстро.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)), + Text( + 'Начнем? Это быстро.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), const SizedBox(height: 24), FilledButton( onPressed: _isCreatingEnv ? null : _setupEnv, style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), ), child: _isCreatingEnv ? SizedBox( - width: 18, height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), ) - : const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + : const Text( + 'Начать', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), ), ], ), @@ -382,7 +425,11 @@ class _CloudStorageScreenState extends State ); } - Widget _buildUploadingCenterHint(ColorScheme cs, double t, double availableWidth) { + Widget _buildUploadingCenterHint( + ColorScheme cs, + double t, + double availableWidth, + ) { final cardSide = availableWidth * _cardViewportFraction; return Center( child: Opacity( @@ -412,7 +459,9 @@ class _CloudStorageScreenState extends State ); if (i == 0 && _animateNewCard) { return _FadeScaleEntry( - key: ValueKey('${_files[0].messageId}_${_files[0].time}'), + key: ValueKey( + '${_files[0].messageId}_${_files[0].time}', + ), child: padded, ); } @@ -448,8 +497,10 @@ class _CloudStorageScreenState extends State const SizedBox(height: 8), Text( 'Загрузка ${(progress * 100).toStringAsFixed(0)}%', - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), ), ], ), @@ -556,11 +607,11 @@ class _UploadModeController { final AnimationController anim; _UploadModeController(TickerProvider vsync) - : anim = AnimationController( - vsync: vsync, - duration: _openDuration, - reverseDuration: _closeDuration, - ); + : anim = AnimationController( + vsync: vsync, + duration: _openDuration, + reverseDuration: _closeDuration, + ); bool get isOpen => anim.value > 0; @@ -706,10 +757,7 @@ class _DragDownHintState extends State<_DragDownHint> if (phase > _activeFraction) return (dy: 0, opacity: 0); final local = phase / _activeFraction; final eased = Curves.easeOutCubic.transform(local); - return ( - dy: _startY + eased * _travel, - opacity: (1 - local) * _peakOpacity, - ); + return (dy: _startY + eased * _travel, opacity: (1 - local) * _peakOpacity); } } @@ -738,9 +786,15 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry> @override void initState() { super.initState(); - _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 550)); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 550), + ); _scale = CurvedAnimation(parent: _c, curve: Curves.elasticOut); - _opacity = CurvedAnimation(parent: _c, curve: const Interval(0, 0.4, curve: Curves.easeIn)); + _opacity = CurvedAnimation( + parent: _c, + curve: const Interval(0, 0.4, curve: Curves.easeIn), + ); _c.forward(); } @@ -789,7 +843,7 @@ class _CloudFileCard extends StatelessWidget { final d = DateTime.fromMillisecondsSinceEpoch(millis); final now = DateTime.now(); if (d.year == now.year && d.month == now.month && d.day == now.day) { - return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + return formatClock(d); } return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}'; } @@ -805,7 +859,10 @@ class _CloudFileCard extends StatelessWidget { decoration: BoxDecoration( color: cs.surfaceContainerLow, borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -834,7 +891,10 @@ class _CloudFileCard extends StatelessWidget { const SizedBox(width: 4), Text( _formatTime(file.time), - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 10, + ), ), ], ), @@ -889,18 +949,23 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { chatId: f.chatId, messageId: f.messageId, ); - if (mounted) setState(() { _link = result; _loading = false; }); + if (mounted) { + setState(() { + _link = result; + _loading = false; + }); + } } static String _formatSize(int? bytes) { if (bytes == null) return '—'; - if (bytes < 1024) return '$bytes Б'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + return formatBytes(bytes); } static String _formatExpiry(int expiresMs) { - final remaining = DateTime.fromMillisecondsSinceEpoch(expiresMs).difference(DateTime.now()); + final remaining = DateTime.fromMillisecondsSinceEpoch( + expiresMs, + ).difference(DateTime.now()); if (remaining.isNegative) return 'истекла'; final h = remaining.inHours; final m = remaining.inMinutes % 60; @@ -913,7 +978,8 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final f = widget.file; - final isExpired = _link == null || + final isExpired = + _link == null || _link!.expires <= DateTime.now().millisecondsSinceEpoch; return Container( @@ -922,25 +988,25 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), padding: EdgeInsets.fromLTRB( - 24, 16, 24, + 24, + 16, + 24, MediaQuery.of(context).viewInsets.bottom + 32, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 36, height: 4, - decoration: BoxDecoration( - color: cs.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), + const Center(child: SheetGrabber(margin: EdgeInsets.zero)), + const SizedBox(height: 20), + Text( + f.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 20), - Text(f.name, - style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)), const SizedBox(height: 12), _InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? '—'), const SizedBox(height: 6), @@ -952,16 +1018,27 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { children: [ Expanded( child: isExpired - ? Text('Ссылки пока нет. Создайте.', - style: TextStyle(color: cs.error, fontSize: 13)) - : Text('Ссылка истечет ${_formatExpiry(_link!.expires)}', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ? Text( + 'Ссылки пока нет. Создайте.', + style: TextStyle(color: cs.error, fontSize: 13), + ) + : Text( + 'Ссылка истечет ${_formatExpiry(_link!.expires)}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), ), const SizedBox(width: 8), _loading ? SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), ) : IconButton( icon: Icon( @@ -974,8 +1051,13 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { onPressed: isExpired ? _generateLink : () { - Clipboard.setData(ClipboardData(text: _link!.url)); - showCustomNotification(context, 'Ссылка скопирована'); + Clipboard.setData( + ClipboardData(text: _link!.url), + ); + showCustomNotification( + context, + 'Ссылка скопирована', + ); }, ), ], @@ -996,11 +1078,18 @@ class _InfoRow extends StatelessWidget { final cs = Theme.of(context).colorScheme; return Row( children: [ - Text('$label: ', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + '$label: ', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), Expanded( child: Text( value, - style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500), + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500, + ), overflow: TextOverflow.ellipsis, ), ), @@ -1053,24 +1142,25 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), padding: EdgeInsets.fromLTRB( - 24, 16, 24, + 24, + 16, + 24, MediaQuery.of(context).viewInsets.bottom + 32, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 36, height: 4, - decoration: BoxDecoration( - color: cs.outlineVariant, borderRadius: BorderRadius.circular(2), - ), + const Center(child: SheetGrabber(margin: EdgeInsets.zero)), + const SizedBox(height: 20), + Text( + 'Отправить по ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 20), - Text('Отправить по ID', - style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)), const SizedBox(height: 12), TextField( controller: _controller, @@ -1087,7 +1177,10 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), ), const SizedBox(height: 16), @@ -1095,14 +1188,23 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { onPressed: _sending ? null : _submit, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), child: _sending ? SizedBox( - width: 18, height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), ) - : const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)), + : const Text( + 'Отправить', + style: TextStyle(fontWeight: FontWeight.w600), + ), ), ], ), diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index f07c0e4..10ebc4b 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -10,10 +10,12 @@ import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/protocol/packet.dart'; +import '../../../core/utils/format.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../widgets/login_success_screen.dart'; import '../calls/call_screen.dart'; @@ -53,7 +55,7 @@ class _DebugMenuScreenState extends State { _clearingCache = false; _cacheSize = 0; }); - showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})'); + showCustomNotification(context, 'Кэш очищен (${formatBytes(freed)})'); } void _pickCacheLimit() { @@ -61,9 +63,7 @@ class _DebugMenuScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -106,16 +106,7 @@ class _DebugMenuScreenState extends State { } String _limitLabel(int bytes) => - bytes <= 0 ? 'Без лимита' : _formatBytes(bytes); - - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes Б'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ'; - } + bytes <= 0 ? 'Без лимита' : formatBytes(bytes); @override void dispose() { @@ -133,7 +124,10 @@ class _DebugMenuScreenState extends State { _errors.clear(); }); - Future tryProbe(String label, Future Function() probe) async { + Future tryProbe( + String label, + Future Function() probe, + ) async { try { final res = await probe(); logger.i('debug-search $label($id): $res'); @@ -147,11 +141,15 @@ class _DebugMenuScreenState extends State { await Future.wait([ tryProbe('contactInfo', () async { - final p = await api.sendRequest(Opcode.contactInfo, {'contactIds': [id]}); + final p = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': [id], + }); return p.payload; }), tryProbe('chatInfo', () async { - final p = await api.sendRequest(Opcode.chatInfo, {'chatIds': [id]}); + final p = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [id], + }); return p.payload; }), tryProbe('publicSearch', () => ChatsModule.searchById(api, id)), @@ -704,7 +702,7 @@ class _DebugMenuScreenState extends State { Text( _clearingCache ? 'Очистка…' - : 'Занято: ${_formatBytes(_cacheSize)}', + : 'Занято: ${formatBytes(_cacheSize)}', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -958,7 +956,10 @@ class _DebugMenuScreenState extends State { padding: const EdgeInsets.all(12), child: Text( 'Ничего не найдено', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), ), ), for (final hit in _hits) ...[ @@ -1152,7 +1153,11 @@ class _SearchResultCard extends StatelessWidget { ), IconButton( tooltip: 'Скопировать id', - icon: Icon(Symbols.content_copy, size: 18, color: cs.onSurfaceVariant), + icon: Icon( + Symbols.content_copy, + size: 18, + color: cs.onSurfaceVariant, + ), onPressed: () async { await Clipboard.setData(ClipboardData(text: hit.id.toString())); if (context.mounted) { @@ -1292,10 +1297,7 @@ class _ErrorChip extends StatelessWidget { Expanded( child: Text( '$label: $message', - style: TextStyle( - color: cs.onErrorContainer, - fontSize: 12, - ), + style: TextStyle(color: cs.onErrorContainer, fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -1347,4 +1349,4 @@ class _DebugCallButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index ab9b715..067b6c7 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart' import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/utils/format.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import 'web_qr_scan_screen.dart'; class DevicesScreen extends StatefulWidget { @@ -125,16 +127,7 @@ class _DevicesScreenState extends State mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(2), - ), - ), - ), + const Center(child: SheetGrabber(margin: EdgeInsets.zero)), const SizedBox(height: 20), Text( 'Вход по QR', @@ -158,8 +151,7 @@ class _DevicesScreenState extends State children: [ Expanded( child: OutlinedButton( - onPressed: () => - Navigator.of(sheetContext).pop(false), + onPressed: () => Navigator.of(sheetContext).pop(false), child: Text( 'Отмена', style: TextStyle(color: cs.onSurface), @@ -169,8 +161,7 @@ class _DevicesScreenState extends State const SizedBox(width: 12), Expanded( child: FilledButton( - onPressed: () => - Navigator.of(sheetContext).pop(true), + onPressed: () => Navigator.of(sheetContext).pop(true), child: const Text('Войти'), ), ), @@ -186,7 +177,8 @@ class _DevicesScreenState extends State } Future _startWebQrAuth() async { - final canScan = !kIsWeb && + final canScan = + !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); @@ -310,29 +302,14 @@ class _DevicesScreenState extends State if (now.year == date.year && now.month == date.month && now.day == date.day) { - return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + return formatClock(date); } - final months = [ - 'янв.', - 'февр.', - 'мар.', - 'апр.', - 'мая', - 'июня', - 'июля', - 'авг.', - 'сент.', - 'окт.', - 'нояб.', - 'дек.', - ]; - if (now.year == date.year) { - return '${date.day} ${months[date.month - 1]}'; + return '${date.day} ${kRuMonthsShort[date.month - 1]}'; } - return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}'; + return formatDateNumeric(date); } @override diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 200bc49..a1da0b7 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -7,8 +7,6 @@ import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/custom_notification.dart'; -const int _maxAvatarBytes = 8 * 1024 * 1024; - class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -62,7 +60,9 @@ class _EditProfileScreenState extends State { try { final newProfile = await accountModule.updateProfileName( firstName, - _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), + _lastNameController.text.trim().isEmpty + ? null + : _lastNameController.text.trim(), ); _avatarUrl = newProfile.baseUrl; _photoId = newProfile.photoId; @@ -92,8 +92,10 @@ class _EditProfileScreenState extends State { if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); return; } - if (bytes.length > _maxAvatarBytes) { - if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + if (bytes.length > kMaxAvatarBytes) { + if (mounted) { + showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + } return; } if (!mounted) return; @@ -183,7 +185,10 @@ class _EditProfileScreenState extends State { ) : Text( l10n?.editProfileSave ?? 'Save', - style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600), + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -214,7 +219,8 @@ class _EditProfileScreenState extends State { alignment: Alignment.center, child: Text( _firstNameController.text.isNotEmpty - ? _firstNameController.text[0].toUpperCase() + ? _firstNameController.text[0] + .toUpperCase() : '?', style: TextStyle( color: cs.onPrimaryContainer, @@ -234,7 +240,11 @@ class _EditProfileScreenState extends State { shape: BoxShape.circle, ), child: IconButton( - icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20), + icon: Icon( + Symbols.camera_alt, + color: cs.onPrimary, + size: 20, + ), onPressed: _changeAvatar, ), ), @@ -274,13 +284,21 @@ class _EditProfileScreenState extends State { ); } - Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) { + Widget _buildTextField( + String label, + TextEditingController controller, + ColorScheme cs, { + bool enabled = true, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), ), TextField( controller: controller, @@ -292,10 +310,13 @@ class _EditProfileScreenState extends State { borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), ), ), ], ); } -} \ No newline at end of file +} diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 58febca..04477cb 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -5,6 +5,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/section_header.dart'; class InfoScreen extends StatefulWidget { const InfoScreen({super.key}); @@ -65,13 +66,13 @@ class _InfoScreenState extends State { body: _isLoading ? const Center(child: CircularProgressIndicator()) : _info == null - ? Center( - child: Text( - 'No data', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ) - : _buildContent(cs, l10n!), + ? Center( + child: Text( + 'No data', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ) + : _buildContent(cs, l10n!), ); } @@ -114,29 +115,49 @@ class _InfoScreenState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - _buildSectionTitle(l10n.infoAccountSection, cs), - ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)), + SectionHeader(l10n.infoAccountSection), + ...accountKeys.entries.map( + (e) => + _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs), + ), const SizedBox(height: 16), - _buildSectionTitle(l10n.infoServerSection, cs), - ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)), + SectionHeader(l10n.infoServerSection), + ...serverKeys.entries.map( + (e) => _buildRow( + e.key, + e.value, + _formatValue(server?[e.key], e.key), + cs, + ), + ), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoYMapSection, cs), + SectionHeader(l10n.infoYMapSection), _buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs), - _buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs), - _buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs), + _buildRow( + 'geocoder', + l10n.infoGeocoder, + yMap?['geocoder']?.toString() ?? '-', + cs, + ), + _buildRow( + 'static', + l10n.infoStatic, + yMap?['static']?.toString() ?? '-', + cs, + ), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoFileUploadTypes, cs), + SectionHeader(l10n.infoFileUploadTypes), _buildListRow(server?['file-upload-unsupported-types'] as List?, cs), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoWhiteListLinks, cs), + SectionHeader(l10n.infoWhiteListLinks), _buildListRow(server?['white-list-links'] as List?, cs), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoUserSection, cs), + SectionHeader(l10n.infoUserSection), if (user != null) ...user.entries .where((e) => e.value != null) @@ -147,21 +168,6 @@ class _InfoScreenState extends State { ); } - Widget _buildSectionTitle(String title, ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - ); - } - Widget _buildRow(String key, String label, String value, ColorScheme cs) { return Container( margin: const EdgeInsets.only(bottom: 1), @@ -224,7 +230,10 @@ class _InfoScreenState extends State { children: items .map( (item) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), decoration: BoxDecoration( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), @@ -250,7 +259,10 @@ class _InfoScreenState extends State { if (key == 'edit-timeout' && value is int && value > 0) { final weeks = value ~/ 604800; final days = (value % 604800) ~/ 86400; - if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); + if (weeks > 0) { + return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}' + .trim(); + } final h = value ~/ 3600; final m = (value % 3600) ~/ 60; if (h > 0) return '${h}h ${m}m'; @@ -279,4 +291,4 @@ class _InfoScreenState extends State { if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн'; return 'дн'; } -} \ No newline at end of file +} diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index ee5b9f5..30ae97d 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/section_header.dart'; +import '../../widgets/sheet_helpers.dart'; + class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -29,9 +32,7 @@ class _NotificationsScreenState extends State { final picked = await showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (context) { return SafeArea( child: Padding( @@ -67,10 +68,7 @@ class _NotificationsScreenState extends State { ), title: Text( s, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - ), + style: TextStyle(color: cs.onSurface, fontSize: 16), ), ), ], @@ -90,17 +88,18 @@ class _NotificationsScreenState extends State { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E( - titleText: 'Уведомления', - backgroundColor: cs.surface, - ), + appBar: AppBarM3E(titleText: 'Уведомления', backgroundColor: cs.surface), body: SafeArea( top: false, child: ListView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ - _sectionHeader(cs, 'FKM'), + const SectionHeader( + 'FKM', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), _card(cs, [ _toggleRow( cs, @@ -113,7 +112,11 @@ class _NotificationsScreenState extends State { ), ]), const SizedBox(height: 20), - _sectionHeader(cs, 'Настройки уведомлений'), + const SectionHeader( + 'Настройки уведомлений', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), _card(cs, [ _toggleRow( cs, @@ -140,7 +143,11 @@ class _NotificationsScreenState extends State { ), ]), const SizedBox(height: 20), - _sectionHeader(cs, 'Звук'), + const SectionHeader( + 'Звук', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), _card(cs, [ _tappableRow( cs, @@ -156,21 +163,6 @@ class _NotificationsScreenState extends State { ); } - Widget _sectionHeader(ColorScheme cs, String title) { - return Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 14, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), - ), - ); - } - Widget _card(ColorScheme cs, List children) { return Container( decoration: BoxDecoration( @@ -275,10 +267,7 @@ class _NotificationsScreenState extends State { ), Text( trailingText, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - ), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(width: 6), Icon(Symbols.chevron_right, color: cs.outline, size: 20), diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index 84ee9b9..12e95f2 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show TwoFactorDetails; import '../../../core/storage/app_database.dart'; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; class PasswordEntryScreen extends StatefulWidget { @@ -270,36 +271,22 @@ class _PasswordEntryScreenState extends State { ); } - void _showRemoveConfirmation(BuildContext context, ColorScheme cs) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)), - content: Text( + Future _showRemoveConfirmation( + BuildContext context, + ColorScheme cs, + ) async { + final confirmed = await showConfirmDialog( + context, + title: 'Удалить пароль?', + message: 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', - style: TextStyle(color: cs.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Отмена', style: TextStyle(color: cs.primary)), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TwoFactorRemoveScreen(), - ), - ); - }, - child: Text('Удалить', style: TextStyle(color: cs.error)), - ), - ], - ), + confirmLabel: 'Удалить', + destructive: true, + ); + if (!confirmed || !context.mounted) return; + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TwoFactorRemoveScreen()), ); } } @@ -813,10 +800,7 @@ class _TwoFactorManageScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - _PasswordField( - controller: _passwordController, - hintText: 'Пароль', - ), + _PasswordField(controller: _passwordController, hintText: 'Пароль'), const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1414,10 +1398,7 @@ class _TwoFactorRemoveScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - _PasswordField( - controller: _passwordController, - hintText: 'Пароль', - ), + _PasswordField(controller: _passwordController, hintText: 'Пароль'), const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1457,10 +1438,7 @@ class _PasswordField extends StatefulWidget { final TextEditingController controller; final String hintText; - const _PasswordField({ - required this.controller, - required this.hintText, - }); + const _PasswordField({required this.controller, required this.hintText}); @override State<_PasswordField> createState() => _PasswordFieldState(); diff --git a/lib/frontend/screens/profile/performance_screen.dart b/lib/frontend/screens/profile/performance_screen.dart index de98406..6d146f3 100644 --- a/lib/frontend/screens/profile/performance_screen.dart +++ b/lib/frontend/screens/profile/performance_screen.dart @@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/utils/haptics.dart'; +import '../../widgets/confirm_dialog.dart'; class PerformanceScreen extends StatefulWidget { const PerformanceScreen({super.key}); @@ -25,7 +26,8 @@ class _PerformanceScreenState extends State { } bool _isInSafeZone(double v) => - v >= AppCacheExtent.lowWarnThreshold && v < AppCacheExtent.highWarnThreshold; + v >= AppCacheExtent.lowWarnThreshold && + v < AppCacheExtent.highWarnThreshold; void _onChanged(double v) { setState(() { @@ -41,8 +43,7 @@ class _PerformanceScreenState extends State { if (inLow && !_lowWarnDismissed) { final ok = await _showWarning( - text: - 'Производительность приложения может снизиться, вы уверены?', + text: 'Производительность приложения может снизиться, вы уверены?', ); if (ok) { _lowWarnDismissed = true; @@ -73,37 +74,13 @@ class _PerformanceScreenState extends State { await AppCacheExtent.save(v); } - Future _showWarning({required String text}) async { - final cs = Theme.of(context).colorScheme; - final res = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - content: Text( - text, - style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - 'Нет', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ), - FilledButton.tonal( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Да'), - ), - ], - ); - }, + Future _showWarning({required String text}) { + return showConfirmDialog( + context, + message: text, + confirmLabel: 'Да', + cancelLabel: 'Нет', ); - return res ?? false; } @override diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 680234b..a4bf972 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -5,7 +5,9 @@ import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show PrivacyConfig, BlockedContact; import '../../../core/storage/app_database.dart'; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import 'password_entry_screen.dart'; class SecurityScreen extends StatefulWidget { @@ -539,14 +541,7 @@ class _SecurityScreenState extends State mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), + const SheetGrabber(margin: EdgeInsets.zero), const SizedBox(height: 16), Text( title, @@ -598,37 +593,20 @@ class _SecurityScreenState extends State ); } - void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) { + Future _showHiddenStatusSheet( + BuildContext context, + ColorScheme cs, + ) async { final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; if (currentValue == 'NONE') { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), - content: Text( - 'Вы не сможете видеть статусы посещения других пользователей.', - style: TextStyle(color: cs.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Отмена', style: TextStyle(color: cs.primary)), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - _updateSetting('HIDDEN', false); - }, - child: Text('Да', style: TextStyle(color: cs.primary)), - ), - ], - ), + final confirmed = await showConfirmDialog( + context, + title: 'Вы уверены?', + message: 'Вы не сможете видеть статусы посещения других пользователей.', + confirmLabel: 'Да', ); + if (confirmed) _updateSetting('HIDDEN', false); return; } @@ -644,14 +622,7 @@ class _SecurityScreenState extends State mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), + const SheetGrabber(margin: EdgeInsets.zero), const SizedBox(height: 16), Text( 'Видеть статус «в сети»', @@ -683,32 +654,17 @@ class _SecurityScreenState extends State ); } - void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), - content: Text( - 'Вы не сможете видеть статусы посещения других пользователей.', - style: TextStyle(color: cs.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Отмена', style: TextStyle(color: cs.primary)), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - _updateSetting('HIDDEN', true); - }, - child: Text('Да', style: TextStyle(color: cs.primary)), - ), - ], - ), + Future _showHiddenStatusConfirmDialog( + BuildContext context, + ColorScheme cs, + ) async { + final confirmed = await showConfirmDialog( + context, + title: 'Вы уверены?', + message: 'Вы не сможете видеть статусы посещения других пользователей.', + confirmLabel: 'Да', ); + if (confirmed) _updateSetting('HIDDEN', true); } Widget _buildOptionSheetItem( diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index d8c7ba1..dd43344 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,6 +1,5 @@ import 'dart:async'; -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'; @@ -12,6 +11,8 @@ import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/info_action_sheet.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/sheet_helpers.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; import 'cloud_storage_screen.dart'; @@ -144,9 +145,7 @@ class _SettingsTabState extends State { final confirmed = await showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (ctx) { return SafeArea( child: Padding( @@ -245,11 +244,14 @@ class _SettingsTabState extends State { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), -child: _buildSection( + child: _buildSection( context, cs, items: [ - const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), + const _SettingsItem( + icon: Symbols.badge, + label: 'Цифровой ID', + ), const _SettingsItem( icon: Symbols.language, label: 'Войти в Сферум', @@ -344,15 +346,9 @@ child: _buildSection( context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(24), - ), - ), + shape: kSheetShape, builder: (_) { - return SafeArea( - child: const ProxySettingsSheet(), - ); + return SafeArea(child: const ProxySettingsSheet()); }, ); }, @@ -407,10 +403,7 @@ child: _buildSection( child: Align( alignment: Alignment.topCenter, heightFactor: animation.value.clamp(0.0, 1.0), - child: FadeTransition( - opacity: animation, - child: child, - ), + child: FadeTransition(opacity: animation, child: child), ), ); }, @@ -418,10 +411,7 @@ child: _buildSection( return Stack( alignment: Alignment.topCenter, clipBehavior: Clip.none, - children: [ - ...previousChildren, - ?currentChild, - ], + children: [...previousChildren, ?currentChild], ); }, child: _debugMenuVisible @@ -557,18 +547,11 @@ child: _buildSection( width: 2.5, ), ), - child: ClipOval( - child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: _profile!.baseUrl!, - fit: BoxFit.cover, - memCacheWidth: 240, - memCacheHeight: 240, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (context, url, error) => - _buildPlaceholderAvatar(cs, name), - ) - : _buildPlaceholderAvatar(cs, name), + child: KometAvatar( + name: name, + imageUrl: _profile?.baseUrl, + size: 88, + fontSize: 32, ), ), const SizedBox(height: 14), @@ -614,21 +597,6 @@ child: _buildSection( ); } - Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { - return Container( - color: cs.primaryContainer, - alignment: Alignment.center, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 32, - fontWeight: FontWeight.bold, - ), - ), - ); - } - Widget _buildSection( BuildContext context, ColorScheme cs, { diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index 48434c0..bf173af 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -14,6 +14,7 @@ import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/info_action_sheet.dart'; +import '../../widgets/section_header.dart'; import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -621,19 +622,6 @@ class _SpoofScreenState extends State { ); } - Widget _buildSectionHeader(BuildContext context, String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ); - } - Widget _buildMainDataCard() { final l10n = AppLocalizations.of(context)!; return Card( @@ -642,7 +630,11 @@ class _SpoofScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader(context, l10n.spoofMainSectionTitle), + SectionHeader( + l10n.spoofMainSectionTitle, + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + fontSize: 22, + ), TextField( controller: _deviceNameController, decoration: _inputDecoration( @@ -672,7 +664,11 @@ class _SpoofScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader(context, l10n.spoofRegionalSectionTitle), + SectionHeader( + l10n.spoofRegionalSectionTitle, + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + fontSize: 22, + ), TextField( controller: _screenController, decoration: _inputDecoration( @@ -721,7 +717,11 @@ class _SpoofScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader(context, l10n.spoofIdentifiersSectionTitle), + SectionHeader( + l10n.spoofIdentifiersSectionTitle, + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + fontSize: 22, + ), _buildDescriptionTile( icon: Icons.info_outline, color: Theme.of(context).colorScheme.tertiary, diff --git a/lib/frontend/widgets/account_switcher_overlay.dart b/lib/frontend/widgets/account_switcher_overlay.dart index 983692c..7012edd 100644 --- a/lib/frontend/widgets/account_switcher_overlay.dart +++ b/lib/frontend/widgets/account_switcher_overlay.dart @@ -1,6 +1,5 @@ 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'; @@ -8,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/haptics.dart'; +import 'komet_avatar.dart'; class AccountSwitcherController extends ChangeNotifier { Offset? pointer; @@ -301,9 +301,7 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> highlighted: _hoveredIndex == i, active: _accounts[i].id == _activeId, ), - _AddAccountRow( - highlighted: _hoveredIndex == _accounts.length, - ), + _AddAccountRow(highlighted: _hoveredIndex == _accounts.length), ], ), ), @@ -363,17 +361,15 @@ class _AccountRow extends StatelessWidget { ) : 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), + child: KometAvatar( + name: fullName, + imageUrl: profile.baseUrl, + size: 36, + backgroundColor: highlighted + ? cs.primaryContainer + : cs.surfaceContainerHighest, + foregroundColor: cs.onSurface, + fontSize: 16, ), ), const SizedBox(width: 12), @@ -418,21 +414,6 @@ class _AccountRow extends StatelessWidget { ), ); } - - 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 { diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 81d5bd6..1467aed 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/media/gallery_source.dart'; +import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; const List _navItems = [ @@ -126,7 +128,7 @@ class _AttachmentSheetState extends State { clipBehavior: Clip.antiAlias, child: Column( children: [ - _buildHandle(cs), + const SheetGrabber(), Expanded( child: Stack( children: [ @@ -178,18 +180,6 @@ class _AttachmentSheetState extends State { static const double _barHeight = SlidingPillNav.height + _pillMargin; static const Duration _navAnim = Duration(milliseconds: 300); - Widget _buildHandle(ColorScheme cs) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 10), - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ); - } - Widget _buildPages( ScrollController scrollController, ColorScheme cs, @@ -597,7 +587,7 @@ class _GalleryTileState extends State<_GalleryTile> { ), if (item.duration != null) Text( - _formatDuration(item.duration!), + formatDurationMmSs(item.duration!), style: const TextStyle( color: Colors.white, fontSize: 11, @@ -617,12 +607,6 @@ class _GalleryTileState extends State<_GalleryTile> { ), ); } - - String _formatDuration(Duration d) { - final m = d.inMinutes; - final s = (d.inSeconds % 60).toString().padLeft(2, '0'); - return '$m:$s'; - } } class _SelectionCheck extends StatelessWidget { diff --git a/lib/frontend/widgets/confirm_dialog.dart b/lib/frontend/widgets/confirm_dialog.dart new file mode 100644 index 0000000..e158c4d --- /dev/null +++ b/lib/frontend/widgets/confirm_dialog.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// Shared confirmation dialog. Returns true if confirmed, false otherwise. +Future showConfirmDialog( + BuildContext context, { + String? title, + required String message, + String confirmLabel = 'OK', + String cancelLabel = 'Отмена', + bool destructive = false, +}) async { + final cs = Theme.of(context).colorScheme; + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + title: title == null + ? null + : Text(title, style: TextStyle(color: cs.onSurface)), + content: Text( + message, + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelLabel, style: TextStyle(color: cs.onSurfaceVariant)), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(true), + style: destructive + ? FilledButton.styleFrom( + backgroundColor: cs.errorContainer, + foregroundColor: cs.onErrorContainer, + ) + : null, + child: Text(confirmLabel), + ), + ], + ), + ); + return result ?? false; +} diff --git a/lib/frontend/widgets/komet_avatar.dart b/lib/frontend/widgets/komet_avatar.dart new file mode 100644 index 0000000..000eea7 --- /dev/null +++ b/lib/frontend/widgets/komet_avatar.dart @@ -0,0 +1,58 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +/// Circular avatar: shows [imageUrl] when available, otherwise the first letter +/// of [name] on a colored background. Falls back to the letter on image error. +class KometAvatar extends StatelessWidget { + final String name; + final String? imageUrl; + final double size; + final Color? backgroundColor; + final Color? foregroundColor; + final double? fontSize; + + const KometAvatar({ + super.key, + required this.name, + required this.size, + this.imageUrl, + this.backgroundColor, + this.foregroundColor, + this.fontSize, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bg = backgroundColor ?? cs.primaryContainer; + final fg = foregroundColor ?? cs.onPrimaryContainer; + final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final placeholder = Center( + child: Text( + letter, + style: TextStyle( + color: fg, + fontSize: fontSize ?? size * 0.4, + fontWeight: FontWeight.bold, + ), + ), + ); + final url = imageUrl; + final cache = (size * 3).round(); + return Container( + width: size, + height: size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + child: (url != null && url.isNotEmpty) + ? CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: cache, + memCacheHeight: cache, + errorWidget: (_, _, _) => placeholder, + ) + : placeholder, + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index a8f5ef9..2ca460e 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -7,6 +7,7 @@ import '../../backend/modules/messages.dart'; import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.dart'; +import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../core/utils/file_download.dart'; import '../../core/utils/media_cache.dart'; @@ -58,13 +59,14 @@ class MessageBubble extends StatelessWidget { static const Radius _photoRadius = Radius.circular(photoBorderRadius); static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); - static const BorderRadius _reactionChipRadius = - BorderRadius.all(Radius.circular(10)); + static const BorderRadius _reactionChipRadius = BorderRadius.all( + Radius.circular(10), + ); static Color bubbleTextColor(BuildContext context) => Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black; + ? Colors.white + : Colors.black; final CachedMessage message; final bool isMe; @@ -108,13 +110,15 @@ class MessageBubble extends StatelessWidget { final hasPrevFromMe = prevMessage?.senderId == message.senderId && !prevMessage!.isControl; - final prevTimeDiff = - hasPrevFromMe ? message.time - prevMessage!.time : 999999999; + final prevTimeDiff = hasPrevFromMe + ? message.time - prevMessage!.time + : 999999999; final hasNextFromMe = nextMessage?.senderId == message.senderId && !nextMessage!.isControl; - final nextTimeDiff = - hasNextFromMe ? nextMessage!.time - message.time : 999999999; + final nextTimeDiff = hasNextFromMe + ? nextMessage!.time - message.time + : 999999999; final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; @@ -133,9 +137,11 @@ class MessageBubble extends StatelessWidget { if (first is ForwardedMessageAttachment) { final fwd = first; final hasContact = fwd.originalContact != null; - final hasPhoto = fwd.originalAttachments != null && + final hasPhoto = + fwd.originalAttachments != null && fwd.originalAttachments!.any((a) => a is PhotoAttachment); - final hasOther = fwd.originalAttachments != null && + final hasOther = + fwd.originalAttachments != null && fwd.originalAttachments!.isNotEmpty; if (hasContact || hasPhoto || hasOther) return MessageType.attachment; return MessageType.text; @@ -219,8 +225,10 @@ class MessageBubble extends StatelessWidget { bool hasPhotoWithCaption, bool hasMultiplePhotosNoCaption, ) { - final isTop = shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle; - final isBottom = shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle; + final isTop = + shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle; + final isBottom = + shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle; return computeBubbleRadius( isMe: isMe, isTop: isTop, @@ -238,7 +246,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) { return CircleAvatar( radius: 15, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: cs.primaryContainer, ); } @@ -280,36 +292,35 @@ class MessageBubble extends StatelessWidget { final padding = _paddingFor(contentType, shape); final showAvatarSlot = !isMe; - final showAvatar = showAvatarSlot && + final showAvatar = + showAvatarSlot && chatType == "CHAT" && nextMessage?.senderId != message.senderId; final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; - final bubbleColor = - isMe ? cs.primaryContainer : cs.surfaceContainerHighest; + final bubbleColor = isMe ? cs.primaryContainer : cs.surfaceContainerHighest; _BubbleCtx makeCtx() => _BubbleCtx( - context: context, - cs: cs, - text: textColor, - shape: shape, - contentType: contentType, - hasPhotoWithCaption: hasPhotoCap, - hasMultiplePhotosNoCaption: hasMultiPhotos, - reactionInfo: _resolveReactionInfo(), - ); + context: context, + cs: cs, + text: textColor, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoCap, + hasMultiplePhotosNoCaption: hasMultiPhotos, + reactionInfo: _resolveReactionInfo(), + ); final Widget bubbleContent = reactionsListenable != null && contentType == MessageType.text - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, _, _) => _buildContent(makeCtx()), - ) - : _buildContent(makeCtx()); + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, _, _) => _buildContent(makeCtx()), + ) + : _buildContent(makeCtx()); final reactionsUnder = _reactionsUnderBubble(contentType); - final reactionsInside = - contentType != MessageType.text && !reactionsUnder; + final reactionsInside = contentType != MessageType.text && !reactionsUnder; return GestureDetector( onTap: Haptics.tap, @@ -322,8 +333,9 @@ class MessageBubble extends StatelessWidget { ), child: Align( child: Row( - mainAxisAlignment: - isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, spacing: 8, crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -337,8 +349,9 @@ class MessageBubble extends StatelessWidget { backgroundColor: Color(0x00000000), ), Column( - crossAxisAlignment: - isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ ListenableBuilder( listenable: Listenable.merge([ @@ -540,7 +553,8 @@ class MessageBubble extends StatelessWidget { Widget _buildTextContent(_BubbleCtx ctx) { final attachments = message.attachments; - final isForwardedContact = attachments != null && + final isForwardedContact = + attachments != null && attachments.isNotEmpty && attachments.first is ForwardedMessageAttachment && (attachments.first as ForwardedMessageAttachment).originalContact != @@ -558,21 +572,18 @@ class MessageBubble extends StatelessWidget { ? _buildForwardedInlineText(ctx, forwarded) : Text( message.text ?? '', - style: TextStyle( - color: ctx.text, - fontSize: 16, - height: 1.3, - ), + style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), ); final metaWidget = Text( message.status == 'EDITED' - ? '${_formatTime(message.time)} ред.' - : _formatTime(message.time), + ? '${formatClock(DateTime.fromMillisecondsSinceEpoch(message.time))} ред.' + : formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)), style: TextStyle(color: ctx.dim, fontSize: 10), ); - final showSender = message.senderId != message.accountId && + final showSender = + message.senderId != message.accountId && prevMessage?.senderId != message.senderId && chatType == "CHAT"; @@ -604,10 +615,7 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.only(bottom: 2), child: metaWidget, ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), - ], + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], ], ), ], @@ -625,21 +633,18 @@ class MessageBubble extends StatelessWidget { style: TextStyle(color: ctx.text), ), Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible(child: textWidget), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: metaWidget, - ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), - ], - ], - ), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: textWidget), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: metaWidget, + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], + ], + ), ], ); } @@ -667,7 +672,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -735,8 +744,9 @@ class MessageBubble extends StatelessWidget { if (fwd.originalContact != null) { return _buildForwardedContactContent(ctx, fwd); } - final photos = - fwd.originalAttachments?.whereType().toList(); + final photos = fwd.originalAttachments + ?.whereType() + .toList(); if (photos != null && photos.isNotEmpty) { return _buildForwardedPhotoContent(ctx, fwd, photos); } @@ -885,7 +895,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -954,7 +968,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -1007,10 +1025,12 @@ class MessageBubble extends StatelessWidget { final matchBottom = !ctx.hasPhotoWithCaption; final topR = matchTop ? _bigRadius : _photoRadius; - final bottomL = - matchBottom ? (isMe ? _bigRadius : _smallRadius) : _smallRadius; - final bottomR = - matchBottom ? (isMe ? _smallRadius : _bigRadius) : _smallRadius; + final bottomL = matchBottom + ? (isMe ? _bigRadius : _smallRadius) + : _smallRadius; + final bottomR = matchBottom + ? (isMe ? _smallRadius : _bigRadius) + : _smallRadius; return ClipRRect( borderRadius: BorderRadius.only( @@ -1123,8 +1143,8 @@ class MessageBubble extends StatelessWidget { Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; - final cachePx = - (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); + final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio) + .round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1160,8 +1180,8 @@ class MessageBubble extends StatelessWidget { String overlay, ) { final imageUrl = photo.baseUrl ?? ''; - final cachePx = - (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); + final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio) + .round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1270,8 +1290,11 @@ class MessageBubble extends StatelessWidget { color: Colors.black54, shape: BoxShape.circle, ), - child: const Icon(Symbols.play_arrow, - color: Colors.white, size: 30), + child: const Icon( + Symbols.play_arrow, + color: Colors.white, + size: 30, + ), ), ), Positioned.fill( @@ -1289,10 +1312,7 @@ class MessageBubble extends StatelessWidget { ); } - Future _playVideo( - BuildContext context, - MessageAttachment video, - ) async { + Future _playVideo(BuildContext context, MessageAttachment video) async { final videoId = (video as dynamic).videoId as int?; final token = (video as dynamic).videoToken as String?; if (videoId == null) { @@ -1335,125 +1355,123 @@ class MessageBubble extends StatelessWidget { Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; - final sizeStr = _formatFileSize(size); + final sizeStr = formatBytes(size); final fileId = (file as dynamic).fileId as int?; final cacheName = '${fileId}_$name'; return IntrinsicWidth( child: Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.primaryContainer, - borderRadius: BorderRadius.circular(10), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.description, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 20, + ), ), - child: Icon( - Symbols.description, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 20, - ), - ), - const SizedBox(width: 10), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name, - style: TextStyle( - color: ctx.text, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier(cacheName), - builder: (context, progress, _) => Text( - progress != null - ? '${(progress * 100).round()}% · $sizeStr' - : sizeStr, + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, style: TextStyle( - color: ctx.dim, - fontSize: 12, + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, height: 1.2, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ), - ], + const SizedBox(height: 2), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier( + cacheName, + ), + builder: (context, progress, _) => Text( + progress != null + ? '${(progress * 100).round()}% · $sizeStr' + : sizeStr, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), + ), + ), + ], + ), ), - ), - const SizedBox(width: 12), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier(cacheName), - builder: (context, progress, _) { - final downloading = progress != null; - return GestureDetector( - onTap: downloading - ? null - : () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: downloading - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress > 0 ? progress : null, + const SizedBox(width: 12), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + final downloading = progress != null; + return GestureDetector( + onTap: downloading + ? null + : () => _downloadFile(ctx.context, file, name), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues( + alpha: 0.12, + ) + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: downloading + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + ), + ) + : Icon( + Symbols.download, color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 18, ), - ) - : Icon( - Symbols.download, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 18, - ), - ), - ); - }, - ), - ], - ), - _buildMeta(ctx), - ], + ), + ); + }, + ), + ], + ), + _buildMeta(ctx), + ], + ), ), - ), ); } - String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ'; - } - Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { final url = sticker.baseUrl ?? ''; final preview = sticker.previewData ?? ''; @@ -1533,8 +1551,7 @@ class MessageBubble extends StatelessWidget { ) : Icon( Symbols.person, - color: - isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, size: 24, ), ), @@ -1559,11 +1576,7 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 2), Text( contactData.phoneNumber!, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, - ), + style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2), ), ], ], @@ -1614,7 +1627,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -1816,13 +1833,10 @@ class MessageBubble extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ Text( - _formatTime(message.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)), style: TextStyle(color: ctx.dim, fontSize: 11), ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), - ], + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], ], ), ); @@ -1840,7 +1854,7 @@ class MessageBubble extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: Text( - _formatTime(message.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)), style: const TextStyle( color: Colors.white, fontSize: 10, @@ -1880,13 +1894,6 @@ class MessageBubble extends StatelessWidget { return Icon(icon, size: 14, color: color); } - - String _formatTime(int timestamp) { - final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } } class _VoiceMessageBubble extends StatefulWidget { @@ -1941,19 +1948,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { super.dispose(); } - String _formatDuration(int seconds) { - final min = seconds ~/ 60; - final sec = seconds % 60; - return '$min:${sec.toString().padLeft(2, '0')}'; - } - - String _formatTime(int timestamp) { - final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } - Widget _buildStatusIcon() { final status = widget.status; IconData icon; @@ -2050,16 +2044,17 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { ), child: ValueListenableBuilder( valueListenable: _progress, - builder: (context, progress, _) => FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: progress.clamp(0.0, 1.0), - child: Container( - decoration: BoxDecoration( - color: waveActiveColor, - borderRadius: BorderRadius.circular(2), + builder: (context, progress, _) => + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), + ), ), - ), - ), ), ), ); @@ -2103,7 +2098,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { width: 32, child: Center( child: Text( - _formatDuration(widget.duration), + formatSecondsMmSs(widget.duration), style: TextStyle( color: widget.textColor.withValues(alpha: 0.7), fontSize: 11, @@ -2133,7 +2128,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { ), if (!_transcriptionVisible) ...[ Text( - _formatTime(widget.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), style: TextStyle( color: widget.textColor.withValues(alpha: 0.6), fontSize: 10, @@ -2151,7 +2146,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { mainAxisAlignment: MainAxisAlignment.end, children: [ Text( - _formatTime(widget.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), style: TextStyle( color: widget.textColor.withValues(alpha: 0.6), fontSize: 10, diff --git a/lib/frontend/widgets/section_header.dart b/lib/frontend/widgets/section_header.dart new file mode 100644 index 0000000..8188d43 --- /dev/null +++ b/lib/frontend/widgets/section_header.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// Small primary-colored section title used across settings/profile screens. +class SectionHeader extends StatelessWidget { + final String title; + final EdgeInsetsGeometry padding; + final double fontSize; + + const SectionHeader( + this.title, { + super.key, + this.padding = const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), + this.fontSize = 13, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: padding, + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: fontSize, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/sheet_helpers.dart b/lib/frontend/widgets/sheet_helpers.dart new file mode 100644 index 0000000..64909a0 --- /dev/null +++ b/lib/frontend/widgets/sheet_helpers.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// Standard rounded top shape for modal bottom sheets. +const RoundedRectangleBorder kSheetShape = RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), +); + +/// The little drag "grabber" pill shown at the top of a bottom sheet. +class SheetGrabber extends StatelessWidget { + final EdgeInsetsGeometry margin; + + const SheetGrabber({ + super.key, + this.margin = const EdgeInsets.symmetric(vertical: 10), + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + width: 40, + height: 4, + margin: margin, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ); + } +} From 260e94632f0b2a8a4c3bc3f42b90a42b69954280 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 14:00:20 +0300 Subject: [PATCH 6/8] fix(ios): drop private entitlements from pseudo-signed IPA --- .github/workflows/build-ios.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 29c898b..38db764 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -54,9 +54,7 @@ jobs: - platform-application get-task-allow - com.apple.private.security.no-container PLIST From 8a39862f8409c37875b4862badb3e545c45f5c28 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 14:25:30 +0300 Subject: [PATCH 7/8] fix(chats): fall back to cached title/iconUrl for dialogs --- lib/frontend/screens/chats/chat_list_screen.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index c5117cd..5926bdc 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1429,8 +1429,9 @@ class _ChatListScreenState extends State break; } } - final name = ContactCache.get(secondId); - final avatar = ContactCache.getAvatar(secondId); + final name = ContactCache.get(secondId) ?? chat.title; + final avatar = + ContactCache.getAvatar(secondId) ?? chat.iconUrl; // ContactCache.isOfficial covers contacts loaded via opcode 32; // chat.isOfficial covers contacts from the login payload. final isVerified = From 3d234e686f2c7a0521753acfb528ee07776b200b Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 14:29:31 +0300 Subject: [PATCH 8/8] change build version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3b6d708..b497787 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 0.5.0+10 environment: sdk: ^3.10.4