import 'dart:async'; import 'dart:isolate'; import 'dart:math' as math; import 'dart:io' show Platform; import 'package:drift/drift.dart'; import 'package:notas/data/app_database.dart'; import 'package:notas/data/api_client.dart'; import 'package:notas/data/sync_models.dart'; import 'package:notas/models/note.dart'; import 'package:notas/models/category.dart'; import 'package:notas/data/note_encryption.dart'; import 'package:notas/widgets/sync_status.dart'; class NoteRepository { NoteRepository({ required AppDatabase database, required AuthApi authApi, required String masterKey, }) : _database = database, _authApi = authApi, _masterKey = masterKey; final AppDatabase _database; final AuthApi _authApi; final String _masterKey; Future> loadNotes() async { return _loadNotesFromDatabase(); } Future createNote(Note note) async { final int id = await _database.insertNoteAtTop( NotesCompanion.insert( uuid: note.uuid, title: note.title, body: note.body, createdAt: note.createdAt, updatedAt: note.updatedAt, sortIndex: 0, serverVersion: const Value(0), isDeleted: const Value(false), categoryId: const Value(null), ), ); return note.copyWith(id: id, index: 0); } Future updateNote(Note note) async { final int noteId = note.id ?? (throw ArgumentError('Note id is required to update a note.')); await _database.updateNoteRow( DbNote( id: noteId, uuid: note.uuid, title: note.title, body: note.body, createdAt: note.createdAt, updatedAt: note.updatedAt, sortIndex: note.index, serverVersion: note.serverVersion, isDeleted: note.isDeleted, categoryId: note.categoryId, ), ); return note; } Future deleteNote(Note note) async { final int noteId = note.id ?? (throw ArgumentError('Note id is required to delete a note.')); await _database.deleteNoteAndShift(id: noteId, removedIndex: note.index); } Future moveNote(Note note, int newIndex) async { final int noteId = note.id ?? (throw ArgumentError('Note id is required to reorder a note.')); await _database.moveNote( id: noteId, oldIndex: note.index, newIndex: newIndex, ); } // ========== Sync logic ========== /// Sincroniza notas con el servidor. /// Requiere que el usuario esté autenticado (token válido). Future> performSync({ bool forceFull = false, void Function(SyncStatus status, {double? progress, String? message})? onProgress, }) async { try { onProgress?.call( SyncStatus.preparing, message: 'Preparando sincronización...', ); // Get last sync timestamp final DateTime? lastSync = await _authApi.getLastSyncAt(); final DateTime? lastSyncForRequest = forceFull ? DateTime.utc(1970, 1, 1) : lastSync; // Collect pending local changes. // If we already synced at least once, use updatedAt to avoid re-sending // old notes that were already uploaded. final List unsyncedNotes; final List unsyncedCategories; if (forceFull || lastSync == null) { unsyncedNotes = await _database.getUnsyncedNotes(); unsyncedCategories = await _database.getUnsyncedCategories(); } else { unsyncedNotes = await _database.getNotesChangedSince(lastSync); unsyncedCategories = await _database.getCategoriesChangedSince( lastSync, ); } final int totalNotesToEncrypt = unsyncedNotes.length; // Build sync request (note: we send encrypted data, but locally we have plaintext) // Encrypt all notes before sending. if (totalNotesToEncrypt == 0) { onProgress?.call( SyncStatus.encrypting, progress: 1.0, message: 'No hay notas pendientes de encriptar.', ); } final List encryptedNotesPayload = await _encryptNotesInParallel( unsyncedNotes, masterKey: _masterKey, onProgress: (int encryptedCount) { onProgress?.call( SyncStatus.encrypting, progress: totalNotesToEncrypt == 0 ? 1.0 : encryptedCount / totalNotesToEncrypt, message: 'Encriptando notas para subir: $encryptedCount de $totalNotesToEncrypt', ); }, ); final List categoriesPayload = unsyncedCategories .map((cat) => SyncCategoryPayload.fromCategory(_fromDbCategory(cat))) .toList(); final SyncRequest syncRequest = SyncRequest( lastSyncAt: lastSyncForRequest, changes: SyncChanges( categories: categoriesPayload, notes: encryptedNotesPayload, ), ); // Call sync API onProgress?.call( SyncStatus.uploading, message: 'Subiendo datos al servidor...', ); final Map syncResult = await _authApi.sync(syncRequest); if (syncResult['error'] == true) { return {'error': true, 'message': syncResult['body']}; } final SyncResponse response = syncResult['data'] as SyncResponse; // Apply server changes to local database onProgress?.call( SyncStatus.waitingResponse, message: 'Esperando respuesta del servidor...', ); await _applySyncResponse( response, onDecryptProgress: (int processed, int total) { onProgress?.call( SyncStatus.decrypting, progress: total == 0 ? 1.0 : processed / total, message: total == 0 ? 'Desencriptando datos recibidos...' : 'Desencriptando respuesta: $processed de $total', ); }, ); // Update lastSyncAt await _authApi.setLastSyncAt(response.serverTimestamp); return { 'error': false, 'synced': response.synced, 'notesCount': response.changes.notes.length, 'categoriesCount': response.changes.categories.length, }; } catch (e) { return {'error': true, 'message': e.toString()}; } } Future _applySyncResponse( SyncResponse response, { void Function(int processed, int total)? onDecryptProgress, }) async { // Apply categories from server for (final SyncCategoryResponse catResponse in response.changes.categories) { await _database.upsertCategory( CategoriesCompanion( uuid: Value(catResponse.id), encryptedName: Value(catResponse.encryptedName), serverVersion: Value(catResponse.serverVersion), isDeleted: Value(catResponse.isDeleted), updatedAt: Value(catResponse.updatedAt), ), ); } // Apply notes from server final int totalNotesToDecrypt = response.changes.notes.length; final List> decryptedNotes = await _decryptResponseNotesInParallel( response.changes.notes, masterKey: _masterKey, onProgress: onDecryptProgress == null ? null : (processed) => onDecryptProgress(processed, totalNotesToDecrypt), ); for (var index = 0; index < decryptedNotes.length; index += 1) { final Map decryptedNote = decryptedNotes[index]; final String noteId = decryptedNote['id']! as String; final existingNote = await (_database.select( _database.notes, )..where((n) => n.uuid.equals(noteId))).getSingleOrNull(); final String decryptedTitle = decryptedNote['title']! as String; final String decryptedBody = decryptedNote['body']! as String; final SyncNoteResponse noteResponse = response.changes.notes[index]; if (existingNote != null) { // Update existing note await _database.updateNoteRow( DbNote( id: existingNote.id, uuid: noteId, title: decryptedTitle, body: decryptedBody, createdAt: existingNote.createdAt, updatedAt: noteResponse.updatedAt, sortIndex: noteResponse.position, serverVersion: noteResponse.serverVersion, isDeleted: noteResponse.isDeleted, categoryId: noteResponse.categoryId, ), ); } else { // Insert new note await _database .into(_database.notes) .insert( NotesCompanion( uuid: Value(noteResponse.id), title: Value(decryptedTitle), body: Value(decryptedBody), createdAt: Value(noteResponse.updatedAt), updatedAt: Value(noteResponse.updatedAt), sortIndex: Value(noteResponse.position), serverVersion: Value(noteResponse.serverVersion), isDeleted: Value(noteResponse.isDeleted), categoryId: Value(noteResponse.categoryId), ), ); } } } Future> _loadNotesFromDatabase() async { final List rows = await _database.getAllNotes(); return rows.map(_fromDbNote).toList(); } Note _fromDbNote(DbNote row) { return Note( id: row.id, uuid: row.uuid, title: row.title, body: row.body, createdAt: row.createdAt, updatedAt: row.updatedAt, index: row.sortIndex, serverVersion: row.serverVersion, isDeleted: row.isDeleted, categoryId: row.categoryId, ); } Category _fromDbCategory(DbCategory row) { return Category( uuid: row.uuid, encryptedName: row.encryptedName, serverVersion: row.serverVersion, isDeleted: row.isDeleted, updatedAt: row.updatedAt, ); } } List>>> _encryptNoteBatches( List notes, { required String masterKey, }) { if (notes.isEmpty) { return >>>[]; } final int workerCount = _parallelWorkerCount(notes.length); final int batchSize = (notes.length / workerCount).ceil(); final List>>> batchFutures = []; for (var start = 0; start < notes.length; start += batchSize) { final int end = math.min(start + batchSize, notes.length); final List> batchNotes = []; for (var index = start; index < end; index += 1) { batchNotes.add(_dbNoteToEncryptionInput(notes[index], index)); } batchFutures.add( Isolate.run( () => _encryptNoteBatch({'masterKey': masterKey, 'notes': batchNotes}), ), ); } return batchFutures; } Future> _encryptNotesInParallel( List notes, { required String masterKey, void Function(int encryptedCount)? onProgress, }) async { final List>>> batchFutures = _encryptNoteBatches(notes, masterKey: masterKey); final List orderedPayloads = List.filled( notes.length, null, ); var encryptedCount = 0; await for (final List> batchResult in Stream.fromFutures( batchFutures, )) { for (final Map row in batchResult) { final int index = row['index']! as int; orderedPayloads[index] = _syncNotePayloadFromEncryptionResult(row); } encryptedCount += batchResult.length; onProgress?.call(encryptedCount); } return orderedPayloads.cast(); } List>>> _decryptNoteBatches( List notes, { required String masterKey, }) { if (notes.isEmpty) { return >>>[]; } final int workerCount = _parallelWorkerCount(notes.length); final int batchSize = (notes.length / workerCount).ceil(); final List>>> batchFutures = []; for (var start = 0; start < notes.length; start += batchSize) { final int end = math.min(start + batchSize, notes.length); final List> batchNotes = []; for (var index = start; index < end; index += 1) { batchNotes.add(_syncNoteToDecryptionInput(notes[index], index)); } batchFutures.add( Isolate.run( () => _decryptNoteBatch({'masterKey': masterKey, 'notes': batchNotes}), ), ); } return batchFutures; } Future>> _decryptResponseNotesInParallel( List notes, { required String masterKey, void Function(int processed)? onProgress, }) async { final List>>> batchFutures = _decryptNoteBatches(notes, masterKey: masterKey); final List?> decryptedNotes = List?>.filled(notes.length, null); var processed = 0; await for (final List> batchResult in Stream.fromFutures( batchFutures, )) { for (final Map row in batchResult) { final int index = row['index']! as int; decryptedNotes[index] = row; } processed += batchResult.length; onProgress?.call(processed); } return decryptedNotes.cast>(); } Map _syncNoteToDecryptionInput( SyncNoteResponse row, int index, ) { return { 'index': index, 'id': row.id, 'encryptedTitle': row.encryptedTitle, 'encryptedBody': row.encryptedBody, }; } Map _dbNoteToEncryptionInput(DbNote row, int index) { return { 'index': index, 'uuid': row.uuid, 'title': row.title, 'body': row.body, 'createdAt': row.createdAt.toIso8601String(), 'updatedAt': row.updatedAt.toIso8601String(), 'categoryId': row.categoryId, 'serverVersion': row.serverVersion, 'position': row.sortIndex, 'isDeleted': row.isDeleted, }; } SyncNotePayload _syncNotePayloadFromEncryptionResult(Map row) { return SyncNotePayload( id: row['id']! as String, categoryId: row['categoryId'] as String?, encryptedTitle: row['encryptedTitle']! as String, encryptedBody: row['encryptedBody']! as String, serverVersion: row['serverVersion']! as int, position: row['position']! as int, isDeleted: row['isDeleted']! as bool, updatedAt: DateTime.parse(row['updatedAt']! as String), ); } Future>> _encryptNoteBatch( Map request, ) async { final String masterKey = request['masterKey']! as String; final List> notes = (request['notes']! as List) .cast>(); final List> encryptedNotes = []; for (final Map note in notes) { final String title = note['title']! as String; final String body = note['body']! as String; final String encryptedTitle = await NoteEncryption.encryptNote( title, masterKey, ); final String encryptedBody = await NoteEncryption.encryptNote( body, masterKey, ); encryptedNotes.add({ 'index': note['index'] as int, 'id': note['uuid'] as String, 'categoryId': note['categoryId'] as String?, 'encryptedTitle': encryptedTitle, 'encryptedBody': encryptedBody, 'serverVersion': note['serverVersion']! as int, 'position': note['position']! as int, 'isDeleted': note['isDeleted']! as bool, 'updatedAt': note['updatedAt']! as String, }); } return encryptedNotes; } Future>> _decryptNoteBatch( Map request, ) async { final String masterKey = request['masterKey']! as String; final List> notes = (request['notes']! as List) .cast>(); final List> decryptedNotes = []; for (final Map note in notes) { String decryptedTitle = 'Encrypted'; String decryptedBody = 'Encrypted'; try { decryptedTitle = await NoteEncryption.decryptNote( note['encryptedTitle']! as String, masterKey, ); decryptedBody = await NoteEncryption.decryptNote( note['encryptedBody']! as String, masterKey, ); } catch (e) { print('Failed to decrypt note ${note['id']}: $e'); } decryptedNotes.add({ 'index': note['index'] as int, 'id': note['id'] as String, 'title': decryptedTitle, 'body': decryptedBody, }); } return decryptedNotes; } int _parallelWorkerCount(int itemCount) { final int cappedByCpu = math.max( 1, (Platform.numberOfProcessors * 0.6).floor(), ); return math.max(1, math.min(itemCount, cappedByCpu)); }