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'; part 'app_database.g.dart'; @DataClassName('DbCategory') class Categories extends Table { 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 => {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 => 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)); // ========== 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: Value(true)), ); } // ========== Notes ========== Future> getAllNotes() { return (select(notes) ..orderBy([(note) => OrderingTerm(expression: note.sortIndex)]) ..where((n) => n.isDeleted.equals(false))) .get(); } Future insertNoteAtTop(NotesCompanion note) { return transaction(() async { await customStatement( '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))); }); } 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), ), ); await customStatement( 'UPDATE notes SET sort_index = sort_index - 1, is_dirty = 1 WHERE sort_index > ? AND is_deleted = 0', [removedIndex], ); } 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, }) { if (oldIndex == newIndex) { return Future.value(); } return transaction(() async { final List all = await (select( notes, )..where((n) => n.isDeleted.equals(false))).get(); final int count = all.length; if (count == 0) { return; } final int maxIndex = count - 1; final int safeOld = oldIndex.clamp(0, maxIndex); final int safeNew = newIndex.clamp(0, maxIndex); if (safeOld == safeNew) { return; } if (safeOld < safeNew) { await customStatement( '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, is_dirty = 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0', [safeNew, safeOld], ); } await (update(notes)..where((n) => n.id.equals(id))).write( NotesCompanion( sortIndex: Value(safeNew), updatedAt: Value(DateTime.now()), isDirty: const Value(true), ), ); }); } Future> getNotesChangedSince(DateTime since) { return (select( notes, )..where((n) => n.updatedAt.isBiggerThanValue(since))).get(); } Future> getDeletedNotes() { return (select(notes)..where( (n) => n.isDeleted.equals(true) & n.title.isNotValue('') & n.body.isNotValue(''), )) .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'"); }, ); }); }