From d344adf0358c795530ec8591153542235c816095 Mon Sep 17 00:00:00 2001 From: jganenok Date: Thu, 4 Dec 2025 20:17:22 +0700 Subject: [PATCH] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=20=D0=BA=20=D0=BD=D0=B5=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=BD=D0=BD=D1=8Bm=20GET=20OUT,=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B0=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=B9,=20=D0=B1=D0=BE=D0=BB=D0=B5=D0=B5=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=84=D0=BE=D1=82=D0=BE,=20=D0=B2=D0=BE=D0=B7=D0=BC?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=B4=D0=B5=D1=80=D1=80=D0=B8=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8E=20=D0=B4=D0=BB=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D0=BF=D0=BA=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B5=D0=BBi=D1=84=D0=BE=D0=BD=D0=B5,=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BB=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D1=83=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D1=83=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D1=83=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=84=D1=83=D0=BD?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=D0=BC=20'=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D1=8B'=20=D0=B8=20=D0=BD=D0=B0=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=82=D0=B0=D0=BC=20=D0=B2=20=D1=8D=D1=82=D0=BE=D0=BC=20=D0=BD?= =?UTF-8?q?=D1=83=20=D1=8D=D1=82=D0=BE=D0=BC=20=D0=BD=D1=83=20=D0=B2=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BD=D1=8F=D0=BB=D0=B8,=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20=D1=87=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D0=BA=D1=83=20=D0=BF=D1=80=D1=8F=D0=BC=20=D1=81=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B0(=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=B2=20=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B3=D0=B4=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D0=BE=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D1=82),=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D1=87=D0=B5=D0=BA=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../key (2).properties | 0 lib/api/api_service_auth.dart | 28 +- lib/screens/chat_screen.dart | 556 +++++++++++------- lib/screens/settings/storage_screen.dart | 44 +- lib/widgets/chat_message_bubble.dart | 64 +- lib/widgets/user_profile_panel.dart | 92 ++- pubspec.yaml | 2 + 7 files changed, 533 insertions(+), 253 deletions(-) rename key (2).properties => android/key (2).properties (100%) diff --git a/key (2).properties b/android/key (2).properties similarity index 100% rename from key (2).properties rename to android/key (2).properties diff --git a/lib/api/api_service_auth.dart b/lib/api/api_service_auth.dart index 4a3b199..cb50244 100644 --- a/lib/api/api_service_auth.dart +++ b/lib/api/api_service_auth.dart @@ -129,11 +129,29 @@ extension ApiServiceAuth on ApiService { await prefs.setString('userId', userId); } + // Полный сброс сессии как при переключении аккаунта + _messageQueue.clear(); + _lastChatsPayload = null; + _chatsFetchedInThisSession = false; + _isSessionOnline = false; + _isSessionReady = false; + _handshakeSent = false; + disconnect(); await connect(); + await waitUntilOnline(); + await getChatsAndContacts(force: true); - print("Токен и UserID успешно сохранены"); + + // Обновляем профиль аккаунта из свежих данных + final profileJson = _lastChatsPayload?['profile']; + if (profileJson != null) { + final profileObj = Profile.fromJson(profileJson); + await accountManager.updateAccountProfile(account.id, profileObj); + } + + print("Токен и UserID успешно сохранены, сессия перезапущена"); } Future hasToken() async { @@ -286,7 +304,9 @@ extension ApiServiceAuth on ApiService { _sendMessage(17, payload); try { - final response = await completer.future.timeout(const Duration(seconds: 30)); + final response = await completer.future.timeout( + const Duration(seconds: 30), + ); subscription.cancel(); final payload = response['payload']; @@ -318,7 +338,9 @@ extension ApiServiceAuth on ApiService { _sendMessage(18, payload); try { - final response = await completer.future.timeout(const Duration(seconds: 30)); + final response = await completer.future.timeout( + const Duration(seconds: 30), + ); subscription.cancel(); final payload = response['payload']; diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 67f7c53..cfb9168 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -19,6 +19,7 @@ import 'package:gwid/services/avatar_cache_service.dart'; import 'package:gwid/services/chat_read_settings_service.dart'; import 'package:gwid/services/contact_local_names_service.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:gwid/screens/group_settings_screen.dart'; import 'package:gwid/screens/edit_contact_screen.dart'; import 'package:gwid/widgets/contact_name_widget.dart'; @@ -30,6 +31,7 @@ import 'package:video_player/video_player.dart'; import 'package:gwid/screens/chat_encryption_settings_screen.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:gwid/services/chat_encryption_service.dart'; +import 'package:lottie/lottie.dart'; bool _debugShowExactDate = false; @@ -59,43 +61,8 @@ class DateSeparatorItem extends ChatItem { DateSeparatorItem(this.date); } -class UnreadSeparatorItem extends ChatItem {} - -class _UnreadSeparatorChip extends StatelessWidget { - const _UnreadSeparatorChip(); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - const Expanded(child: Divider(thickness: 1)), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: colors.primary.withOpacity(0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - 'НЕПРОЧИТАННЫЕ СООБЩЕНИЯ', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.6, - color: colors.primary, - ), - ), - ), - const SizedBox(width: 8), - const Expanded(child: Divider(thickness: 1)), - ], - ), - ); - } -} +// Раньше здесь были классы для разделителя "НЕПРОЧИТАННЫЕ СООБЩЕНИЯ". +// Функция отключена по требованию, поэтому доп. элементы не используем. class _EmptyChatWidget extends StatelessWidget { final Map? sticker; @@ -157,12 +124,32 @@ class _EmptyChatWidget extends StatelessWidget { '🎨 _buildSticker: url=$url, lottieUrl=$lottieUrl, width=$width, height=$height', ); - // Для отображения используем обычный url (статичное изображение) - // lottieUrl - это для анимации, но пока используем статичное изображение - // Если есть url, используем его, иначе пробуем lottieUrl - final imageUrl = url ?? lottieUrl; + // Если есть Lottie-анимация — пытаемся показать её (особенно актуально на телефоне) + if (lottieUrl != null && lottieUrl.isNotEmpty) { + print('🎨 Пытаемся показать Lottie-анимацию: $lottieUrl'); + return SizedBox( + width: width, + height: height, + child: Lottie.network( + lottieUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + print('❌ Ошибка загрузки Lottie: $error'); + print('❌ StackTrace Lottie: $stackTrace'); + // Фоллбек: пробуем статичное изображение по url + if (url != null && url.isNotEmpty) { + return Image.network(url, fit: BoxFit.contain); + } + return Icon(Icons.emoji_emotions, size: width, color: Colors.grey); + }, + ), + ); + } - print('🎨 Используемый URL для стикера: $imageUrl'); + // Иначе показываем статичную картинку по обычному url + final imageUrl = url; + + print('🎨 Используемый URL для статичного стикера: $imageUrl'); if (imageUrl != null && imageUrl.isNotEmpty) { return SizedBox( @@ -261,10 +248,180 @@ class _ChatScreenState extends State { final Map _contactDetailsCache = {}; final Set _loadingContactIds = {}; - String? - _lastReadMessageId; // последнее прочитанное нами сообщение в этом чате + // Локальный счётчик непрочитанных (используется только для первичной инициализации). int _initialUnreadCount = 0; - bool _hasUnreadSeparator = false; + + // Сообщения, для которых сейчас "отправляется" реакция (показываем часы на реакции). + final Set _sendingReactions = {}; + + // ======================= Attachments helpers ======================= + + Future _onAttachPressed() async { + // Мобильные платформы — плашка снизу + if (Platform.isAndroid || Platform.isIOS) { + if (!mounted) return; + final colors = Theme.of(context).colorScheme; + + await showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: colors.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const Text( + 'Отправить вложение', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: colors.primary.withOpacity(0.10), + foregroundColor: colors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 12, + ), + ), + icon: const Icon(Icons.photo_library_outlined), + label: const Text('Фото / видео'), + onPressed: () async { + Navigator.of(ctx).pop(); + final result = await _pickPhotosFlow(context); + if (!mounted) return; + if (result != null && result.paths.isNotEmpty) { + await ApiService.instance.sendPhotoMessages( + widget.chatId, + localPaths: result.paths, + caption: result.caption, + senderId: _actualMyId, + ); + } + }, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.outlineVariant), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 12, + ), + ), + icon: const Icon(Icons.insert_drive_file_outlined), + label: const Text('Файл с устройства'), + onPressed: () async { + Navigator.of(ctx).pop(); + await ApiService.instance.sendFileMessage( + widget.chatId, + senderId: _actualMyId, + ); + }, + ), + ), + ], + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + 'Скоро здесь появятся последние отправленные файлы.', + style: TextStyle( + fontSize: 12, + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } else { + // Десктоп: простое меню вместо плашки + if (!mounted) return; + final choice = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Отправить вложение'), + children: [ + SimpleDialogOption( + onPressed: () => Navigator.of(ctx).pop('media'), + child: Row( + children: const [ + Icon(Icons.photo_library_outlined), + SizedBox(width: 8), + Text('Фото / видео'), + ], + ), + ), + SimpleDialogOption( + onPressed: () => Navigator.of(ctx).pop('file'), + child: Row( + children: const [ + Icon(Icons.insert_drive_file_outlined), + SizedBox(width: 8), + Text('Файл с устройства'), + ], + ), + ), + ], + ), + ); + + if (choice == 'media') { + final result = await _pickPhotosFlow(context); + if (result != null && result.paths.isNotEmpty) { + await ApiService.instance.sendPhotoMessages( + widget.chatId, + localPaths: result.paths, + caption: result.caption, + senderId: _actualMyId, + ); + } + } else if (choice == 'file') { + await ApiService.instance.sendFileMessage( + widget.chatId, + senderId: _actualMyId, + ); + } + } + } int? _actualMyId; @@ -301,6 +458,8 @@ class _ChatScreenState extends State { } void _scrollToBottom() { + // Плавный скролл — используем только по явному действию (кнопка "вниз" и т.п.) + if (!_itemScrollController.isAttached) return; _itemScrollController.scrollTo( index: 0, duration: const Duration(milliseconds: 300), @@ -308,6 +467,16 @@ class _ChatScreenState extends State { ); } + void _jumpToBottom() { + // Мгновенный прыжок в самый низ — используем при входе в чат, + // чтобы не было "подпрыгивания" списка из-за анимации. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_itemScrollController.isAttached) { + _itemScrollController.jumpTo(index: 0); + } + }); + } + void _loadContactDetails() { final chatData = ApiService.instance.lastChatsPayload; if (chatData != null && chatData['contacts'] != null) { @@ -821,16 +990,6 @@ class _ChatScreenState extends State { _isUserAtBottom = isAtBottom; _showScrollToBottomNotifier.value = !isAtBottom; - // Если мы внизу и была плашка непрочитанных — считаем, что всё прочитано: - // сбрасываем lastRead и просто скрываем саму плашку (без изменения списка). - if (isAtBottom && _hasUnreadSeparator) { - _lastReadMessageId = null; - _hasUnreadSeparator = false; - if (mounted) { - setState(() {}); - } - } - // Проверяем, доскроллил ли пользователь до самого старого сообщения (вверх) // При reverse: true, последний визуальный элемент (самый большой index) = самое старое сообщение if (positions.isNotEmpty && _chatItems.isNotEmpty) { @@ -981,7 +1140,17 @@ class _ChatScreenState extends State { _removeMessages(deletedMessageIds); } } else if (opcode == 178) { - if (chatIdNormalized == widget.chatId) { + // cmd == 1: это ACK на отправку реакции, без messageId — просто снимаем флаг "отправляется" + if (cmd == 1) { + if (_sendingReactions.isNotEmpty) { + _sendingReactions.clear(); + if (mounted) { + setState(() {}); + } + } + } + // cmd == 0: широковещательное обновление реакций с messageId и reactionInfo + if (cmd == 0 && chatIdNormalized == widget.chatId) { final messageId = payload['messageId'] as String?; final reactionInfo = payload['reactionInfo'] as Map?; if (messageId != null && reactionInfo != null) { @@ -1134,30 +1303,13 @@ class _ChatScreenState extends State { '📜 Первая загрузка: загружено ${allMessages.length} сообщений, показано ${_messages.length}, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime', ); - // Если есть непрочитанные, пытаемся вычислить последнее прочитанное сообщение - // очень грубо: берём N сообщений перед концом списка (N = initialUnreadCount), - // и считаем последним прочитанным то, что стоит ровно перед ними. - if (widget.initialUnreadCount > 0 && - allMessages.length > widget.initialUnreadCount) { - final lastReadGlobalIndex = - allMessages.length - widget.initialUnreadCount - 1; - final lastReadMessage = allMessages[lastReadGlobalIndex]; - _lastReadMessageId = lastReadMessage.id; - } else { - _lastReadMessageId = null; - } - _buildChatItems(); _isLoadingHistory = false; }); - // После первой загрузки истории скроллим к последнему прочитанному - if (_lastReadMessageId != null) { - _scrollToLastReadMessage(); - } else { - // Если нечего читать (нет lastRead), просто остаёмся внизу - _scrollToBottom(); - } + // Функция "перейти к последнему непрочитанному" отключена. + // Всегда стартуем с низа истории без анимации, чтобы не было подпрыгиваний. + _jumpToBottom(); _updatePinnedMessage(); // Если чат пустой, загружаем стикер для пустого состояния @@ -1170,9 +1322,7 @@ class _ChatScreenState extends State { setState(() { _isLoadingHistory = false; }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Не удалось обновить историю чата')), - ); + // Не показываем всплывающее сообщение "Не удалось обновить историю чата". } } @@ -1307,16 +1457,6 @@ class _ChatScreenState extends State { final List items = []; final source = _messages; - // Находим индекс последнего прочитанного сообщения (если оно есть) - int? lastReadIndex; - if (_lastReadMessageId != null) { - lastReadIndex = source.indexWhere((m) => m.id == _lastReadMessageId); - if (lastReadIndex == -1) { - lastReadIndex = null; - } - } - _hasUnreadSeparator = false; - for (int i = 0; i < source.length; i++) { final currentMessage = source[i]; final previousMessage = (i > 0) ? source[i - 1] : null; @@ -1334,16 +1474,6 @@ class _ChatScreenState extends State { final isGrouped = _isMessageGrouped(currentMessage, previousMessage); - print( - 'DEBUG GROUPING: Message ${i}: sender=${currentMessage.senderId}, time=${currentMessage.time}', - ); - if (previousMessage != null) { - print( - 'DEBUG GROUPING: Previous: sender=${previousMessage.senderId}, time=${previousMessage.time}', - ); - print('DEBUG GROUPING: isGrouped=$isGrouped'); - } - final isFirstInGroup = previousMessage == null || !_isMessageGrouped(currentMessage, previousMessage); @@ -1352,10 +1482,6 @@ class _ChatScreenState extends State { i == source.length - 1 || !_isMessageGrouped(source[i + 1], currentMessage); - print( - 'DEBUG GROUPING: isFirstInGroup=$isFirstInGroup, isLastInGroup=$isLastInGroup', - ); - items.add( MessageItem( currentMessage, @@ -1364,12 +1490,6 @@ class _ChatScreenState extends State { isGrouped: isGrouped, ), ); - - // Если это последнее прочитанное сообщение, сразу после него вставляем разделитель - if (lastReadIndex != null && i == lastReadIndex) { - items.add(UnreadSeparatorItem()); - _hasUnreadSeparator = true; - } } _chatItems = items; @@ -1525,35 +1645,6 @@ class _ChatScreenState extends State { }); } - void _scrollToLastReadMessage() { - final lastReadId = _lastReadMessageId; - if (lastReadId == null) return; - - int? targetChatItemIndex; - for (int i = 0; i < _chatItems.length; i++) { - final item = _chatItems[i]; - if (item is MessageItem && item.message.id == lastReadId) { - targetChatItemIndex = i; - break; - } - } - - if (targetChatItemIndex == null) return; - if (!_itemScrollController.isAttached) return; - - final visualIndex = _chatItems.length - 1 - targetChatItemIndex; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_itemScrollController.isAttached) { - _itemScrollController.scrollTo( - index: visualIndex, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutCubic, - ); - } - }); - } - void _addMessage(Message message, {bool forceScroll = false}) { if (_messages.any((m) => m.id == message.id)) { print('Сообщение ${message.id} уже существует, пропускаем добавление'); @@ -1644,6 +1735,13 @@ class _ChatScreenState extends State { final updatedMessage = message.copyWith(reactionInfo: reactionInfo); _messages[messageIndex] = updatedMessage; + // Снимаем флаг "отправляется" для этой реакции (если был) + if (_sendingReactions.remove(messageId)) { + print( + '✅ Реакция для сообщения $messageId успешно подтверждена сервером', + ); + } + _buildChatItems(); print('Обновлена реакция для сообщения $messageId: $reactionInfo'); @@ -1689,6 +1787,9 @@ class _ChatScreenState extends State { ); _messages[messageIndex] = updatedMessage; + // Помечаем, что реакция для этого сообщения сейчас отправляется + _sendingReactions.add(messageId); + _buildChatItems(); print('Оптимистично добавлена реакция $emoji к сообщению $messageId'); @@ -1739,6 +1840,9 @@ class _ChatScreenState extends State { ); _messages[messageIndex] = updatedMessage; + // Помечаем, что удаление реакции сейчас отправляется + _sendingReactions.add(messageId); + _buildChatItems(); print('Оптимистично удалена реакция с сообщения $messageId'); @@ -3084,6 +3188,8 @@ class _ChatScreenState extends State { message: item.message, isMe: isMe, readStatus: readStatus, + isReactionSending: _sendingReactions + .contains(item.message.id), deferImageLoading: deferImageLoading, myUserId: _actualMyId, chatId: widget.chatId, @@ -3237,10 +3343,6 @@ class _ChatScreenState extends State { return finalMessageWidget; } else if (item is DateSeparatorItem) { return _DateSeparatorChip(date: item.date); - } else if (item is UnreadSeparatorItem) { - return _hasUnreadSeparator - ? const _UnreadSeparatorChip() - : const SizedBox.shrink(); } if (isLastVisual && _isLoadingMore) { return TweenAnimationBuilder( @@ -4187,46 +4289,7 @@ class _ChatScreenState extends State { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(24), - onTap: isBlocked - ? null - : () async { - final result = await _pickPhotosFlow(context); - if (result != null && - result.paths.isNotEmpty) { - await ApiService.instance.sendPhotoMessages( - widget.chatId, - localPaths: result.paths, - caption: result.caption, - senderId: _actualMyId, - ); - } - }, - child: Padding( - padding: const EdgeInsets.all(6.0), - child: Icon( - Icons.photo_library_outlined, - color: isBlocked - ? Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.3) - : Theme.of(context).colorScheme.primary, - size: 24, - ), - ), - ), - ), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: isBlocked - ? null - : () async { - await ApiService.instance.sendFileMessage( - widget.chatId, - senderId: _actualMyId, - ); - }, + onTap: isBlocked ? null : _onAttachPressed, child: Padding( padding: const EdgeInsets.all(6.0), child: Icon( @@ -5186,6 +5249,28 @@ class _SendPhotosDialogState extends State<_SendPhotosDialog> { super.dispose(); } + Future _pickMoreDesktop() async { + try { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: FileType.image, + ); + if (result == null || result.files.isEmpty) return; + + _pickedPaths + ..clear() + ..addAll(result.files.where((f) => f.path != null).map((f) => f.path!)); + _previews + ..clear() + ..addAll(_pickedPaths.map((p) => FileImage(File(p)) as ImageProvider)); + if (mounted) { + setState(() {}); + } + } catch (e) { + debugPrint('Ошибка выбора фото на десктопе: $e'); + } + } + @override Widget build(BuildContext context) { return AlertDialog( @@ -5204,22 +5289,7 @@ class _SendPhotosDialogState extends State<_SendPhotosDialog> { ), const SizedBox(height: 12), FilledButton.icon( - onPressed: () async { - try { - final imgs = await ImagePicker().pickMultiImage( - imageQuality: 100, - ); - if (imgs.isNotEmpty) { - _pickedPaths - ..clear() - ..addAll(imgs.map((e) => e.path)); - _previews - ..clear() - ..addAll(imgs.map((e) => FileImage(File(e.path)))); - setState(() {}); - } - } catch (_) {} - }, + onPressed: _pickMoreDesktop, icon: const Icon(Icons.photo_library), label: Text( _pickedPaths.isEmpty @@ -5837,7 +5907,7 @@ class _ContactProfileDialogState extends State { else const SizedBox(height: 16), - if (!widget.isChannel) + if (!widget.isChannel) ...[ SizedBox( width: double.infinity, child: ElevatedButton.icon( @@ -5874,6 +5944,94 @@ class _ContactProfileDialogState extends State { ), ), ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + final isInContacts = + ApiService.instance.getCachedContact( + widget.contact.id, + ) != + null; + if (isInContacts) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Уже в контактах'), + behavior: SnackBarBehavior.floating, + ), + ); + } + return; + } + + try { + await ApiService.instance.addContact( + widget.contact.id, + ); + await ApiService.instance + .requestContactsByIds([ + widget.contact.id, + ]); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Запрос на добавление в контакты отправлен', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Ошибка при добавлении в контакты: $e', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + }, + icon: const Icon(Icons.person_add), + label: const Text('В контакты'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + // Уже в этом чате — просто закрываем панель, + // чтобы пользователь мог сразу написать. + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.message), + label: const Text('Написать сообщение'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], ], ), ); diff --git a/lib/screens/settings/storage_screen.dart b/lib/screens/settings/storage_screen.dart index fa9c8f7..9137c15 100644 --- a/lib/screens/settings/storage_screen.dart +++ b/lib/screens/settings/storage_screen.dart @@ -669,23 +669,10 @@ class _StorageScreenState extends State Future _selectDownloadFolder() async { try { String? selectedDirectory; - - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { - // На десктопе используем getDirectoryPath - selectedDirectory = await FilePicker.platform.getDirectoryPath(); - } else { - // На мобильных платформах file_picker может не поддерживать выбор папки - // Используем диалог с текстовым вводом или просто показываем сообщение - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Выбор папки доступен только на десктопных платформах'), - duration: Duration(seconds: 3), - ), - ); - } - return; - } + + // На всех платформах, где поддерживается, пробуем открыть диалог выбора папки. + // На Android/iOS FilePicker сам использует системный проводник/документы. + selectedDirectory = await FilePicker.platform.getDirectoryPath(); if (selectedDirectory != null && selectedDirectory.isNotEmpty) { await DownloadPathHelper.setDownloadDirectory(selectedDirectory); @@ -717,9 +704,7 @@ class _StorageScreenState extends State context: context, builder: (context) => AlertDialog( title: const Text('Сбросить папку загрузки'), - content: const Text( - 'Вернуть папку загрузки к значению по умолчанию?', - ), + content: const Text('Вернуть папку загрузки к значению по умолчанию?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), @@ -752,7 +737,8 @@ class _StorageScreenState extends State future: DownloadPathHelper.getDisplayPath(), builder: (context, snapshot) { final currentPath = snapshot.data ?? 'Загрузка...'; - final isCustom = snapshot.hasData && + final isCustom = + snapshot.hasData && currentPath != 'Не указано' && !currentPath.contains('Downloads') && !currentPath.contains('Download'); @@ -770,10 +756,7 @@ class _StorageScreenState extends State children: [ Row( children: [ - Icon( - Icons.folder_outlined, - color: colors.primary, - ), + Icon(Icons.folder_outlined, color: colors.primary), const SizedBox(width: 12), Expanded( child: Text( @@ -822,11 +805,7 @@ class _StorageScreenState extends State ), ), if (isCustom) - Icon( - Icons.check_circle, - color: colors.primary, - size: 20, - ), + Icon(Icons.check_circle, color: colors.primary, size: 20), ], ), ), @@ -848,7 +827,10 @@ class _StorageScreenState extends State OutlinedButton( onPressed: _resetDownloadFolder, style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), ), child: const Icon(Icons.refresh), ), diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 832b537..95bfb1e 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -379,6 +379,8 @@ class ChatMessageBubble extends StatelessWidget { final int? chatId; final bool isEncryptionPasswordSet; final String? decryptedText; + // Идёт ли сейчас отправка/удаление реакции для этого сообщения + final bool isReactionSending; const ChatMessageBubble({ super.key, @@ -414,6 +416,7 @@ class ChatMessageBubble extends StatelessWidget { this.chatId, this.isEncryptionPasswordSet = false, this.decryptedText, + this.isReactionSending = false, }); String _formatMessageTime(BuildContext context, int timestamp) { @@ -1166,17 +1169,32 @@ class ChatMessageBubble extends StatelessWidget { : textColor.withOpacity(0.1), borderRadius: BorderRadius.circular(16), ), - child: Text( - '$emoji $count', - style: TextStyle( - fontSize: 12, - fontWeight: isUserReaction - ? FontWeight.w600 - : FontWeight.w500, - color: isUserReaction - ? Theme.of(context).colorScheme.primary - : textColor.withOpacity(0.9), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$emoji $count', + style: TextStyle( + fontSize: 12, + fontWeight: isUserReaction + ? FontWeight.w600 + : FontWeight.w500, + color: isUserReaction + ? Theme.of(context).colorScheme.primary + : textColor.withOpacity(0.9), + ), + ), + if (isUserReaction && isReactionSending) ...[ + const SizedBox(width: 4), + _RotatingIcon( + icon: Icons.watch_later_outlined, + size: 12, + color: Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280), + ), + ], + ], ), ), ); @@ -3340,10 +3358,10 @@ class ChatMessageBubble extends StatelessWidget { String? token, int? chatId, }) async { - // Initialize progress - FileDownloadProgressService().updateProgress(fileId, 0.0); - try { + // Initialize progress + FileDownloadProgressService().updateProgress(fileId, 0.0); + // Get Downloads directory using helper final downloadDir = await DownloadPathHelper.getDownloadDirectory(); @@ -5634,10 +5652,22 @@ class _FullScreenPhotoViewerState extends State { onPressed: () => Navigator.of(context).pop(), ), if (widget.attach != null) - IconButton( - icon: const Icon(Icons.download, color: Colors.white), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueAccent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + icon: const Icon(Icons.download), + label: const Text( + 'Скачать', + style: TextStyle(fontWeight: FontWeight.w600), + ), onPressed: _downloadPhoto, - tooltip: 'Скачать фото', ), ], ), diff --git a/lib/widgets/user_profile_panel.dart b/lib/widgets/user_profile_panel.dart index 71f6153..7228ca4 100644 --- a/lib/widgets/user_profile_panel.dart +++ b/lib/widgets/user_profile_panel.dart @@ -41,7 +41,10 @@ class _UserProfilePanelState extends State { final ScrollController _nameScrollController = ScrollController(); String? _localDescription; StreamSubscription? _changesSubscription; + StreamSubscription? _wsSubscription; bool _isOpeningChat = false; + bool _isInContacts = false; + bool _isAddingToContacts = false; String get _displayName { final displayName = getContactDisplayName( @@ -64,20 +67,50 @@ class _UserProfilePanelState extends State { void initState() { super.initState(); _loadLocalDescription(); + _checkIfInContacts(); _changesSubscription = ContactLocalNamesService().changes.listen(( contactId, ) { if (contactId == widget.userId && mounted) { _loadLocalDescription(); + _checkIfInContacts(); } }); + _wsSubscription = ApiService.instance.messages.listen((msg) { + try { + if (msg['opcode'] == 34 && + msg['cmd'] == 1 && + msg['payload'] != null && + msg['payload']['contact'] != null) { + final contactJson = msg['payload']['contact'] as Map; + final id = contactJson['id'] as int?; + if (id == widget.userId && mounted) { + final contact = Contact.fromJson(contactJson); + ApiService.instance.updateContactCache([contact]); + setState(() { + _isInContacts = true; + }); + } + } + } catch (_) {} + }); + WidgetsBinding.instance.addPostFrameCallback((_) { _checkNameLength(); }); } + Future _checkIfInContacts() async { + final cached = ApiService.instance.getCachedContact(widget.userId); + if (mounted) { + setState(() { + _isInContacts = cached != null; + }); + } + } + Future _loadLocalDescription() async { final localData = await ContactLocalNamesService().getContactData( widget.userId, @@ -92,6 +125,7 @@ class _UserProfilePanelState extends State { @override void dispose() { _changesSubscription?.cancel(); + _wsSubscription?.cancel(); _nameScrollController.dispose(); super.dispose(); } @@ -234,14 +268,24 @@ class _UserProfilePanelState extends State { _buildActionButton( icon: Icons.phone, label: 'Позвонить', - onPressed: null, + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Звонков пока нету'), + behavior: SnackBarBehavior.floating, + ), + ); + }, colors: colors, ), _buildActionButton( icon: Icons.person_add, - label: 'В контакты', - onPressed: null, + label: _isInContacts ? 'В контактах' : 'В контакты', + onPressed: _isInContacts || _isAddingToContacts + ? null + : _handleAddToContacts, colors: colors, + isLoading: _isAddingToContacts, ), _buildActionButton( icon: Icons.message, @@ -386,4 +430,46 @@ class _UserProfilePanelState extends State { } } } + + Future _handleAddToContacts() async { + if (_isAddingToContacts || _isInContacts) return; + + setState(() { + _isAddingToContacts = true; + }); + + try { + // Отправляем opcode=34 с action="ADD" + await ApiService.instance.addContact(widget.userId); + + // Пытаемся сразу подтянуть обновлённые данные контакта + await ApiService.instance.requestContactsByIds([widget.userId]); + + await _checkIfInContacts(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Запрос на добавление в контакты отправлен'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при добавлении в контакты: $e'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isAddingToContacts = false; + }); + } + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 43a1d51..c7793c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,6 +122,8 @@ dependencies: flutter_secure_storage: ^9.2.4 flutter_inappwebview: ^6.1.5 + lottie: ^3.1.2 + chewie: ^1.7.5 just_audio: ^0.9.40