diff --git a/lib/app.dart b/lib/app.dart index 9bde887..577df3a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -753,14 +753,14 @@ class _NotesAppState extends State } }); } - } catch (e) { + } catch (e, st) { if (!mounted) { return; } setState(() { _syncStatus = SyncStatus.error; - _syncErrorMessage = e.toString(); + _syncErrorMessage = '$e\n\nStackTrace: $st'; _syncProgress = null; _syncDetailMessage = null; }); diff --git a/lib/data/api_client.dart b/lib/data/api_client.dart index 22ea4ac..557d38a 100644 --- a/lib/data/api_client.dart +++ b/lib/data/api_client.dart @@ -277,9 +277,23 @@ class AuthApi { debugPrint('Response body: ${res.body}'); if (res.statusCode >= 200 && res.statusCode < 300) { - final Map json = - jsonDecode(res.body) as Map; - return {'error': false, 'data': SyncResponse.fromJson(json)}; + try { + final Map json = + jsonDecode(res.body) as Map; + 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 @@ -298,8 +312,16 @@ class AuthApi { try { final dynamic decoded = jsonDecode(res.body); return {'error': true, 'status': res.statusCode, 'body': decoded}; - } catch (_) { - return {'error': true, 'status': res.statusCode, 'body': res.body}; + } catch (e, st) { + 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(), + }; } } diff --git a/lib/data/app_database.dart b/lib/data/app_database.dart index e386b23..ea7a6b8 100644 --- a/lib/data/app_database.dart +++ b/lib/data/app_database.dart @@ -9,22 +9,23 @@ part 'app_database.g.dart'; @DataClassName('DbCategory') class Categories extends Table { - TextColumn get uuid => text().unique()(); + TextColumn get id => text()(); TextColumn get encryptedName => text().named('encrypted_name')(); IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))(); BoolColumn get isDeleted => 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')(); @override - Set get primaryKey => {uuid}; + Set get primaryKey => {id}; } @DataClassName('DbNote') class Notes extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get uuid => text().unique()(); + TextColumn get id => text().named('id')(); TextColumn get title => text()(); TextColumn get body => text()(); DateTimeColumn get createdAt => dateTime().named('created_at')(); @@ -35,12 +36,34 @@ class Notes extends Table { BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))(); TextColumn get categoryId => text().nullable().named('category_id')(); + BoolColumn get isDirty => + boolean().named('is_dirty').withDefault(const Constant(true))(); + + @override + Set get primaryKey => {id}; } @DriftDatabase(tables: [Notes, Categories]) class AppDatabase extends _$AppDatabase { @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}) : super(_openConnection(encryptionKey)); @@ -53,8 +76,8 @@ class AppDatabase extends _$AppDatabase { return into(categories).insertOnConflictUpdate(category); } - Future deleteCategory(String uuid) { - return (update(categories)..where((c) => c.uuid.equals(uuid))).write( + Future deleteCategory(String id) { + return (update(categories)..where((c) => c.id.equals(id))).write( CategoriesCompanion(isDeleted: Value(true)), ); } @@ -70,7 +93,7 @@ class AppDatabase extends _$AppDatabase { Future insertNoteAtTop(NotesCompanion note) { return transaction(() async { 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(0))); }); @@ -90,28 +113,29 @@ class AppDatabase extends _$AppDatabase { return update(notes).replace(note); } - Future deleteNote(int id, int removedIndex) async { + Future deleteNote(String id, int removedIndex) async { await (update(notes)..where((n) => n.id.equals(id))).write( NotesCompanion( isDeleted: const Value(true), updatedAt: Value(DateTime.now()), + isDirty: const Value(true), ), ); 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], ); } Future deleteNoteAndShift({ - required int id, + required String id, required int removedIndex, }) { return deleteNote(id, removedIndex); } - Future permanentlyDeleteNote(int id) async { + Future permanentlyDeleteNote(String id) async { await (update(notes)..where((n) => n.id.equals(id))).write( NotesCompanion( title: const Value(''), @@ -119,12 +143,13 @@ class AppDatabase extends _$AppDatabase { categoryId: const Value(null), isDeleted: const Value(true), updatedAt: Value(DateTime.now()), + isDirty: const Value(true), ), ); } Future moveNote({ - required int id, + required String id, required int oldIndex, required int newIndex, }) { @@ -133,9 +158,9 @@ class AppDatabase extends _$AppDatabase { } return transaction(() async { - final List all = await (select(notes) - ..where((n) => n.isDeleted.equals(false))) - .get(); + final List all = await (select( + notes, + )..where((n) => n.isDeleted.equals(false))).get(); final int count = all.length; if (count == 0) { @@ -153,12 +178,12 @@ class AppDatabase extends _$AppDatabase { if (safeOld < safeNew) { 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], ); } else { 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], ); } @@ -167,6 +192,7 @@ class AppDatabase extends _$AppDatabase { NotesCompanion( sortIndex: Value(safeNew), updatedAt: Value(DateTime.now()), + isDirty: const Value(true), ), ); }); @@ -179,8 +205,12 @@ class AppDatabase extends _$AppDatabase { } Future> getDeletedNotes() { - return (select(notes) - ..where((n) => n.isDeleted.equals(true) & n.title.isNotValue('') & n.body.isNotValue(''))) + return (select(notes)..where( + (n) => + n.isDeleted.equals(true) & + n.title.isNotValue('') & + n.body.isNotValue(''), + )) .get(); } @@ -192,15 +222,11 @@ class AppDatabase extends _$AppDatabase { // ========== Sync helpers ========== Future> getUnsyncedNotes() { - return (select(notes) - ..where((n) => n.isDeleted.equals(true) | n.serverVersion.equals(0))) - .get(); + return (select(notes)..where((n) => n.isDirty.equals(true))).get(); } Future> getUnsyncedCategories() { - return (select(categories) - ..where((c) => c.isDeleted.equals(true) | c.serverVersion.equals(0))) - .get(); + return (select(categories)..where((c) => c.isDirty.equals(true))).get(); } } diff --git a/lib/data/app_database.g.dart b/lib/data/app_database.g.dart index aae36b1..205a49f 100644 --- a/lib/data/app_database.g.dart +++ b/lib/data/app_database.g.dart @@ -10,26 +10,12 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { $NotesTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _idMeta = const VerificationMeta('id'); @override - late final GeneratedColumn id = GeneratedColumn( + late final GeneratedColumn id = GeneratedColumn( 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'PRIMARY KEY AUTOINCREMENT', - ), - ); - static const VerificationMeta _uuidMeta = const VerificationMeta('uuid'); - @override - late final GeneratedColumn uuid = GeneratedColumn( - 'uuid', - aliasedName, - false, type: DriftSqlType.string, requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), ); static const VerificationMeta _titleMeta = const VerificationMeta('title'); @override @@ -120,10 +106,24 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _isDirtyMeta = const VerificationMeta( + 'isDirty', + ); + @override + late final GeneratedColumn isDirty = GeneratedColumn( + 'is_dirty', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_dirty" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); @override List get $columns => [ id, - uuid, title, body, createdAt, @@ -132,6 +132,7 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { serverVersion, isDeleted, categoryId, + isDirty, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -147,14 +148,8 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('uuid')) { - context.handle( - _uuidMeta, - uuid.isAcceptableOrUnknown(data['uuid']!, _uuidMeta), - ); } else if (isInserting) { - context.missing(_uuidMeta); + context.missing(_idMeta); } if (data.containsKey('title')) { context.handle( @@ -217,6 +212,12 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { categoryId.isAcceptableOrUnknown(data['category_id']!, _categoryIdMeta), ); } + if (data.containsKey('is_dirty')) { + context.handle( + _isDirtyMeta, + isDirty.isAcceptableOrUnknown(data['is_dirty']!, _isDirtyMeta), + ); + } return context; } @@ -227,12 +228,8 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return DbNote( id: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - uuid: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}uuid'], + data['${effectivePrefix}id'], )!, title: attachedDatabase.typeMapping.read( DriftSqlType.string, @@ -266,6 +263,10 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { DriftSqlType.string, data['${effectivePrefix}category_id'], ), + isDirty: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_dirty'], + )!, ); } @@ -276,8 +277,7 @@ class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> { } class DbNote extends DataClass implements Insertable { - final int id; - final String uuid; + final String id; final String title; final String body; final DateTime createdAt; @@ -286,9 +286,9 @@ class DbNote extends DataClass implements Insertable { final int serverVersion; final bool isDeleted; final String? categoryId; + final bool isDirty; const DbNote({ required this.id, - required this.uuid, required this.title, required this.body, required this.createdAt, @@ -297,12 +297,12 @@ class DbNote extends DataClass implements Insertable { required this.serverVersion, required this.isDeleted, this.categoryId, + required this.isDirty, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); - map['uuid'] = Variable(uuid); + map['id'] = Variable(id); map['title'] = Variable(title); map['body'] = Variable(body); map['created_at'] = Variable(createdAt); @@ -313,13 +313,13 @@ class DbNote extends DataClass implements Insertable { if (!nullToAbsent || categoryId != null) { map['category_id'] = Variable(categoryId); } + map['is_dirty'] = Variable(isDirty); return map; } NotesCompanion toCompanion(bool nullToAbsent) { return NotesCompanion( id: Value(id), - uuid: Value(uuid), title: Value(title), body: Value(body), createdAt: Value(createdAt), @@ -330,6 +330,7 @@ class DbNote extends DataClass implements Insertable { categoryId: categoryId == null && nullToAbsent ? const Value.absent() : Value(categoryId), + isDirty: Value(isDirty), ); } @@ -339,8 +340,7 @@ class DbNote extends DataClass implements Insertable { }) { serializer ??= driftRuntimeOptions.defaultSerializer; return DbNote( - id: serializer.fromJson(json['id']), - uuid: serializer.fromJson(json['uuid']), + id: serializer.fromJson(json['id']), title: serializer.fromJson(json['title']), body: serializer.fromJson(json['body']), createdAt: serializer.fromJson(json['createdAt']), @@ -349,14 +349,14 @@ class DbNote extends DataClass implements Insertable { serverVersion: serializer.fromJson(json['serverVersion']), isDeleted: serializer.fromJson(json['isDeleted']), categoryId: serializer.fromJson(json['categoryId']), + isDirty: serializer.fromJson(json['isDirty']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), - 'uuid': serializer.toJson(uuid), + 'id': serializer.toJson(id), 'title': serializer.toJson(title), 'body': serializer.toJson(body), 'createdAt': serializer.toJson(createdAt), @@ -365,12 +365,12 @@ class DbNote extends DataClass implements Insertable { 'serverVersion': serializer.toJson(serverVersion), 'isDeleted': serializer.toJson(isDeleted), 'categoryId': serializer.toJson(categoryId), + 'isDirty': serializer.toJson(isDirty), }; } DbNote copyWith({ - int? id, - String? uuid, + String? id, String? title, String? body, DateTime? createdAt, @@ -379,9 +379,9 @@ class DbNote extends DataClass implements Insertable { int? serverVersion, bool? isDeleted, Value categoryId = const Value.absent(), + bool? isDirty, }) => DbNote( id: id ?? this.id, - uuid: uuid ?? this.uuid, title: title ?? this.title, body: body ?? this.body, createdAt: createdAt ?? this.createdAt, @@ -390,11 +390,11 @@ class DbNote extends DataClass implements Insertable { serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, categoryId: categoryId.present ? categoryId.value : this.categoryId, + isDirty: isDirty ?? this.isDirty, ); DbNote copyWithCompanion(NotesCompanion data) { return DbNote( id: data.id.present ? data.id.value : this.id, - uuid: data.uuid.present ? data.uuid.value : this.uuid, title: data.title.present ? data.title.value : this.title, body: data.body.present ? data.body.value : this.body, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, @@ -407,6 +407,7 @@ class DbNote extends DataClass implements Insertable { categoryId: data.categoryId.present ? data.categoryId.value : this.categoryId, + isDirty: data.isDirty.present ? data.isDirty.value : this.isDirty, ); } @@ -414,7 +415,6 @@ class DbNote extends DataClass implements Insertable { String toString() { return (StringBuffer('DbNote(') ..write('id: $id, ') - ..write('uuid: $uuid, ') ..write('title: $title, ') ..write('body: $body, ') ..write('createdAt: $createdAt, ') @@ -422,7 +422,8 @@ class DbNote extends DataClass implements Insertable { ..write('sortIndex: $sortIndex, ') ..write('serverVersion: $serverVersion, ') ..write('isDeleted: $isDeleted, ') - ..write('categoryId: $categoryId') + ..write('categoryId: $categoryId, ') + ..write('isDirty: $isDirty') ..write(')')) .toString(); } @@ -430,7 +431,6 @@ class DbNote extends DataClass implements Insertable { @override int get hashCode => Object.hash( id, - uuid, title, body, createdAt, @@ -439,13 +439,13 @@ class DbNote extends DataClass implements Insertable { serverVersion, isDeleted, categoryId, + isDirty, ); @override bool operator ==(Object other) => identical(this, other) || (other is DbNote && other.id == this.id && - other.uuid == this.uuid && other.title == this.title && other.body == this.body && other.createdAt == this.createdAt && @@ -453,12 +453,12 @@ class DbNote extends DataClass implements Insertable { other.sortIndex == this.sortIndex && other.serverVersion == this.serverVersion && other.isDeleted == this.isDeleted && - other.categoryId == this.categoryId); + other.categoryId == this.categoryId && + other.isDirty == this.isDirty); } class NotesCompanion extends UpdateCompanion { - final Value id; - final Value uuid; + final Value id; final Value title; final Value body; final Value createdAt; @@ -467,9 +467,10 @@ class NotesCompanion extends UpdateCompanion { final Value serverVersion; final Value isDeleted; final Value categoryId; + final Value isDirty; + final Value rowid; const NotesCompanion({ this.id = const Value.absent(), - this.uuid = const Value.absent(), this.title = const Value.absent(), this.body = const Value.absent(), this.createdAt = const Value.absent(), @@ -478,10 +479,11 @@ class NotesCompanion extends UpdateCompanion { this.serverVersion = const Value.absent(), this.isDeleted = const Value.absent(), this.categoryId = const Value.absent(), + this.isDirty = const Value.absent(), + this.rowid = const Value.absent(), }); NotesCompanion.insert({ - this.id = const Value.absent(), - required String uuid, + required String id, required String title, required String body, required DateTime createdAt, @@ -490,15 +492,16 @@ class NotesCompanion extends UpdateCompanion { this.serverVersion = const Value.absent(), this.isDeleted = const Value.absent(), this.categoryId = const Value.absent(), - }) : uuid = Value(uuid), + this.isDirty = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), title = Value(title), body = Value(body), createdAt = Value(createdAt), updatedAt = Value(updatedAt), sortIndex = Value(sortIndex); static Insertable custom({ - Expression? id, - Expression? uuid, + Expression? id, Expression? title, Expression? body, Expression? createdAt, @@ -507,10 +510,11 @@ class NotesCompanion extends UpdateCompanion { Expression? serverVersion, Expression? isDeleted, Expression? categoryId, + Expression? isDirty, + Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, - if (uuid != null) 'uuid': uuid, if (title != null) 'title': title, if (body != null) 'body': body, if (createdAt != null) 'created_at': createdAt, @@ -519,12 +523,13 @@ class NotesCompanion extends UpdateCompanion { if (serverVersion != null) 'server_version': serverVersion, if (isDeleted != null) 'is_deleted': isDeleted, if (categoryId != null) 'category_id': categoryId, + if (isDirty != null) 'is_dirty': isDirty, + if (rowid != null) 'rowid': rowid, }); } NotesCompanion copyWith({ - Value? id, - Value? uuid, + Value? id, Value? title, Value? body, Value? createdAt, @@ -533,10 +538,11 @@ class NotesCompanion extends UpdateCompanion { Value? serverVersion, Value? isDeleted, Value? categoryId, + Value? isDirty, + Value? rowid, }) { return NotesCompanion( id: id ?? this.id, - uuid: uuid ?? this.uuid, title: title ?? this.title, body: body ?? this.body, createdAt: createdAt ?? this.createdAt, @@ -545,6 +551,8 @@ class NotesCompanion extends UpdateCompanion { serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, categoryId: categoryId ?? this.categoryId, + isDirty: isDirty ?? this.isDirty, + rowid: rowid ?? this.rowid, ); } @@ -552,10 +560,7 @@ class NotesCompanion extends UpdateCompanion { Map toColumns(bool nullToAbsent) { final map = {}; if (id.present) { - map['id'] = Variable(id.value); - } - if (uuid.present) { - map['uuid'] = Variable(uuid.value); + map['id'] = Variable(id.value); } if (title.present) { map['title'] = Variable(title.value); @@ -581,6 +586,12 @@ class NotesCompanion extends UpdateCompanion { if (categoryId.present) { map['category_id'] = Variable(categoryId.value); } + if (isDirty.present) { + map['is_dirty'] = Variable(isDirty.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } return map; } @@ -588,7 +599,6 @@ class NotesCompanion extends UpdateCompanion { String toString() { return (StringBuffer('NotesCompanion(') ..write('id: $id, ') - ..write('uuid: $uuid, ') ..write('title: $title, ') ..write('body: $body, ') ..write('createdAt: $createdAt, ') @@ -596,7 +606,9 @@ class NotesCompanion extends UpdateCompanion { ..write('sortIndex: $sortIndex, ') ..write('serverVersion: $serverVersion, ') ..write('isDeleted: $isDeleted, ') - ..write('categoryId: $categoryId') + ..write('categoryId: $categoryId, ') + ..write('isDirty: $isDirty, ') + ..write('rowid: $rowid') ..write(')')) .toString(); } @@ -608,15 +620,14 @@ class $CategoriesTable extends Categories final GeneratedDatabase attachedDatabase; final String? _alias; $CategoriesTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _uuidMeta = const VerificationMeta('uuid'); + static const VerificationMeta _idMeta = const VerificationMeta('id'); @override - late final GeneratedColumn uuid = GeneratedColumn( - 'uuid', + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), ); static const VerificationMeta _encryptedNameMeta = const VerificationMeta( 'encryptedName', @@ -656,6 +667,21 @@ class $CategoriesTable extends Categories ), defaultValue: const Constant(false), ); + static const VerificationMeta _isDirtyMeta = const VerificationMeta( + 'isDirty', + ); + @override + late final GeneratedColumn isDirty = GeneratedColumn( + 'is_dirty', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_dirty" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); static const VerificationMeta _updatedAtMeta = const VerificationMeta( 'updatedAt', ); @@ -669,10 +695,11 @@ class $CategoriesTable extends Categories ); @override List get $columns => [ - uuid, + id, encryptedName, serverVersion, isDeleted, + isDirty, updatedAt, ]; @override @@ -687,13 +714,10 @@ class $CategoriesTable extends Categories }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('uuid')) { - context.handle( - _uuidMeta, - uuid.isAcceptableOrUnknown(data['uuid']!, _uuidMeta), - ); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { - context.missing(_uuidMeta); + context.missing(_idMeta); } if (data.containsKey('encrypted_name')) { context.handle( @@ -721,6 +745,12 @@ class $CategoriesTable extends Categories isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta), ); } + if (data.containsKey('is_dirty')) { + context.handle( + _isDirtyMeta, + isDirty.isAcceptableOrUnknown(data['is_dirty']!, _isDirtyMeta), + ); + } if (data.containsKey('updated_at')) { context.handle( _updatedAtMeta, @@ -733,14 +763,14 @@ class $CategoriesTable extends Categories } @override - Set get $primaryKey => {uuid}; + Set get $primaryKey => {id}; @override DbCategory map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return DbCategory( - uuid: attachedDatabase.typeMapping.read( + id: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}uuid'], + data['${effectivePrefix}id'], )!, encryptedName: attachedDatabase.typeMapping.read( DriftSqlType.string, @@ -754,6 +784,10 @@ class $CategoriesTable extends Categories DriftSqlType.bool, data['${effectivePrefix}is_deleted'], )!, + isDirty: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_dirty'], + )!, updatedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}updated_at'], @@ -768,35 +802,39 @@ class $CategoriesTable extends Categories } class DbCategory extends DataClass implements Insertable { - final String uuid; + final String id; final String encryptedName; final int serverVersion; final bool isDeleted; + final bool isDirty; final DateTime updatedAt; const DbCategory({ - required this.uuid, + required this.id, required this.encryptedName, required this.serverVersion, required this.isDeleted, + required this.isDirty, required this.updatedAt, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['uuid'] = Variable(uuid); + map['id'] = Variable(id); map['encrypted_name'] = Variable(encryptedName); map['server_version'] = Variable(serverVersion); map['is_deleted'] = Variable(isDeleted); + map['is_dirty'] = Variable(isDirty); map['updated_at'] = Variable(updatedAt); return map; } CategoriesCompanion toCompanion(bool nullToAbsent) { return CategoriesCompanion( - uuid: Value(uuid), + id: Value(id), encryptedName: Value(encryptedName), serverVersion: Value(serverVersion), isDeleted: Value(isDeleted), + isDirty: Value(isDirty), updatedAt: Value(updatedAt), ); } @@ -807,10 +845,11 @@ class DbCategory extends DataClass implements Insertable { }) { serializer ??= driftRuntimeOptions.defaultSerializer; return DbCategory( - uuid: serializer.fromJson(json['uuid']), + id: serializer.fromJson(json['id']), encryptedName: serializer.fromJson(json['encryptedName']), serverVersion: serializer.fromJson(json['serverVersion']), isDeleted: serializer.fromJson(json['isDeleted']), + isDirty: serializer.fromJson(json['isDirty']), updatedAt: serializer.fromJson(json['updatedAt']), ); } @@ -818,30 +857,33 @@ class DbCategory extends DataClass implements Insertable { Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'uuid': serializer.toJson(uuid), + 'id': serializer.toJson(id), 'encryptedName': serializer.toJson(encryptedName), 'serverVersion': serializer.toJson(serverVersion), 'isDeleted': serializer.toJson(isDeleted), + 'isDirty': serializer.toJson(isDirty), 'updatedAt': serializer.toJson(updatedAt), }; } DbCategory copyWith({ - String? uuid, + String? id, String? encryptedName, int? serverVersion, bool? isDeleted, + bool? isDirty, DateTime? updatedAt, }) => DbCategory( - uuid: uuid ?? this.uuid, + id: id ?? this.id, encryptedName: encryptedName ?? this.encryptedName, serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, + isDirty: isDirty ?? this.isDirty, updatedAt: updatedAt ?? this.updatedAt, ); DbCategory copyWithCompanion(CategoriesCompanion data) { return DbCategory( - uuid: data.uuid.present ? data.uuid.value : this.uuid, + id: data.id.present ? data.id.value : this.id, encryptedName: data.encryptedName.present ? data.encryptedName.value : this.encryptedName, @@ -849,6 +891,7 @@ class DbCategory extends DataClass implements Insertable { ? data.serverVersion.value : this.serverVersion, isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, + isDirty: data.isDirty.present ? data.isDirty.value : this.isDirty, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, ); } @@ -856,85 +899,100 @@ class DbCategory extends DataClass implements Insertable { @override String toString() { return (StringBuffer('DbCategory(') - ..write('uuid: $uuid, ') + ..write('id: $id, ') ..write('encryptedName: $encryptedName, ') ..write('serverVersion: $serverVersion, ') ..write('isDeleted: $isDeleted, ') + ..write('isDirty: $isDirty, ') ..write('updatedAt: $updatedAt') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(uuid, encryptedName, serverVersion, isDeleted, updatedAt); + int get hashCode => Object.hash( + id, + encryptedName, + serverVersion, + isDeleted, + isDirty, + updatedAt, + ); @override bool operator ==(Object other) => identical(this, other) || (other is DbCategory && - other.uuid == this.uuid && + other.id == this.id && other.encryptedName == this.encryptedName && other.serverVersion == this.serverVersion && other.isDeleted == this.isDeleted && + other.isDirty == this.isDirty && other.updatedAt == this.updatedAt); } class CategoriesCompanion extends UpdateCompanion { - final Value uuid; + final Value id; final Value encryptedName; final Value serverVersion; final Value isDeleted; + final Value isDirty; final Value updatedAt; final Value rowid; const CategoriesCompanion({ - this.uuid = const Value.absent(), + this.id = const Value.absent(), this.encryptedName = const Value.absent(), this.serverVersion = const Value.absent(), this.isDeleted = const Value.absent(), + this.isDirty = const Value.absent(), this.updatedAt = const Value.absent(), this.rowid = const Value.absent(), }); CategoriesCompanion.insert({ - required String uuid, + required String id, required String encryptedName, this.serverVersion = const Value.absent(), this.isDeleted = const Value.absent(), + this.isDirty = const Value.absent(), required DateTime updatedAt, this.rowid = const Value.absent(), - }) : uuid = Value(uuid), + }) : id = Value(id), encryptedName = Value(encryptedName), updatedAt = Value(updatedAt); static Insertable custom({ - Expression? uuid, + Expression? id, Expression? encryptedName, Expression? serverVersion, Expression? isDeleted, + Expression? isDirty, Expression? updatedAt, Expression? rowid, }) { return RawValuesInsertable({ - if (uuid != null) 'uuid': uuid, + if (id != null) 'id': id, if (encryptedName != null) 'encrypted_name': encryptedName, if (serverVersion != null) 'server_version': serverVersion, if (isDeleted != null) 'is_deleted': isDeleted, + if (isDirty != null) 'is_dirty': isDirty, if (updatedAt != null) 'updated_at': updatedAt, if (rowid != null) 'rowid': rowid, }); } CategoriesCompanion copyWith({ - Value? uuid, + Value? id, Value? encryptedName, Value? serverVersion, Value? isDeleted, + Value? isDirty, Value? updatedAt, Value? rowid, }) { return CategoriesCompanion( - uuid: uuid ?? this.uuid, + id: id ?? this.id, encryptedName: encryptedName ?? this.encryptedName, serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, + isDirty: isDirty ?? this.isDirty, updatedAt: updatedAt ?? this.updatedAt, rowid: rowid ?? this.rowid, ); @@ -943,8 +1001,8 @@ class CategoriesCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; - if (uuid.present) { - map['uuid'] = Variable(uuid.value); + if (id.present) { + map['id'] = Variable(id.value); } if (encryptedName.present) { map['encrypted_name'] = Variable(encryptedName.value); @@ -955,6 +1013,9 @@ class CategoriesCompanion extends UpdateCompanion { if (isDeleted.present) { map['is_deleted'] = Variable(isDeleted.value); } + if (isDirty.present) { + map['is_dirty'] = Variable(isDirty.value); + } if (updatedAt.present) { map['updated_at'] = Variable(updatedAt.value); } @@ -967,10 +1028,11 @@ class CategoriesCompanion extends UpdateCompanion { @override String toString() { return (StringBuffer('CategoriesCompanion(') - ..write('uuid: $uuid, ') + ..write('id: $id, ') ..write('encryptedName: $encryptedName, ') ..write('serverVersion: $serverVersion, ') ..write('isDeleted: $isDeleted, ') + ..write('isDirty: $isDirty, ') ..write('updatedAt: $updatedAt, ') ..write('rowid: $rowid') ..write(')')) @@ -992,8 +1054,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { typedef $$NotesTableCreateCompanionBuilder = NotesCompanion Function({ - Value id, - required String uuid, + required String id, required String title, required String body, required DateTime createdAt, @@ -1002,11 +1063,12 @@ typedef $$NotesTableCreateCompanionBuilder = Value serverVersion, Value isDeleted, Value categoryId, + Value isDirty, + Value rowid, }); typedef $$NotesTableUpdateCompanionBuilder = NotesCompanion Function({ - Value id, - Value uuid, + Value id, Value title, Value body, Value createdAt, @@ -1015,6 +1077,8 @@ typedef $$NotesTableUpdateCompanionBuilder = Value serverVersion, Value isDeleted, Value categoryId, + Value isDirty, + Value rowid, }); class $$NotesTableFilterComposer extends Composer<_$AppDatabase, $NotesTable> { @@ -1025,16 +1089,11 @@ class $$NotesTableFilterComposer extends Composer<_$AppDatabase, $NotesTable> { super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( + ColumnFilters get id => $composableBuilder( column: $table.id, builder: (column) => ColumnFilters(column), ); - ColumnFilters get uuid => $composableBuilder( - column: $table.uuid, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get title => $composableBuilder( column: $table.title, builder: (column) => ColumnFilters(column), @@ -1074,6 +1133,11 @@ class $$NotesTableFilterComposer extends Composer<_$AppDatabase, $NotesTable> { column: $table.categoryId, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get isDirty => $composableBuilder( + column: $table.isDirty, + builder: (column) => ColumnFilters(column), + ); } class $$NotesTableOrderingComposer @@ -1085,16 +1149,11 @@ class $$NotesTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( + ColumnOrderings get id => $composableBuilder( column: $table.id, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get uuid => $composableBuilder( - column: $table.uuid, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get title => $composableBuilder( column: $table.title, builder: (column) => ColumnOrderings(column), @@ -1134,6 +1193,11 @@ class $$NotesTableOrderingComposer column: $table.categoryId, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get isDirty => $composableBuilder( + column: $table.isDirty, + builder: (column) => ColumnOrderings(column), + ); } class $$NotesTableAnnotationComposer @@ -1145,12 +1209,9 @@ class $$NotesTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get uuid => - $composableBuilder(column: $table.uuid, builder: (column) => column); - GeneratedColumn get title => $composableBuilder(column: $table.title, builder: (column) => column); @@ -1178,6 +1239,9 @@ class $$NotesTableAnnotationComposer column: $table.categoryId, builder: (column) => column, ); + + GeneratedColumn get isDirty => + $composableBuilder(column: $table.isDirty, builder: (column) => column); } class $$NotesTableTableManager @@ -1208,8 +1272,7 @@ class $$NotesTableTableManager $$NotesTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value id = const Value.absent(), - Value uuid = const Value.absent(), + Value id = const Value.absent(), Value title = const Value.absent(), Value body = const Value.absent(), Value createdAt = const Value.absent(), @@ -1218,9 +1281,10 @@ class $$NotesTableTableManager Value serverVersion = const Value.absent(), Value isDeleted = const Value.absent(), Value categoryId = const Value.absent(), + Value isDirty = const Value.absent(), + Value rowid = const Value.absent(), }) => NotesCompanion( id: id, - uuid: uuid, title: title, body: body, createdAt: createdAt, @@ -1229,11 +1293,12 @@ class $$NotesTableTableManager serverVersion: serverVersion, isDeleted: isDeleted, categoryId: categoryId, + isDirty: isDirty, + rowid: rowid, ), createCompanionCallback: ({ - Value id = const Value.absent(), - required String uuid, + required String id, required String title, required String body, required DateTime createdAt, @@ -1242,9 +1307,10 @@ class $$NotesTableTableManager Value serverVersion = const Value.absent(), Value isDeleted = const Value.absent(), Value categoryId = const Value.absent(), + Value isDirty = const Value.absent(), + Value rowid = const Value.absent(), }) => NotesCompanion.insert( id: id, - uuid: uuid, title: title, body: body, createdAt: createdAt, @@ -1253,6 +1319,8 @@ class $$NotesTableTableManager serverVersion: serverVersion, isDeleted: isDeleted, categoryId: categoryId, + isDirty: isDirty, + rowid: rowid, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -1278,19 +1346,21 @@ typedef $$NotesTableProcessedTableManager = >; typedef $$CategoriesTableCreateCompanionBuilder = CategoriesCompanion Function({ - required String uuid, + required String id, required String encryptedName, Value serverVersion, Value isDeleted, + Value isDirty, required DateTime updatedAt, Value rowid, }); typedef $$CategoriesTableUpdateCompanionBuilder = CategoriesCompanion Function({ - Value uuid, + Value id, Value encryptedName, Value serverVersion, Value isDeleted, + Value isDirty, Value updatedAt, Value rowid, }); @@ -1304,8 +1374,8 @@ class $$CategoriesTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get uuid => $composableBuilder( - column: $table.uuid, + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column), ); @@ -1324,6 +1394,11 @@ class $$CategoriesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get isDirty => $composableBuilder( + column: $table.isDirty, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get updatedAt => $composableBuilder( column: $table.updatedAt, builder: (column) => ColumnFilters(column), @@ -1339,8 +1414,8 @@ class $$CategoriesTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get uuid => $composableBuilder( - column: $table.uuid, + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column), ); @@ -1359,6 +1434,11 @@ class $$CategoriesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get isDirty => $composableBuilder( + column: $table.isDirty, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get updatedAt => $composableBuilder( column: $table.updatedAt, builder: (column) => ColumnOrderings(column), @@ -1374,8 +1454,8 @@ class $$CategoriesTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get uuid => - $composableBuilder(column: $table.uuid, builder: (column) => column); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); GeneratedColumn get encryptedName => $composableBuilder( column: $table.encryptedName, @@ -1390,6 +1470,9 @@ class $$CategoriesTableAnnotationComposer GeneratedColumn get isDeleted => $composableBuilder(column: $table.isDeleted, builder: (column) => column); + GeneratedColumn get isDirty => + $composableBuilder(column: $table.isDirty, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); } @@ -1425,33 +1508,37 @@ class $$CategoriesTableTableManager $$CategoriesTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value uuid = const Value.absent(), + Value id = const Value.absent(), Value encryptedName = const Value.absent(), Value serverVersion = const Value.absent(), Value isDeleted = const Value.absent(), + Value isDirty = const Value.absent(), Value updatedAt = const Value.absent(), Value rowid = const Value.absent(), }) => CategoriesCompanion( - uuid: uuid, + id: id, encryptedName: encryptedName, serverVersion: serverVersion, isDeleted: isDeleted, + isDirty: isDirty, updatedAt: updatedAt, rowid: rowid, ), createCompanionCallback: ({ - required String uuid, + required String id, required String encryptedName, Value serverVersion = const Value.absent(), Value isDeleted = const Value.absent(), + Value isDirty = const Value.absent(), required DateTime updatedAt, Value rowid = const Value.absent(), }) => CategoriesCompanion.insert( - uuid: uuid, + id: id, encryptedName: encryptedName, serverVersion: serverVersion, isDeleted: isDeleted, + isDirty: isDirty, updatedAt: updatedAt, rowid: rowid, ), diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 13ce29d..cb4e378 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -35,9 +35,9 @@ class NoteRepository { } Future createNote(Note note) async { - final int id = await _database.insertNoteAtTop( + await _database.insertNoteAtTop( NotesCompanion.insert( - uuid: note.uuid, + id: note.id, title: note.title, body: note.body, createdAt: note.createdAt, @@ -45,56 +45,77 @@ class NoteRepository { sortIndex: 0, serverVersion: const Value(0), 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 updateNote(Note note) async { - final int noteId = - note.id ?? - (throw ArgumentError('Note id is required to update a note.')); + 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: noteId, - uuid: note.uuid, + id: row.id, title: note.title, body: note.body, - createdAt: note.createdAt, + createdAt: row.createdAt, updatedAt: note.updatedAt, - sortIndex: note.index, + sortIndex: row.sortIndex, serverVersion: note.serverVersion, isDeleted: false, 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 deleteNote(Note note) async { - final int noteId = - note.id ?? - (throw ArgumentError('Note id is required to delete a note.')); + final DbNote? existingNote = await (_database.select( + _database.notes, + )..where((n) => n.id.equals(note.id))).getSingleOrNull(); - if (note.isDeleted || note.isPermanentlyDeleted) { - await _database.permanentlyDeleteNote(noteId); + 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: noteId, removedIndex: note.index); + await _database.deleteNoteAndShift( + id: row.id, + removedIndex: row.sortIndex, + ); } } Future moveNote(Note note, int newIndex) async { - final int noteId = - note.id ?? - (throw ArgumentError('Note id is required to reorder a note.')); + 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: noteId, - oldIndex: note.index, + id: row.id, + oldIndex: row.sortIndex, newIndex: newIndex, ); } @@ -120,26 +141,17 @@ class NoteRepository { ? 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, - ); - } + // 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 (note: we send encrypted data, but locally we have plaintext) - // Encrypt notes that still contain content before sending. + // Build sync request: local data is plaintext, encryption happens only + // for the outbound payload. if (totalNotesToEncrypt == 0) { onProgress?.call( SyncStatus.encrypting, @@ -164,9 +176,8 @@ class NoteRepository { }, ); - final List categoriesPayload = unsyncedCategories - .map((cat) => SyncCategoryPayload.fromCategory(_fromDbCategory(cat))) - .toList(); + final List categoriesPayload = + await _encryptCategories(unsyncedCategories, masterKey: _masterKey); final SyncRequest syncRequest = SyncRequest( lastSyncAt: lastSyncForRequest, @@ -184,7 +195,27 @@ class NoteRepository { final Map syncResult = await _authApi.sync(syncRequest); if (syncResult['error'] == true) { - return {'error': true, 'message': syncResult['body']}; + 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; @@ -217,8 +248,11 @@ class NoteRepository { 'notesCount': response.changes.notes.length, 'categoriesCount': response.changes.categories.length, }; - } catch (e) { - return {'error': true, 'message': e.toString()}; + } catch (e, st) { + return { + 'error': true, + 'message': '$e\n\nStackTrace: $st', + }; } } @@ -229,13 +263,23 @@ class NoteRepository { // Apply categories from server for (final SyncCategoryResponse catResponse in response.changes.categories) { + final String categoryName = + catResponse.isDeleted || catResponse.encryptedName.isEmpty + ? '' + : await NoteEncryption.decryptNote( + catResponse.encryptedName, + _masterKey, + ); + + await _database.upsertCategory( CategoriesCompanion( - uuid: Value(catResponse.id), - encryptedName: Value(catResponse.encryptedName), + id: Value(catResponse.id), + encryptedName: Value(categoryName), serverVersion: Value(catResponse.serverVersion), isDeleted: Value(catResponse.isDeleted), updatedAt: Value(catResponse.updatedAt), + isDirty: const Value(false), ), ); } @@ -254,14 +298,14 @@ class NoteRepository { for (var index = 0; index < decryptedNotes.length; index += 1) { final Map 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( _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 decryptedBody = decryptedNote['body']! as String; - final SyncNoteResponse noteResponse = response.changes.notes[index]; + final String decryptedTitle = (decryptedNote['title'] as String?) ?? ''; + final String decryptedBody = (decryptedNote['body'] as String?) ?? ''; final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted; if (existingNote != null) { @@ -269,15 +313,15 @@ class NoteRepository { await _database.updateNoteRow( DbNote( id: existingNote.id, - uuid: noteId, title: isPermanentlyDeleted ? '' : decryptedTitle, body: isPermanentlyDeleted ? '' : decryptedBody, createdAt: existingNote.createdAt, updatedAt: noteResponse.updatedAt, - sortIndex: noteResponse.position, + sortIndex: noteResponse.position.round(), serverVersion: noteResponse.serverVersion, isDeleted: noteResponse.isDeleted, categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId, + isDirty: false, ), ); } else { @@ -286,15 +330,18 @@ class NoteRepository { .into(_database.notes) .insert( NotesCompanion( - uuid: Value(noteResponse.id), + id: Value(noteResponse.id), title: Value(isPermanentlyDeleted ? '' : decryptedTitle), body: Value(isPermanentlyDeleted ? '' : decryptedBody), createdAt: Value(noteResponse.updatedAt), updatedAt: Value(noteResponse.updatedAt), - sortIndex: Value(noteResponse.position), + sortIndex: Value(noteResponse.position.round()), serverVersion: Value(noteResponse.serverVersion), 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) { return Note( id: row.id, - uuid: row.uuid, title: row.title, body: row.body, createdAt: row.createdAt, updatedAt: row.updatedAt, - index: row.sortIndex, + position: row.sortIndex.toDouble(), serverVersion: row.serverVersion, isDeleted: row.isDeleted, isPermanentlyDeleted: _isPermanentlyDeleted(row), categoryId: row.categoryId, - ); - } - - Category _fromDbCategory(DbCategory row) { - return Category( - uuid: row.uuid, - encryptedName: row.encryptedName, - serverVersion: row.serverVersion, - isDeleted: row.isDeleted, - updatedAt: row.updatedAt, + isDirty: row.isDirty, ); } } @@ -395,6 +432,35 @@ Future> _encryptNotesInParallel( return orderedPayloads.cast(); } +Future> _encryptCategories( + List categories, { + required String masterKey, +}) async { + final List 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>>> _decryptNoteBatches( List notes, { required String masterKey, @@ -467,7 +533,7 @@ Map _dbNoteToEncryptionInput(DbNote row, int index) { return { 'index': index, - 'uuid': row.uuid, + 'id': row.id, 'title': row.title, 'body': row.body, 'createdAt': row.createdAt.toIso8601String(), @@ -487,7 +553,7 @@ SyncNotePayload _syncNotePayloadFromEncryptionResult(Map row) { encryptedTitle: row['encryptedTitle']! as String, encryptedBody: row['encryptedBody']! as String, serverVersion: row['serverVersion']! as int, - position: row['position']! 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), @@ -513,19 +579,13 @@ Future>> _encryptNoteBatch( encryptedTitle = ''; encryptedBody = ''; } else { - encryptedTitle = await NoteEncryption.encryptNote( - title, - masterKey, - ); - encryptedBody = await NoteEncryption.encryptNote( - body, - masterKey, - ); + encryptedTitle = await NoteEncryption.encryptNote(title, masterKey); + encryptedBody = await NoteEncryption.encryptNote(body, masterKey); } encryptedNotes.add({ 'index': note['index'] as int, - 'id': note['uuid'] as String, + 'id': note['id'] as String, 'categoryId': note['categoryId'] as String?, 'encryptedTitle': encryptedTitle, 'encryptedBody': encryptedBody, @@ -570,9 +630,11 @@ Future>> _decryptNoteBatch( decryptedBody = ''; } + final String noteId = note['id']! as String; + decryptedNotes.add({ 'index': note['index'] as int, - 'id': note['id'] as String, + 'id': noteId, 'title': decryptedTitle, 'body': decryptedBody, 'isPermanentlyDeleted': isPermanentlyDeleted, diff --git a/lib/data/sync_models.dart b/lib/data/sync_models.dart index 2432fa7..23e376a 100644 --- a/lib/data/sync_models.dart +++ b/lib/data/sync_models.dart @@ -98,16 +98,19 @@ class SyncCategoryPayload { required this.updatedAt, }); - final String id; // uuid + final String id; final String encryptedName; final int serverVersion; final bool isDeleted; final DateTime updatedAt; - factory SyncCategoryPayload.fromCategory(Category category) { + factory SyncCategoryPayload.fromCategory( + Category category, { + required String encryptedName, + }) { return SyncCategoryPayload( - id: category.uuid, - encryptedName: category.encryptedName, + id: category.id, + encryptedName: encryptedName, serverVersion: category.serverVersion, isDeleted: category.isDeleted, updatedAt: category.updatedAt, @@ -132,18 +135,18 @@ class SyncNotePayload { required this.encryptedTitle, required this.encryptedBody, required this.serverVersion, - this.position = 0, + this.position = 0.0, this.isDeleted = false, this.isPermanentlyDeleted = false, required this.updatedAt, }); - final String id; // uuid + final String id; final String? categoryId; final String encryptedTitle; final String encryptedBody; final int serverVersion; - final int position; + final double position; final bool isDeleted; final bool isPermanentlyDeleted; final DateTime updatedAt; @@ -155,12 +158,12 @@ class SyncNotePayload { bool isPermanentlyDeleted = false, }) { return SyncNotePayload( - id: note.uuid, + id: note.id, categoryId: note.categoryId, encryptedTitle: encryptedTitle, encryptedBody: encryptedBody, serverVersion: note.serverVersion, - position: note.index, + position: note.position, isDeleted: note.isDeleted, isPermanentlyDeleted: isPermanentlyDeleted, updatedAt: note.updatedAt, @@ -212,8 +215,7 @@ class SyncCategoryResponse { this.isDeleted = false, required this.updatedAt, }); - - final String id; // uuid + final String id; final String encryptedName; final int serverVersion; final bool isDeleted; @@ -229,10 +231,10 @@ class SyncCategoryResponse { ); } - Category toCategory() { + Category toCategory({required String name}) { return Category( - uuid: id, - encryptedName: encryptedName, + id: id, + name: name, serverVersion: serverVersion, isDeleted: isDeleted, updatedAt: updatedAt, @@ -247,18 +249,17 @@ class SyncNoteResponse { required this.encryptedTitle, required this.encryptedBody, required this.serverVersion, - this.position = 0, + this.position = 0.0, this.isDeleted = false, this.isPermanentlyDeleted = false, required this.updatedAt, }); - - final String id; // uuid + final String id; final String? categoryId; final String encryptedTitle; final String encryptedBody; final int serverVersion; - final int position; + final double position; final bool isDeleted; final bool isPermanentlyDeleted; final DateTime updatedAt; @@ -272,7 +273,7 @@ class SyncNoteResponse { encryptedTitle: _readOptionalStringValue(json['encrypted_title']), encryptedBody: _readOptionalStringValue(json['encrypted_body']), serverVersion: _readIntValue(json['serverVersion']), - position: json['position'] as int? ?? 0, + position: (json['position'] as num?)?.toDouble() ?? 0, isDeleted: json['isDeleted'] as bool? ?? false, isPermanentlyDeleted: json['isPermanentlyDeleted'] as bool? ?? false, updatedAt: DateTime.parse(json['updatedAt'] as String), @@ -281,15 +282,16 @@ class SyncNoteResponse { Note toNote() { return Note( - uuid: id, + id: id, title: isPermanentlyDeleted ? '' : 'Encrypted', body: isPermanentlyDeleted ? '' : 'Encrypted', createdAt: updatedAt, updatedAt: updatedAt, - index: position, + position: position, serverVersion: serverVersion, isDeleted: isDeleted, categoryId: categoryId, + isDirty: false, ); } } diff --git a/lib/models/category.dart b/lib/models/category.dart index 8b17510..18e7cf8 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -2,32 +2,36 @@ import 'package:uuid/uuid.dart'; class Category { Category({ - String? uuid, - required this.encryptedName, + String? id, + required this.name, this.serverVersion = 0, this.isDeleted = false, required this.updatedAt, - }) : uuid = uuid ?? Uuid().v4(); + this.isDirty = true, + }) : id = id ?? Uuid().v4(); - final String uuid; - final String encryptedName; + final String id; + final String name; final int serverVersion; final bool isDeleted; final DateTime updatedAt; + final bool isDirty; Category copyWith({ - String? uuid, - String? encryptedName, + String? id, + String? name, int? serverVersion, bool? isDeleted, DateTime? updatedAt, + bool? isDirty, }) { return Category( - uuid: uuid ?? this.uuid, - encryptedName: encryptedName ?? this.encryptedName, + id: id ?? this.id, + name: name ?? this.name, serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, updatedAt: updatedAt ?? this.updatedAt, + isDirty: isDirty ?? this.isDirty, ); } @@ -37,9 +41,9 @@ class Category { return true; } - return other is Category && uuid == other.uuid; + return other is Category && id == other.id; } @override - int get hashCode => uuid.hashCode; + int get hashCode => id.hashCode; } diff --git a/lib/models/note.dart b/lib/models/note.dart index ae5fd68..0627ed5 100644 --- a/lib/models/note.dart +++ b/lib/models/note.dart @@ -2,63 +2,58 @@ import 'package:uuid/uuid.dart'; // Model: Note // - 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 { Note({ - this.id, - String? uuid, + String? id, required this.title, required this.body, required this.createdAt, required this.updatedAt, - required this.index, + required this.position, + this.categoryId, this.serverVersion = 0, this.isDeleted = false, this.isPermanentlyDeleted = false, - this.categoryId, - }) : uuid = uuid ?? Uuid().v4(); + this.isDirty = true, + }) : id = id ?? Uuid().v4(); - final int? id; - final String uuid; + final String id; final String title; final String body; final DateTime createdAt; final DateTime updatedAt; - final int index; + final double position; + final String? categoryId; final int serverVersion; final bool isDeleted; final bool isPermanentlyDeleted; - final String? categoryId; + final bool isDirty; Note copyWith({ - int? id, - String? uuid, + String? id, String? title, String? body, DateTime? createdAt, DateTime? updatedAt, - int? index, + double? position, + String? categoryId, int? serverVersion, bool? isDeleted, bool? isPermanentlyDeleted, - String? categoryId, + bool? isDirty, }) { return Note( id: id ?? this.id, - uuid: uuid ?? this.uuid, title: title ?? this.title, body: body ?? this.body, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - index: index ?? this.index, + position: position ?? this.position, + categoryId: categoryId ?? this.categoryId, serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, isPermanentlyDeleted: isPermanentlyDeleted ?? this.isPermanentlyDeleted, - categoryId: categoryId ?? this.categoryId, + isDirty: isDirty ?? this.isDirty, ); } @@ -68,9 +63,9 @@ class Note { return true; } - return other is Note && uuid == other.uuid; + return other is Note && id == other.id; } @override - int get hashCode => uuid.hashCode; -} \ No newline at end of file + int get hashCode => id.hashCode; +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index f42589e..6b31330 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -154,9 +154,9 @@ class _HomeScreenState extends State { // Don't let DB errors cause the app to reset the vault automatically. debugPrint('Failed to move note: $e\n$st'); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error al reordenar la nota: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error al reordenar la nota: $e'))); } } @@ -385,9 +385,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(12), ), child: NoteCard( - key: ValueKey( - filteredNotes[index].id ?? - filteredNotes[index].index, + key: ValueKey( + filteredNotes[index].id, ), note: filteredNotes[index], onTap: () => @@ -510,9 +509,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(12), ), child: NoteCard( - key: ValueKey( - filteredNotes[index].id ?? - filteredNotes[index].index, + key: ValueKey( + filteredNotes[index].id, ), note: filteredNotes[index], onTap: () => diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index 4371683..5901a73 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -89,7 +89,7 @@ class _NoteEditorScreenState extends State { @override void initState() { super.initState(); - _isNewNote = widget.note?.id == null; + _isNewNote = widget.note == null; if (_isNewNote) { final DateTime now = DateTime.now(); @@ -98,7 +98,7 @@ class _NoteEditorScreenState extends State { body: '', createdAt: now, updatedAt: now, - index: 0, + position: 0, ); } else { _currentNote = widget.note!; @@ -143,6 +143,7 @@ class _NoteEditorScreenState extends State { title: title.isEmpty ? 'Sin título' : title, body: body, updatedAt: DateTime.now(), + isDirty: true, ); _complete(updatedNote);