Cut per-keystroke O(N) work to keep capture smooth at scale

The capture screen fed the on-disk BACKLOG.md backup off an always-live
watchNotes() subscription, so every keystroke re-queried the whole table,
re-parsed every row, and rebuilt the full Markdown export on the UI
isolate — O(notes) per character. It also fired a whole-screen setState
just to update the "Saved locally" timestamp.

- Add NoteRepository.changes: an O(1) broadcast tick after each write
  (upsert/delete/merge; importNotes transitively). LocalBackup now pulls
  notes via an injected fetcher only when its 2s debounce fires, so the
  query + parse + Markdown export runs at most once per idle window
  instead of on every keystroke. Capture listens to changes, not
  watchNotes; a launch-time schedule() keeps BACKLOG.md fresh.
- Scope the save indicator to a ValueNotifier/ValueListenableBuilder so a
  keystroke updates one line of text, not the AppBar/dropdowns/editor.
- Key list tiles by note id for identity-based reorder diffing.

Measured on a 4K/144Hz desktop the UI-thread build stays <=3.3ms;
coverage held at 100% on all changed files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 21:51:46 +02:00
parent 0823e33d72
commit 01411810da
8 changed files with 187 additions and 46 deletions

View File

@ -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<void> _changes = StreamController<void>.broadcast();
/// Fires after each successful write ([upsert], [delete], [merge], and
/// transitively [importNotes]). Emits `void` — pull the data on demand.
Stream<void> 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<void> 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<void> merge(CrdtChangeset changeset) => _crdt.merge(changeset);
Future<void> merge(CrdtChangeset changeset) async {
await _crdt.merge(changeset);
_notifyChanged();
}
/// Closes the underlying database.
Future<void> close() => _crdt.close();
/// Closes the underlying database and the [changes] stream.
Future<void> 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.

View File

@ -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<List<Note>> Function() fetch;
/// Reads the backup file's contents, or null if it does not exist.
final Future<String?> 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<Note> 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<void> _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<List<Note>> recover() async {

View File

@ -57,7 +57,7 @@ class _CaptureScreenState extends State<CaptureScreen>
/// 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<List<Note>>? _notesSub;
StreamSubscription<void>? _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<CaptureScreen>
/// 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<DateTime?> _lastSavedAt = ValueNotifier<DateTime?>(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<CaptureScreen>
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<CaptureScreen>
@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<CaptureScreen>
// (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<File> backupFile() async {
if (Platform.isAndroid || Platform.isIOS) {
final dir = await getApplicationDocumentsDirectory();
@ -137,6 +148,7 @@ class _CaptureScreenState extends State<CaptureScreen>
}
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<CaptureScreen>
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<CaptureScreen>
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<CaptureScreen>
_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<CaptureScreen>
// 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<DateTime?>(
valueListenable: _lastSavedAt,
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
),
),
],

View File

@ -229,6 +229,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
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;

View File

@ -23,6 +23,7 @@ void main() {
Map<String, Object> prefs = const {},
http.Client? httpClient,
List<Note> 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 <Note>[],
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 <Note>[],
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 = <String>[];
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'));

View File

@ -19,6 +19,7 @@ class FakeNoteRepository implements NoteRepository {
final List<Note> _notes;
final _controller = StreamController<List<Note>>.broadcast();
final _changesController = StreamController<void>.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<void> get changes => _changesController.stream;
@override
Future<void> upsert(Note note) async {
_notes
@ -100,5 +105,6 @@ class FakeNoteRepository implements NoteRepository {
@override
Future<void> close() async {
await _controller.close();
await _changesController.close();
}
}

View File

@ -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 = <String>[];
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 <Note>[],
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 <Note>[],
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 <Note>[],
reader: () async => ' \n ',
writer: (_) async {},
);

View File

@ -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<String, Object?>.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
});
});
}