From d4dc1c4ca6272de9efcd0c159b8c0065ec6a4f55 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 11:53:23 +0300 Subject: [PATCH] feat: implement chat caching and synchronization from login payload --- lib/backend/modules/account.dart | 2 + lib/backend/modules/chats.dart | 204 +++++++++++++++++++++++++++++ lib/core/storage/app_database.dart | 50 ++++++- 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index d73ec1f..a2ce112 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -4,6 +4,7 @@ import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; +import 'chats.dart'; enum AuthRequestType { startAuth('START_AUTH'), @@ -347,6 +348,7 @@ class AccountModule { logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}'); await _saveSyncState(data, serverTime, profile.id); + await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); return LoginResult( profile: profile, diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index e69de29..d9c7d91 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -0,0 +1,204 @@ +import '../../core/storage/app_database.dart'; + +class CachedChat { + final int id; + final int accountId; + final String type; + final String? title; + final String? iconUrl; + final int? lastMsgId; + final int? lastMsgTime; + final String? lastMsgText; + final int? lastMsgSenderId; + final int unreadCount; + final int lastEventTime; + final int cachedAt; + + const CachedChat({ + required this.id, + required this.accountId, + required this.type, + this.title, + this.iconUrl, + this.lastMsgId, + this.lastMsgTime, + this.lastMsgText, + this.lastMsgSenderId, + required this.unreadCount, + required this.lastEventTime, + required this.cachedAt, + }); + + factory CachedChat.fromDbRow(Map row) => CachedChat( + id: row['id'] as int, + accountId: row['account_id'] as int, + type: row['type'] as String, + title: row['title'] as String?, + iconUrl: row['icon_url'] as String?, + lastMsgId: row['last_msg_id'] as int?, + lastMsgTime: row['last_msg_time'] as int?, + lastMsgText: row['last_msg_text'] as String?, + lastMsgSenderId: row['last_msg_sender'] as int?, + unreadCount: row['unread_count'] as int, + lastEventTime: row['last_event_time'] as int, + cachedAt: row['cached_at'] as int, + ); + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'type': type, + 'title': title, + 'icon_url': iconUrl, + 'last_msg_id': lastMsgId, + 'last_msg_time': lastMsgTime, + 'last_msg_text': lastMsgText, + 'last_msg_sender': lastMsgSenderId, + 'unread_count': unreadCount, + 'last_event_time': lastEventTime, + 'cached_at': cachedAt, + }; +} + +class ChatsModule { + /// Парсит и кэширует чаты из payload opcode 19. + /// + /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же + /// ответа. На warm start контакты не приходят — используется существующий + /// кэш. + static Future syncFromLoginPayload( + Map data, + int accountId, + int currentUserId, + ) async { + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return; + + final contactsMap = _buildContactsMap(data['contacts']); + final cachedAt = DateTime.now().millisecondsSinceEpoch; + + final existingRows = await AppDatabase.loadChats(accountId); + final existing = { + for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row), + }; + + final rows = chats + .whereType() + .map((c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + existing, + cachedAt, + )) + .whereType() + .map((c) => c.toDbRow()) + .toList(); + + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + } + } + + static Future> getChats(int accountId) async { + final rows = await AppDatabase.loadChats(accountId); + return rows.map(CachedChat.fromDbRow).toList(); + } + + static Future clearCache(int accountId) => + AppDatabase.clearChatsCache(accountId); + + // internal + + static Map> _buildContactsMap(dynamic contacts) { + if (contacts is! List) return {}; + final result = >{}; + for (final c in contacts.whereType()) { + final id = c['id']; + if (id is int) result[id] = c.cast(); + } + return result; + } + + static CachedChat? _parseChat( + Map chat, + int accountId, + int currentUserId, + Map> contactsMap, + Map existing, + int cachedAt, + ) { + final id = chat['id']; + if (id is! int) return null; + + final type = (chat['type'] as String?) ?? 'DIALOG'; + + String? title; + String? iconUrl; + + if (type == 'DIALOG') { + final otherId = _otherParticipantId(chat['participants'], currentUserId); + final contact = otherId != null ? contactsMap[otherId] : null; + + if (contact != null) { + title = _nameFromContact(contact); + iconUrl = contact['baseUrl'] as String?; + } else { + // Warm start: контакты не пришли — берём из кэша + title = existing[id]?.title; + iconUrl = existing[id]?.iconUrl; + } + } else { + title = chat['title'] as String?; + iconUrl = chat['baseIconUrl'] as String?; + } + + final lastMsg = chat['lastMessage']; + int? lastMsgId; + int? lastMsgTime; + String? lastMsgText; + int? lastMsgSenderId; + + if (lastMsg is Map) { + lastMsgId = lastMsg['id'] as int?; + lastMsgTime = lastMsg['time'] as int?; + lastMsgText = lastMsg['text'] as String?; + lastMsgSenderId = lastMsg['sender'] as int?; + } + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: title, + iconUrl: iconUrl, + lastMsgId: lastMsgId, + lastMsgTime: lastMsgTime, + lastMsgText: lastMsgText, + lastMsgSenderId: lastMsgSenderId, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + ); + } + + static int? _otherParticipantId(dynamic participants, int currentUserId) { + if (participants is! Map) return null; + for (final key in participants.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != currentUserId) return id; + } + return null; + } + + static String? _nameFromContact(Map contact) { + final names = contact['names']; + if (names is! List || names.isEmpty) return null; + final name = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) as Map; + return name['name'] as String?; + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 8542c77..3c0409a 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -118,7 +118,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 2, + version: 3, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -129,6 +129,9 @@ class AppDatabase { await db.execute('DROP TABLE IF EXISTS sync_state'); await db.execute(_syncStateSchema); } + if (oldVersion < 3) { + await db.execute(_chatsCacheSchema); + } }, ); } @@ -150,6 +153,7 @@ class AppDatabase { ) '''); await db.execute(_syncStateSchema); + await db.execute(_chatsCacheSchema); } static const _syncStateSchema = ''' @@ -161,6 +165,24 @@ class AppDatabase { ) '''; + static const _chatsCacheSchema = ''' + CREATE TABLE chats_cache ( + id INTEGER NOT NULL, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT, + icon_url TEXT, + last_msg_id INTEGER, + last_msg_time INTEGER, + last_msg_text TEXT, + last_msg_sender INTEGER, + unread_count INTEGER NOT NULL DEFAULT 0, + last_event_time INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL, + PRIMARY KEY (id, account_id) + ) + '''; + static Future saveProfile(ProfileData profile) async { final db = await _instance; await db.insert( @@ -258,4 +280,30 @@ class AppDatabase { await _db?.close(); _db = null; } + + // Chats cache + + static Future saveChats(List> rows) async { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert('chats_cache', row, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + static Future>> loadChats(int accountId) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: 'account_id = ?', + whereArgs: [accountId], + orderBy: 'last_event_time DESC', + ); + } + + static Future clearChatsCache(int accountId) async { + final db = await _instance; + await db.delete('chats_cache', where: 'account_id = ?', whereArgs: [accountId]); + } }