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'; import 'package:flutter/foundation.dart' show debugPrint; 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> loadDeletedNotes() async { return _loadDeletedNotesFromDatabase(); } Future> loadCategories() async { final List dbCategories = await _database.getAllCategories(); final List categories = []; for (final DbCategory row in dbCategories) { categories.add( Category( id: row.id, name: row.name, serverVersion: row.serverVersion, isDeleted: row.isDeleted, updatedAt: row.updatedAt, isDirty: row.isDirty, colorValue: row.colorValue, iconCodePoint: row.iconCodePoint, ), ); } return categories; } Future createCategory(Category category) async { debugPrint('createCategory called with: ${category.name}'); await _database.upsertCategory( CategoriesCompanion.insert( id: category.id, name: category.name, updatedAt: category.updatedAt, serverVersion: const Value(0), isDeleted: const Value(false), isDirty: const Value(true), colorValue: Value(category.colorValue), iconCodePoint: Value(category.iconCodePoint), ), ); debugPrint('Category inserted to database'); } Future deleteCategory(String id) async { await _database.deleteCategory(id); await _database.customStatement( 'UPDATE notes SET category_id = NULL, is_dirty = 1 WHERE category_id = ?', [id], ); } Future createNote(Note note) async { await _database.insertNoteAtTop( NotesCompanion.insert( id: note.id, title: note.title, body: note.body, createdAt: note.createdAt, updatedAt: note.updatedAt, sortIndex: 0, serverVersion: const Value(0), isDeleted: const Value(false), categoryId: Value(note.categoryId), isDirty: const Value(true), ), ); return note.copyWith(position: 0, isDirty: true); } Future updateNote(Note note) async { final DbNote? existingNote = await (_database.select( _database.notes, )..where((n) => n.id.equals(note.id))).getSingleOrNull(); final DbNote row = existingNote ?? (throw ArgumentError('Note not found for id ${note.id}.')); await _database.updateNoteRow( DbNote( id: row.id, title: note.title, body: note.body, createdAt: row.createdAt, updatedAt: note.updatedAt, sortIndex: row.sortIndex, serverVersion: note.serverVersion, isDeleted: false, categoryId: note.categoryId, isDirty: true, ), ); return note.copyWith( isDeleted: false, isPermanentlyDeleted: false, isDirty: true, position: row.sortIndex.toDouble(), ); } Future deleteNote(Note note) async { final DbNote? existingNote = await (_database.select( _database.notes, )..where((n) => n.id.equals(note.id))).getSingleOrNull(); final DbNote row = existingNote ?? (throw ArgumentError('Note not found for id ${note.id}.')); if (row.isDeleted || note.isDeleted || note.isPermanentlyDeleted) { await _database.permanentlyDeleteNote(row.id); } else { await _database.deleteNoteAndShift( id: row.id, removedIndex: row.sortIndex, ); } } Future moveNote(Note note, int newIndex) async { final DbNote? existingNote = await (_database.select( _database.notes, )..where((n) => n.id.equals(note.id))).getSingleOrNull(); final DbNote row = existingNote ?? (throw ArgumentError('Note not found for id ${note.id}.')); await _database.moveNote( id: row.id, oldIndex: row.sortIndex, 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. Dirty flags are the source of truth for // outbound sync; `lastSyncAt` is only used for asking the server what it // changed since our previous successful sync. final List unsyncedNotes = await _database.getUnsyncedNotes(); final List unsyncedCategories = await _database .getUnsyncedCategories(); final int totalNotesToEncrypt = unsyncedNotes.length; // Build sync request: local data is plaintext, encryption happens only // for the outbound payload. 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 = await _encryptCategories(unsyncedCategories, masterKey: _masterKey); 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) { final List details = []; final Object? message = syncResult['message']; final Object? exception = syncResult['exception']; final Object? stackTrace = syncResult['stackTrace']; final Object? body = syncResult['body']; if (message != null) details.add(message.toString()); if (exception != null && exception.toString() != details.firstOrNull) { details.add('Exception: ${exception.toString()}'); } if (body != null) { details.add('Body: ${body.toString()}'); } if (stackTrace != null) { details.add('StackTrace: ${stackTrace.toString()}'); } return {'error': true, 'message': details.join('\n\n')}; } 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, st) { return {'error': true, 'message': '$e\n\nStackTrace: $st'}; } } Future _applySyncResponse( SyncResponse response, { void Function(int processed, int total)? onDecryptProgress, }) async { // Apply categories from server for (final SyncCategoryResponse catResponse in response.changes.categories) { final Category category = await catResponse.toCategory( masterKey: _masterKey, ); await _database.upsertCategory( CategoriesCompanion( id: Value(category.id), name: Value(category.name), serverVersion: Value(category.serverVersion), isDeleted: Value(category.isDeleted), colorValue: Value(category.colorValue), iconCodePoint: Value(category.iconCodePoint), updatedAt: Value(category.updatedAt), isDirty: const Value(false), ), ); } // 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 SyncNoteResponse noteResponse = response.changes.notes[index]; final String noteId = (decryptedNote['id'] as String?) ?? noteResponse.id; final existingNote = await (_database.select( _database.notes, )..where((n) => n.id.equals(noteId))).getSingleOrNull(); final String decryptedTitle = (decryptedNote['title'] as String?) ?? ''; final String decryptedBody = (decryptedNote['body'] as String?) ?? ''; final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted; if (existingNote != null) { // Update existing note await _database.updateNoteRow( DbNote( id: existingNote.id, title: isPermanentlyDeleted ? '' : decryptedTitle, body: isPermanentlyDeleted ? '' : decryptedBody, createdAt: existingNote.createdAt, updatedAt: noteResponse.updatedAt, sortIndex: noteResponse.position.round(), serverVersion: noteResponse.serverVersion, isDeleted: noteResponse.isDeleted, categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId, isDirty: false, ), ); } else { // Insert new note await _database .into(_database.notes) .insert( NotesCompanion( id: Value(noteResponse.id), title: Value(isPermanentlyDeleted ? '' : decryptedTitle), body: Value(isPermanentlyDeleted ? '' : decryptedBody), createdAt: Value(noteResponse.updatedAt), updatedAt: Value(noteResponse.updatedAt), sortIndex: Value(noteResponse.position.round()), serverVersion: Value(noteResponse.serverVersion), isDeleted: Value(noteResponse.isDeleted), categoryId: Value( isPermanentlyDeleted ? null : noteResponse.categoryId, ), isDirty: const Value(false), ), ); } } } Future> _loadNotesFromDatabase() async { final List rows = await _database.getAllNotes(); return rows.map(_fromDbNote).toList(); } Future> _loadDeletedNotesFromDatabase() async { final List rows = await _database.getDeletedNotes(); return rows.map(_fromDbNote).toList(); } Note _fromDbNote(DbNote row) { return Note( id: row.id, title: row.title, body: row.body, createdAt: row.createdAt, updatedAt: row.updatedAt, position: row.sortIndex.toDouble(), serverVersion: row.serverVersion, isDeleted: row.isDeleted, isPermanentlyDeleted: _isPermanentlyDeleted(row), categoryId: row.categoryId, isDirty: row.isDirty, ); } } 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(); } Future> _encryptCategories( List categories, { required String masterKey, }) async { final List payloads = []; for (final DbCategory row in categories) { payloads.add( await SyncCategoryPayload.fromCategory( Category( id: row.id, name: row.name, serverVersion: row.serverVersion, isDeleted: row.isDeleted, updatedAt: row.updatedAt, isDirty: row.isDirty, colorValue: row.colorValue, iconCodePoint: row.iconCodePoint, ), masterKey: masterKey, ), ); } return payloads; } 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, 'isPermanentlyDeleted': row.isPermanentlyDeleted, }; } Map _dbNoteToEncryptionInput(DbNote row, int index) { final bool isPermanentlyDeleted = _isPermanentlyDeleted(row); return { 'index': index, 'id': row.id, '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, 'isPermanentlyDeleted': isPermanentlyDeleted, }; } 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 num).toDouble(), isDeleted: row['isDeleted']! as bool, isPermanentlyDeleted: row['isPermanentlyDeleted']! 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 bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool; final String title = note['title']! as String; final String body = note['body']! as String; final String encryptedTitle; final String encryptedBody; if (isPermanentlyDeleted) { encryptedTitle = ''; encryptedBody = ''; } else { encryptedTitle = await NoteEncryption.encryptNote(title, masterKey); encryptedBody = await NoteEncryption.encryptNote(body, masterKey); } encryptedNotes.add({ 'index': note['index'] as int, 'id': note['id'] 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, 'isPermanentlyDeleted': isPermanentlyDeleted, '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) { final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool; String decryptedTitle = 'Encrypted'; String decryptedBody = 'Encrypted'; if (!isPermanentlyDeleted) { try { decryptedTitle = await NoteEncryption.decrypt( note['encryptedTitle']! as String, masterKey, ); decryptedBody = await NoteEncryption.decrypt( note['encryptedBody']! as String, masterKey, ); } catch (e) { print('Failed to decrypt note ${note['id']}: $e'); } } else { decryptedTitle = ''; decryptedBody = ''; } final String noteId = note['id']! as String; decryptedNotes.add({ 'index': note['index'] as int, 'id': noteId, 'title': decryptedTitle, 'body': decryptedBody, 'isPermanentlyDeleted': isPermanentlyDeleted, }); } return decryptedNotes; } bool _isPermanentlyDeleted(DbNote row) { return row.isDeleted && row.title.isEmpty && row.body.isEmpty; } int _parallelWorkerCount(int itemCount) { final int cappedByCpu = math.max( 1, (Platform.numberOfProcessors * 0.6).floor(), ); return math.max(1, math.min(itemCount, cappedByCpu)); }