import 'package:chepuhagram/data/models/message_model.dart'; import 'package:drift/drift.dart'; import 'package:drift_sqflite/drift_sqflite.dart'; part 'local_db_service.g.dart'; class Messages extends Table { IntColumn get id => integer().autoIncrement()(); IntColumn get senderId => integer().named('sender_id')(); IntColumn get receiverId => integer().named('receiver_id')(); TextColumn get content => text().named('content')(); TextColumn get timestamp => text().named('timestamp')(); TextColumn get deliveredAt => text().named('delivered_at').nullable()(); TextColumn get readAt => text().named('read_at').nullable()(); IntColumn get replyToId => integer().named('reply_to_id').nullable()(); TextColumn get replyToText => text().named('reply_to_text').nullable()(); TextColumn get editedAt => text().named('edited_at').nullable()(); TextColumn get messageType => text().named('message_type').withDefault(const Constant('text'))(); TextColumn get fileId => text().named('file_id').nullable()(); TextColumn get encryptedKey => text().named('encrypted_key').nullable()(); TextColumn get fileName => text().named('file_name').nullable()(); IntColumn get fileSize => integer().named('file_size').nullable()(); } class FileNameMappings extends Table { TextColumn get fileId => text().named('file_id')(); TextColumn get originalFileName => text().named('original_file_name')(); @override Set get primaryKey => {fileId}; } @DriftDatabase(tables: [Messages, FileNameMappings]) class LocalDbService extends _$LocalDbService { factory LocalDbService() => instance; LocalDbService._internal() : super(_openConnection()) { print('LocalDbService constructor called'); } static final LocalDbService instance = LocalDbService._internal(); @override int get schemaVersion => 9; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (Migrator m) async { await m.createAll(); }, onUpgrade: (Migrator m, int from, int to) async { if (from < 8) { await m.deleteTable('messages'); await m.createAll(); return; } if (from < 9) { await m.createTable(fileNameMappings); } }, ); Future clearDatabase() async { await delete(messages).go(); } Future saveMessages(List messageList) async { if (messageList.isEmpty) return; // Преобразуем входящие данные в компаньоны заранее final companions = messageList.map((msg) { final int? id; final int senderId; final int receiverId; final String content; final String timestamp; final int? replyToId; final String? replyToText; final String? editedAt; final String messageType; final String? fileId; final String? encryptedKey; final String? fileName; final int? fileSize; if (msg is MessageModel) { id = msg.id; senderId = msg.senderId; receiverId = msg.receiverId; content = msg.text; timestamp = msg.createdAt.toIso8601String(); replyToId = msg.replyToId; replyToText = msg.replyToText; editedAt = msg.editedAt?.toIso8601String(); messageType = msg.messageType.name; fileId = msg.fileId; encryptedKey = msg.encryptedFileKey; fileName = msg.fileName; fileSize = msg.fileSize; } else { id = msg['id'] == null ? null : int.tryParse(msg['id'].toString()); senderId = int.parse(msg['sender_id'].toString()); receiverId = int.parse(msg['receiver_id'].toString()); content = msg['content']?.toString() ?? ''; timestamp = msg['timestamp']?.toString() ?? DateTime.now().toIso8601String(); replyToId = msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()); replyToText = msg['reply_to_text']?.toString(); editedAt = msg['edited_at']?.toString(); messageType = msg['message_type']?.toString() ?? 'text'; fileId = msg['file_id']?.toString(); encryptedKey = msg['encrypted_key']?.toString(); fileName = msg['file_name']?.toString(); fileSize = msg['file_size'] == null ? null : int.tryParse(msg['file_size'].toString()); } return MessagesCompanion( id: id == null ? const Value.absent() : Value(id), senderId: Value(senderId), receiverId: Value(receiverId), content: Value(content), timestamp: Value(timestamp), deliveredAt: const Value(null), readAt: const Value(null), replyToId: Value(replyToId), replyToText: Value(replyToText), editedAt: Value(editedAt), messageType: Value(messageType), fileId: Value(fileId), encryptedKey: Value(encryptedKey), fileName: Value(fileName), fileSize: Value(fileSize), ); }).toList(); // Выполняем все операции в рамках ОДНОЙ транзакции БД await transaction(() async { // Быстрая пакетная вставка/обновление await batch((b) { b.insertAll( messages, companions, mode: InsertMode .insertOrReplace, // Безопасный аналог insertOnConflictUpdate для batch ); }); }); } Future>> getChatHistory( int contactId, 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, mode: OrderingMode.desc), ]); if (limit != null) { query.limit(limit, offset: offset); } final rows = await query.get(); return rows.map((row) => row.toJson()).toList(); } Future deleteChatHistory(int contactId, int myId) async { return await (delete(messages)..where( (tbl) => (tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) | (tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)), )) .go(); } Future?> getLastMessage(int contactId, int myId) 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, mode: OrderingMode.desc, ), ]) ..limit(1)) .get(); final rows = await query; if (rows.isEmpty) return null; return rows.first.toJson(); } Future insertMessage(MessageModel message) async { int? id = message.id ?? message.tempId; int senderId = message.senderId; int receiverId = message.receiverId; String content = message.text; String timestamp = message.createdAt.toIso8601String(); int? replyToId = message.replyToId; String? replyToText = message.replyToText; String? editedAt = message.editedAt?.toIso8601String(); String messageType = message.messageType.name; String? fileId = message.fileId; String? encryptedKey = message.encryptedFileKey; String? fileName = message.fileName; int? fileSize = message.fileSize; try { await into(messages).insert( MessagesCompanion( id: id == null ? const Value.absent() : Value(id), senderId: Value(senderId), receiverId: Value(receiverId), content: Value(content), timestamp: Value(timestamp), deliveredAt: const Value(null), readAt: const Value(null), replyToId: Value(replyToId), replyToText: Value(replyToText), editedAt: Value(editedAt), messageType: Value(messageType), fileId: Value(fileId), encryptedKey: Value(encryptedKey), fileName: Value(fileName), fileSize: Value(fileSize), ), mode: InsertMode.insertOrReplace, ); } catch (e) { print('LocalDbService.insertMessage failed: $e'); rethrow; } } Future messageExists(int messageId) async { final query = select(messages)..where((tbl) => tbl.id.equals(messageId)); final result = await query.getSingleOrNull(); return result != null; } Future updateSentMessage(int tempId, int serverId) async { await (update(messages)..where((tbl) => tbl.id.equals(tempId))).write( MessagesCompanion(id: Value(serverId)), ); } Future updateDeliveredAt(int messageId, DateTime deliveredAt) async { await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write( MessagesCompanion(deliveredAt: Value(deliveredAt.toIso8601String())), ); } Future updateReadAt(int messageId, DateTime readAt) async { await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write( MessagesCompanion(readAt: Value(readAt.toIso8601String())), ); } Future updateMessageContent( int messageId, String content, DateTime? editedAt, ) async { await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write( MessagesCompanion( content: Value(content), editedAt: Value(editedAt?.toIso8601String()), ), ); } Future saveOriginalFileNameForFileId( String fileId, String fileName, ) async { await into(fileNameMappings).insert( FileNameMappingsCompanion( fileId: Value(fileId), originalFileName: Value(fileName), ), mode: InsertMode.insertOrReplace, ); } Future getOriginalFileNameForFileId(String fileId) async { final query = select(fileNameMappings) ..where((tbl) => tbl.fileId.equals(fileId)); final result = await query.getSingleOrNull(); return result?.originalFileName; } Future deleteMessage(int messageId) async { await (delete(messages)..where((tbl) => tbl.id.equals(messageId))).go(); } Future>> getMessages( int contactId, int myId, { int limitBefore = 20, int limitAfter = 20, int? anchorMessageId, }) async { final chatMessages = (select(messages) ..where( (tbl) => (tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) | (tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)), )); if (anchorMessageId == null) { // No anchor, just get the latest messages chatMessages ..orderBy([ (tbl) => OrderingTerm(expression: tbl.timestamp, mode: OrderingMode.desc), ]) ..limit(limitBefore + limitAfter); final result = await chatMessages.get(); return result.map((row) => row.toJson()).toList(); } else { // We have an anchor. We need messages before and after. // This requires two separate queries that we combine. final anchorTimestampQuery = select(messages) ..where((tbl) => tbl.id.equals(anchorMessageId)); final anchorMessage = await anchorTimestampQuery.getSingleOrNull(); if (anchorMessage == null) return []; final anchorTimestamp = anchorMessage.timestamp; // Query for messages OLDER than the anchor final olderMessagesQuery = (select(messages) ..where( (tbl) => ((tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) | (tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId))) & tbl.timestamp.isSmallerThan(Constant(anchorTimestamp)), ) ..orderBy([ (tbl) => OrderingTerm(expression: tbl.timestamp, mode: OrderingMode.desc), ]) ..limit(limitBefore)); // Query for messages NEWER than the anchor (including anchor) final newerMessagesQuery = (select(messages) ..where( (tbl) => ((tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) | (tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId))) & tbl.timestamp.isBiggerOrEqual(Constant(anchorTimestamp)), ) ..orderBy([ (tbl) => OrderingTerm( expression: tbl.timestamp, mode: OrderingMode.asc, // ascending to get those immediately after ), ]) ..limit(limitAfter)); final olderMessages = await olderMessagesQuery.get(); final newerMessages = await newerMessagesQuery.get(); final allMessages = [...olderMessages, ...newerMessages.reversed]; return allMessages.map((row) => row.toJson()).toList(); } } Future getFirstUnreadMessageId(int contactId, int myId) async { final query = select(messages) ..where( (tbl) => tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId) & tbl.readAt.isNull(), ) ..orderBy([ (tbl) => OrderingTerm(expression: tbl.timestamp, mode: OrderingMode.asc), ]) ..limit(1); final result = await query.getSingleOrNull(); return result?.id; } } QueryExecutor _openConnection() { // This now uses the SqfliteQueryExecutor, which will respect the global // databaseFactory set in `main.dart`. This fixes the 'unable to open file' // error on Windows. The database file will be 'chat_app.db' inside the // path configured in `main.dart`. return SqfliteQueryExecutor.inDatabaseFolder(path: 'chat_app.db'); }