mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
Notes list & filtering: - Text-search filter plus independent date-range filters for both created and last-updated (AND-combined), a priority filter, and a new status filter. Default view hides Done/Abandoned and renders as "unfiltered" (no badge for the default state); fixed badge clipping. - NoteSort options wired into the list UI; watchCount() for the "N saved". Status & priority: - New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable attribute on every note, with capture-screen dropdown. - Removed "None" priority: every note is Low/Medium/High, default Medium. Schema migration v2->v3 rewrites legacy priority 0 -> Medium. Export / import: - NotesMarkdown round-trippable single-file format with HTML-comment markers. - Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md) and "Import notes" (file picker + safe newer-wins merge by id). Structured template: - Every new note pre-fills the richer what/where/must/nice/out/done/depends/ estimate/refs scaffold. Tests: - New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage (note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share branch excluded via coverage:ignore (unreachable on the Linux test host). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
392 lines
13 KiB
Dart
392 lines
13 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 }
|
|
|
|
/// Summary of an [NoteRepository.importNotes] run, for user feedback.
|
|
class ImportOutcome {
|
|
const ImportOutcome({
|
|
required this.added,
|
|
required this.updated,
|
|
required this.skipped,
|
|
});
|
|
|
|
/// Notes that did not exist locally and were created.
|
|
final int added;
|
|
|
|
/// Existing notes overwritten because the import was newer.
|
|
final int updated;
|
|
|
|
/// Notes skipped because the local copy was the same age or newer.
|
|
final int skipped;
|
|
|
|
/// Total notes considered in the import.
|
|
int get total => added + updated + skipped;
|
|
}
|
|
|
|
/// 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: _schemaVersion,
|
|
onCreate: _onCreate,
|
|
onUpgrade: _onUpgrade,
|
|
);
|
|
return NoteRepository._(crdt);
|
|
}
|
|
|
|
/// Opens a transient in-memory database; intended for tests.
|
|
static Future<NoteRepository> openInMemory() async {
|
|
final crdt = await SqliteCrdt.openInMemory(
|
|
version: _schemaVersion,
|
|
onCreate: _onCreate,
|
|
onUpgrade: _onUpgrade,
|
|
);
|
|
return NoteRepository._(crdt);
|
|
}
|
|
|
|
/// Current schema version. Bump when adding columns and add the matching
|
|
/// branch to [_onUpgrade] so existing on-device databases migrate.
|
|
static const int _schemaVersion = 3;
|
|
|
|
/// Creates the schema for a brand-new database. Plain columns only; the
|
|
/// CRDT layer adds its own bookkeeping columns transparently. ISO-8601
|
|
/// strings keep timestamps human-readable and lexicographically sortable.
|
|
static Future<void> _onCreate(CrdtTableExecutor db, int version) async {
|
|
await db.execute('''
|
|
CREATE TABLE notes (
|
|
id TEXT NOT NULL,
|
|
text TEXT NOT NULL DEFAULT '',
|
|
priority INTEGER NOT NULL DEFAULT 2,
|
|
status INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (id)
|
|
)
|
|
''');
|
|
}
|
|
|
|
/// Migrates an existing database forward one version at a time.
|
|
///
|
|
/// - v1 → v2 adds the [Status] column (rows back-fill to `Status.todo`).
|
|
/// - v2 → v3 drops the old "none" priority: any legacy `0` becomes
|
|
/// `Priority.medium` (2) so every note has a real priority and shows up
|
|
/// in priority filters. This runs deterministically on every device, so
|
|
/// the back-fill converges without needing a synced changeset.
|
|
static Future<void> _onUpgrade(CrdtTableExecutor db, int from, int to) async {
|
|
if (from < 2) {
|
|
await db.execute(
|
|
'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0',
|
|
);
|
|
}
|
|
if (from < 3) {
|
|
await db.execute('UPDATE notes SET priority = 2 WHERE priority = 0');
|
|
}
|
|
}
|
|
|
|
/// 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, status, created_at, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
text = ?2,
|
|
priority = ?3,
|
|
status = ?4,
|
|
updated_at = ?6
|
|
''',
|
|
[
|
|
note.id,
|
|
note.text,
|
|
note.priority.value,
|
|
note.status.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]);
|
|
}
|
|
|
|
/// Merges [incoming] notes (e.g. from an imported file) into local storage.
|
|
///
|
|
/// Safe by design — it never destroys a *newer* local edit: an incoming
|
|
/// note overwrites the local one only when its [Note.updatedAt] is strictly
|
|
/// newer, or when the id is not present locally. This makes re-importing a
|
|
/// stale backup a no-op for notes you've since edited, upholding the
|
|
/// "never lose ideas" guarantee. Notes absent from [incoming] are kept.
|
|
Future<ImportOutcome> importNotes(List<Note> incoming) async {
|
|
final existing = {for (final n in await listNotes()) n.id: n};
|
|
var added = 0;
|
|
var updated = 0;
|
|
var skipped = 0;
|
|
for (final note in incoming) {
|
|
final local = existing[note.id];
|
|
if (local == null) {
|
|
await upsert(note);
|
|
added++;
|
|
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
|
await upsert(note);
|
|
updated++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
return ImportOutcome(added: added, updated: updated, skipped: skipped);
|
|
}
|
|
|
|
/// Returns the live notes matching [filter], ordered by [sort].
|
|
Future<List<Note>> listNotes({
|
|
NoteSort sort = NoteSort.modifiedDesc,
|
|
NoteFilter filter = const NoteFilter(),
|
|
}) async {
|
|
final (where, args) = _buildWhere(filter);
|
|
final rows = await _crdt.query(
|
|
'SELECT * FROM notes WHERE $where ${_orderBy(sort)}',
|
|
args,
|
|
);
|
|
return rows.map(Note.fromRow).toList();
|
|
}
|
|
|
|
/// Emits the matching, ordered note list and re-emits whenever the table
|
|
/// changes, so the UI can stay in sync without manual refreshes.
|
|
///
|
|
/// [watch] takes an args *builder* (`() => args`); the captured [args]
|
|
/// list is immutable for this call because [filter] is immutable, so the
|
|
/// builder is safe to re-invoke.
|
|
Stream<List<Note>> watchNotes({
|
|
NoteSort sort = NoteSort.modifiedDesc,
|
|
NoteFilter filter = const NoteFilter(),
|
|
}) {
|
|
final (where, args) = _buildWhere(filter);
|
|
return _crdt
|
|
.watch('SELECT * FROM notes WHERE $where ${_orderBy(sort)}', () => args)
|
|
.map((rows) => rows.map(Note.fromRow).toList());
|
|
}
|
|
|
|
/// Emits the live count of non-deleted notes. Cheaper than [watchNotes]
|
|
/// for a header badge: SQLite computes the count without materialising or
|
|
/// parsing any rows, so per-keystroke autosave doesn't churn the UI.
|
|
Stream<int> watchCount() {
|
|
return _crdt
|
|
.watch('SELECT COUNT(*) AS c FROM notes WHERE is_deleted = 0')
|
|
.map((rows) => (rows.first['c'] as int?) ?? 0);
|
|
}
|
|
|
|
/// 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';
|
|
}
|
|
}
|
|
|
|
/// Builds the parameterised WHERE clause for [filter].
|
|
///
|
|
/// Returns the clause body (always rooted at `is_deleted = 0` so
|
|
/// tombstones stay hidden) and the positional argument list. All user
|
|
/// input is bound as parameters — never string-interpolated — so the
|
|
/// query is injection-safe. Date bounds use ISO-8601 string comparison,
|
|
/// which is valid because the stored timestamps are fixed-width and
|
|
/// lexicographically ordered.
|
|
(String, List<Object?>) _buildWhere(NoteFilter filter) {
|
|
final clauses = <String>['is_deleted = 0'];
|
|
final args = <Object?>[];
|
|
|
|
final query = filter.query.trim();
|
|
if (query.isNotEmpty) {
|
|
// Escape LIKE wildcards in the user's text so a literal '%' or '_'
|
|
// matches itself. LIKE is ASCII-case-insensitive by default.
|
|
final escaped = query
|
|
.replaceAll(r'\', r'\\')
|
|
.replaceAll('%', r'\%')
|
|
.replaceAll('_', r'\_');
|
|
clauses.add(r"text LIKE ? ESCAPE '\'");
|
|
args.add('%$escaped%');
|
|
}
|
|
|
|
if (filter.priorities.isNotEmpty) {
|
|
final placeholders = List.filled(
|
|
filter.priorities.length,
|
|
'?',
|
|
).join(', ');
|
|
clauses.add('priority IN ($placeholders)');
|
|
args.addAll(filter.priorities.map((p) => p.value));
|
|
}
|
|
|
|
if (filter.statuses.isNotEmpty) {
|
|
final placeholders = List.filled(filter.statuses.length, '?').join(', ');
|
|
clauses.add('status IN ($placeholders)');
|
|
args.addAll(filter.statuses.map((s) => s.value));
|
|
}
|
|
|
|
_addDateBounds(
|
|
clauses,
|
|
args,
|
|
'created_at',
|
|
filter.createdFrom,
|
|
filter.createdTo,
|
|
);
|
|
_addDateBounds(
|
|
clauses,
|
|
args,
|
|
'updated_at',
|
|
filter.updatedFrom,
|
|
filter.updatedTo,
|
|
);
|
|
|
|
return (clauses.join(' AND '), args);
|
|
}
|
|
|
|
/// Appends inclusive day-granularity bounds for [column] to [clauses].
|
|
///
|
|
/// [from]/[to] are treated as whole calendar days: `from` includes its
|
|
/// entire day (compared from 00:00:00) and `to` includes its entire day
|
|
/// (compared with `< to+1 day`), matching how a user reads a date range.
|
|
void _addDateBounds(
|
|
List<String> clauses,
|
|
List<Object?> args,
|
|
String column,
|
|
DateTime? from,
|
|
DateTime? to,
|
|
) {
|
|
if (from != null) {
|
|
clauses.add('$column >= ?');
|
|
args.add(_startOfDay(from).toIso8601String());
|
|
}
|
|
if (to != null) {
|
|
clauses.add('$column < ?');
|
|
args.add(_startOfDay(to).add(const Duration(days: 1)).toIso8601String());
|
|
}
|
|
}
|
|
|
|
/// Midnight (local) of [t]'s calendar day.
|
|
static DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.day);
|
|
}
|
|
|
|
/// An immutable set of constraints for querying notes.
|
|
///
|
|
/// All fields combine with logical AND. Empty/null fields impose no
|
|
/// constraint. Lives in the data layer so the SQL it drives never leaks
|
|
/// into the UI. Construct copies with [copyWith] when toggling one facet.
|
|
class NoteFilter {
|
|
const NoteFilter({
|
|
this.query = '',
|
|
this.priorities = const {},
|
|
this.statuses = const {},
|
|
this.createdFrom,
|
|
this.createdTo,
|
|
this.updatedFrom,
|
|
this.updatedTo,
|
|
});
|
|
|
|
/// Case-insensitive substring matched against the note body.
|
|
final String query;
|
|
|
|
/// Notes must have one of these priorities. Empty means "any priority".
|
|
final Set<Priority> priorities;
|
|
|
|
/// Notes must have one of these statuses. Empty means "any status".
|
|
final Set<Status> statuses;
|
|
|
|
/// Inclusive lower/upper bounds (by calendar day) on the creation date.
|
|
final DateTime? createdFrom;
|
|
final DateTime? createdTo;
|
|
|
|
/// Inclusive lower/upper bounds (by calendar day) on the last-updated date.
|
|
final DateTime? updatedFrom;
|
|
final DateTime? updatedTo;
|
|
|
|
/// True when no constraint is active (the unfiltered, full list).
|
|
bool get isEmpty =>
|
|
query.trim().isEmpty &&
|
|
priorities.isEmpty &&
|
|
statuses.isEmpty &&
|
|
createdFrom == null &&
|
|
createdTo == null &&
|
|
updatedFrom == null &&
|
|
updatedTo == null;
|
|
|
|
/// Number of distinct active facets, for an "N filters" badge in the UI.
|
|
int get activeCount {
|
|
var n = 0;
|
|
if (query.trim().isNotEmpty) n++;
|
|
if (priorities.isNotEmpty) n++;
|
|
if (statuses.isNotEmpty) n++;
|
|
if (createdFrom != null || createdTo != null) n++;
|
|
if (updatedFrom != null || updatedTo != null) n++;
|
|
return n;
|
|
}
|
|
|
|
/// Returns a copy with selected facets replaced. A `null` argument keeps
|
|
/// the current value; clearing a date is done via the dedicated [clear]
|
|
/// flags so `null` can mean "unchanged".
|
|
NoteFilter copyWith({
|
|
String? query,
|
|
Set<Priority>? priorities,
|
|
Set<Status>? statuses,
|
|
DateTime? createdFrom,
|
|
DateTime? createdTo,
|
|
DateTime? updatedFrom,
|
|
DateTime? updatedTo,
|
|
bool clearCreated = false,
|
|
bool clearUpdated = false,
|
|
}) {
|
|
return NoteFilter(
|
|
query: query ?? this.query,
|
|
priorities: priorities ?? this.priorities,
|
|
statuses: statuses ?? this.statuses,
|
|
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
|
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
|
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
|
|
updatedTo: clearUpdated ? null : (updatedTo ?? this.updatedTo),
|
|
);
|
|
}
|
|
}
|