Files
notas/lib/data/note_repository.dart
T
Marcos efe602a5da feat: Implement note encryption and synchronization features
- Added NoteEncryption class for encrypting and decrypting note content using AES-GCM.
- Updated NoteRepository to handle synchronization of notes and categories with the server, including encryption of note data before sending.
- Introduced SyncRequest and SyncResponse models for managing synchronization data.
- Enhanced LocalVaultService to store and retrieve the encryption key.
- Modified HomeScreen and SettingsScreen to trigger synchronization after note operations and manage API endpoint settings.
- Added SyncStatusIndicator to provide visual feedback on synchronization status in the app title bar.
- Created Category model to manage note categories with encryption support.
- Updated note model to include UUID, server version, deletion status, and category ID.
- Added necessary UI elements for displaying and managing the encryption key in SettingsScreen.
- Updated dependencies in pubspec.yaml for cryptography and HTTP handling.
2026-05-18 16:11:19 +02:00

257 lines
7.9 KiB
Dart

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';
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<List<Note>> loadNotes() async {
return _loadNotesFromDatabase();
}
Future<Note> 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<Note> 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<void> 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<void> 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<Map<String, dynamic>> performSync({bool forceFull = false}) async {
try {
// Get last sync timestamp
final DateTime? lastSync = await _authApi.getLastSyncAt();
final DateTime? lastSyncForRequest = forceFull ? DateTime.utc(1970, 1, 1) : lastSync;
// Collect pending changes
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
final List<DbCategory> unsyncedCategories = await _database.getUnsyncedCategories();
// Build sync request (note: we send encrypted data, but locally we have plaintext)
// Encrypt all notes before sending
final List<SyncNotePayload> encryptedNotesPayload = [];
for (final dbNote in unsyncedNotes) {
final note = _fromDbNote(dbNote);
final encryptedTitle = await NoteEncryption.encryptNote(note.title, _masterKey);
final encryptedBody = await NoteEncryption.encryptNote(note.body, _masterKey);
encryptedNotesPayload.add(
SyncNotePayload.fromNote(
note,
encryptedTitle: encryptedTitle,
encryptedBody: encryptedBody,
),
);
}
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
.map((cat) => SyncCategoryPayload.fromCategory(
_fromDbCategory(cat),
))
.toList();
final SyncRequest syncRequest = SyncRequest(
lastSyncAt: lastSyncForRequest,
changes: SyncChanges(
categories: categoriesPayload,
notes: encryptedNotesPayload,
),
);
// Call sync API
final Map<String, dynamic> 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
await _applySyncResponse(response);
// 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<void> _applySyncResponse(SyncResponse response) 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
for (final SyncNoteResponse noteResponse in response.changes.notes) {
final existingNote = await (_database.select(_database.notes)
..where((n) => n.uuid.equals(noteResponse.id)))
.getSingleOrNull();
// Decrypt note content
String decryptedTitle = 'Encrypted';
String decryptedBody = 'Encrypted';
try {
decryptedTitle = await NoteEncryption.decryptNote(
noteResponse.encryptedTitle,
_masterKey,
);
decryptedBody = await NoteEncryption.decryptNote(
noteResponse.encryptedBody,
_masterKey,
);
} catch (e) {
// If decryption fails, keep default encrypted placeholders
print('Failed to decrypt note ${noteResponse.id}: $e');
}
if (existingNote != null) {
// Update existing note
await _database.updateNoteRow(
DbNote(
id: existingNote.id,
uuid: noteResponse.id,
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<List<Note>> _loadNotesFromDatabase() async {
final List<DbNote> 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,
);
}
}