import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:notas/data/note_positioning.dart'; part 'app_database.g.dart'; @DataClassName('DbCategory') class Categories extends Table { TextColumn get id => text()(); TextColumn get name => text().named('name')(); IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))(); BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))(); IntColumn get colorValue => integer().nullable().named('color_value')(); IntColumn get iconCodePoint => integer().nullable().named('icon_code_point')(); BoolColumn get isDirty => boolean().named('is_dirty').withDefault(const Constant(true))(); DateTimeColumn get updatedAt => dateTime().named('updated_at')(); @override Set get primaryKey => {id}; } @DataClassName('DbNote') class Notes extends Table { TextColumn get id => text().named('id')(); TextColumn get title => text()(); TextColumn get body => text()(); DateTimeColumn get createdAt => dateTime().named('created_at')(); DateTimeColumn get updatedAt => dateTime().named('updated_at')(); IntColumn get sortIndex => integer().named('sort_index')(); IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))(); 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 => 4; @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'); } if (from < 3) { await migrator.addColumn(categories, categories.colorValue); await migrator.addColumn(categories, categories.iconCodePoint); await customStatement('UPDATE categories SET color_value = NULL'); await customStatement('UPDATE categories SET icon_code_point = NULL'); } if (from < 4) { final List activeNotes = await (select(notes) ..where((n) => n.isDeleted.equals(false)) ..orderBy([ (note) => OrderingTerm(expression: note.sortIndex), ])) .get(); final List rebalancedPositions = rebalanceNotePositions( activeNotes.length, ); for (var index = 0; index < activeNotes.length; index += 1) { final DbNote row = activeNotes[index]; await (update(notes)..where((n) => n.id.equals(row.id))).write( NotesCompanion( sortIndex: Value(rebalancedPositions[index]), updatedAt: Value(DateTime.now()), isDirty: const Value(true), ), ); } } }, ); AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey)); // ========== Categories ========== Future> getAllCategories() { return (select(categories)..where((c) => c.isDeleted.equals(false))).get(); } Future upsertCategory(CategoriesCompanion category) { return into(categories).insertOnConflictUpdate(category); } Future deleteCategory(String id) { return (update(categories)..where((c) => c.id.equals(id))).write( CategoriesCompanion( isDeleted: const Value(true), updatedAt: Value(DateTime.now()), isDirty: const Value(true), ), ); } // ========== Notes ========== Future> getAllNotes() { return (select(notes) ..orderBy([ (note) => OrderingTerm( expression: note.sortIndex, mode: OrderingMode.desc, ), ]) ..where((n) => n.isDeleted.equals(false))) .get(); } Future insertNoteAtTop(NotesCompanion note) { return transaction(() async { final DbNote? topNote = await (select(notes) ..where((n) => n.isDeleted.equals(false)) ..orderBy([ (note) => OrderingTerm( expression: note.sortIndex, mode: OrderingMode.desc, ), ]) ..limit(1)) .getSingleOrNull(); final int nextSortIndex = topNote == null ? 0 : topNote.sortIndex + notePositionStep; await into(notes).insert( note.copyWith(sortIndex: Value(nextSortIndex)), ); return nextSortIndex; }); } Future replaceAllNotes(List noteList) { return transaction(() async { await (delete(notes)..where((n) => n.isDeleted.equals(false))).go(); for (final NotesCompanion note in noteList) { await into(notes).insert(note); } }); } Future updateNoteRow(DbNote note) { return update(notes).replace(note); } 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), ), ); } Future deleteNoteAndShift({ required String id, required int removedIndex, }) { return deleteNote(id, removedIndex); } Future permanentlyDeleteNote(String id) async { await (update(notes)..where((n) => n.id.equals(id))).write( NotesCompanion( title: const Value(''), body: const Value(''), categoryId: const Value(null), isDeleted: const Value(true), updatedAt: Value(DateTime.now()), isDirty: const Value(true), ), ); } Future moveNote({ required String id, required int oldIndex, required int newIndex, }) { return transaction(() async { final List orderedNotes = await (select(notes) ..where((n) => n.isDeleted.equals(false)) ..orderBy([ (note) => OrderingTerm( expression: note.sortIndex, mode: OrderingMode.desc, ), ])) .get(); final int currentIndex = orderedNotes.indexWhere((DbNote row) => row.id == id); if (currentIndex == -1) { return; } final int safeNewIndex = newIndex.clamp(0, orderedNotes.length - 1); if (currentIndex == safeNewIndex) { return; } final DbNote movedNote = orderedNotes.removeAt(currentIndex); orderedNotes.insert(safeNewIndex, movedNote); final int? newStoredPosition; if (safeNewIndex == 0) { newStoredPosition = orderedNotes[1].sortIndex + notePositionStep; } else if (safeNewIndex == orderedNotes.length - 1) { newStoredPosition = orderedNotes[orderedNotes.length - 2].sortIndex - notePositionStep; } else { newStoredPosition = midpointNotePosition( higherPosition: orderedNotes[safeNewIndex - 1].sortIndex, lowerPosition: orderedNotes[safeNewIndex + 1].sortIndex, ); } if (newStoredPosition == null) { final List rebalancedPositions = rebalanceNotePositions( orderedNotes.length, ); for (var index = 0; index < orderedNotes.length; index += 1) { final DbNote row = orderedNotes[index]; await (update(notes)..where((n) => n.id.equals(row.id))).write( NotesCompanion( sortIndex: Value(rebalancedPositions[index]), updatedAt: Value(DateTime.now()), isDirty: const Value(true), ), ); } return; } await (update(notes)..where((n) => n.id.equals(id))).write( NotesCompanion( sortIndex: Value(newStoredPosition), updatedAt: Value(DateTime.now()), isDirty: const Value(true), ), ); }); } Future> getNotesChangedSince(DateTime since) { return (select( notes, )..where((n) => n.updatedAt.isBiggerThanValue(since))).get(); } Future> getDeletedNotes() { // A note is considered deleted (in the trash) when `is_deleted` is true // and at least one of `title` or `body` is not empty. Previously the // query required both title AND body to be non-empty which excluded // notes that had an empty body (common) from appearing in the trash. return (select(notes) ..where( (n) => n.isDeleted.equals(true) & (n.title.isNotValue('') | n.body.isNotValue('')), ) ..orderBy([ (note) => OrderingTerm( expression: note.sortIndex, mode: OrderingMode.desc, ), ])) .get(); } Future> getCategoriesChangedSince(DateTime since) { return (select( categories, )..where((c) => c.updatedAt.isBiggerThanValue(since))).get(); } // ========== Sync helpers ========== Future> getUnsyncedNotes() { return (select(notes)..where((n) => n.isDirty.equals(true))).get(); } Future> getUnsyncedCategories() { return (select(categories)..where((c) => c.isDirty.equals(true))).get(); } } LazyDatabase _openConnection(String encryptionKey) { return LazyDatabase(() async { final Directory supportDir = await getApplicationSupportDirectory(); final File file = File(p.join(supportDir.path, 'notes.sqlite')); return NativeDatabase( file, setup: (database) { final String escapedKey = encryptionKey.replaceAll("'", "''"); // sqlite3mc can emulate SQLCipher file format for compatibility. database.execute("PRAGMA cipher = 'sqlcipher'"); database.execute('PRAGMA legacy = 4'); database.execute("PRAGMA key = '$escapedKey'"); }, ); }); }