Files
notas/lib/data/app_database.dart
T

265 lines
8.2 KiB
Dart

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))();
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<Column> 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<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [Notes, Categories])
class AppDatabase extends _$AppDatabase {
@override
int get schemaVersion => 3;
@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');
}
},
);
AppDatabase({required String encryptionKey})
: super(_openConnection(encryptionKey));
// ========== Categories ==========
Future<List<DbCategory>> getAllCategories() {
return (select(categories)..where((c) => c.isDeleted.equals(false))).get();
}
Future<int> upsertCategory(CategoriesCompanion category) {
return into(categories).insertOnConflictUpdate(category);
}
Future<void> 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<List<DbNote>> getAllNotes() {
return (select(notes)
..orderBy([(note) => OrderingTerm(expression: note.sortIndex)])
..where((n) => n.isDeleted.equals(false)))
.get();
}
Future<int> 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<int>(0)));
});
}
Future<void> replaceAllNotes(List<NotesCompanion> 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<void> updateNoteRow(DbNote note) {
return update(notes).replace(note);
}
Future<void> 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<void> deleteNoteAndShift({
required String id,
required int removedIndex,
}) {
return deleteNote(id, removedIndex);
}
Future<void> 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<void> moveNote({
required String id,
required int oldIndex,
required int newIndex,
}) {
if (oldIndex == newIndex) {
return Future<void>.value();
}
return transaction(() async {
final List<DbNote> 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<int>(safeNew),
updatedAt: Value<DateTime>(DateTime.now()),
isDirty: const Value(true),
),
);
});
}
Future<List<DbNote>> getNotesChangedSince(DateTime since) {
return (select(
notes,
)..where((n) => n.updatedAt.isBiggerThanValue(since))).get();
}
Future<List<DbNote>> 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('')),
))
.get();
}
Future<List<DbCategory>> getCategoriesChangedSince(DateTime since) {
return (select(
categories,
)..where((c) => c.updatedAt.isBiggerThanValue(since))).get();
}
// ========== Sync helpers ==========
Future<List<DbNote>> getUnsyncedNotes() {
return (select(notes)..where((n) => n.isDirty.equals(true))).get();
}
Future<List<DbCategory>> 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'");
},
);
});
}