import 'dart:io'; import 'package:chepuhagram/data/models/message_model.dart'; import 'package:drift/drift.dart'; import 'package:drift_sqflite/drift_sqflite.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.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 List incomingIds = messageList .map( (msg) => (msg is MessageModel) ? msg.id : (msg['id'] == null ? null : int.tryParse(msg['id'].toString())), ) .whereType() .toList(); // Преобразуем входящие данные в компаньоны заранее 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 { if (incomingIds.isNotEmpty) { // ВНИМАНИЕ: Ограничьте удаление только текущим чатом, // иначе эта строка очистит сообщения из всех остальных диалогов! final first = companions.first; await (delete(messages)..where( (tbl) => ((tbl.senderId.equals(first.senderId.value) & tbl.receiverId.equals(first.receiverId.value)) | (tbl.senderId.equals(first.receiverId.value) & tbl.receiverId.equals(first.senderId.value))) & tbl.id.isNotIn(incomingIds), )) .go(); } // Быстрая пакетная вставка/обновление 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 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).insertOnConflictUpdate( FileNameMappingsCompanion( fileId: Value(fileId), originalFileName: Value(fileName), ), ); } 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(); } } 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'); }