diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 17e7eee..544bfbb 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:sqlite_crdt/sqlite_crdt.dart'; import 'note.dart'; @@ -38,6 +40,23 @@ class NoteRepository { final SqliteCrdt _crdt; + /// Ticks once (with no payload) after every write, so lightweight listeners + /// can react without re-querying the whole table on each keystroke. This is + /// deliberately cheaper than [watchNotes]: it carries no rows, so a consumer + /// that only needs "something changed" (e.g. the debounced disk backup) pays + /// O(1) per write instead of re-parsing every note on every character. + final StreamController _changes = StreamController.broadcast(); + + /// Fires after each successful write ([upsert], [delete], [merge], and + /// transitively [importNotes]). Emits `void` — pull the data on demand. + Stream get changes => _changes.stream; + + /// Signals [changes] listeners that the table was written. Guarded so a late + /// write during teardown can't add to a closed controller. + void _notifyChanged() { + if (!_changes.isClosed) _changes.add(null); + } + /// Opens (or creates) the database at [path] and ensures the schema. /// /// [path] should be an absolute file path on desktop/mobile, or the @@ -125,12 +144,14 @@ class NoteRepository { note.updatedAt.toIso8601String(), ], ); + _notifyChanged(); } /// Soft-deletes a note. The CRDT keeps a tombstone so the deletion /// propagates on the next sync instead of resurrecting the row. Future delete(String id) async { await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]); + _notifyChanged(); } /// Merges [incoming] notes (e.g. from an imported file) into local storage. @@ -207,10 +228,16 @@ class NoteRepository { /// Merges a remote changeset into local storage (conflict-free, /// last-writer-wins per row via the Hybrid Logical Clock). - Future merge(CrdtChangeset changeset) => _crdt.merge(changeset); + Future merge(CrdtChangeset changeset) async { + await _crdt.merge(changeset); + _notifyChanged(); + } - /// Closes the underlying database. - Future close() => _crdt.close(); + /// Closes the underlying database and the [changes] stream. + Future close() async { + await _changes.close(); + await _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. diff --git a/lib/sync/local_backup.dart b/lib/sync/local_backup.dart index 87b881b..72f5fca 100644 --- a/lib/sync/local_backup.dart +++ b/lib/sync/local_backup.dart @@ -12,13 +12,22 @@ import 'notes_markdown.dart'; /// fully testable; the platform-specific path lives in the caller. /// /// Writes are debounced so a burst of keystrokes produces a single export. +/// +/// The note snapshot is pulled lazily via [fetch] only when the debounce +/// fires — never per keystroke — so serialising every note stays off the +/// typing hot path and the cost is independent of how fast the user types. class LocalBackup { LocalBackup({ + required this.fetch, required this.reader, required this.writer, this.debounce = const Duration(seconds: 2), }); + /// Pulls the current notes to export. Invoked once per debounced write, so + /// the O(notes) query + serialization happens at most once per idle window. + final Future> Function() fetch; + /// Reads the backup file's contents, or null if it does not exist. final Future Function() reader; @@ -30,19 +39,25 @@ class LocalBackup { Timer? _timer; - /// Schedules a debounced export of [notes]. Repeated calls reset the timer, - /// so only the latest snapshot is written. A zero [debounce] writes - /// immediately (and schedules no timer). - void scheduleExport(List notes) { + /// Schedules a debounced backup write. Repeated calls reset the timer, so a + /// burst of writes collapses to one export of the latest snapshot. A zero + /// [debounce] exports immediately (and schedules no timer). + void schedule() { _timer?.cancel(); - final markdown = NotesMarkdown.export(notes); if (debounce == Duration.zero) { - writer(markdown); + unawaited(_export()); } else { - _timer = Timer(debounce, () => writer(markdown)); + _timer = Timer(debounce, () => unawaited(_export())); } } + /// Pulls the latest notes and writes the Markdown backup. Returned so tests + /// (and a zero-debounce caller) can await the write. + Future _export() async { + final notes = await fetch(); + await writer(NotesMarkdown.export(notes)); + } + /// Reads the backup file and parses it into notes for recovery. Returns an /// empty list when there is no (usable) backup. Future> recover() async { diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index f393101..aa77b53 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -57,7 +57,7 @@ class _CaptureScreenState extends State /// Keeps an always-current Markdown backup on local disk and recovers from /// it on launch (third durability layer beside sync + Android Auto Backup). late final LocalBackup _localBackup; - StreamSubscription>? _notesSub; + StreamSubscription? _changesSub; /// Latest assembled text from the editor; persisted on change and re-saved /// when only priority/status change. @@ -70,7 +70,11 @@ class _CaptureScreenState extends State /// keystroke of a fresh draft. String? _draftId; DateTime? _draftCreatedAt; - DateTime? _lastSavedAt; + + /// Time of the last local save, shown in the tiny save indicator. A notifier + /// (not a `setState` field) so a keystroke updates only that one line of text + /// instead of rebuilding the AppBar, dropdowns, and editor every character. + final ValueNotifier _lastSavedAt = ValueNotifier(null); /// Priority/status applied to the current draft. Chosen before or during /// typing; persisted on the first keystroke and on every later change. @@ -89,13 +93,19 @@ class _CaptureScreenState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _localBackup = widget.localBackup ?? _platformLocalBackup(); + _localBackup = + widget.localBackup ?? _platformLocalBackup(widget.repository); // Recover from the local backup first (covers an empty DB after a wipe), - // then keep the backup current as notes change. + // then keep the backup current as notes change. The change tick is O(1) per + // write; the backup pulls the notes itself only when its debounce fires. _recoverFromBackup(); - _notesSub = widget.repository.watchNotes().listen( - _localBackup.scheduleExport, + _changesSub = widget.repository.changes.listen( + (_) => _localBackup.schedule(), ); + // Also refresh the on-disk backup once on launch (the change tick only + // fires on writes), so a deleted/stale BACKLOG.md is regenerated even if + // the user makes no edits this session. + _localBackup.schedule(); SyncSettings.load().then((s) { if (!mounted) return; setState(() => _settings = s); @@ -106,8 +116,9 @@ class _CaptureScreenState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _notesSub?.cancel(); + _changesSub?.cancel(); _localBackup.dispose(); + _lastSavedAt.dispose(); super.dispose(); } @@ -125,7 +136,7 @@ class _CaptureScreenState extends State // (the path the user's workflow already reads), or the app documents dir on // mobile (which Android Auto Backup includes). Exercised by running the app; // tests inject an in-memory LocalBackup instead. - static LocalBackup _platformLocalBackup() { + static LocalBackup _platformLocalBackup(NoteRepository repository) { Future backupFile() async { if (Platform.isAndroid || Platform.isIOS) { final dir = await getApplicationDocumentsDirectory(); @@ -137,6 +148,7 @@ class _CaptureScreenState extends State } return LocalBackup( + fetch: repository.listNotes, reader: () async { final file = await backupFile(); return file.existsSync() ? file.readAsString() : null; @@ -259,7 +271,7 @@ class _CaptureScreenState extends State updatedAt: now, ), ); - if (mounted) setState(() => _lastSavedAt = now); + if (mounted) _lastSavedAt.value = now; } /// Applies a new priority to the draft, persisting immediately if a note @@ -290,7 +302,7 @@ class _CaptureScreenState extends State updatedAt: now, ), ); - if (mounted) setState(() => _lastSavedAt = now); + if (mounted) _lastSavedAt.value = now; } /// Finalises the current idea and resets the field to a fresh template. @@ -302,11 +314,11 @@ class _CaptureScreenState extends State _draftText = ''; _draftId = null; _draftCreatedAt = null; - _lastSavedAt = null; _draftPriority = Priority.defaultValue; _draftStatus = Status.todo; _chromeVisible = true; }); + _lastSavedAt.value = null; if (saved) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -410,11 +422,14 @@ class _CaptureScreenState extends State // Leave room so the Save FAB doesn't cover the save indicator. Padding( padding: const EdgeInsets.only(right: 96), - child: Text( - _lastSavedAt == null - ? 'Autosaves as you type' - : 'Saved locally at ${_formatTime(_lastSavedAt!)}', - style: theme.textTheme.bodySmall, + child: ValueListenableBuilder( + valueListenable: _lastSavedAt, + builder: (context, savedAt, _) => Text( + savedAt == null + ? 'Autosaves as you type' + : 'Saved locally at ${_formatTime(savedAt)}', + style: theme.textTheme.bodySmall, + ), ), ), ], diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index e9ed736..061e666 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -229,6 +229,7 @@ class _NotesListScreenState extends State { itemCount: notes.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, i) => _NoteTile( + key: ValueKey(notes[i].id), note: notes[i], onTap: () => _openNote(notes[i]), onActions: () => _openNoteActions(notes[i]), @@ -249,6 +250,7 @@ class _NoteTile extends StatelessWidget { required this.note, required this.onTap, required this.onActions, + super.key, }); final Note note; diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index fd562eb..c340807 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -23,6 +23,7 @@ void main() { Map prefs = const {}, http.Client? httpClient, List seed = const [], + FakeNoteRepository? repository, LocalBackup? localBackup, }) async { SharedPreferences.setMockInitialValues(prefs); @@ -33,13 +34,14 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final repo = FakeNoteRepository(seed); + final repo = repository ?? FakeNoteRepository(seed); addTearDown(repo.close); // Default to an in-memory, no-op backup so tests never touch real disk // (the production backup writes ~/todo/BACKLOG.md on the Linux test host). final backup = localBackup ?? LocalBackup( + fetch: repo.listNotes, reader: () async => null, writer: (_) async {}, debounce: Duration.zero, @@ -329,6 +331,7 @@ void main() { ), ]); final backup = LocalBackup( + fetch: () async => const [], reader: () async => markdown, writer: (_) async {}, debounce: Duration.zero, @@ -344,6 +347,7 @@ void main() { testWidgets('does not recover when the DB already has notes', (tester) async { final backup = LocalBackup( + fetch: () async => const [], reader: () async => NotesMarkdown.export([ Note( id: 'r1', @@ -377,15 +381,19 @@ void main() { testWidgets('writes the local backup as notes change', (tester) async { final writes = []; + final repo = FakeNoteRepository(); + // fetch pulls from the repo on write, mirroring production wiring. final backup = LocalBackup( + fetch: repo.listNotes, reader: () async => null, writer: (md) async => writes.add(md), debounce: Duration.zero, ); - await pumpCapture(tester, localBackup: backup); + await pumpCapture(tester, repository: repo, localBackup: backup); await tester.enterText(find.byType(TextField).first, 'Backed up idea'); - await tester.pump(); + await tester + .pump(); // let the change tick → schedule → async fetch/write run expect(writes, isNotEmpty); expect(writes.last, contains('Backed up idea')); diff --git a/test/fake_note_repository.dart b/test/fake_note_repository.dart index b8e7892..9a98eac 100644 --- a/test/fake_note_repository.dart +++ b/test/fake_note_repository.dart @@ -19,6 +19,7 @@ class FakeNoteRepository implements NoteRepository { final List _notes; final _controller = StreamController>.broadcast(); + final _changesController = StreamController.broadcast(); NoteSort? lastSort; NoteFilter? lastFilter; @@ -32,8 +33,12 @@ class FakeNoteRepository implements NoteRepository { void _emit() { if (!_controller.isClosed) _controller.add(List.unmodifiable(_notes)); + if (!_changesController.isClosed) _changesController.add(null); } + @override + Stream get changes => _changesController.stream; + @override Future upsert(Note note) async { _notes @@ -100,5 +105,6 @@ class FakeNoteRepository implements NoteRepository { @override Future close() async { await _controller.close(); + await _changesController.close(); } } diff --git a/test/local_backup_test.dart b/test/local_backup_test.dart index 3641383..0e0a9cf 100644 --- a/test/local_backup_test.dart +++ b/test/local_backup_test.dart @@ -14,36 +14,50 @@ void main() { updatedAt: DateTime(2026, 6, 15), ); - group('scheduleExport', () { - test('zero debounce writes the exported markdown immediately', () { - String? written; - final backup = LocalBackup( - reader: () async => null, - writer: (md) async => written = md, - debounce: Duration.zero, - ); + group('schedule', () { + test('zero debounce fetches and writes the markdown immediately', () { + fakeAsync((async) { + String? written; + final backup = LocalBackup( + fetch: () async => [note('a', '# A')], + reader: () async => null, + writer: (md) async => written = md, + debounce: Duration.zero, + ); - backup.scheduleExport([note('a', '# A')]); - expect(written, isNotNull); - expect(written, contains('# A')); - backup.dispose(); + backup.schedule(); + async.flushMicrotasks(); // drain the async fetch + write + expect(written, isNotNull); + expect(written, contains('# A')); + backup.dispose(); + }); }); - test('debounced writes coalesce to the latest snapshot', () { + test('a burst of schedules fetches once, at the latest snapshot', () { fakeAsync((async) { + var fetches = 0; + var current = [note('a', '# first')]; final writes = []; final backup = LocalBackup( + fetch: () async { + fetches++; + return current; + }, reader: () async => null, writer: (md) async => writes.add(md), debounce: const Duration(seconds: 2), ); - backup.scheduleExport([note('a', '# first')]); + backup.schedule(); async.elapse(const Duration(seconds: 1)); // not yet - backup.scheduleExport([note('a', '# second')]); // resets the timer + current = [note('a', '# second')]; // pulled lazily at fire time + backup.schedule(); // resets the timer + expect(fetches, 0); // nothing pulled while typing expect(writes, isEmpty); async.elapse(const Duration(seconds: 2)); + async.flushMicrotasks(); + expect(fetches, 1); // two schedules → a single export expect(writes, hasLength(1)); expect(writes.single, contains('# second')); backup.dispose(); @@ -54,16 +68,18 @@ void main() { fakeAsync((async) { var calls = 0; final backup = LocalBackup( + fetch: () async => [note('a', '# x')], reader: () async => null, writer: (_) async => calls++, debounce: const Duration(seconds: 2), ); - backup.scheduleExport([note('a', '# x')]); + backup.schedule(); backup.dispose(); async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); - expect(calls, 0); // timer was cancelled + expect(calls, 0); // timer was cancelled before it could fetch/write }); }); }); @@ -72,6 +88,7 @@ void main() { test('parses a backup file into notes', () async { final markdown = NotesMarkdown.export([note('a', '# Recovered')]); final backup = LocalBackup( + fetch: () async => const [], reader: () async => markdown, writer: (_) async {}, ); @@ -83,6 +100,7 @@ void main() { test('returns empty when there is no backup file', () async { final backup = LocalBackup( + fetch: () async => const [], reader: () async => null, writer: (_) async {}, ); @@ -91,6 +109,7 @@ void main() { test('returns empty when the backup file is blank', () async { final backup = LocalBackup( + fetch: () async => const [], reader: () async => ' \n ', writer: (_) async {}, ); diff --git a/test/note_repository_test.dart b/test/note_repository_test.dart index c9383d9..8cdcb89 100644 --- a/test/note_repository_test.dart +++ b/test/note_repository_test.dart @@ -439,4 +439,53 @@ void main() { expect(clone.updatedTo, base.updatedTo); }); }); + + // getChangeset() serialises HLCs as String and returns read-only rows; + // merge() casts `hlc` to Hlc and mutates, so revive it into fresh mutable + // maps with parsed HLCs first (mirrors SyncService._decodeChangeset). + CrdtChangeset reviveChangeset(CrdtChangeset raw) => raw.map( + (table, records) => MapEntry( + table, + records.map((r) { + final record = Map.from(r); + record['hlc'] = Hlc.parse(record['hlc'] as String); + return record; + }).toList(), + ), + ); + + group('changes', () { + test('ticks once per write across upsert, delete, and merge', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + var ticks = 0; + final sub = repo.changes.listen((_) => ticks++); + addTearDown(sub.cancel); + + await repo.upsert(note('a', 'x')); + await repo.delete('a'); + + // A changeset from another device, merged in, must also tick. + final other = await NoteRepository.openInMemory(); + addTearDown(other.close); + await other.upsert(note('b', 'from other')); + await repo.merge(reviveChangeset(await other.getChangeset())); + + await pumpEventQueue(); // let the broadcast events dispatch + expect(ticks, 3); + }); + + test('importNotes ticks once per note it writes', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + var ticks = 0; + final sub = repo.changes.listen((_) => ticks++); + addTearDown(sub.cancel); + + await repo.importNotes([note('a', 'x'), note('b', 'y')]); + + await pumpEventQueue(); + expect(ticks, 2); // two new notes → two upserts → two ticks + }); + }); }