mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
Flutter app for Android + Linux desktop. Captures ideas with per-keystroke local autosave to a CRDT-backed SQLite store (sqlite_crdt), and syncs through a private GitHub repo using per-device changeset files (conflict-free last-writer-wins merge). Includes GitHub OAuth device-flow sign-in with PAT fallback, a barebones notes list, and sync settings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
4.8 KiB
Dart
150 lines
4.8 KiB
Dart
import 'package:sqlite_crdt/sqlite_crdt.dart';
|
|
|
|
import 'note.dart';
|
|
|
|
/// How the history list should be ordered.
|
|
enum NoteSort {
|
|
createdDesc,
|
|
modifiedDesc,
|
|
alphabetical,
|
|
priorityDesc,
|
|
}
|
|
|
|
/// Local-first persistence for [Note]s, backed by a CRDT SQLite database.
|
|
///
|
|
/// Every write goes straight to local storage so the app works fully
|
|
/// offline. The CRDT metadata (Hybrid Logical Clock timestamps per row)
|
|
/// lets a future sync layer merge two devices conflict-free using
|
|
/// last-writer-wins per note. This class owns the schema and exposes a
|
|
/// small, intention-revealing API; SQL never leaks past this boundary.
|
|
class NoteRepository {
|
|
NoteRepository._(this._crdt);
|
|
|
|
final SqliteCrdt _crdt;
|
|
|
|
/// Opens (or creates) the database at [path] and ensures the schema.
|
|
///
|
|
/// [path] should be an absolute file path on desktop/mobile, or the
|
|
/// special in-memory path used by tests.
|
|
static Future<NoteRepository> open(String path) async {
|
|
final crdt = await SqliteCrdt.open(
|
|
path,
|
|
version: 1,
|
|
onCreate: (db, version) async {
|
|
// Plain columns only; the CRDT layer adds its own bookkeeping
|
|
// columns transparently. ISO-8601 strings keep timestamps both
|
|
// human-readable and lexicographically sortable.
|
|
await db.execute('''
|
|
CREATE TABLE notes (
|
|
id TEXT NOT NULL,
|
|
text TEXT NOT NULL DEFAULT '',
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (id)
|
|
)
|
|
''');
|
|
},
|
|
);
|
|
return NoteRepository._(crdt);
|
|
}
|
|
|
|
/// Opens a transient in-memory database; intended for tests.
|
|
static Future<NoteRepository> openInMemory() async {
|
|
final crdt = await SqliteCrdt.openInMemory(
|
|
version: 1,
|
|
onCreate: (db, version) async {
|
|
await db.execute('''
|
|
CREATE TABLE notes (
|
|
id TEXT NOT NULL,
|
|
text TEXT NOT NULL DEFAULT '',
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (id)
|
|
)
|
|
''');
|
|
},
|
|
);
|
|
return NoteRepository._(crdt);
|
|
}
|
|
|
|
/// Inserts a new note or updates the existing one with the same [id].
|
|
///
|
|
/// This is the single write path used by the capture screen's
|
|
/// character-by-character autosave: it is cheap and idempotent.
|
|
Future<void> upsert(Note note) async {
|
|
await _crdt.execute(
|
|
'''
|
|
INSERT INTO notes (id, text, priority, created_at, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
text = ?2,
|
|
priority = ?3,
|
|
updated_at = ?5
|
|
''',
|
|
[
|
|
note.id,
|
|
note.text,
|
|
note.priority.value,
|
|
note.createdAt.toIso8601String(),
|
|
note.updatedAt.toIso8601String(),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Soft-deletes a note. The CRDT keeps a tombstone so the deletion
|
|
/// propagates on the next sync instead of resurrecting the row.
|
|
Future<void> delete(String id) async {
|
|
await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]);
|
|
}
|
|
|
|
/// Returns all live notes ordered by [sort].
|
|
Future<List<Note>> listNotes({
|
|
NoteSort sort = NoteSort.modifiedDesc,
|
|
}) async {
|
|
final rows = await _crdt
|
|
.query('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}');
|
|
return rows.map(Note.fromRow).toList();
|
|
}
|
|
|
|
/// Emits the ordered note list and re-emits whenever the table changes,
|
|
/// so the UI can stay in sync without manual refreshes.
|
|
Stream<List<Note>> watchNotes({
|
|
NoteSort sort = NoteSort.modifiedDesc,
|
|
}) {
|
|
return _crdt
|
|
.watch('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}')
|
|
.map((rows) => rows.map(Note.fromRow).toList());
|
|
}
|
|
|
|
/// This device's stable CRDT node id. Used to name its changeset file
|
|
/// in the sync repo so two devices never write the same file.
|
|
String get nodeId => _crdt.nodeId;
|
|
|
|
/// Returns this device's full CRDT changeset for upload.
|
|
Future<CrdtChangeset> getChangeset() => _crdt.getChangeset();
|
|
|
|
/// Merges a remote changeset into local storage (conflict-free,
|
|
/// last-writer-wins per row via the Hybrid Logical Clock).
|
|
Future<void> merge(CrdtChangeset changeset) => _crdt.merge(changeset);
|
|
|
|
/// Closes the underlying database.
|
|
Future<void> close() => _crdt.close();
|
|
|
|
/// Maps a [NoteSort] to a SQL ORDER BY clause. Centralised so the sort
|
|
/// options used by the live and one-shot queries can never drift apart.
|
|
String _orderBy(NoteSort sort) {
|
|
switch (sort) {
|
|
case NoteSort.createdDesc:
|
|
return 'ORDER BY created_at DESC';
|
|
case NoteSort.modifiedDesc:
|
|
return 'ORDER BY updated_at DESC';
|
|
case NoteSort.alphabetical:
|
|
return 'ORDER BY text COLLATE NOCASE ASC';
|
|
case NoteSort.priorityDesc:
|
|
return 'ORDER BY priority DESC, updated_at DESC';
|
|
}
|
|
}
|
|
}
|