diff --git a/lib/api/api_service_chats.dart b/lib/api/api_service_chats.dart index edd7534..a3f3d66 100644 --- a/lib/api/api_service_chats.dart +++ b/lib/api/api_service_chats.dart @@ -726,6 +726,53 @@ extension ApiServiceChats on ApiService { } } + /// Загружает старые сообщения начиная с указанного timestamp + /// [fromTimestamp] - timestamp в миллисекундах самого старого загруженного сообщения + /// [backward] - количество сообщений для загрузки (по умолчанию 30) + Future> loadOlderMessagesByTimestamp( + int chatId, + int fromTimestamp, { + int backward = 30, + }) async { + await waitUntilOnline(); + + print( + "📜 Запрашиваем старые сообщения для чата $chatId начиная с timestamp $fromTimestamp (backward: $backward)", + ); + + final payload = { + "chatId": chatId, + "from": fromTimestamp, + "forward": 0, + "backward": backward, + "getMessages": true, + }; + + try { + final int seq = _sendMessage(49, payload); + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 15)); + + if (response['cmd'] == 3) { + final error = response['payload']; + print('❌ Ошибка получения старых сообщений: $error'); + return []; + } + + final List messagesJson = response['payload']?['messages'] ?? []; + final messagesList = + messagesJson.map((json) => Message.fromJson(json)).toList() + ..sort((a, b) => a.time.compareTo(b.time)); + + print('✅ Получено ${messagesList.length} старых сообщений'); + return messagesList; + } catch (e) { + print('❌ Ошибка при получении старых сообщений: $e'); + return []; + } + } + void sendNavEvent(String event, {int? screenTo, int? screenFrom}) { if (_userId == null) return; diff --git a/lib/api/api_service_contacts.dart b/lib/api/api_service_contacts.dart index 07aed84..ad0bf40 100644 --- a/lib/api/api_service_contacts.dart +++ b/lib/api/api_service_contacts.dart @@ -248,23 +248,17 @@ extension ApiServiceContacts on ApiService { } Future getChatIdByUserId(int userId) async { - // Используем формулу: chatId = userId1 ^ userId2 - // где userId1 - наш ID, userId2 - ID собеседника + // ПИДОРИСТИЧЕСКАЯ ФОРМУЛА ОТ ДЕДА chatId = userId1 ^ userId2 if (_userId == null) { - print('⚠️ Не удалось вычислить chatId: наш userId не установлен'); return null; } final chatId = _userId! ^ userId; - print('✅ Вычислен chatId для диалога: наш userId=$_userId, собеседник userId=$userId, chatId=$chatId'); return chatId; } Future> fetchContactsByIds(List contactIds) async { if (contactIds.isEmpty) { - print( - '⚠️ [fetchContactsByIds] Пустой список contactIds - пропускаем запрос', - ); return []; } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index a7d2af5..a161d30 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -670,6 +670,26 @@ class _ChatScreenState extends State { final isAtBottom = positions.first.index == 0; _isUserAtBottom = isAtBottom; _showScrollToBottomNotifier.value = !isAtBottom; + + // Проверяем, доскроллил ли пользователь до самого старого сообщения (вверх) + // При reverse: true, последний визуальный элемент (самый большой index) = самое старое сообщение + if (positions.isNotEmpty && _chatItems.isNotEmpty) { + final maxIndex = positions.map((p) => p.index).reduce((a, b) => a > b ? a : b); + // При reverse: true, когда maxIndex близок к _chatItems.length - 1, мы вверху (старые сообщения) + final threshold = _chatItems.length > 5 ? 3 : 1; // Загружаем когда осталось 3 элемента до верха + final isNearTop = maxIndex >= _chatItems.length - threshold; + + // Если доскроллили близко к верху и есть еще сообщения, загружаем + if (isNearTop && _hasMore && !_isLoadingMore && _messages.isNotEmpty && _oldestLoadedTime != null) { + print('📜 Пользователь доскроллил близко к верху (maxIndex: $maxIndex, total: ${_chatItems.length}), загружаем старые сообщения...'); + // Вызываем после build фазы, чтобы избежать setState() во время build + Future.microtask(() { + if (mounted && _hasMore && !_isLoadingMore) { + _loadMore(); + } + }); + } + } } }); @@ -834,6 +854,14 @@ class _ChatScreenState extends State { if (!mounted) return; _messages.clear(); _messages.addAll(cachedMessages); + + // Устанавливаем _oldestLoadedTime и _hasMore для кэшированных сообщений + if (_messages.isNotEmpty) { + _oldestLoadedTime = _messages.first.time; + // Предполагаем, что могут быть еще сообщения (будет обновлено после загрузки с сервера) + _hasMore = true; + print('📜 Загружено из кэша: ${_messages.length} сообщений, _oldestLoadedTime=$_oldestLoadedTime'); + } if (widget.isGroupChat) { await _loadGroupParticipants(); @@ -921,7 +949,10 @@ class _ChatScreenState extends State { _messages.clear(); _messages.addAll(slice); _oldestLoadedTime = _messages.isNotEmpty ? _messages.first.time : null; - _hasMore = allMessages.length > _messages.length; + // Если получили максимальное количество сообщений (1000), возможно есть еще + // Также проверяем, есть ли сообщения старше самого старого загруженного + _hasMore = allMessages.length >= 1000 || allMessages.length > _messages.length; + print('📜 Первая загрузка: загружено ${allMessages.length} сообщений, показано ${_messages.length}, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime'); _buildChatItems(); _isLoadingHistory = false; }); @@ -954,42 +985,80 @@ class _ChatScreenState extends State { } Future _loadMore() async { - if (_isLoadingMore || !_hasMore) return; - _isLoadingMore = true; - setState(() {}); - - final all = await ApiService.instance.getMessageHistory( - widget.chatId, - force: false, - ); - if (!mounted) return; - - final page = _anyOptimize ? _optPage : _pageSize; - - final older = all - .where((m) => m.time < (_oldestLoadedTime ?? 1 << 62)) - .toList(); - - if (older.isEmpty) { + print('📜 _loadMore() вызвана: _isLoadingMore=$_isLoadingMore, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime'); + + if (_isLoadingMore || !_hasMore) { + print('📜 _loadMore() пропущена: _isLoadingMore=$_isLoadingMore, _hasMore=$_hasMore'); + return; + } + + if (_messages.isEmpty || _oldestLoadedTime == null) { + print('📜 _loadMore() пропущена: _messages.isEmpty=${_messages.isEmpty}, _oldestLoadedTime=$_oldestLoadedTime'); _hasMore = false; - _isLoadingMore = false; - setState(() {}); return; } - older.sort((a, b) => a.time.compareTo(b.time)); - final take = older.length > page - ? older.sublist(older.length - page) - : older; - - _messages.insertAll(0, take); - _oldestLoadedTime = _messages.first.time; - _hasMore = all.length > _messages.length; - - _buildChatItems(); - _isLoadingMore = false; + _isLoadingMore = true; setState(() {}); - _updatePinnedMessage(); + + try { + print('📜 Загружаем старые сообщения для chatId=${widget.chatId}, fromTimestamp=$_oldestLoadedTime'); + // Загружаем старые сообщения начиная с timestamp самого старого загруженного сообщения + final olderMessages = await ApiService.instance.loadOlderMessagesByTimestamp( + widget.chatId, + _oldestLoadedTime!, + backward: 30, + ); + + print('📜 Получено ${olderMessages.length} старых сообщений'); + + if (!mounted) return; + + if (olderMessages.isEmpty) { + // Больше нет старых сообщений + _hasMore = false; + _isLoadingMore = false; + setState(() {}); + return; + } + + // Фильтруем дубликаты - оставляем только те сообщения, которых еще нет в списке + final existingMessageIds = _messages.map((m) => m.id).toSet(); + final newMessages = olderMessages.where((m) => !existingMessageIds.contains(m.id)).toList(); + + if (newMessages.isEmpty) { + // Все сообщения уже есть в списке + _hasMore = false; + _isLoadingMore = false; + setState(() {}); + return; + } + + print('📜 Добавляем ${newMessages.length} новых старых сообщений (отфильтровано ${olderMessages.length - newMessages.length} дубликатов)'); + + // Добавляем старые сообщения в начало списка + _messages.insertAll(0, newMessages); + _oldestLoadedTime = _messages.first.time; + + // Проверяем, есть ли еще сообщения (если получили меньше 30, значит это последние) + _hasMore = olderMessages.length >= 30; + + _buildChatItems(); + _isLoadingMore = false; + + if (mounted) { + setState(() {}); + } + + _updatePinnedMessage(); + } catch (e) { + print('❌ Ошибка при загрузке старых сообщений: $e'); + if (mounted) { + _isLoadingMore = false; + _hasMore = false; + setState(() {}); + } + } } bool _isSameDay(DateTime date1, DateTime date2) { @@ -1067,6 +1136,16 @@ class _ChatScreenState extends State { ); } _chatItems = items; + + // Очищаем ключи для сообщений, которых больше нет в списке + final currentMessageIds = _messages.map((m) => m.id).toSet(); + final keysToRemove = _messageKeys.keys.where((id) => !currentMessageIds.contains(id)).toList(); + for (final id in keysToRemove) { + _messageKeys.remove(id); + } + if (keysToRemove.isNotEmpty) { + print('📜 Очищено ${keysToRemove.length} ключей для удаленных сообщений'); + } } void _updatePinnedMessage() { @@ -2447,11 +2526,8 @@ class _ChatScreenState extends State { final isLastVisual = index == _chatItems.length - 1; - if (isLastVisual && - _hasMore && - !_isLoadingMore) { - _loadMore(); - } + // Убрали вызов _loadMore() отсюда - он вызывается из _itemPositionsListener + // чтобы избежать setState() во время build фазы if (item is MessageItem) { final message = item.message; diff --git a/lib/services/cache_service.dart b/lib/services/cache_service.dart index 4dd6751..72442a8 100644 --- a/lib/services/cache_service.dart +++ b/lib/services/cache_service.dart @@ -22,8 +22,9 @@ class CacheService { Directory? _cacheDirectory; - // LZ4 сжатие для экономии места - final Lz4Codec _lz4Codec = Lz4Codec(); + // LZ4 сжатие для экономии места (может быть null если библиотека недоступна) + Lz4Codec? _lz4Codec; + bool _lz4Available = false; // Синхронизация операций очистки кэша static final _clearLock = Object(); @@ -43,6 +44,17 @@ class CacheService { await _createCacheDirectories(); + // Пытаемся инициализировать LZ4, если не получится - используем обычное кэширование + try { + _lz4Codec = Lz4Codec(); + _lz4Available = true; + print('✅ CacheService: LZ4 compression доступна'); + } catch (e) { + _lz4Codec = null; + _lz4Available = false; + print('⚠️ CacheService: LZ4 compression недоступна, используется обычное кэширование: $e'); + } + print('CacheService инициализирован'); } @@ -240,13 +252,26 @@ class CacheService { final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { - // Сжимаем данные перед сохранением - final compressedData = _lz4Codec.encode(response.bodyBytes); - await existingFile.writeAsBytes(compressedData); + // Сжимаем данные перед сохранением, если LZ4 доступна + if (_lz4Available && _lz4Codec != null) { + try { + final compressedData = _lz4Codec!.encode(response.bodyBytes); + await existingFile.writeAsBytes(compressedData); + } catch (e) { + // Если сжатие не удалось, сохраняем без сжатия + print('⚠️ Ошибка сжатия файла $url, сохраняем без сжатия: $e'); + await existingFile.writeAsBytes(response.bodyBytes); + } + } else { + // LZ4 недоступна, сохраняем без сжатия + await existingFile.writeAsBytes(response.bodyBytes); + } return filePath; } } catch (e) { print('Ошибка кэширования файла $url: $e'); + // Не вызываем запрос повторно при ошибке + return null; } return null; @@ -309,17 +334,22 @@ class CacheService { Future getCachedFileBytes(String url, {String? customKey}) async { final file = await getCachedFile(url, customKey: customKey); if (file != null && await file.exists()) { - final compressedData = await file.readAsBytes(); - try { - // Декомпрессируем данные - final decompressedData = _lz4Codec.decode(compressedData); - return Uint8List.fromList(decompressedData); - } catch (e) { - // Если декомпрессия не удалась, возможно файл не сжат (старый формат) - print( - 'Ошибка декомпрессии файла $url, пробуем прочитать как обычный файл: $e', - ); - return compressedData; + final fileData = await file.readAsBytes(); + // Пытаемся декомпрессировать, если LZ4 доступна + if (_lz4Available && _lz4Codec != null) { + try { + final decompressedData = _lz4Codec!.decode(fileData); + return Uint8List.fromList(decompressedData); + } catch (e) { + // Если декомпрессия не удалась, возможно файл не сжат (старый формат или LZ4 недоступна) + print( + '⚠️ Ошибка декомпрессии файла $url, пробуем прочитать как обычный файл: $e', + ); + return fileData; + } + } else { + // LZ4 недоступна, возвращаем данные как есть + return fileData; } } return null; @@ -388,8 +418,8 @@ class CacheService { 'memorySize': sizes['memory'], 'filesSizeMB': (sizes['files']! / (1024 * 1024)).toStringAsFixed(2), 'maxMemorySize': _maxMemoryCacheSize, - 'compression_enabled': true, - 'compression_algorithm': 'LZ4', + 'compression_enabled': _lz4Available, + 'compression_algorithm': _lz4Available ? 'LZ4' : 'none', }; } @@ -447,9 +477,20 @@ class CacheService { await audioDir.create(recursive: true); } - // Сжимаем аудио данные перед сохранением - final compressedData = _lz4Codec.encode(response.bodyBytes); - await existingFile.writeAsBytes(compressedData); + // Сжимаем аудио данные перед сохранением, если LZ4 доступна + if (_lz4Available && _lz4Codec != null) { + try { + final compressedData = _lz4Codec!.encode(response.bodyBytes); + await existingFile.writeAsBytes(compressedData); + } catch (e) { + // Если сжатие не удалось, сохраняем без сжатия + print('⚠️ Ошибка сжатия аудио файла $url, сохраняем без сжатия: $e'); + await existingFile.writeAsBytes(response.bodyBytes); + } + } else { + // LZ4 недоступна, сохраняем без сжатия + await existingFile.writeAsBytes(response.bodyBytes); + } final fileSize = await existingFile.length(); print( 'CacheService: Audio cached successfully: $filePath (compressed size: $fileSize bytes)', @@ -507,17 +548,22 @@ class CacheService { }) async { final file = await getCachedAudioFile(url, customKey: customKey); if (file != null && await file.exists()) { - final compressedData = await file.readAsBytes(); - try { - // Декомпрессируем данные - final decompressedData = _lz4Codec.decode(compressedData); - return Uint8List.fromList(decompressedData); - } catch (e) { - // Если декомпрессия не удалась, возможно файл не сжат (старый формат) - print( - 'Ошибка декомпрессии аудио файла $url, пробуем прочитать как обычный файл: $e', - ); - return compressedData; + final fileData = await file.readAsBytes(); + // Пытаемся декомпрессировать, если LZ4 доступна + if (_lz4Available && _lz4Codec != null) { + try { + final decompressedData = _lz4Codec!.decode(fileData); + return Uint8List.fromList(decompressedData); + } catch (e) { + // Если декомпрессия не удалась, возможно файл не сжат (старый формат или LZ4 недоступна) + print( + '⚠️ Ошибка декомпрессии аудио файла $url, пробуем прочитать как обычный файл: $e', + ); + return fileData; + } + } else { + // LZ4 недоступна, возвращаем данные как есть + return fileData; } } return null; diff --git a/lib/utils/image_cache_service.dart b/lib/utils/image_cache_service.dart index 4c1e2f0..0cd9612 100644 --- a/lib/utils/image_cache_service.dart +++ b/lib/utils/image_cache_service.dart @@ -18,8 +18,9 @@ class ImageCacheService { ); // Кеш изображений на 7 дней late Directory _cacheDirectory; - // LZ4 сжатие для экономии места - final Lz4Codec _lz4Codec = Lz4Codec(); + // LZ4 сжатие для экономии места (может быть null если библиотека недоступна) + Lz4Codec? _lz4Codec; + bool _lz4Available = false; Future initialize() async { final appDir = await getApplicationDocumentsDirectory(); @@ -29,6 +30,17 @@ class ImageCacheService { await _cacheDirectory.create(recursive: true); } + // Пытаемся инициализировать LZ4, если не получится - используем обычное кэширование + try { + _lz4Codec = Lz4Codec(); + _lz4Available = true; + print('✅ LZ4 compression доступна'); + } catch (e) { + _lz4Codec = null; + _lz4Available = false; + print('⚠️ LZ4 compression недоступна, используется обычное кэширование: $e'); + } + await _cleanupExpiredCache(); } @@ -56,9 +68,20 @@ class ImageCacheService { final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { final file = File(getCachedImagePath(url)); - // Сжимаем данные перед сохранением - final compressedData = _lz4Codec.encode(response.bodyBytes); - await file.writeAsBytes(compressedData); + // Сжимаем данные перед сохранением, если LZ4 доступна + if (_lz4Available && _lz4Codec != null) { + try { + final compressedData = _lz4Codec!.encode(response.bodyBytes); + await file.writeAsBytes(compressedData); + } catch (e) { + // Если сжатие не удалось, сохраняем без сжатия + print('⚠️ Ошибка сжатия изображения $url, сохраняем без сжатия: $e'); + await file.writeAsBytes(response.bodyBytes); + } + } else { + // LZ4 недоступна, сохраняем без сжатия + await file.writeAsBytes(response.bodyBytes); + } await _updateFileAccessTime(file); @@ -77,17 +100,22 @@ class ImageCacheService { }) async { final file = await loadImage(url, forceRefresh: forceRefresh); if (file != null) { - final compressedData = await file.readAsBytes(); - try { - // Декомпрессируем данные - final decompressedData = _lz4Codec.decode(compressedData); - return Uint8List.fromList(decompressedData); - } catch (e) { - // Если декомпрессия не удалась, возможно файл не сжат (старый формат) - print( - 'Ошибка декомпрессии изображения $url, пробуем прочитать как обычный файл: $e', - ); - return compressedData; + final fileData = await file.readAsBytes(); + // Пытаемся декомпрессировать, если LZ4 доступна + if (_lz4Available && _lz4Codec != null) { + try { + final decompressedData = _lz4Codec!.decode(fileData); + return Uint8List.fromList(decompressedData); + } catch (e) { + // Если декомпрессия не удалась, возможно файл не сжат (старый формат или LZ4 недоступна) + print( + '⚠️ Ошибка декомпрессии изображения $url, пробуем прочитать как обычный файл: $e', + ); + return fileData; + } + } else { + // LZ4 недоступна, возвращаем данные как есть + return fileData; } } return null; @@ -237,8 +265,8 @@ class ImageCacheService { 'cache_size_mb': (size / (1024 * 1024)).toStringAsFixed(2), 'file_count': fileCount, 'cache_directory': _cacheDirectory.path, - 'compression_enabled': true, - 'compression_algorithm': 'LZ4', + 'compression_enabled': _lz4Available, + 'compression_algorithm': _lz4Available ? 'LZ4' : 'none', }; } }