diff --git a/lib/data/datasources/local_db_service.dart b/lib/data/datasources/local_db_service.dart index c6612ec..f9beb5a 100644 --- a/lib/data/datasources/local_db_service.dart +++ b/lib/data/datasources/local_db_service.dart @@ -182,15 +182,26 @@ class LocalDbService extends _$LocalDbService { Future>> getChatHistory( int contactId, - int myId, - ) async { + int myId, { + int? limit, + int? offset, + }) async { final query = select(messages) ..where( (tbl) => (tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) | (tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)), ) - ..orderBy([(tbl) => OrderingTerm(expression: tbl.timestamp)]); + ..orderBy([ + (tbl) => OrderingTerm( + expression: tbl.timestamp, + mode: OrderingMode.desc, + ), + ]); + + if (limit != null) { + query.limit(limit, offset: offset); + } final rows = await query.get(); return rows.map((row) => row.toJson()).toList(); diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index 650e9e2..648bd80 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -20,7 +20,9 @@ class ApiService extends ChangeNotifier { Future getUserByUsername(String username) async { try { // Подставляй свой эндпоинт, например: /users/by-username/ - final response = await Dio().get('${AppConstants.baseUrl}//users/by-username/$username'); + final response = await Dio().get( + '${AppConstants.baseUrl}//users/by-username/$username', + ); if (response.statusCode == 200 && response.data != null) { // Парсим полученные данные в модель контакта. @@ -411,11 +413,15 @@ class ApiService extends ChangeNotifier { Future> getChatHistory( int contactId, { + int? anchorId, + int limitBefore = 20, + int limitAfter = 20, bool forceRefresh = false, }) async { final token = await getAccessToken(); final Map requestHeaders = { + 'Content-Type': 'application/json', 'Authorization': 'Bearer $token', }; @@ -423,15 +429,14 @@ class ApiService extends ChangeNotifier { requestHeaders['Cache-Control'] = 'no-cache'; } - final response = await http.get( - Uri.parse( - '${AppConstants.baseUrl}/messages/history/${contactId.toString()}', - ), - headers: { - 'Content-Type': 'application/json', - "Authorization": "Bearer $token", - }, - ); + // Собираем URL с новыми параметрами двунаправленной пагинации + String urlStr = + '${AppConstants.baseUrl}/messages/history/${contactId.toString()}?limit_before=$limitBefore&limit_after=$limitAfter'; + if (anchorId != null) { + urlStr += '&anchor_id=$anchorId'; + } + + final response = await http.get(Uri.parse(urlStr), headers: requestHeaders); return jsonDecode(utf8.decode(response.bodyBytes)) as List; } diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index b5127ba..94ac745 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -44,6 +44,7 @@ class ChatScreen extends StatefulWidget { final void Function(Contact contact)? onOpenProfile; final VoidCallback? onBack; final bool showBackButton; + final Function(int contactId)? onMessageRead; const ChatScreen({ super.key, @@ -51,6 +52,7 @@ class ChatScreen extends StatefulWidget { this.onOpenProfile, this.onBack, this.showBackButton = true, + this.onMessageRead, }); @override @@ -88,7 +90,7 @@ class _ChatScreenState extends State with RouteAware { String? _pendingFileName; File? _pendingFile; Uint8List? _previewBytes; - double _inputBarHeight = 0; + double _inputBarHeight = 64; SecretKey? _chatSharedSecret; final Map> _messageProgressNotifiers = {}; @@ -115,6 +117,15 @@ class _ChatScreenState extends State with RouteAware { List? _cameras; bool _isCameraInitialized = false; + int _limitBefore = 20; + int _limitAfter = 20; + bool _isLoadingOlder = false; + bool _isLoadingNewer = false; + bool _hasMoreOlder = true; + bool _hasMoreNewer = false; + bool _suppressPagination = true; + bool _isFirstSizeMeasurement = true; + @override void initState() { super.initState(); @@ -603,8 +614,8 @@ class _ChatScreenState extends State with RouteAware { lName = ''; } - final String localFullName = '${_currentContact.name} ${_currentContact.surname}' - .trim(); + final String localFullName = + '${_currentContact.name} ${_currentContact.surname}'.trim(); final contactInitials = localFullName.isNotEmpty ? localFullName @@ -726,8 +737,6 @@ class _ChatScreenState extends State with RouteAware { child: CustomScrollView( controller: _scrollController, reverse: true, - cacheExtent: - 0, // Сохраняем: строго запрещает предзагрузку элементов вне экрана physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -743,133 +752,199 @@ class _ChatScreenState extends State with RouteAware { top: 8, ), sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final msg = messages[messages.length - 1 - index]; - final keyId = msg.id ?? msg.tempId ?? index; - final itemKey = _messageKeys.putIfAbsent( - keyId, - () => GlobalKey(), - ); - final isMedia = - msg.messageType == MessageType.image || - msg.messageType == MessageType.video || - msg.messageType == MessageType.file; - - final showDateDivider = _isNewDay(index); - - // Формируем основное содержимое элемента сообщения - Widget itemChild = Column( - crossAxisAlignment: CrossAxisAlignment.start, - key: itemKey, - mainAxisSize: MainAxisSize.min, - children: [ - if (showDateDivider) - Container( - padding: const EdgeInsets.symmetric( - vertical: 10, + delegate: SliverChildBuilderDelegate( + (context, index) { + // 1. НИЖНИЙ ЛОАДЕР (если есть куда листать вниз к новым сообщениям) + if (_hasMoreNewer && index == 0) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 20), + alignment: Alignment.center, + child: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, ), - alignment: Alignment.center, - child: Container( + ), + ); + } + + // Сдвигаем рабочий индекс для массива сообщений, если внизу отрисован лоадер + final int messageIndexInSliver = _hasMoreNewer + ? index - 1 + : index; + + // 2. ВЕРХНИЙ ЛОАДЕР (если есть куда листать вверх к старой истории) + if (_hasMoreOlder && + messageIndexInSliver == messages.length) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 20), + alignment: Alignment.center, + child: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ); + } + final msg = + messages[messages.length - + 1 - + messageIndexInSliver]; + final keyId = msg.id ?? msg.tempId ?? index; + final itemKey = _messageKeys.putIfAbsent( + keyId, + () => GlobalKey(), + ); + final isMedia = + msg.messageType == MessageType.image || + msg.messageType == MessageType.video || + msg.messageType == MessageType.file; + + final showDateDivider = _isNewDay( + messageIndexInSliver, + ); + + // Формируем основное содержимое элемента сообщения + Widget itemChild = Column( + crossAxisAlignment: CrossAxisAlignment.start, + key: itemKey, + mainAxisSize: MainAxisSize.min, + children: [ + if (showDateDivider) + Container( padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, + vertical: 10, ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withOpacity(0.75), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _formatDividerDate(msg.createdAt), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.75), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _formatDividerDate(msg.createdAt), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), ), ), ), - ), - Dismissible( - direction: DismissDirection.endToStart, - key: ValueKey('dismiss_$keyId'), - confirmDismiss: - (DismissDirection direction) async { - String text = msg.text; - if (msg.text.isEmpty && - msg.messageType == MessageType.image) { - text = "[Фото]"; - } - setState( - () => _replyTo = msg.copyWith(text: text), - ); - return false; - }, - child: RepaintBoundary( - child: MessageBubble( - key: ValueKey( - '${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}', + Dismissible( + direction: DismissDirection.endToStart, + key: ValueKey('dismiss_$keyId'), + confirmDismiss: + (DismissDirection direction) async { + String text = msg.text; + if (msg.text.isEmpty && + msg.messageType == + MessageType.image) { + text = "[Фото]"; + } + setState( + () => + _replyTo = msg.copyWith(text: text), + ); + return false; + }, + child: RepaintBoundary( + child: MessageBubble( + key: ValueKey( + '${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}', + ), + message: msg, + onReplyTap: msg.replyToId != null + ? () => _scrollToMessage(msg.replyToId) + : null, + onReplyToTap: () { + String text = msg.text; + if (msg.text.isEmpty && + msg.messageType == + MessageType.image) { + text = "[Фото]"; + } + setState( + () => + _replyTo = msg.copyWith(text: text), + ); + }, + onImageTap: () => _openFullScreenMedia(msg), + onEditTap: () => _editMessage(msg), + onDeleteTap: () => _deleteMessage(msg), + onDownloadRequested: (m) async { + await _ensureFileDecrypted( + m, + dontLoad: false, + ); + }, + onDownloadRequestedWithoutLoad: (m) async { + await _ensureFileDecrypted( + m, + dontLoad: true, + ); + }, + autoLoadMedia: + msg.messageType != MessageType.image + ? true + : (msg.fileSize == null || + msg.fileSize! <= + _autoMediaLoadLimitBytes), + downloadProgress: + _messageProgressNotifiers['${msg.fileId}'], + onDownloadStoped: (m) async { + await _stopFileLoading(msg); + }, ), - message: msg, - onTap: () => _showMessageActions(msg), - onReplyTap: msg.replyToId != null - ? () => _scrollToMessage(msg.replyToId) - : null, - onImageTap: () => _openFullScreenMedia(msg), - onDownloadRequested: (m) async { - await _ensureFileDecrypted( - m, - dontLoad: false, - ); - }, - onDownloadRequestedWithoutLoad: (m) async { - await _ensureFileDecrypted( - m, - dontLoad: true, - ); - }, - autoLoadMedia: - msg.messageType != MessageType.image - ? true - : (msg.fileSize == null || - msg.fileSize! <= - _autoMediaLoadLimitBytes), - downloadProgress: - _messageProgressNotifiers['${msg.fileId}'], - onDownloadStoped: (m) async { - await _stopFileLoading(msg); - }, ), ), - ), - ], - ); - - // Если это медиафайл или документ, оборачиваем в VisibilityDetector - if (isMedia) { - return VisibilityDetector( - key: ValueKey('visible_${keyId}'), - onVisibilityChanged: (visibilityInfo) { - // Как только элемент показался в зоне видимости хотя бы на 10% - if (visibilityInfo.visibleFraction > 0.1) { - if (msg.fileSize == null || msg.fileSize == 0) { - print( - "Элемент стал видим. Фоновый запрос размера для: ${msg.fileId}", - ); - _fetchFileSizeIfNeeded(msg); - } - } - }, - child: itemChild, + ], ); - } - // Обычный текст возвращаем без детектора видимости - return itemChild; - }, childCount: messages.length), + final bool isUnreadIncoming = + !msg.isMe && msg.status != MessageStatus.read; + + if (isMedia || isUnreadIncoming) { + return VisibilityDetector( + key: ValueKey('visible_${keyId}'), + onVisibilityChanged: (visibilityInfo) { + if (visibilityInfo.visibleFraction > 0.35) { + if (isMedia && + (msg.fileSize == null || + msg.fileSize == 0)) { + _fetchFileSizeIfNeeded(msg); + } + if (isUnreadIncoming) { + _markAsRead( + msg, + ); // Плавное чтение при попадании на экран + } + } + }, + child: itemChild, + ); + } + + return itemChild; + }, + // Если есть куда скроллить наверх, увеличиваем размер списка на 1 под лоадер + childCount: + messages.length + + (_hasMoreOlder ? 1 : 0) + + (_hasMoreNewer ? 1 : 0), + ), ), ), ], @@ -977,23 +1052,6 @@ class _ChatScreenState extends State with RouteAware { bottom: 16, child: _MeasureSize( onChange: (size) { - if (_inputBarHeight != size.height) { - setState(() { - _inputBarHeight = size.height; - }); - - if (!_showScrollToEnd) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - 0.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - } - }); - } - } }, child: SafeArea( top: false, @@ -2196,7 +2254,7 @@ class _ChatScreenState extends State with RouteAware { '-i', tempInputPath, '-vf', - 'crop=min(iw\\,ih):min(iw\\,ih),scale=512:512', + 'crop=min(iw\,ih):min(iw\,ih),scale=512:512', '-vcodec', 'libx264', '-crf', @@ -2806,16 +2864,6 @@ class _ChatScreenState extends State with RouteAware { if (!mounted) return; - final serverMessageId = int.tryParse(data['id']?.toString() ?? ''); - if (serverMessageId != null && - !_sentReadReceipts.contains(serverMessageId)) { - Provider.of( - context, - listen: false, - ).sendReadReceipt(serverMessageId); - _sentReadReceipts.add(serverMessageId); - } - final replyToText = await _decryptReplyText( data['reply_to_text']?.toString(), sharedSecret, @@ -2904,24 +2952,23 @@ class _ChatScreenState extends State with RouteAware { await prefs.remove(_notificationLaunchKey); try { - print('[DEBUG] Начало загрузки истории'); + print('[DEBUG] Начало загрузки анкерной истории'); final myPrivKey = await _cryptoService.getPrivateKey(); final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, widget.contact.publicKey!, ); - print('[DEBUG] Ключи получены'); _chatSharedSecret = sharedSecret; - // 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД (Оно читает ШИФРТЕКСТ и дешифрует для UI) + // 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД final cached = await _localDbService.getChatHistory( widget.contact.id, myId, ); - print('[DEBUG] Локальная история загружена: ${cached.length} сообщений'); - Map localMessagesMap = {}; + int? anchorId; + for (var msg in cached) { final parsed = await _parseAndDecryptMessage( msg, @@ -2931,6 +2978,14 @@ class _ChatScreenState extends State with RouteAware { ); if (parsed != null && parsed.id != null) { localMessagesMap[parsed.id!] = parsed; + + // ТРИГГЕР АНКЕРА: Ищем первое непрочитанное сообщение от собеседника в локальном кэше + if (anchorId == null && + !parsed.isMe && + parsed.status != MessageStatus.read) { + anchorId = parsed.id; + print('[DEBUG] Найдено анкерное сообщение в кэше: ID $anchorId'); + } } } @@ -2943,15 +2998,20 @@ class _ChatScreenState extends State with RouteAware { }); } - // 2. ФОНОВАЯ ЗАГРУЗКА АКТУАЛЬНОЙ ИСТОРИИ ИЗ API - final history = await apiService.getChatHistory(widget.contact.id); - print('[DEBUG] Загружена история из API: ${history.length}'); + // 2. ФОНОВАЯ ЗАГРУЗКА АКТУАЛЬНОЙ ИСТОРИИ ИЗ API ВОКРУГ АНКЕРА + final history = await apiService.getChatHistory( + widget.contact.id, + anchorId: anchorId, + limitBefore: _limitBefore, + limitAfter: _limitAfter, + ); + print( + '[DEBUG] Загружена история из API вокрут анкера: ${history.length}', + ); final alreadyReadIncomingMessageIds = {}; - List loadedMessages = - []; // Список для отображения в UI (дешифрованный) - List encryptedMessagesForStorage = - []; // Список для кэширования в БД (ЗАШИФРОВАННЫЙ) + List loadedMessages = []; + List encryptedMessagesForStorage = []; for (var msg in history) { final msgId = int.tryParse(msg['id']?.toString() ?? ''); @@ -2961,11 +3021,9 @@ class _ChatScreenState extends State with RouteAware { alreadyReadIncomingMessageIds.add(msgId); } - // Извлекаем оригинальный сырой шифртекст из ответа сервера final String rawCiphertext = msg['content'].toString(); final String? rawEncryptedReplyText = msg['reply_to_text']?.toString(); - // Парсим и расшифровываем сообщение для UI (с защитой от повторной дешифровки) final parsed = await _parseAndDecryptMessage( msg, sharedSecret, @@ -2974,21 +3032,17 @@ class _ChatScreenState extends State with RouteAware { ); if (parsed != null) { loadedMessages.add(parsed); - - // ФИКС КВОТЫ И БЕЗОПАСНОСТИ: Создаем клон модели специально для БД, - // подменяя открытый текст на исходный шифртекст сервера encryptedMessagesForStorage.add( MessageModel( id: parsed.id, - text: rawCiphertext, // ТЕПЕРЬ ТУТ ХРАНИТСЯ ШИФРТЕКСТ + text: rawCiphertext, isMe: parsed.isMe, senderId: parsed.senderId, receiverId: parsed.receiverId, createdAt: parsed.createdAt, status: parsed.status, replyToId: parsed.replyToId, - replyToText: - rawEncryptedReplyText, // ТЕПЕРЬ ТУТ ТОЖЕ ШИФРТЕКСТ ОТВЕТА + replyToText: rawEncryptedReplyText, editedAt: parsed.editedAt, messageType: parsed.messageType, fileId: parsed.fileId, @@ -3001,39 +3055,70 @@ class _ChatScreenState extends State with RouteAware { } } - loadedMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - - // 3. СОХРАНЕНИЕ В ЛОКАЛЬНУЮ БД СТРОГО ЗАШИФРОВАННЫХ ДАННЫХ try { - print( - '[DEBUG] Начинаем сохранение истории в локальную БД в зашифрованном виде', - ); - // Передаем список с шифртекстом. Метод saveMessages запишет в базу именно его - await _localDbService.saveMessages(encryptedMessagesForStorage); - print('[DEBUG] Сообщения успешно защищены и сохранены в локальную бд'); + if (encryptedMessagesForStorage.isNotEmpty) { + await _localDbService.saveMessages(encryptedMessagesForStorage); + } } catch (e) { print("[ERROR] Ошибка сохранения истории в локальную базу: $e"); } - // 4. ФИНАЛЬНОЕ ОБНОВЛЕНИЕ ИНТЕРФЕЙСА (Интерфейс видит чистый текст, база — шифртекст) if (!mounted) return; setState(() { - messages = loadedMessages; + // 1. Сортируем пришедшие от сервера данные в хронологическом порядке loadedMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + // --- УМНОЕ СЛИЯНИЕ ВМЕСТО ПЕРЕЗАПИСИ --- + // Собираем ID сообщений, которые только что прилетели из API + final apiIds = loadedMessages.map((m) => m.id).toSet(); + + // Оставляем в текущем списке (из кэша) только те сообщения, которых НЕТ в ответе API. + // Это сохранит длинную историю кэша (чтобы список не схлопнулся) + сбережет временные сообщения + final localPreserved = messages.where((m) => m.id == null || !apiIds.contains(m.id)).toList(); + + // Объединяем кэш и новые данные от сервера в единый массив + final combined = [...localPreserved, ...loadedMessages]; + // Финально сортируем объединенный массив по дате + combined.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + messages = combined; _isKeyLoading = false; + + // --- ТОЧНЫЙ РАСЧЁТ ФЛАГОВ ПАГИНАЦИИ --- + if (anchorId != null) { + // Считаем, сколько сообщений реально пришло старше и новее нашего анкера в серверной пачке + final olderCount = loadedMessages.where((m) => m.id != null && m.id! < anchorId!).length; + final newerCount = loadedMessages.where((m) => m.id != null && m.id! > anchorId!).length; + + // Крутилки загрузки появятся только если сервер вернул РОВНО лимит страниц (значит есть что качать дальше) + _hasMoreOlder = olderCount >= _limitBefore; + _hasMoreNewer = newerCount >= _limitAfter; + } else { + // Если анкера не было, мы в самом низу чата (у свежих сообщений) + _hasMoreNewer = false; + _hasMoreOlder = history.length >= _limitBefore; + } }); - // Отправка отчетов о прочтении... - for (final m in loadedMessages) { - if (m.isMe || m.id == null) continue; - if (alreadyReadIncomingMessageIds.contains(m.id)) continue; - if (_sentReadReceipts.contains(m.id)) continue; + // Скроллим к анкеру с управлением предохранителем + if (anchorId != null) { + _suppressPagination = + true; // Жестко держим блок во время рендеринга и прыжка - Provider.of( - context, - listen: false, - ).sendReadReceipt(m.id!); - _sentReadReceipts.add(m.id!); + /*WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 1500), () async { + await _scrollToMessage(anchorId); + if (mounted) { + setState(() { + _suppressPagination = + false; // Прыжок завершен, теперь скроллить безопасно! + }); + } + }); + });*/ + } else { + // Если анкера нет, пагинация разрешена сразу + _suppressPagination = false; } } catch (e) { print("Ошибка загрузки истории: $e"); @@ -3042,6 +3127,189 @@ class _ChatScreenState extends State with RouteAware { } } + Future _markAsRead(MessageModel msg) async { + // Проверяем, что сообщение не наше, еще не прочитано и имеет валидный серверный ID + if (msg.id == null || msg.isMe || msg.status == MessageStatus.read) return; + if (_sentReadReceipts.contains(msg.id)) return; + + // Сразу блокируем повторную отправку + _sentReadReceipts.add(msg.id!); + + print('[DEBUG] Плавное прочтение сообщения ID: ${msg.id}'); + + // 1. Отправляем сигнал на бэкенд через вебсокет + _socketService.sendReadReceipt(msg.id!); + + // 2. Обновляем статус сообщения внутри текущего UI чата + _updateMessageInList( + msg.id!, + (m) => m.copyWith(status: MessageStatus.read), + ); + + // 3. Обновляем статус в локальной SQLite (Drift) базе данных + try { + await _localDbService.updateReadAt(msg.id!, DateTime.now()); + } catch (e) { + print('Ошибка при сохранении статуса прочтения в БД: $e'); + } + + // 4. Вызываем твой callback, чтобы на десктопе/планшете счетчик в списке чатов уменьшался на лету + widget.onMessageRead?.call(_currentContact.id); + } + + // 1. ПОДГРУЗКА СТАРЫХ СООБЩЕНИЙ (ВВЕРХ ХРОНОЛОГИИ) + Future _loadOlderMessages() async { + if (_isLoadingOlder || + !_hasMoreOlder || + messages.isEmpty || + _chatSharedSecret == null) + return; + + setState(() => _isLoadingOlder = true); + + try { + final int? anchorId = messages.first.id; + if (anchorId == null) return; + + print('[DEBUG] Загрузка старой истории. Анкер: $anchorId'); + final history = await apiService.getChatHistory( + _currentContact.id, + anchorId: anchorId, + limitBefore: _limitBefore, + limitAfter: 0, + ); + + if (history.isEmpty) { + setState(() { + _hasMoreOlder = false; + _isLoadingOlder = false; + }); + return; + } + + List loadedOlder = []; + DateTime now = DateTime.now(); + Duration offset = now.timeZoneOffset; + + for (var msg in history) { + final parsed = await _parseAndDecryptMessage( + msg, + _chatSharedSecret!, + offset, + {}, + ); + if (parsed != null) loadedOlder.add(parsed); + } + + if (!mounted) return; + setState(() { + loadedOlder.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + // --- ЗАЩИТА ОТ ДУБЛИКАТОВ --- + // Собираем ID всех сообщений, которые УЖЕ отрисованы на экране + final existingIds = messages.map((m) => m.id).toSet(); + // Фильтруем новую пачку, убирая то, что уже есть (включая сообщение-анкер) + final uniqueOlder = loadedOlder + .where((m) => !existingIds.contains(m.id)) + .toList(); + + // Вставляем в начало только уникальные сообщения + messages.insertAll(0, uniqueOlder); + + _isLoadingOlder = false; + if (history.length < _limitBefore) _hasMoreOlder = false; + }); + } catch (e) { + print("Ошибка загрузки старых сообщений: $e"); + setState(() => _isLoadingOlder = false); + } + } + + // 2. ПОДГРУЗКА НОВЫХ СООБЩЕНИЙ (ВНИЗ ХРОНОЛОГИИ К СВЕЖИМ) + Future _loadNewerMessages() async { + if (_isLoadingNewer || + !_hasMoreNewer || + messages.isEmpty || + _chatSharedSecret == null) + return; + + setState(() => _isLoadingNewer = true); + + try { + final int? anchorId = messages.last.id; + if (anchorId == null) return; + + print('[DEBUG] Загрузка свежей истории. Анкер: $anchorId'); + final history = await apiService.getChatHistory( + _currentContact.id, + anchorId: anchorId, + limitBefore: 0, + limitAfter: _limitAfter, + ); + + if (history.isEmpty) { + setState(() { + _hasMoreNewer = false; + _isLoadingNewer = false; + }); + return; + } + + List loadedNewer = []; + DateTime now = DateTime.now(); + Duration offset = now.timeZoneOffset; + + for (var msg in history) { + final parsed = await _parseAndDecryptMessage( + msg, + _chatSharedSecret!, + offset, + {}, + ); + if (parsed != null) loadedNewer.add(parsed); + } + + if (!mounted) return; + + // --- СТАБИЛИЗАЦИЯ СКРОЛЛА: Фиксируем расстояние от верхнего (неизменного) края чата --- + double? distanceFromTop; + if (_scrollController.hasClients) { + distanceFromTop = _scrollController.position.maxScrollExtent - _scrollController.position.pixels; + } + + setState(() { + loadedNewer.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + // Защита от дубликатов + final existingIds = messages.map((m) => m.id).toSet(); + final uniqueNewer = loadedNewer + .where((m) => !existingIds.contains(m.id)) + .toList(); + + // Добавляем новые порции в КОНЕЦ массива (вниз UI) + messages.addAll(uniqueNewer); + _isLoadingNewer = false; + if (history.length < _limitAfter) _hasMoreNewer = false; + }); + + // --- СТАБИЛИЗАЦИЯ СКРОЛЛА: Возвращаем скролл ровно на ту же инвариантную позицию --- + if (distanceFromTop != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + final double newMaxScroll = _scrollController.position.maxScrollExtent; + final double targetPixels = newMaxScroll - distanceFromTop!; + + // Прыгаем мгновенно без анимации, пользователь вообще не заметит вставки элементов + _scrollController.jumpTo(targetPixels); + } + }); + } + } catch (e) { + print("Ошибка загрузки новых сообщений: $e"); + setState(() => _isLoadingNewer = false); + } + } + Future _parseAndDecryptMessage( Map msg, SecretKey sharedSecret, @@ -3110,12 +3378,10 @@ class _ChatScreenState extends State with RouteAware { ? MessageStatus.sent : MessageStatus.delivered; - if (senderId == myId) { - if (readAt != null) { - status = MessageStatus.read; - } else if (deliveredAt != null) { - status = MessageStatus.delivered; - } + if (readAt != null) { + status = MessageStatus.read; + } else if (deliveredAt != null) { + status = MessageStatus.delivered; } final rawFileId = msg['file_id']?.toString() ?? msg['fileId']?.toString(); @@ -3538,6 +3804,24 @@ class _ChatScreenState extends State with RouteAware { Future _updateScrollButtonVisibility() async { if (!mounted) return; + + if (_suppressPagination) return; + + if (_scrollController.hasClients) { + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + + // Прокрутка НАВЕРХ к старым сообщениям + if (maxScroll - currentScroll <= 200) { + _loadOlderMessages(); + } + + // Прокрутка ВНИЗ к новым сообщениям + if (currentScroll <= 200) { + _loadNewerMessages(); + } + } + final shouldShow = _scrollController.hasClients && _scrollController.offset > 100; if (shouldShow != _showScrollToEnd) { diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 7986cc9..f01a969 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -337,7 +337,8 @@ class _ContactsScreenState extends State with RouteAware { final localName = _localFullNames[contact.id]; final displayName = (localName != null && localName.isNotEmpty) ? localName - : '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim(); + : '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}' + .trim(); final contactInitials = displayName.isNotEmpty ? displayName @@ -565,6 +566,23 @@ class _ContactsScreenState extends State with RouteAware { onOpenProfile: _openProfile, onBack: _clearSelectedContact, showBackButton: false, + onMessageRead: (contactId) { + final contactProvider = context.read(); + + final contact = contactProvider.contacts.firstWhere( + (c) => c.id == contactId, + orElse: () => _selectedContact!, + ); + + final currentUnread = contact.unreadCount; + + if (currentUnread > 0) { + contactProvider.updateContact( + contactId, + unreadCount: currentUnread - 1, + ); + } + }, ); } diff --git a/lib/presentation/widgets/message_bubble.dart b/lib/presentation/widgets/message_bubble.dart index 3ccf2dd..9231aae 100644 --- a/lib/presentation/widgets/message_bubble.dart +++ b/lib/presentation/widgets/message_bubble.dart @@ -24,6 +24,7 @@ class MessageBubble extends StatefulWidget { final VoidCallback? onTap; final VoidCallback? onReplyTap; final VoidCallback? onImageTap; + final VoidCallback? onReplyToTap; final VoidCallback? onEditTap; final VoidCallback? onDeleteTap; @@ -39,6 +40,7 @@ class MessageBubble extends StatefulWidget { this.onTap, this.onReplyTap, this.onImageTap, + this.onReplyToTap, this.onEditTap, this.onDeleteTap, this.onDownloadRequested, @@ -81,7 +83,8 @@ class _MessageBubbleState extends State { _resolveFileSize(); _generateVideoThumbnail(); - if (widget.message.messageType == MessageType.image && widget.message.localFile != null) { + if (widget.message.messageType == MessageType.image && + widget.message.localFile != null) { _loadImageDimensionsFromFile(widget.message.localFile!); } @@ -539,32 +542,32 @@ class _MessageBubbleState extends State { final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final RelativeRect position = RelativeRect.fromRect( - Rect.fromLTWH(details.globalPosition.dx, details.globalPosition.dy, 30, 30), - Offset.zero & overlay.size); + Rect.fromLTWH( + details.globalPosition.dx, + details.globalPosition.dy, + 30, + 30, + ), + Offset.zero & overlay.size, + ); showMenu( context: context, position: position, items: [ - const PopupMenuItem( - value: 'reply', - child: Text('Reply'), - ), + const PopupMenuItem(value: 'reply', child: Text('Ответить')), if (widget.message.text.isNotEmpty) const PopupMenuItem( value: 'copy', - child: Text('Copy Text'), + child: Text('Копировать текст'), ), if (widget.message.isMe) const PopupMenuItem( value: 'edit', - child: Text('Edit'), + child: Text('Редактировать'), ), if (widget.message.isMe) - const PopupMenuItem( - value: 'delete', - child: Text('Delete'), - ), + const PopupMenuItem(value: 'delete', child: Text('Удалить')), ], ).then((String? value) { if (value != null) { @@ -576,7 +579,7 @@ class _MessageBubbleState extends State { void _handleMenuSelection(String value) { switch (value) { case 'reply': - widget.onReplyTap?.call(); + widget.onReplyToTap?.call(); break; case 'copy': Clipboard.setData(ClipboardData(text: widget.message.text)); @@ -590,10 +593,16 @@ class _MessageBubbleState extends State { } } - TextSpan _buildTextSpan(String text, Color primaryColor, Color linkColor, double fontSize) { + TextSpan _buildTextSpan( + String text, + Color primaryColor, + Color linkColor, + double fontSize, + ) { final List children = []; final RegExp linkRegExp = RegExp( - r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'); + r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+', + ); final matches = linkRegExp.allMatches(text); int lastMatchEnd = 0; @@ -605,7 +614,11 @@ class _MessageBubbleState extends State { children.add( TextSpan( text: linkText, - style: TextStyle(color: linkColor, fontWeight: FontWeight.bold, decoration: TextDecoration.underline), + style: TextStyle( + color: linkColor, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), recognizer: TapGestureRecognizer() ..onTap = () async { String url = linkText; @@ -613,7 +626,7 @@ class _MessageBubbleState extends State { url = 'https://$url'; } final Uri uri = Uri.parse(url); - if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { throw Exception('Could not launch $uri'); } }, @@ -647,7 +660,7 @@ class _MessageBubbleState extends State { final double bodyFontSize = isLargeScreen ? 15.5 : 14.0; final double timeFontSize = isLargeScreen ? 11.0 : 10.0; final double replyFontSize = isLargeScreen ? 13.0 : 12.0; - + final double paddingVertical = isLargeScreen ? 12.0 : 10.0; final double paddingHorizontal = isLargeScreen ? 16.0 : 14.0; @@ -656,8 +669,9 @@ class _MessageBubbleState extends State { child: GestureDetector( onSecondaryTapDown: _showContextMenu, onLongPressStart: (details) { - final tapDownDetails = - TapDownDetails(globalPosition: details.globalPosition); + final tapDownDetails = TapDownDetails( + globalPosition: details.globalPosition, + ); _showContextMenu(tapDownDetails); }, child: Material( @@ -672,16 +686,20 @@ class _MessageBubbleState extends State { ), child: Container( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal), + padding: EdgeInsets.symmetric( + vertical: paddingVertical, + horizontal: paddingHorizontal, + ), constraints: BoxConstraints( // Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop) maxWidth: math.min(screenWidth * 0.75, 460.0), ), decoration: BoxDecoration( color: isMe - ? Theme.of(context).colorScheme.brightness == Brightness.dark - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.primary + ? Theme.of(context).colorScheme.brightness == + Brightness.dark + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.primary : Colors.grey[800], borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), @@ -692,27 +710,51 @@ class _MessageBubbleState extends State { ), child: IntrinsicWidth( child: Column( - crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ if (widget.message.replyToText != null) ...[ - _buildReplyWidget(isMe, secondaryTextColor, replyFontSize), + _buildReplyWidget( + isMe, + secondaryTextColor, + replyFontSize, + ), ], Align( - alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen), + alignment: isMe + ? Alignment.centerRight + : Alignment.centerLeft, + child: _buildMessageBody( + primaryTextColor, + secondaryTextColor, + isLargeScreen, + ), ), if (widget.message.text.isNotEmpty) ...[ const SizedBox(height: 4), Align( alignment: Alignment.centerLeft, child: SelectableText.rich( - _buildTextSpan(widget.message.text, primaryTextColor, linkColor, bodyFontSize), - style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize), + _buildTextSpan( + widget.message.text, + primaryTextColor, + linkColor, + bodyFontSize, + ), + style: TextStyle( + color: primaryTextColor, + fontSize: bodyFontSize, + ), ), ), ], const SizedBox(height: 4), - _buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize), + _buildTimeAndStatusRow( + isMe, + secondaryTextColor, + timeFontSize, + ), ], ), ), @@ -723,7 +765,11 @@ class _MessageBubbleState extends State { ); } - Widget _buildMessageBody(Color primaryColor, Color secondaryColor, bool isLargeScreen) { + Widget _buildMessageBody( + Color primaryColor, + Color secondaryColor, + bool isLargeScreen, + ) { switch (widget.message.messageType) { case MessageType.image: return _buildImagePreview(primaryColor, secondaryColor, isLargeScreen); @@ -732,9 +778,17 @@ class _MessageBubbleState extends State { case MessageType.file: return _buildFileBubble(primaryColor, secondaryColor, isLargeScreen); case MessageType.videoNote: - return _buildVideoNotePreview(primaryColor, secondaryColor, isLargeScreen); + return _buildVideoNotePreview( + primaryColor, + secondaryColor, + isLargeScreen, + ); case MessageType.voiceNote: - return _buildVoiceNoteBubble(primaryColor, secondaryColor, isLargeScreen); + return _buildVoiceNoteBubble( + primaryColor, + secondaryColor, + isLargeScreen, + ); default: // For text-only messages, we don't need a body here as it's handled outside. if (widget.message.messageType == MessageType.text) { @@ -745,7 +799,11 @@ class _MessageBubbleState extends State { } } - Widget _buildImagePreview(Color textCol, Color subTextCol, bool isLargeScreen) { + Widget _buildImagePreview( + Color textCol, + Color subTextCol, + bool isLargeScreen, + ) { _resolveFileSize(); final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; @@ -794,13 +852,20 @@ class _MessageBubbleState extends State { children: [ if (isDownloaded) Image.file( - widget.message.localFile!, + widget.message.localFile!, fit: BoxFit.cover, width: finalWidth, height: finalHeight, ) else - _buildMediaPlaceholder(Icons.image, "Фото", isTooLarge, textCol, subTextCol, isLargeScreen), + _buildMediaPlaceholder( + Icons.image, + "Фото", + isTooLarge, + textCol, + subTextCol, + isLargeScreen, + ), if (!isDownloaded && !_isDownloading && !isSending && @@ -810,7 +875,10 @@ class _MessageBubbleState extends State { bottom: 8, left: 8, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), decoration: BoxDecoration( color: Colors.black.withOpacity(0.55), borderRadius: BorderRadius.circular(10), @@ -818,11 +886,19 @@ class _MessageBubbleState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.arrow_downward_rounded, color: Colors.white, size: 12), + const Icon( + Icons.arrow_downward_rounded, + color: Colors.white, + size: 12, + ), const SizedBox(width: 3), Text( displaySize, - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -830,7 +906,10 @@ class _MessageBubbleState extends State { ), if (_isMediaLoading || isSending || isEncrypting) Positioned.fill( - child: _buildProgressOverlay((isSending || isEncrypting), isEncrypting), + child: _buildProgressOverlay( + (isSending || isEncrypting), + isEncrypting, + ), ), ], ), @@ -839,7 +918,11 @@ class _MessageBubbleState extends State { ); } - Widget _buildVideoPreview(Color textCol, Color subTextCol, bool isLargeScreen) { + Widget _buildVideoPreview( + Color textCol, + Color subTextCol, + bool isLargeScreen, + ) { final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; @@ -848,8 +931,12 @@ class _MessageBubbleState extends State { final cachedSize = _mediaCache.getDimensions(_messageKeyId); final cachedThumbPath = _mediaCache.getThumbnailPath(_messageKeyId); - double vidWidth = cachedSize != null && cachedSize.width > 0 ? cachedSize.width : 240.0; - double vidHeight = cachedSize != null && cachedSize.height > 0 ? cachedSize.height : 160.0; + double vidWidth = cachedSize != null && cachedSize.width > 0 + ? cachedSize.width + : 240.0; + double vidHeight = cachedSize != null && cachedSize.height > 0 + ? cachedSize.height + : 160.0; double aspectRatio = vidWidth / vidHeight; aspectRatio = aspectRatio.clamp(0.5, 2.0); @@ -888,18 +975,41 @@ class _MessageBubbleState extends State { children: [ if (isDownloaded && !isSending && !isEncrypting) ...[ if (cachedThumbPath != null) - Image.file(File(cachedThumbPath), fit: BoxFit.cover, width: finalWidth, height: finalHeight) + Image.file( + File(cachedThumbPath), + fit: BoxFit.cover, + width: finalWidth, + height: finalHeight, + ) else - const Center(child: CircularProgressIndicator(color: Colors.white)), - Icon(Icons.play_circle_fill, color: Colors.white.withOpacity(0.9), size: isLargeScreen ? 52 : 44), + const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + Icon( + Icons.play_circle_fill, + color: Colors.white.withOpacity(0.9), + size: isLargeScreen ? 52 : 44, + ), ] else ...[ if (!_isDisplayableFileReady && (isSending || isEncrypting) && widget.message.localFile != null && cachedThumbPath != null) - Image.file(File(cachedThumbPath), fit: BoxFit.cover, width: finalWidth, height: finalHeight) + Image.file( + File(cachedThumbPath), + fit: BoxFit.cover, + width: finalWidth, + height: finalHeight, + ) else - _buildMediaPlaceholder(Icons.videocam, "Видео", isTooLarge, textCol, subTextCol, isLargeScreen), + _buildMediaPlaceholder( + Icons.videocam, + "Видео", + isTooLarge, + textCol, + subTextCol, + isLargeScreen, + ), ], if (!isDownloaded && !_isDownloading && @@ -910,19 +1020,30 @@ class _MessageBubbleState extends State { bottom: 8, left: 8, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), decoration: BoxDecoration( color: Colors.black.withOpacity(0.55), borderRadius: BorderRadius.circular(10), - ), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.arrow_downward_rounded, color: Colors.white, size: 12), + const Icon( + Icons.arrow_downward_rounded, + color: Colors.white, + size: 12, + ), const SizedBox(width: 3), Text( displaySize, - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -930,7 +1051,10 @@ class _MessageBubbleState extends State { ), if (_isDownloading || isSending || isEncrypting) Positioned.fill( - child: _buildProgressOverlay((isSending || isEncrypting), isEncrypting), + child: _buildProgressOverlay( + (isSending || isEncrypting), + isEncrypting, + ), ), ], ), @@ -950,7 +1074,9 @@ class _MessageBubbleState extends State { ) { _resolveFileSize(); final displaySize = formatBytes(_calculatedFileSize, 1); - final sizeString = _calculatedFileSize > 0 ? " ($displaySize)" : " (Загрузка...)"; + final sizeString = _calculatedFileSize > 0 + ? " ($displaySize)" + : " (Загрузка...)"; if (_isMediaLoading) return const SizedBox.shrink(); return Container( @@ -959,17 +1085,31 @@ class _MessageBubbleState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(isTooLarge ? Icons.download_for_offline : icon, size: isLargeScreen ? 48 : 42, color: subTextCol), + Icon( + isTooLarge ? Icons.download_for_offline : icon, + size: isLargeScreen ? 48 : 42, + color: subTextCol, + ), const SizedBox(height: 6), Text( - isTooLarge ? "Файл слишком большой$sizeString" : "$typeLabel$sizeString", + isTooLarge + ? "Файл слишком большой$sizeString" + : "$typeLabel$sizeString", textAlign: TextAlign.center, - style: TextStyle(fontSize: isLargeScreen ? 13 : 12, color: textCol, fontWeight: FontWeight.w500), + style: TextStyle( + fontSize: isLargeScreen ? 13 : 12, + color: textCol, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 4), Text( "Нажмите для загрузки", - style: TextStyle(fontSize: isLargeScreen ? 11 : 10, color: isTooLarge ? Colors.black54 : subTextCol, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: isLargeScreen ? 11 : 10, + color: isTooLarge ? Colors.black54 : subTextCol, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -995,7 +1135,12 @@ class _MessageBubbleState extends State { tween: Tween(begin: 0.0, end: currentProgress), duration: const Duration(milliseconds: 150), builder: (context, val, _) { - return _buildCircularIndicator(val, isEncrypting ? "Шифрование" : "Отправка", false, _calculatedFileSize); + return _buildCircularIndicator( + val, + isEncrypting ? "Шифрование" : "Отправка", + false, + _calculatedFileSize, + ); }, ); }, @@ -1004,7 +1149,12 @@ class _MessageBubbleState extends State { tween: Tween(begin: 0.0, end: 0.0), duration: const Duration(milliseconds: 150), builder: (context, val, _) { - return _buildCircularIndicator(val, isEncrypting ? "Шифрование" : "Отправка", false, _calculatedFileSize); + return _buildCircularIndicator( + val, + isEncrypting ? "Шифрование" : "Отправка", + false, + _calculatedFileSize, + ); }, ), ] else ...[ @@ -1018,7 +1168,12 @@ class _MessageBubbleState extends State { tween: Tween(begin: 0.0, end: value ?? 0.0), duration: const Duration(milliseconds: 150), builder: (context, val, _) { - return _buildCircularIndicator(val, "Загрузка", isIndeterminate, _calculatedFileSize); + return _buildCircularIndicator( + val, + "Загрузка", + isIndeterminate, + _calculatedFileSize, + ); }, ); }, @@ -1065,11 +1220,19 @@ class _MessageBubbleState extends State { if (!isIndeterminate) Text( "${(val * 100).toInt()}%", - style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), Text( label, - style: TextStyle(color: Colors.white60, fontSize: isIndeterminate ? 10 : 8, fontWeight: FontWeight.bold), + style: TextStyle( + color: Colors.white60, + fontSize: isIndeterminate ? 10 : 8, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -1084,7 +1247,11 @@ class _MessageBubbleState extends State { ), child: Text( progressText, - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -1104,7 +1271,7 @@ class _MessageBubbleState extends State { ? 'Загрузка' : ''; final displaySize = formatBytes(_calculatedFileSize, 1); - + // Адаптивная ширина файловой плашки final double bubbleWidth = isLargeScreen ? 340.0 : 260.0; @@ -1131,9 +1298,20 @@ class _MessageBubbleState extends State { Stack( alignment: Alignment.center, children: [ - Icon(Icons.insert_drive_file, size: isLargeScreen ? 42 : 38, color: subTextCol), - if (!_isMediaLoading && !isDownloaded && !isSending && !isEncrypting) - Icon(Icons.download_rounded, size: isLargeScreen ? 18 : 16, color: Colors.white70), + Icon( + Icons.insert_drive_file, + size: isLargeScreen ? 42 : 38, + color: subTextCol, + ), + if (!_isMediaLoading && + !isDownloaded && + !isSending && + !isEncrypting) + Icon( + Icons.download_rounded, + size: isLargeScreen ? 18 : 16, + color: Colors.white70, + ), ], ), const SizedBox(width: 10), @@ -1145,7 +1323,11 @@ class _MessageBubbleState extends State { widget.message.fileName ?? 'Файл', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontWeight: FontWeight.bold, fontSize: isLargeScreen ? 14.5 : 13.0, color: textCol), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: isLargeScreen ? 14.5 : 13.0, + color: textCol, + ), ), Row( children: [ @@ -1154,7 +1336,10 @@ class _MessageBubbleState extends State { displaySize, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: isLargeScreen ? 12 : 11, color: subTextCol), + style: TextStyle( + fontSize: isLargeScreen ? 12 : 11, + color: subTextCol, + ), ), ), const SizedBox(width: 6), @@ -1165,7 +1350,10 @@ class _MessageBubbleState extends State { status, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: isLargeScreen ? 12 : 11, color: subTextCol), + style: TextStyle( + fontSize: isLargeScreen ? 12 : 11, + color: subTextCol, + ), ), ), ), @@ -1173,7 +1361,9 @@ class _MessageBubbleState extends State { ), if (_isMediaLoading || isSending || isEncrypting) ValueListenableBuilder( - valueListenable: widget.downloadProgress ?? ValueNotifier(0.0), + valueListenable: + widget.downloadProgress ?? + ValueNotifier(0.0), builder: (context, value, _) { final progress = value ?? 0.0; return Padding( @@ -1190,14 +1380,25 @@ class _MessageBubbleState extends State { ], ), ), - if (!isDownloaded && !_isMediaLoading && !isSending && !isEncrypting) + if (!isDownloaded && + !_isMediaLoading && + !isSending && + !isEncrypting) IconButton( - icon: Icon(Icons.download_rounded, color: Colors.white70, size: isLargeScreen ? 24 : 20), + icon: Icon( + Icons.download_rounded, + color: Colors.white70, + size: isLargeScreen ? 24 : 20, + ), onPressed: _handleDownload, ), if (!isDownloaded && _isMediaLoading && !isSending && !isEncrypting) IconButton( - icon: Icon(Icons.cancel, color: Colors.white70, size: isLargeScreen ? 24 : 20), + icon: Icon( + Icons.cancel, + color: Colors.white70, + size: isLargeScreen ? 24 : 20, + ), onPressed: _handleStopDownload, ), ], @@ -1236,7 +1437,11 @@ class _MessageBubbleState extends State { widget.message.replyToText!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: subTextCol, fontSize: replyFontSize, fontStyle: FontStyle.italic), + style: TextStyle( + color: subTextCol, + fontSize: replyFontSize, + fontStyle: FontStyle.italic, + ), ), ), ], @@ -1245,7 +1450,11 @@ class _MessageBubbleState extends State { ); } - Widget _buildVideoNotePreview(Color primaryTextColor, Color secondaryTextColor, bool isLargeScreen) { + Widget _buildVideoNotePreview( + Color primaryTextColor, + Color secondaryTextColor, + bool isLargeScreen, + ) { final file = widget.message.localFile; final path = file?.path ?? ""; final id = widget.message.id ?? widget.message.tempId ?? "no_id"; @@ -1253,14 +1462,18 @@ class _MessageBubbleState extends State { final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; - debugPrint('==> BUBBLE_VIDEO_RENDER: msgId=$id, status=${widget.message.status}, hasFile=${file != null}, path=$path'); + debugPrint( + '==> BUBBLE_VIDEO_RENDER: msgId=$id, status=${widget.message.status}, hasFile=${file != null}, path=$path', + ); return ValueListenableBuilder( valueListenable: InlineVideoNotePlayer.activeVideoPathNotifier, builder: (context, activePath, _) { final bool isActive = activePath == path; // Масштабируем кружки-видеозаметки на десктопе - final double size = isActive ? (isLargeScreen ? 300 : 260) : (isLargeScreen ? 190 : 160); + final double size = isActive + ? (isLargeScreen ? 300 : 260) + : (isLargeScreen ? 190 : 160); return GestureDetector( onTap: (_isDownloading || isSending || isEncrypting || !isDownloaded) @@ -1291,24 +1504,38 @@ class _MessageBubbleState extends State { ? InlineVideoNotePlayer(videoPath: path) : Container( color: Colors.black54, - child: Icon(Icons.play_arrow_rounded, size: isLargeScreen ? 54 : 48, color: primaryTextColor), + child: Icon( + Icons.play_arrow_rounded, + size: isLargeScreen ? 54 : 48, + color: primaryTextColor, + ), ), ), ), - if (!isDownloaded && !_isDownloading && !isSending && !isEncrypting) + if (!isDownloaded && + !_isDownloading && + !isSending && + !isEncrypting) ClipOval( child: Container( width: size, height: size, color: Colors.black.withOpacity(0.4), - child: Icon(Icons.arrow_downward_rounded, color: Colors.white, size: isLargeScreen ? 42 : 36), + child: Icon( + Icons.arrow_downward_rounded, + color: Colors.white, + size: isLargeScreen ? 42 : 36, + ), ), ), if (_isDownloading || isSending || isEncrypting) SizedBox( width: size, height: size, - child: _buildProgressOverlay((isSending || isEncrypting), isEncrypting), + child: _buildProgressOverlay( + (isSending || isEncrypting), + isEncrypting, + ), ), ], ), @@ -1317,27 +1544,31 @@ class _MessageBubbleState extends State { ); } - Widget _buildVoiceNoteBubble(Color textCol, Color subTextCol, bool isLargeScreen) { + Widget _buildVoiceNoteBubble( + Color textCol, + Color subTextCol, + bool isLargeScreen, + ) { final String path = widget.message.localFile?.path ?? ''; final bool isDownloaded = path.isNotEmpty && File(path).existsSync(); final bool isSendingNow = widget.message.status == MessageStatus.sending; - + final double noteWidth = isLargeScreen ? 280.0 : 240.0; if (!isDownloaded) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - constraints: BoxConstraints( - minWidth: noteWidth, - maxWidth: noteWidth, - ), + constraints: BoxConstraints(minWidth: noteWidth, maxWidth: noteWidth), child: Row( children: [ SizedBox( width: 28, height: 28, child: _isMediaLoading - ? CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(textCol), strokeWidth: 2.5) + ? CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(textCol), + strokeWidth: 2.5, + ) : Icon(Icons.download_rounded, color: textCol, size: 28), ), const SizedBox(width: 12), @@ -1350,12 +1581,19 @@ class _MessageBubbleState extends State { "Голосовое сообщение", maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: isLargeScreen ? 15 : 14, color: textCol, fontWeight: FontWeight.w500), + style: TextStyle( + fontSize: isLargeScreen ? 15 : 14, + color: textCol, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 2), Text( _isMediaLoading ? "Загрузка..." : "Нажмите для скачивания", - style: TextStyle(fontSize: isLargeScreen ? 12 : 11, color: subTextCol), + style: TextStyle( + fontSize: isLargeScreen ? 12 : 11, + color: subTextCol, + ), ), ], ), @@ -1376,7 +1614,9 @@ class _MessageBubbleState extends State { child: AbsorbPointer( absorbing: isSendingNow, child: InlineVoiceNotePlayer( - key: ValueKey('voice_note_${widget.message.fileId ?? widget.message.tempId}'), + key: ValueKey( + 'voice_note_${widget.message.fileId ?? widget.message.tempId}', + ), audioPath: path, isLargeScreen: isLargeScreen, ), @@ -1385,23 +1625,34 @@ class _MessageBubbleState extends State { ); } - Widget _buildTimeAndStatusRow(bool isMe, Color secondaryTextColor, double timeFontSize) { + Widget _buildTimeAndStatusRow( + bool isMe, + Color secondaryTextColor, + double timeFontSize, + ) { final timeStr = "${widget.message.createdAt.hour.toString().padLeft(2, '0')}:${widget.message.createdAt.minute.toString().padLeft(2, '0')}"; return Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(timeStr, style: TextStyle(color: secondaryTextColor, fontSize: timeFontSize)), + Text( + timeStr, + style: TextStyle(color: secondaryTextColor, fontSize: timeFontSize), + ), if (widget.message.editedAt != null) Text( " (изменено)", - style: TextStyle(color: secondaryTextColor, fontSize: timeFontSize, fontStyle: FontStyle.italic), + style: TextStyle( + color: secondaryTextColor, + fontSize: timeFontSize, + fontStyle: FontStyle.italic, + ), ), if (isMe) ...[ const SizedBox(width: 4), Icon( - widget.message.status == MessageStatus.read ? Icons.done_all : Icons.done, + _statusIcon(widget.message.status), color: secondaryTextColor, size: timeFontSize + 4, ), @@ -1410,11 +1661,28 @@ class _MessageBubbleState extends State { ); } + IconData _statusIcon(MessageStatus status) { + switch (status) { + case MessageStatus.sending: + case MessageStatus.encrypting: + return Icons.access_time; + case MessageStatus.sent: + case MessageStatus.delivered: + return Icons.done; + case MessageStatus.read: + return Icons.done_all; + case MessageStatus.failed: + return Icons.error; + } + } + String formatBytes(int bytes, int decimals) { if (bytes <= 0) return "0 B"; const suffixes = ["B", "KB", "MB", "GB", "TB"]; var i = (log(bytes) / log(1024)).floor(); - return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + " " + suffixes[i]; + return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + + " " + + suffixes[i]; } } @@ -1464,7 +1732,9 @@ class _InlineVideoNotePlayerState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _initVideoWithDelay(); }); - InlineVideoNotePlayer.activeVideoPathNotifier.addListener(_onActiveVideoChanged); + InlineVideoNotePlayer.activeVideoPathNotifier.addListener( + _onActiveVideoChanged, + ); } void _initVideoWithDelay() async { @@ -1484,7 +1754,9 @@ class _InlineVideoNotePlayerState extends State { @override void dispose() { - InlineVideoNotePlayer.activeVideoPathNotifier.removeListener(_onActiveVideoChanged); + InlineVideoNotePlayer.activeVideoPathNotifier.removeListener( + _onActiveVideoChanged, + ); _controller?.removeListener(_videoListener); _controller?.dispose(); super.dispose(); @@ -1514,7 +1786,9 @@ class _InlineVideoNotePlayerState extends State { _controller!.removeListener(_videoListener); final oldController = _controller!; _controller = null; - oldController.dispose().catchError((e) => debugPrint('Error disposing vc: $e')); + oldController.dispose().catchError( + (e) => debugPrint('Error disposing vc: $e'), + ); } _initVideo(); } @@ -1531,19 +1805,21 @@ class _InlineVideoNotePlayerState extends State { _initError = null; _controller = VideoPlayerController.file(file) - ..initialize().then((_) { - _isInitializing = false; - _initError = null; - if (mounted) setState(() {}); - }).catchError((e) { - _isInitializing = false; - _initError = e.toString(); - final oldController = _controller; - _controller = null; - oldController?.removeListener(_videoListener); - oldController?.dispose().catchError((_) {}); - if (mounted) setState(() {}); - }); + ..initialize() + .then((_) { + _isInitializing = false; + _initError = null; + if (mounted) setState(() {}); + }) + .catchError((e) { + _isInitializing = false; + _initError = e.toString(); + final oldController = _controller; + _controller = null; + oldController?.removeListener(_videoListener); + oldController?.dispose().catchError((_) {}); + if (mounted) setState(() {}); + }); _controller?.addListener(_videoListener); } @@ -1556,7 +1832,8 @@ class _InlineVideoNotePlayerState extends State { if (isInitialized && _wasPlaying && !isPlaying) { _isExpanded = false; - if (InlineVideoNotePlayer.activeVideoPathNotifier.value == widget.videoPath) { + if (InlineVideoNotePlayer.activeVideoPathNotifier.value == + widget.videoPath) { InlineVideoNotePlayer.activeVideoPathNotifier.value = null; } } @@ -1585,7 +1862,8 @@ class _InlineVideoNotePlayerState extends State { if (_controller!.value.isPlaying) { _controller!.pause(); _isExpanded = false; - if (InlineVideoNotePlayer.activeVideoPathNotifier.value == widget.videoPath) { + if (InlineVideoNotePlayer.activeVideoPathNotifier.value == + widget.videoPath) { InlineVideoNotePlayer.activeVideoPathNotifier.value = null; } } else { @@ -1600,8 +1878,11 @@ class _InlineVideoNotePlayerState extends State { @override Widget build(BuildContext context) { final bool isLargeScreen = MediaQuery.of(context).size.width > 750; - final double size = _isExpanded ? (isLargeScreen ? 300.0 : 260.0) : (isLargeScreen ? 190.0 : 160.0); - final bool isInitialized = _controller != null && _controller!.value.isInitialized; + final double size = _isExpanded + ? (isLargeScreen ? 300.0 : 260.0) + : (isLargeScreen ? 190.0 : 160.0); + final bool isInitialized = + _controller != null && _controller!.value.isInitialized; final bool hasInitError = _initError != null; double progress = 0.0; @@ -1616,7 +1897,10 @@ class _InlineVideoNotePlayerState extends State { curve: Curves.easeOut, width: size, height: size, - decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black12), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black12, + ), child: ClipOval( child: Stack( alignment: Alignment.center, @@ -1640,19 +1924,41 @@ class _InlineVideoNotePlayerState extends State { ), ) else - const Center(child: SizedBox(width: 30, height: 30, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white70)))), + const Center( + child: SizedBox( + width: 30, + height: 30, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white70), + ), + ), + ), if (isInitialized && !hasInitError) Positioned.fill( child: IgnorePointer( child: CustomPaint( - painter: CircleProgressPainter(progress: progress, progressColor: Colors.white, backgroundColor: Colors.white30, strokeWidth: 4.0), + painter: CircleProgressPainter( + progress: progress, + progressColor: Colors.white, + backgroundColor: Colors.white30, + strokeWidth: 4.0, + ), ), ), ), if (isInitialized && !_controller!.value.isPlaying && !hasInitError) IgnorePointer( - child: Container(color: Colors.black26, alignment: Alignment.center, child: const Icon(Icons.play_arrow, size: 40, color: Colors.white)), + child: Container( + color: Colors.black26, + alignment: Alignment.center, + child: const Icon( + Icons.play_arrow, + size: 40, + color: Colors.white, + ), + ), ), ], ), @@ -1683,8 +1989,11 @@ class _InlineVideoInitErrorFallback extends StatelessWidget { children: [ Icon(Icons.play_disabled, color: Colors.white70, size: 40), SizedBox(height: 8), - Text('Видео не воспроизводится - Нажмите, чтобы открыть внешним плеером', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12)), + Text( + 'Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 12), + ), ], ), ), @@ -1727,7 +2036,13 @@ class CircleProgressPainter extends CustomPainter { double startAngle = -math.pi / 2; double sweepAngle = 2 * math.pi * progress; - canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, progressPaint); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + progressPaint, + ); } @override @@ -1741,7 +2056,11 @@ class CircleProgressPainter extends CustomPainter { class InlineVoiceNotePlayer extends StatefulWidget { final String audioPath; final bool isLargeScreen; - const InlineVoiceNotePlayer({super.key, required this.audioPath, required this.isLargeScreen}); + const InlineVoiceNotePlayer({ + super.key, + required this.audioPath, + required this.isLargeScreen, + }); @override State createState() => _InlineVoiceNotePlayerState(); @@ -1767,7 +2086,8 @@ class _InlineVoiceNotePlayerState extends State { } void _checkAndSetupSource() { - if (widget.audioPath.isEmpty || _sourceInitialized || _isInitializing) return; + if (widget.audioPath.isEmpty || _sourceInitialized || _isInitializing) + return; final file = File(widget.audioPath); if (!file.existsSync()) return; @@ -1780,7 +2100,9 @@ class _InlineVoiceNotePlayerState extends State { _isInitializing = false; return; } - setState(() { _isInitializing = false; }); + setState(() { + _isInitializing = false; + }); }); } @@ -1794,7 +2116,9 @@ class _InlineVoiceNotePlayerState extends State { return; } - _fileWatchTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) { + _fileWatchTimer = Timer.periodic(const Duration(milliseconds: 250), ( + timer, + ) { if (!mounted || widget.audioPath.isEmpty) { timer.cancel(); return; @@ -1816,12 +2140,16 @@ class _InlineVoiceNotePlayerState extends State { await _audioPlayer.setSource(DeviceFileSource(path)); if (!mounted) return; - setState(() { _sourceInitialized = true; }); + setState(() { + _sourceInitialized = true; + }); final d = await _audioPlayer.getDuration(); if (!mounted) return; if (d != null && d.inMilliseconds > 0) { - setState(() { _duration = d; }); + setState(() { + _duration = d; + }); } } catch (e) { debugPrint('[AUDIO ERROR] Ошибка установки источника: $e'); @@ -1833,7 +2161,10 @@ class _InlineVoiceNotePlayerState extends State { super.didUpdateWidget(oldWidget); final bool pathChanged = oldWidget.audioPath != widget.audioPath; - final bool fileJustAppeared = widget.audioPath.isNotEmpty && !_sourceInitialized && File(widget.audioPath).existsSync(); + final bool fileJustAppeared = + widget.audioPath.isNotEmpty && + !_sourceInitialized && + File(widget.audioPath).existsSync(); if (pathChanged) { _sourceInitialized = false; @@ -1862,7 +2193,9 @@ class _InlineVoiceNotePlayerState extends State { _audioPlayer.onDurationChanged.listen((newDuration) { if (mounted && newDuration.inMilliseconds > 0) { - setState(() { _duration = newDuration; }); + setState(() { + _duration = newDuration; + }); } }); @@ -1881,7 +2214,8 @@ class _InlineVoiceNotePlayerState extends State { } void _togglePlay() async { - if (widget.audioPath.isEmpty || !File(widget.audioPath).existsSync()) return; + if (widget.audioPath.isEmpty || !File(widget.audioPath).existsSync()) + return; if (!_sourceInitialized) _checkAndSetupSource(); @@ -1892,7 +2226,8 @@ class _InlineVoiceNotePlayerState extends State { } } - bool get _isFileAvailable => widget.audioPath.isNotEmpty && File(widget.audioPath).existsSync(); + bool get _isFileAvailable => + widget.audioPath.isNotEmpty && File(widget.audioPath).existsSync(); @override void dispose() { @@ -1913,32 +2248,40 @@ class _InlineVoiceNotePlayerState extends State { final bool hasDuration = _hasValidDuration; final bool isReady = fileAvailable && _sourceInitialized && hasDuration; final String statusText; - + if (!fileAvailable) { statusText = 'Загрузка...'; } else if (!_sourceInitialized || !hasDuration) { statusText = 'Подготовка...'; } else { - statusText = "${_formatDuration(_position)} / ${_formatDuration(_duration)}"; + statusText = + "${_formatDuration(_position)} / ${_formatDuration(_duration)}"; } final double durationMs = _duration.inMilliseconds.toDouble(); final double positionMs = _position.inMilliseconds.toDouble(); final bool canSeek = hasDuration; final double safeMax = durationMs > 0 ? durationMs : 1.0; - final double safeValue = durationMs > 0 ? positionMs.clamp(0.0, safeMax) : 0.0; + final double safeValue = durationMs > 0 + ? positionMs.clamp(0.0, safeMax) + : 0.0; final double playerWidth = widget.isLargeScreen ? 280.0 : 240.0; return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), width: playerWidth, - decoration: BoxDecoration(color: Colors.black12, borderRadius: BorderRadius.circular(10)), + decoration: BoxDecoration( + color: Colors.black12, + borderRadius: BorderRadius.circular(10), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: Icon(_isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled), + icon: Icon( + _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, + ), iconSize: widget.isLargeScreen ? 40 : 36, color: fileAvailable ? Colors.white : Colors.white38, onPressed: fileAvailable ? _togglePlay : null, @@ -1952,8 +2295,13 @@ class _InlineVoiceNotePlayerState extends State { data: SliderTheme.of(context).copyWith( trackHeight: 3, padding: EdgeInsets.zero, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5, elevation: 0), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 8), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 5, + elevation: 0, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 8, + ), ), child: Container( height: 20, @@ -1967,15 +2315,27 @@ class _InlineVoiceNotePlayerState extends State { value: safeValue, onChanged: canSeek ? (value) async { - await _audioPlayer.seek(Duration(milliseconds: value.toInt())); + await _audioPlayer.seek( + Duration(milliseconds: value.toInt()), + ); } : null, ), ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Text(statusText, style: TextStyle(fontSize: widget.isLargeScreen ? 12 : 11, color: Colors.white70, fontWeight: FontWeight.w500)), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: Text( + statusText, + style: TextStyle( + fontSize: widget.isLargeScreen ? 12 : 11, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), ), ], ), diff --git a/srv/app/api/endpoints/messages.py b/srv/app/api/endpoints/messages.py index 08e0af7..f95bb05 100644 --- a/srv/app/api/endpoints/messages.py +++ b/srv/app/api/endpoints/messages.py @@ -25,16 +25,55 @@ async def get_chat_history( contact_id: int, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db), - limit: int = 50 + anchor_id: int = None, # ID сообщения, вокруг которого строим выборку + limit_before: int = 20, # Сколько сообщений загрузить ДО (старше анкера) + limit_after: int = 20 # Сколько сообщений загрузить ПОСЛЕ (новее анкера) ): - messages = db.query(models.Message).filter( - (models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) | - (models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id) - ).order_by(models.Message.timestamp.desc()).limit(limit).all() - print( - f"DEBUG get_chat_history: user={current_user.id}, contact={contact_id}, count={len(messages)}, ids={[m.id for m in messages]}", + # Базовый фильтр для получения сообщений конкретного диалога + chat_filter = ( + ((models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id)) | + ((models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)) ) - return jsonable_encoder(messages) + + # КЕЙС 1: Анкер не передан — отдаем самый "хвост" чата (последние свежие сообщения) + if anchor_id is None: + messages = db.query(models.Message).filter(chat_filter)\ + .order_by(models.Message.id.desc())\ + .limit(limit_before)\ + .all() + + print(f"DEBUG history (Tail): user={current_user.id}, contact={contact_id}, count={len(messages)}") + return jsonable_encoder(messages) + + # КЕЙС 2: Передан anchor_id — собираем данные "вокруг" него + print(f"DEBUG history (Anchor): user={current_user.id}, contact={contact_id}, anchor={anchor_id}") + + # 1. Тянем сообщения СТАРШЕ анкера (id < anchor_id) + # Сортируем DESC, чтобы взять ближайшие к анкеру сообщения + older_messages = db.query(models.Message).filter(chat_filter, models.Message.id < anchor_id)\ + .order_by(models.Message.id.desc())\ + .limit(limit_before)\ + .all() + + # 2. Тянем само якорное сообщение (чтобы оно гарантированно попало в выборку) + anchor_message = db.query(models.Message).filter(chat_filter, models.Message.id == anchor_id).first() + + # 3. Тянем сообщения НОВЕЕ анкера (id > anchor_id) + # Сортируем ASC, чтобы взять идущие строго за анкером сообщения + newer_messages = db.query(models.Message).filter(chat_filter, models.Message.id > anchor_id)\ + .order_by(models.Message.id.asc())\ + .limit(limit_after)\ + .all() + + # Собираем все три куска в единый плоский список + combined_messages = older_messages + ([anchor_message] if anchor_message else []) + newer_messages + + # ВАЖНО: Сортируем итоговый массив по убыванию (DESC), + # чтобы фронтенд получил структуру в привычном для него хронологическом порядке (от новых к старым) + combined_messages.sort(key=lambda msg: msg.id, reverse=True) + + print(f"DEBUG history (Combined): total_count={len(combined_messages)}, older={len(older_messages)}, newer={len(newer_messages)}") + return jsonable_encoder(combined_messages) @messagesRouter.get("/last")