From 628c809fd2179f460662a149220845a286b183ea Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 7 Jun 2026 14:06:35 +0700 Subject: [PATCH] =?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), + ), + ); + } +}