From 91d2afbb57c8eaf10cc9d21b74d582b899b4989d Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Apr 2026 20:32:22 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=8F=D1=82=D0=BA=D0=B8=20=D0=BC=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=86=D0=B5=D0=BB=D1=83=D0=B9=D1=82=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=85=D1=83=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 4 +- lib/backend/modules/messages.dart | 163 +++++++++++++++ lib/core/protocol/packet.dart | 111 +++++----- lib/core/storage/app_database.dart | 62 +++++- lib/core/transport/receiver.dart | 16 +- .../screens/chats/chat_list_screen.dart | 6 +- lib/frontend/screens/chats/chat_screen.dart | 197 +++++++++++++----- lib/main.dart | 2 + 8 files changed, 446 insertions(+), 115 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 59efde3..1eeb9dd 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -199,8 +199,8 @@ class Api { logger.i('Сессия: ${state.name}'); } - void _onDataReceived(Uint8List data) { - for (final packet in _receiver.feed(data)) { + Future _onDataReceived(Uint8List data) async { + await for (final packet in _receiver.feed(data)) { _dispatcher.dispatch(packet); } } diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e69de29..61d7614 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/storage/app_database.dart'; + +class CachedMessage { + final String id; + final int accountId; + final int chatId; + final int senderId; + final String? text; + final int time; + final String? status; + final Map? payload; + + const CachedMessage({ + required this.id, + required this.accountId, + required this.chatId, + required this.senderId, + this.text, + required this.time, + this.status, + this.payload, + }); + + factory CachedMessage.fromDbRow(Map row) { + Map? payload; + final payloadRaw = row['payload']; + if (payloadRaw is String && payloadRaw.isNotEmpty) { + try { + payload = jsonDecode(payloadRaw) as Map; + } catch (_) {} + } + + return CachedMessage( + id: row['id'] as String, + accountId: row['account_id'] as int, + chatId: row['chat_id'] as int, + senderId: row['sender_id'] as int, + text: row['text'] as String?, + time: row['time'] as int, + status: row['status'] as String?, + payload: payload, + ); + } + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'chat_id': chatId, + 'sender_id': senderId, + 'text': text, + 'time': time, + 'status': status, + 'payload': payload != null ? jsonEncode(payload) : null, + }; +} + +class MessagesModule { + final Api _api; + + MessagesModule(this._api); + + /// Загружает историю сообщений для указанного чата. + /// + /// [fromTime] — опционально, время от которого грузить (миллисекунды). + /// Если не указано, грузит самые свежие. + /// [count] — количество сообщений. + Future> fetchHistory( + int accountId, + int chatId, { + int? fromTime, + int count = 50, + }) async { + final payload = { + 'chatId': chatId, + 'from': + fromTime ?? + (DateTime.now().millisecondsSinceEpoch + + 86400000), // +1 день для запаса + 'forward': 0, + 'backward': count, + 'getMessages': true, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + + if (!response.isOk) return []; + + final data = response.payload; + if (data is! Map) return []; + + final messagesData = data['messages']; + if (messagesData is! List) return []; + + final List results = []; + final List> rows = []; + + // Обрабатываем сообщения "кусочками", чтобы не фризить UI при маппинге большого количества данных + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + + final msg = _parseMessage(m.cast(), accountId, chatId); + if (msg != null) { + results.add(msg); + rows.add(msg.toDbRow()); + } + + // Каждые 20 сообщений даем UI-потоку "дохнуть" и отрисовать кадр + if (i > 0 && i % 20 == 0) { + await Future.delayed(Duration.zero); + } + } + + if (rows.isNotEmpty) { + AppDatabase.saveMessages(rows).ignore(); + } + + return results; + } + + /// Загружает сообщения из локальной базы данных. + Future> getLocalHistory( + int accountId, + int chatId, { + int limit = 50, + int offset = 0, + }) async { + final rows = await AppDatabase.loadMessages( + accountId, + chatId, + limit: limit, + offset: offset, + ); + return rows.map(CachedMessage.fromDbRow).toList(); + } + + CachedMessage? _parseMessage( + Map m, + int accountId, + int chatId, + ) { + final id = m['id']?.toString(); + if (id == null) return null; + + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: (m['sender'] as int?) ?? 0, + text: m['text'] as String?, + time: (m['time'] as int?) ?? 0, + status: m['status'] as String?, + payload: m + .cast< + String, + dynamic + >(), // Сохраняем весь пакет для гибкости (аттачи и т.д.) + ); + } +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 3455d03..18658da 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; @@ -69,71 +70,75 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { } /// Распаковка пакета от сервера -Packet unpackPacket(Uint8List packet) { - // Для удобства расшифровки пакета переводим в ByteData - ByteData packetData = ByteData.view( - packet.buffer, - packet.offsetInBytes, - packet.lengthInBytes, - ); +Future unpackPacket(Uint8List packet) async { + return Isolate.run(() { + // Для удобства расшифровки пакета переводим в ByteData + ByteData packetData = ByteData.view( + packet.buffer, + packet.offsetInBytes, + packet.lengthInBytes, + ); - // Объяснение каждой переменной смотри в классе Packet + // API версия и cmd представляют из себя 8 битные числа + final apiVer = packetData.getUint8(0) & 0xFF; + final cmd = packetData.getUint8(1) & 0xFF; - // API версия и cmd представляют из себя 8 битные числа - final apiVer = packetData.getUint8(0) & 0xFF; - final cmd = packetData.getUint8(1) & 0xFF; + // Sequence и OPCode представляют из себя 16 битные числа + final seq = packetData.getUint16(2) & 0xFFFF; + final opcode = packetData.getUint16(4) & 0xFFFF; - // Sequence и OPCode представляют из себя 16 битные числа - final seq = packetData.getUint16(2) & 0xFFFF; - final opcode = packetData.getUint16(4) & 0xFFFF; + // После базовых переменных идет длина пакета, является 32 битным числом + final packedLen = packetData.getUint32(6); - // После базовых переменных идет длина пакета, является 32 битным числом - final packedLen = packetData.getUint32(6); + // Compression flag показывает, сжат ли payload + final compFlag = packedLen >> 24; - // Compression flag показывает, сжат ли payload - final compFlag = packedLen >> 24; - - // Длина payload'а - final payloadLength = packedLen & 0xFFFFFF; + // Длина payload'а + final payloadLength = packedLen & 0xFFFFFF; - // Байты payload'а, могут быть сжаты LZ4 - var payloadBytes = packet.buffer.asUint8List(10, payloadLength); + // Байты payload'а, могут быть сжаты LZ4 + var payloadBytes = packet.buffer.asUint8List(10, payloadLength); - dynamic payload; - - if (payloadBytes.isNotEmpty) { - if (compFlag != 0) { - try { - payloadBytes = lz4Decompress( - payloadBytes, - decompressedSize: _maxDecompressedSize, - ); - - } catch (_) { + dynamic payload; + + if (payloadBytes.isNotEmpty) { + if (compFlag != 0) { try { - payloadBytes = _lz4BlockDecompress(payloadBytes, _maxDecompressedSize); - } catch (e) { - logger.e("LZ4 decompression error: $e", error: e); + payloadBytes = lz4Decompress( + payloadBytes, + decompressedSize: _maxDecompressedSize, + ); + } catch (_) { + try { + payloadBytes = _lz4BlockDecompress( + payloadBytes, + _maxDecompressedSize, + ); + } catch (e) { + // В изоляте нельзя использовать логгер, который пишет в терминал через зависимости Flutter, + // но простой print или throw сработает + print("LZ4 decompression error: $e"); + } + } + } + + try { + payload = msgpack.deserialize(payloadBytes); + } catch (e) { + if (payloadBytes.isNotEmpty) { + print("MsgPack deserialization error: $e"); } } } - try { - payload = msgpack.deserialize(payloadBytes); - } catch (e) { - if (payloadBytes.isNotEmpty) { - logger.e("MsgPack deserialization error: $e", error: e); - } - } - } - - return Packet( - api: apiVer, - cmd: cmd, - seq: seq, - opcode: opcode, - payload: payload, - ); + return Packet( + api: apiVer, + cmd: cmd, + seq: seq, + opcode: opcode, + payload: payload, + ); + }); } /// LZ4 block декомпрессия (без frame-заголовка). diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7f5af5f..97b80a9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -120,7 +120,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 5, + version: 6, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -141,6 +141,9 @@ class AppDatabase { await db.execute('DROP TABLE IF EXISTS chats_cache'); await db.execute(_chatsCacheSchema); } + if (oldVersion < 6) { + await db.execute(_messagesSchema); + } }, ); } @@ -164,6 +167,7 @@ class AppDatabase { await db.execute(_syncStateSchema); await db.execute(_chatsCacheSchema); await db.execute(_contactsSchema); + await db.execute(_messagesSchema); } static const _contactsSchema = ''' @@ -211,6 +215,21 @@ class AppDatabase { ) '''; + static const _messagesSchema = ''' + CREATE TABLE messages ( + id TEXT NOT NULL, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + chat_id INTEGER NOT NULL, + sender_id INTEGER NOT NULL, + text TEXT, + time INTEGER NOT NULL, + status TEXT, + payload TEXT, + PRIMARY KEY (id, account_id), + FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE + ) + '''; + static Future saveProfile(ProfileData profile) async { final db = await _instance; await db.insert( @@ -360,4 +379,45 @@ class AppDatabase { whereArgs: [accountId], ); } + + static Future saveMessages(List> rows) async { + final db = await _instance; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + 'messages', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + static Future>> loadMessages( + int accountId, + int chatId, { + int? limit, + int? offset, + }) async { + final db = await _instance; + return db.query( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + orderBy: 'time DESC', + limit: limit, + offset: offset, + ); + } + + static Future clearMessages(int accountId, int chatId) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + } } diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index 25af729..a14476e 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -10,22 +10,22 @@ class PacketReceiver { static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта - /// Добавляет байты в буфер, возвращает все собранные пакеты. + /// Добавляет байты в буфер, возвращает поток собранных пакетов. /// Неполные данные остаются в буфере до следующего вызова. - List feed(Uint8List data) { + Stream feed(Uint8List data) async* { final newBuffer = Uint8List(_buffer.length + data.length); newBuffer.setAll(0, _buffer); newBuffer.setAll(_buffer.length, data); _buffer = newBuffer; if (_buffer.length > _maxBufferSize) { - logger.e('PacketReceiver: переполнение буфера (${_buffer.length} B), сброс'); + logger.e( + 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', + ); reset(); - return []; + return; } - final packets = []; - while (_buffer.length >= headerSize) { final bd = ByteData.view( _buffer.buffer, @@ -42,13 +42,11 @@ class PacketReceiver { _buffer = _buffer.sublist(totalLength); try { - packets.add(unpackPacket(packetBytes)); + yield await unpackPacket(packetBytes); } catch (e) { logger.e('PacketReceiver: ошибка распаковки: $e'); } } - - return packets; } void reset() { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 451322a..cda1a0b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -735,7 +735,11 @@ class _ChatListScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(name: name, imageUrl: imageUrl), + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + ), ), ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 6d2715b..d5484b2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,31 +1,63 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'dart:ui'; +import '../../../main.dart'; +import '../../../core/storage/app_database.dart'; class ChatScreen extends StatefulWidget { + final int chatId; final String name; final String imageUrl; - const ChatScreen({super.key, required this.name, required this.imageUrl}); + const ChatScreen({ + super.key, + required this.chatId, + required this.name, + required this.imageUrl, + }); @override State createState() => _ChatScreenState(); } -class _ChatScreenState extends State { +class _ChatScreenState extends State + with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); bool _hasText = false; + bool _isLoading = true; + late AnimationController _shimmerController; @override void initState() { super.initState(); _messageController.addListener(_onTextChanged); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadHistory(); + } + + Future _loadHistory() async { + try { + final activeProfile = await AppDatabase.loadActiveProfile(); + final myId = activeProfile?.id ?? 0; + await messagesModule.fetchHistory(myId, widget.chatId); + } catch (e) { + debugPrint('Background history fetch failed: $e'); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } } @override void dispose() { _messageController.removeListener(_onTextChanged); _messageController.dispose(); + _shimmerController.dispose(); super.dispose(); } @@ -42,9 +74,8 @@ class _ChatScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, - extendBodyBehindAppBar: true, appBar: AppBar( - backgroundColor: const Color(0xFF1B1B1B).withOpacity(0.8), + backgroundColor: const Color(0xFF1B1B1B), elevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( @@ -77,21 +108,17 @@ class _ChatScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - widget.name, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], + Text( + widget.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), ), const Text( - 'Connecting...', + 'last seen recently', style: TextStyle( color: Colors.grey, fontSize: 12, @@ -118,38 +145,112 @@ class _ChatScreenState extends State { ), ], ), - body: Stack( + body: Column( children: [ - Positioned.fill( - child: widget.imageUrl.isNotEmpty - ? Image.network( - widget.imageUrl, - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ) - : Image.network( - 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ), + Expanded( + child: _isLoading + ? _buildShimmerLoading() + : const SizedBox.shrink(), ), - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black87], - ), - ), - ), - ), - Column(children: [const Spacer(), _buildInputArea(context)]), + _buildInputArea(context), ], ), ); } + Widget _buildShimmerLoading() { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + (0.4 * _shimmerController.value); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 8, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final hasImage = index % 3 == 0; + final hasReactions = index % 2 == 0; + final width1 = 60.0 + (index * 15 % 50); + final width2 = 120.0 + (index * 25 % 80); + + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0xFF2B2B2B), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: width1, + height: 10, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(height: 6), + Container( + width: width2, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(10), + ), + ), + if (hasImage) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + height: 120, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(12), + ), + ), + ], + if (hasReactions) ...[ + const SizedBox(height: 8), + Row( + children: List.generate( + 3, + (i) => Container( + width: 32, + height: 16, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + Widget _buildInputArea(BuildContext context) { return SafeArea( child: Padding( @@ -160,7 +261,6 @@ class _ChatScreenState extends State { Expanded( child: AnimatedContainer( duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, constraints: const BoxConstraints( minHeight: 54, maxHeight: 180, @@ -173,10 +273,7 @@ class _ChatScreenState extends State { width: 0.5, ), ), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 0, - ), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -205,7 +302,9 @@ class _ChatScreenState extends State { ), border: InputBorder.none, isDense: true, - contentPadding: EdgeInsets.zero, + contentPadding: EdgeInsets.symmetric( + vertical: 14, + ), // Выравниваем по Y ), ), ), diff --git a/lib/main.dart b/lib/main.dart index 967727c..a4dc755 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; +import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; import 'frontend/screens/auth/login_screen.dart'; @@ -10,6 +11,7 @@ import 'frontend/screens/chats/chat_list_screen.dart'; final api = Api(); final accountModule = AccountModule(api); +final messagesModule = MessagesModule(api); void main() async { WidgetsFlutterBinding.ensureInitialized();