refactor: Update Note and Category models to use 'id' instead of 'uuid', and adjust related database operations

- Changed 'uuid' to 'id' in Note and Category models for consistency.
- Updated database operations in NoteRepository to reflect the new 'id' field.
- Modified sync models to accommodate changes in Note and Category structures.
- Adjusted the handling of notes and categories during synchronization.
- Refactored the note editor and home screen to use the new 'id' field.
- Ensured that the 'isDirty' flag is properly set and utilized across models.
This commit is contained in:
2026-05-20 11:05:30 +02:00
parent 34f45a912f
commit def755e1c5
10 changed files with 520 additions and 323 deletions
+2 -2
View File
@@ -753,14 +753,14 @@ class _NotesAppState extends State<NotesApp>
} }
}); });
} }
} catch (e) { } catch (e, st) {
if (!mounted) { if (!mounted) {
return; return;
} }
setState(() { setState(() {
_syncStatus = SyncStatus.error; _syncStatus = SyncStatus.error;
_syncErrorMessage = e.toString(); _syncErrorMessage = '$e\n\nStackTrace: $st';
_syncProgress = null; _syncProgress = null;
_syncDetailMessage = null; _syncDetailMessage = null;
}); });
+27 -5
View File
@@ -277,9 +277,23 @@ class AuthApi {
debugPrint('Response body: ${res.body}'); debugPrint('Response body: ${res.body}');
if (res.statusCode >= 200 && res.statusCode < 300) { if (res.statusCode >= 200 && res.statusCode < 300) {
final Map<String, dynamic> json = try {
jsonDecode(res.body) as Map<String, dynamic>; final Map<String, dynamic> json =
return {'error': false, 'data': SyncResponse.fromJson(json)}; jsonDecode(res.body) as Map<String, dynamic>;
return {'error': false, 'data': SyncResponse.fromJson(json)};
} catch (e, st) {
debugPrint('SYNC PARSE ERROR -> $e');
debugPrint(st.toString());
debugPrint('SYNC PARSE RAW BODY -> ${res.body}');
return {
'error': true,
'message': 'Error parseando respuesta de sync: $e',
'exception': e.toString(),
'stackTrace': st.toString(),
'body': res.body,
'status': res.statusCode,
};
}
} }
// If token expired (401), try to refresh // If token expired (401), try to refresh
@@ -298,8 +312,16 @@ class AuthApi {
try { try {
final dynamic decoded = jsonDecode(res.body); final dynamic decoded = jsonDecode(res.body);
return {'error': true, 'status': res.statusCode, 'body': decoded}; return {'error': true, 'status': res.statusCode, 'body': decoded};
} catch (_) { } catch (e, st) {
return {'error': true, 'status': res.statusCode, 'body': res.body}; debugPrint('SYNC HTTP ERROR PARSE FAILED -> $e');
debugPrint(st.toString());
return {
'error': true,
'status': res.statusCode,
'body': res.body,
'exception': e.toString(),
'stackTrace': st.toString(),
};
} }
} }
+52 -26
View File
@@ -9,22 +9,23 @@ part 'app_database.g.dart';
@DataClassName('DbCategory') @DataClassName('DbCategory')
class Categories extends Table { class Categories extends Table {
TextColumn get uuid => text().unique()(); TextColumn get id => text()();
TextColumn get encryptedName => text().named('encrypted_name')(); TextColumn get encryptedName => text().named('encrypted_name')();
IntColumn get serverVersion => IntColumn get serverVersion =>
integer().named('server_version').withDefault(const Constant(0))(); integer().named('server_version').withDefault(const Constant(0))();
BoolColumn get isDeleted => BoolColumn get isDeleted =>
boolean().named('is_deleted').withDefault(const Constant(false))(); boolean().named('is_deleted').withDefault(const Constant(false))();
BoolColumn get isDirty =>
boolean().named('is_dirty').withDefault(const Constant(true))();
DateTimeColumn get updatedAt => dateTime().named('updated_at')(); DateTimeColumn get updatedAt => dateTime().named('updated_at')();
@override @override
Set<Column> get primaryKey => {uuid}; Set<Column> get primaryKey => {id};
} }
@DataClassName('DbNote') @DataClassName('DbNote')
class Notes extends Table { class Notes extends Table {
IntColumn get id => integer().autoIncrement()(); TextColumn get id => text().named('id')();
TextColumn get uuid => text().unique()();
TextColumn get title => text()(); TextColumn get title => text()();
TextColumn get body => text()(); TextColumn get body => text()();
DateTimeColumn get createdAt => dateTime().named('created_at')(); DateTimeColumn get createdAt => dateTime().named('created_at')();
@@ -35,12 +36,34 @@ class Notes extends Table {
BoolColumn get isDeleted => BoolColumn get isDeleted =>
boolean().named('is_deleted').withDefault(const Constant(false))(); boolean().named('is_deleted').withDefault(const Constant(false))();
TextColumn get categoryId => text().nullable().named('category_id')(); TextColumn get categoryId => text().nullable().named('category_id')();
BoolColumn get isDirty =>
boolean().named('is_dirty').withDefault(const Constant(true))();
@override
Set<Column> get primaryKey => {id};
} }
@DriftDatabase(tables: [Notes, Categories]) @DriftDatabase(tables: [Notes, Categories])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
@override @override
int get schemaVersion => 1; int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator migrator) async {
await migrator.createAll();
},
onUpgrade: (Migrator migrator, int from, int to) async {
if (from < 2) {
await migrator.addColumn(notes, notes.isDirty);
await migrator.addColumn(categories, categories.isDirty);
await customStatement('UPDATE notes SET is_dirty = 0');
await customStatement('UPDATE categories SET is_dirty = 0');
}
},
);
AppDatabase({required String encryptionKey}) AppDatabase({required String encryptionKey})
: super(_openConnection(encryptionKey)); : super(_openConnection(encryptionKey));
@@ -53,8 +76,8 @@ class AppDatabase extends _$AppDatabase {
return into(categories).insertOnConflictUpdate(category); return into(categories).insertOnConflictUpdate(category);
} }
Future<void> deleteCategory(String uuid) { Future<void> deleteCategory(String id) {
return (update(categories)..where((c) => c.uuid.equals(uuid))).write( return (update(categories)..where((c) => c.id.equals(id))).write(
CategoriesCompanion(isDeleted: Value(true)), CategoriesCompanion(isDeleted: Value(true)),
); );
} }
@@ -70,7 +93,7 @@ class AppDatabase extends _$AppDatabase {
Future<int> insertNoteAtTop(NotesCompanion note) { Future<int> insertNoteAtTop(NotesCompanion note) {
return transaction(() async { return transaction(() async {
await customStatement( await customStatement(
'UPDATE notes SET sort_index = sort_index + 1 WHERE is_deleted = 0', 'UPDATE notes SET sort_index = sort_index + 1, is_dirty = 1 WHERE is_deleted = 0',
); );
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0))); return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0)));
}); });
@@ -90,28 +113,29 @@ class AppDatabase extends _$AppDatabase {
return update(notes).replace(note); return update(notes).replace(note);
} }
Future<void> deleteNote(int id, int removedIndex) async { Future<void> deleteNote(String id, int removedIndex) async {
await (update(notes)..where((n) => n.id.equals(id))).write( await (update(notes)..where((n) => n.id.equals(id))).write(
NotesCompanion( NotesCompanion(
isDeleted: const Value(true), isDeleted: const Value(true),
updatedAt: Value(DateTime.now()), updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
), ),
); );
await customStatement( await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND is_deleted = 0', 'UPDATE notes SET sort_index = sort_index - 1, is_dirty = 1 WHERE sort_index > ? AND is_deleted = 0',
[removedIndex], [removedIndex],
); );
} }
Future<void> deleteNoteAndShift({ Future<void> deleteNoteAndShift({
required int id, required String id,
required int removedIndex, required int removedIndex,
}) { }) {
return deleteNote(id, removedIndex); return deleteNote(id, removedIndex);
} }
Future<void> permanentlyDeleteNote(int id) async { Future<void> permanentlyDeleteNote(String id) async {
await (update(notes)..where((n) => n.id.equals(id))).write( await (update(notes)..where((n) => n.id.equals(id))).write(
NotesCompanion( NotesCompanion(
title: const Value(''), title: const Value(''),
@@ -119,12 +143,13 @@ class AppDatabase extends _$AppDatabase {
categoryId: const Value(null), categoryId: const Value(null),
isDeleted: const Value(true), isDeleted: const Value(true),
updatedAt: Value(DateTime.now()), updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
), ),
); );
} }
Future<void> moveNote({ Future<void> moveNote({
required int id, required String id,
required int oldIndex, required int oldIndex,
required int newIndex, required int newIndex,
}) { }) {
@@ -133,9 +158,9 @@ class AppDatabase extends _$AppDatabase {
} }
return transaction(() async { return transaction(() async {
final List<DbNote> all = await (select(notes) final List<DbNote> all = await (select(
..where((n) => n.isDeleted.equals(false))) notes,
.get(); )..where((n) => n.isDeleted.equals(false))).get();
final int count = all.length; final int count = all.length;
if (count == 0) { if (count == 0) {
@@ -153,12 +178,12 @@ class AppDatabase extends _$AppDatabase {
if (safeOld < safeNew) { if (safeOld < safeNew) {
await customStatement( await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ? AND is_deleted = 0', 'UPDATE notes SET sort_index = sort_index - 1, is_dirty = 1 WHERE sort_index > ? AND sort_index <= ? AND is_deleted = 0',
[safeOld, safeNew], [safeOld, safeNew],
); );
} else { } else {
await customStatement( await customStatement(
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0', 'UPDATE notes SET sort_index = sort_index + 1, is_dirty = 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0',
[safeNew, safeOld], [safeNew, safeOld],
); );
} }
@@ -167,6 +192,7 @@ class AppDatabase extends _$AppDatabase {
NotesCompanion( NotesCompanion(
sortIndex: Value<int>(safeNew), sortIndex: Value<int>(safeNew),
updatedAt: Value<DateTime>(DateTime.now()), updatedAt: Value<DateTime>(DateTime.now()),
isDirty: const Value(true),
), ),
); );
}); });
@@ -179,8 +205,12 @@ class AppDatabase extends _$AppDatabase {
} }
Future<List<DbNote>> getDeletedNotes() { Future<List<DbNote>> getDeletedNotes() {
return (select(notes) return (select(notes)..where(
..where((n) => n.isDeleted.equals(true) & n.title.isNotValue('') & n.body.isNotValue(''))) (n) =>
n.isDeleted.equals(true) &
n.title.isNotValue('') &
n.body.isNotValue(''),
))
.get(); .get();
} }
@@ -192,15 +222,11 @@ class AppDatabase extends _$AppDatabase {
// ========== Sync helpers ========== // ========== Sync helpers ==========
Future<List<DbNote>> getUnsyncedNotes() { Future<List<DbNote>> getUnsyncedNotes() {
return (select(notes) return (select(notes)..where((n) => n.isDirty.equals(true))).get();
..where((n) => n.isDeleted.equals(true) | n.serverVersion.equals(0)))
.get();
} }
Future<List<DbCategory>> getUnsyncedCategories() { Future<List<DbCategory>> getUnsyncedCategories() {
return (select(categories) return (select(categories)..where((c) => c.isDirty.equals(true))).get();
..where((c) => c.isDeleted.equals(true) | c.serverVersion.equals(0)))
.get();
} }
} }
File diff suppressed because it is too large Load Diff
+144 -82
View File
@@ -35,9 +35,9 @@ class NoteRepository {
} }
Future<Note> createNote(Note note) async { Future<Note> createNote(Note note) async {
final int id = await _database.insertNoteAtTop( await _database.insertNoteAtTop(
NotesCompanion.insert( NotesCompanion.insert(
uuid: note.uuid, id: note.id,
title: note.title, title: note.title,
body: note.body, body: note.body,
createdAt: note.createdAt, createdAt: note.createdAt,
@@ -45,56 +45,77 @@ class NoteRepository {
sortIndex: 0, sortIndex: 0,
serverVersion: const Value(0), serverVersion: const Value(0),
isDeleted: const Value(false), isDeleted: const Value(false),
categoryId: const Value(null), categoryId: Value(note.categoryId),
isDirty: const Value(true),
), ),
); );
return note.copyWith(id: id, index: 0); return note.copyWith(position: 0, isDirty: true);
} }
Future<Note> updateNote(Note note) async { Future<Note> updateNote(Note note) async {
final int noteId = final DbNote? existingNote = await (_database.select(
note.id ?? _database.notes,
(throw ArgumentError('Note id is required to update a note.')); )..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
await _database.updateNoteRow( await _database.updateNoteRow(
DbNote( DbNote(
id: noteId, id: row.id,
uuid: note.uuid,
title: note.title, title: note.title,
body: note.body, body: note.body,
createdAt: note.createdAt, createdAt: row.createdAt,
updatedAt: note.updatedAt, updatedAt: note.updatedAt,
sortIndex: note.index, sortIndex: row.sortIndex,
serverVersion: note.serverVersion, serverVersion: note.serverVersion,
isDeleted: false, isDeleted: false,
categoryId: note.categoryId, categoryId: note.categoryId,
isDirty: true,
), ),
); );
return note.copyWith(isDeleted: false, isPermanentlyDeleted: false); return note.copyWith(
isDeleted: false,
isPermanentlyDeleted: false,
isDirty: true,
position: row.sortIndex.toDouble(),
);
} }
Future<void> deleteNote(Note note) async { Future<void> deleteNote(Note note) async {
final int noteId = final DbNote? existingNote = await (_database.select(
note.id ?? _database.notes,
(throw ArgumentError('Note id is required to delete a note.')); )..where((n) => n.id.equals(note.id))).getSingleOrNull();
if (note.isDeleted || note.isPermanentlyDeleted) { final DbNote row =
await _database.permanentlyDeleteNote(noteId); existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
if (row.isDeleted || note.isDeleted || note.isPermanentlyDeleted) {
await _database.permanentlyDeleteNote(row.id);
} else { } else {
await _database.deleteNoteAndShift(id: noteId, removedIndex: note.index); await _database.deleteNoteAndShift(
id: row.id,
removedIndex: row.sortIndex,
);
} }
} }
Future<void> moveNote(Note note, int newIndex) async { Future<void> moveNote(Note note, int newIndex) async {
final int noteId = final DbNote? existingNote = await (_database.select(
note.id ?? _database.notes,
(throw ArgumentError('Note id is required to reorder a note.')); )..where((n) => n.id.equals(note.id))).getSingleOrNull();
final DbNote row =
existingNote ??
(throw ArgumentError('Note not found for id ${note.id}.'));
await _database.moveNote( await _database.moveNote(
id: noteId, id: row.id,
oldIndex: note.index, oldIndex: row.sortIndex,
newIndex: newIndex, newIndex: newIndex,
); );
} }
@@ -120,26 +141,17 @@ class NoteRepository {
? DateTime.utc(1970, 1, 1) ? DateTime.utc(1970, 1, 1)
: lastSync; : lastSync;
// Collect pending local changes. // Collect pending local changes. Dirty flags are the source of truth for
// If we already synced at least once, use updatedAt to avoid re-sending // outbound sync; `lastSyncAt` is only used for asking the server what it
// old notes that were already uploaded. // changed since our previous successful sync.
final List<DbNote> unsyncedNotes; final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
final List<DbCategory> unsyncedCategories; final List<DbCategory> unsyncedCategories = await _database
.getUnsyncedCategories();
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; final int totalNotesToEncrypt = unsyncedNotes.length;
// Build sync request (note: we send encrypted data, but locally we have plaintext) // Build sync request: local data is plaintext, encryption happens only
// Encrypt notes that still contain content before sending. // for the outbound payload.
if (totalNotesToEncrypt == 0) { if (totalNotesToEncrypt == 0) {
onProgress?.call( onProgress?.call(
SyncStatus.encrypting, SyncStatus.encrypting,
@@ -164,9 +176,8 @@ class NoteRepository {
}, },
); );
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories final List<SyncCategoryPayload> categoriesPayload =
.map((cat) => SyncCategoryPayload.fromCategory(_fromDbCategory(cat))) await _encryptCategories(unsyncedCategories, masterKey: _masterKey);
.toList();
final SyncRequest syncRequest = SyncRequest( final SyncRequest syncRequest = SyncRequest(
lastSyncAt: lastSyncForRequest, lastSyncAt: lastSyncForRequest,
@@ -184,7 +195,27 @@ class NoteRepository {
final Map<String, dynamic> syncResult = await _authApi.sync(syncRequest); final Map<String, dynamic> syncResult = await _authApi.sync(syncRequest);
if (syncResult['error'] == true) { if (syncResult['error'] == true) {
return {'error': true, 'message': syncResult['body']}; final List<String> 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; final SyncResponse response = syncResult['data'] as SyncResponse;
@@ -217,8 +248,11 @@ class NoteRepository {
'notesCount': response.changes.notes.length, 'notesCount': response.changes.notes.length,
'categoriesCount': response.changes.categories.length, 'categoriesCount': response.changes.categories.length,
}; };
} catch (e) { } catch (e, st) {
return {'error': true, 'message': e.toString()}; return {
'error': true,
'message': '$e\n\nStackTrace: $st',
};
} }
} }
@@ -229,13 +263,23 @@ class NoteRepository {
// Apply categories from server // Apply categories from server
for (final SyncCategoryResponse catResponse for (final SyncCategoryResponse catResponse
in response.changes.categories) { in response.changes.categories) {
final String categoryName =
catResponse.isDeleted || catResponse.encryptedName.isEmpty
? ''
: await NoteEncryption.decryptNote(
catResponse.encryptedName,
_masterKey,
);
await _database.upsertCategory( await _database.upsertCategory(
CategoriesCompanion( CategoriesCompanion(
uuid: Value(catResponse.id), id: Value(catResponse.id),
encryptedName: Value(catResponse.encryptedName), encryptedName: Value(categoryName),
serverVersion: Value(catResponse.serverVersion), serverVersion: Value(catResponse.serverVersion),
isDeleted: Value(catResponse.isDeleted), isDeleted: Value(catResponse.isDeleted),
updatedAt: Value(catResponse.updatedAt), updatedAt: Value(catResponse.updatedAt),
isDirty: const Value(false),
), ),
); );
} }
@@ -254,14 +298,14 @@ class NoteRepository {
for (var index = 0; index < decryptedNotes.length; index += 1) { for (var index = 0; index < decryptedNotes.length; index += 1) {
final Map<String, Object?> decryptedNote = decryptedNotes[index]; final Map<String, Object?> decryptedNote = decryptedNotes[index];
final String noteId = decryptedNote['id']! as String; final SyncNoteResponse noteResponse = response.changes.notes[index];
final String noteId = (decryptedNote['id'] as String?) ?? noteResponse.id;
final existingNote = await (_database.select( final existingNote = await (_database.select(
_database.notes, _database.notes,
)..where((n) => n.uuid.equals(noteId))).getSingleOrNull(); )..where((n) => n.id.equals(noteId))).getSingleOrNull();
final String decryptedTitle = decryptedNote['title']! as String; final String decryptedTitle = (decryptedNote['title'] as String?) ?? '';
final String decryptedBody = decryptedNote['body']! as String; final String decryptedBody = (decryptedNote['body'] as String?) ?? '';
final SyncNoteResponse noteResponse = response.changes.notes[index];
final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted; final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted;
if (existingNote != null) { if (existingNote != null) {
@@ -269,15 +313,15 @@ class NoteRepository {
await _database.updateNoteRow( await _database.updateNoteRow(
DbNote( DbNote(
id: existingNote.id, id: existingNote.id,
uuid: noteId,
title: isPermanentlyDeleted ? '' : decryptedTitle, title: isPermanentlyDeleted ? '' : decryptedTitle,
body: isPermanentlyDeleted ? '' : decryptedBody, body: isPermanentlyDeleted ? '' : decryptedBody,
createdAt: existingNote.createdAt, createdAt: existingNote.createdAt,
updatedAt: noteResponse.updatedAt, updatedAt: noteResponse.updatedAt,
sortIndex: noteResponse.position, sortIndex: noteResponse.position.round(),
serverVersion: noteResponse.serverVersion, serverVersion: noteResponse.serverVersion,
isDeleted: noteResponse.isDeleted, isDeleted: noteResponse.isDeleted,
categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId, categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId,
isDirty: false,
), ),
); );
} else { } else {
@@ -286,15 +330,18 @@ class NoteRepository {
.into(_database.notes) .into(_database.notes)
.insert( .insert(
NotesCompanion( NotesCompanion(
uuid: Value(noteResponse.id), id: Value(noteResponse.id),
title: Value(isPermanentlyDeleted ? '' : decryptedTitle), title: Value(isPermanentlyDeleted ? '' : decryptedTitle),
body: Value(isPermanentlyDeleted ? '' : decryptedBody), body: Value(isPermanentlyDeleted ? '' : decryptedBody),
createdAt: Value(noteResponse.updatedAt), createdAt: Value(noteResponse.updatedAt),
updatedAt: Value(noteResponse.updatedAt), updatedAt: Value(noteResponse.updatedAt),
sortIndex: Value(noteResponse.position), sortIndex: Value(noteResponse.position.round()),
serverVersion: Value(noteResponse.serverVersion), serverVersion: Value(noteResponse.serverVersion),
isDeleted: Value(noteResponse.isDeleted), isDeleted: Value(noteResponse.isDeleted),
categoryId: Value(isPermanentlyDeleted ? null : noteResponse.categoryId), categoryId: Value(
isPermanentlyDeleted ? null : noteResponse.categoryId,
),
isDirty: const Value(false),
), ),
); );
} }
@@ -314,26 +361,16 @@ class NoteRepository {
Note _fromDbNote(DbNote row) { Note _fromDbNote(DbNote row) {
return Note( return Note(
id: row.id, id: row.id,
uuid: row.uuid,
title: row.title, title: row.title,
body: row.body, body: row.body,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
index: row.sortIndex, position: row.sortIndex.toDouble(),
serverVersion: row.serverVersion, serverVersion: row.serverVersion,
isDeleted: row.isDeleted, isDeleted: row.isDeleted,
isPermanentlyDeleted: _isPermanentlyDeleted(row), isPermanentlyDeleted: _isPermanentlyDeleted(row),
categoryId: row.categoryId, categoryId: row.categoryId,
); isDirty: row.isDirty,
}
Category _fromDbCategory(DbCategory row) {
return Category(
uuid: row.uuid,
encryptedName: row.encryptedName,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
); );
} }
} }
@@ -395,6 +432,35 @@ Future<List<SyncNotePayload>> _encryptNotesInParallel(
return orderedPayloads.cast<SyncNotePayload>(); return orderedPayloads.cast<SyncNotePayload>();
} }
Future<List<SyncCategoryPayload>> _encryptCategories(
List<DbCategory> categories, {
required String masterKey,
}) async {
final List<SyncCategoryPayload> payloads = [];
for (final DbCategory row in categories) {
final String encryptedName = row.encryptedName.isEmpty
? ''
: await NoteEncryption.encryptNote(row.encryptedName, masterKey);
payloads.add(
SyncCategoryPayload.fromCategory(
Category(
id: row.id,
name: row.encryptedName,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
isDirty: row.isDirty,
),
encryptedName: encryptedName,
),
);
}
return payloads;
}
List<Future<List<Map<String, Object?>>>> _decryptNoteBatches( List<Future<List<Map<String, Object?>>>> _decryptNoteBatches(
List<SyncNoteResponse> notes, { List<SyncNoteResponse> notes, {
required String masterKey, required String masterKey,
@@ -467,7 +533,7 @@ Map<String, Object?> _dbNoteToEncryptionInput(DbNote row, int index) {
return <String, Object?>{ return <String, Object?>{
'index': index, 'index': index,
'uuid': row.uuid, 'id': row.id,
'title': row.title, 'title': row.title,
'body': row.body, 'body': row.body,
'createdAt': row.createdAt.toIso8601String(), 'createdAt': row.createdAt.toIso8601String(),
@@ -487,7 +553,7 @@ SyncNotePayload _syncNotePayloadFromEncryptionResult(Map<String, Object?> row) {
encryptedTitle: row['encryptedTitle']! as String, encryptedTitle: row['encryptedTitle']! as String,
encryptedBody: row['encryptedBody']! as String, encryptedBody: row['encryptedBody']! as String,
serverVersion: row['serverVersion']! as int, serverVersion: row['serverVersion']! as int,
position: row['position']! as int, position: (row['position']! as num).toDouble(),
isDeleted: row['isDeleted']! as bool, isDeleted: row['isDeleted']! as bool,
isPermanentlyDeleted: row['isPermanentlyDeleted']! as bool, isPermanentlyDeleted: row['isPermanentlyDeleted']! as bool,
updatedAt: DateTime.parse(row['updatedAt']! as String), updatedAt: DateTime.parse(row['updatedAt']! as String),
@@ -513,19 +579,13 @@ Future<List<Map<String, Object?>>> _encryptNoteBatch(
encryptedTitle = ''; encryptedTitle = '';
encryptedBody = ''; encryptedBody = '';
} else { } else {
encryptedTitle = await NoteEncryption.encryptNote( encryptedTitle = await NoteEncryption.encryptNote(title, masterKey);
title, encryptedBody = await NoteEncryption.encryptNote(body, masterKey);
masterKey,
);
encryptedBody = await NoteEncryption.encryptNote(
body,
masterKey,
);
} }
encryptedNotes.add(<String, Object?>{ encryptedNotes.add(<String, Object?>{
'index': note['index'] as int, 'index': note['index'] as int,
'id': note['uuid'] as String, 'id': note['id'] as String,
'categoryId': note['categoryId'] as String?, 'categoryId': note['categoryId'] as String?,
'encryptedTitle': encryptedTitle, 'encryptedTitle': encryptedTitle,
'encryptedBody': encryptedBody, 'encryptedBody': encryptedBody,
@@ -570,9 +630,11 @@ Future<List<Map<String, Object?>>> _decryptNoteBatch(
decryptedBody = ''; decryptedBody = '';
} }
final String noteId = note['id']! as String;
decryptedNotes.add(<String, Object?>{ decryptedNotes.add(<String, Object?>{
'index': note['index'] as int, 'index': note['index'] as int,
'id': note['id'] as String, 'id': noteId,
'title': decryptedTitle, 'title': decryptedTitle,
'body': decryptedBody, 'body': decryptedBody,
'isPermanentlyDeleted': isPermanentlyDeleted, 'isPermanentlyDeleted': isPermanentlyDeleted,
+23 -21
View File
@@ -98,16 +98,19 @@ class SyncCategoryPayload {
required this.updatedAt, required this.updatedAt,
}); });
final String id; // uuid final String id;
final String encryptedName; final String encryptedName;
final int serverVersion; final int serverVersion;
final bool isDeleted; final bool isDeleted;
final DateTime updatedAt; final DateTime updatedAt;
factory SyncCategoryPayload.fromCategory(Category category) { factory SyncCategoryPayload.fromCategory(
Category category, {
required String encryptedName,
}) {
return SyncCategoryPayload( return SyncCategoryPayload(
id: category.uuid, id: category.id,
encryptedName: category.encryptedName, encryptedName: encryptedName,
serverVersion: category.serverVersion, serverVersion: category.serverVersion,
isDeleted: category.isDeleted, isDeleted: category.isDeleted,
updatedAt: category.updatedAt, updatedAt: category.updatedAt,
@@ -132,18 +135,18 @@ class SyncNotePayload {
required this.encryptedTitle, required this.encryptedTitle,
required this.encryptedBody, required this.encryptedBody,
required this.serverVersion, required this.serverVersion,
this.position = 0, this.position = 0.0,
this.isDeleted = false, this.isDeleted = false,
this.isPermanentlyDeleted = false, this.isPermanentlyDeleted = false,
required this.updatedAt, required this.updatedAt,
}); });
final String id; // uuid final String id;
final String? categoryId; final String? categoryId;
final String encryptedTitle; final String encryptedTitle;
final String encryptedBody; final String encryptedBody;
final int serverVersion; final int serverVersion;
final int position; final double position;
final bool isDeleted; final bool isDeleted;
final bool isPermanentlyDeleted; final bool isPermanentlyDeleted;
final DateTime updatedAt; final DateTime updatedAt;
@@ -155,12 +158,12 @@ class SyncNotePayload {
bool isPermanentlyDeleted = false, bool isPermanentlyDeleted = false,
}) { }) {
return SyncNotePayload( return SyncNotePayload(
id: note.uuid, id: note.id,
categoryId: note.categoryId, categoryId: note.categoryId,
encryptedTitle: encryptedTitle, encryptedTitle: encryptedTitle,
encryptedBody: encryptedBody, encryptedBody: encryptedBody,
serverVersion: note.serverVersion, serverVersion: note.serverVersion,
position: note.index, position: note.position,
isDeleted: note.isDeleted, isDeleted: note.isDeleted,
isPermanentlyDeleted: isPermanentlyDeleted, isPermanentlyDeleted: isPermanentlyDeleted,
updatedAt: note.updatedAt, updatedAt: note.updatedAt,
@@ -212,8 +215,7 @@ class SyncCategoryResponse {
this.isDeleted = false, this.isDeleted = false,
required this.updatedAt, required this.updatedAt,
}); });
final String id;
final String id; // uuid
final String encryptedName; final String encryptedName;
final int serverVersion; final int serverVersion;
final bool isDeleted; final bool isDeleted;
@@ -229,10 +231,10 @@ class SyncCategoryResponse {
); );
} }
Category toCategory() { Category toCategory({required String name}) {
return Category( return Category(
uuid: id, id: id,
encryptedName: encryptedName, name: name,
serverVersion: serverVersion, serverVersion: serverVersion,
isDeleted: isDeleted, isDeleted: isDeleted,
updatedAt: updatedAt, updatedAt: updatedAt,
@@ -247,18 +249,17 @@ class SyncNoteResponse {
required this.encryptedTitle, required this.encryptedTitle,
required this.encryptedBody, required this.encryptedBody,
required this.serverVersion, required this.serverVersion,
this.position = 0, this.position = 0.0,
this.isDeleted = false, this.isDeleted = false,
this.isPermanentlyDeleted = false, this.isPermanentlyDeleted = false,
required this.updatedAt, required this.updatedAt,
}); });
final String id;
final String id; // uuid
final String? categoryId; final String? categoryId;
final String encryptedTitle; final String encryptedTitle;
final String encryptedBody; final String encryptedBody;
final int serverVersion; final int serverVersion;
final int position; final double position;
final bool isDeleted; final bool isDeleted;
final bool isPermanentlyDeleted; final bool isPermanentlyDeleted;
final DateTime updatedAt; final DateTime updatedAt;
@@ -272,7 +273,7 @@ class SyncNoteResponse {
encryptedTitle: _readOptionalStringValue(json['encrypted_title']), encryptedTitle: _readOptionalStringValue(json['encrypted_title']),
encryptedBody: _readOptionalStringValue(json['encrypted_body']), encryptedBody: _readOptionalStringValue(json['encrypted_body']),
serverVersion: _readIntValue(json['serverVersion']), serverVersion: _readIntValue(json['serverVersion']),
position: json['position'] as int? ?? 0, position: (json['position'] as num?)?.toDouble() ?? 0,
isDeleted: json['isDeleted'] as bool? ?? false, isDeleted: json['isDeleted'] as bool? ?? false,
isPermanentlyDeleted: json['isPermanentlyDeleted'] as bool? ?? false, isPermanentlyDeleted: json['isPermanentlyDeleted'] as bool? ?? false,
updatedAt: DateTime.parse(json['updatedAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String),
@@ -281,15 +282,16 @@ class SyncNoteResponse {
Note toNote() { Note toNote() {
return Note( return Note(
uuid: id, id: id,
title: isPermanentlyDeleted ? '' : 'Encrypted', title: isPermanentlyDeleted ? '' : 'Encrypted',
body: isPermanentlyDeleted ? '' : 'Encrypted', body: isPermanentlyDeleted ? '' : 'Encrypted',
createdAt: updatedAt, createdAt: updatedAt,
updatedAt: updatedAt, updatedAt: updatedAt,
index: position, position: position,
serverVersion: serverVersion, serverVersion: serverVersion,
isDeleted: isDeleted, isDeleted: isDeleted,
categoryId: categoryId, categoryId: categoryId,
isDirty: false,
); );
} }
} }
+15 -11
View File
@@ -2,32 +2,36 @@ import 'package:uuid/uuid.dart';
class Category { class Category {
Category({ Category({
String? uuid, String? id,
required this.encryptedName, required this.name,
this.serverVersion = 0, this.serverVersion = 0,
this.isDeleted = false, this.isDeleted = false,
required this.updatedAt, required this.updatedAt,
}) : uuid = uuid ?? Uuid().v4(); this.isDirty = true,
}) : id = id ?? Uuid().v4();
final String uuid; final String id;
final String encryptedName; final String name;
final int serverVersion; final int serverVersion;
final bool isDeleted; final bool isDeleted;
final DateTime updatedAt; final DateTime updatedAt;
final bool isDirty;
Category copyWith({ Category copyWith({
String? uuid, String? id,
String? encryptedName, String? name,
int? serverVersion, int? serverVersion,
bool? isDeleted, bool? isDeleted,
DateTime? updatedAt, DateTime? updatedAt,
bool? isDirty,
}) { }) {
return Category( return Category(
uuid: uuid ?? this.uuid, id: id ?? this.id,
encryptedName: encryptedName ?? this.encryptedName, name: name ?? this.name,
serverVersion: serverVersion ?? this.serverVersion, serverVersion: serverVersion ?? this.serverVersion,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
isDirty: isDirty ?? this.isDirty,
); );
} }
@@ -37,9 +41,9 @@ class Category {
return true; return true;
} }
return other is Category && uuid == other.uuid; return other is Category && id == other.id;
} }
@override @override
int get hashCode => uuid.hashCode; int get hashCode => id.hashCode;
} }
+18 -23
View File
@@ -2,63 +2,58 @@ import 'package:uuid/uuid.dart';
// Model: Note // Model: Note
// - Representa una nota guardada en la app. // - Representa una nota guardada en la app.
// - `id` es el identificador local de SQLite (autoincrement).
// - `uuid` es el identificador global sincronizado con el servidor.
// - `index` representa el orden visual dentro de la lista.
// - `serverVersion` se usa para resolver conflictos en sync.
// - `isDeleted` marca eliminaciones blandas.
class Note { class Note {
Note({ Note({
this.id, String? id,
String? uuid,
required this.title, required this.title,
required this.body, required this.body,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.index, required this.position,
this.categoryId,
this.serverVersion = 0, this.serverVersion = 0,
this.isDeleted = false, this.isDeleted = false,
this.isPermanentlyDeleted = false, this.isPermanentlyDeleted = false,
this.categoryId, this.isDirty = true,
}) : uuid = uuid ?? Uuid().v4(); }) : id = id ?? Uuid().v4();
final int? id; final String id;
final String uuid;
final String title; final String title;
final String body; final String body;
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final int index; final double position;
final String? categoryId;
final int serverVersion; final int serverVersion;
final bool isDeleted; final bool isDeleted;
final bool isPermanentlyDeleted; final bool isPermanentlyDeleted;
final String? categoryId; final bool isDirty;
Note copyWith({ Note copyWith({
int? id, String? id,
String? uuid,
String? title, String? title,
String? body, String? body,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
int? index, double? position,
String? categoryId,
int? serverVersion, int? serverVersion,
bool? isDeleted, bool? isDeleted,
bool? isPermanentlyDeleted, bool? isPermanentlyDeleted,
String? categoryId, bool? isDirty,
}) { }) {
return Note( return Note(
id: id ?? this.id, id: id ?? this.id,
uuid: uuid ?? this.uuid,
title: title ?? this.title, title: title ?? this.title,
body: body ?? this.body, body: body ?? this.body,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
index: index ?? this.index, position: position ?? this.position,
categoryId: categoryId ?? this.categoryId,
serverVersion: serverVersion ?? this.serverVersion, serverVersion: serverVersion ?? this.serverVersion,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,
isPermanentlyDeleted: isPermanentlyDeleted ?? this.isPermanentlyDeleted, isPermanentlyDeleted: isPermanentlyDeleted ?? this.isPermanentlyDeleted,
categoryId: categoryId ?? this.categoryId, isDirty: isDirty ?? this.isDirty,
); );
} }
@@ -68,9 +63,9 @@ class Note {
return true; return true;
} }
return other is Note && uuid == other.uuid; return other is Note && id == other.id;
} }
@override @override
int get hashCode => uuid.hashCode; int get hashCode => id.hashCode;
} }
+7 -9
View File
@@ -154,9 +154,9 @@ class _HomeScreenState extends State<HomeScreen> {
// Don't let DB errors cause the app to reset the vault automatically. // Don't let DB errors cause the app to reset the vault automatically.
debugPrint('Failed to move note: $e\n$st'); debugPrint('Failed to move note: $e\n$st');
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Error al reordenar la nota: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Error al reordenar la nota: $e')));
} }
} }
@@ -385,9 +385,8 @@ class _HomeScreenState extends State<HomeScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: NoteCard( child: NoteCard(
key: ValueKey<int>( key: ValueKey<String>(
filteredNotes[index].id ?? filteredNotes[index].id,
filteredNotes[index].index,
), ),
note: filteredNotes[index], note: filteredNotes[index],
onTap: () => onTap: () =>
@@ -510,9 +509,8 @@ class _HomeScreenState extends State<HomeScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: NoteCard( child: NoteCard(
key: ValueKey<int>( key: ValueKey<String>(
filteredNotes[index].id ?? filteredNotes[index].id,
filteredNotes[index].index,
), ),
note: filteredNotes[index], note: filteredNotes[index],
onTap: () => onTap: () =>
+3 -2
View File
@@ -89,7 +89,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isNewNote = widget.note?.id == null; _isNewNote = widget.note == null;
if (_isNewNote) { if (_isNewNote) {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
@@ -98,7 +98,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
body: '', body: '',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
index: 0, position: 0,
); );
} else { } else {
_currentNote = widget.note!; _currentNote = widget.note!;
@@ -143,6 +143,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
title: title.isEmpty ? 'Sin título' : title, title: title.isEmpty ? 'Sin título' : title,
body: body, body: body,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isDirty: true,
); );
_complete(updatedNote); _complete(updatedNote);