From 6db9ee11d0baff1d1486502d30ee80765651d03a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 15 Jun 2026 22:11:08 +0200 Subject: [PATCH] Auto-export a local Markdown backup and recover from it on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third durability layer beside GitHub auto-sync and Android Auto Backup: a plain, human/LLM-readable Markdown file kept current on local disk. - LocalBackup (lib/sync): pure, injectable file IO. scheduleExport() debounces writes (a burst of keystrokes → one export); recover() parses the file back into notes. Reused NotesMarkdown serializer. - CaptureScreen wires it: on launch, recover into an *empty* DB only (so a stale backup never clobbers existing notes), then keep the backup current as notes change. Platform path = ~/todo/BACKLOG.md on desktop (the path the user's workflow already reads) or the app documents dir on mobile (covered by Android Auto Backup). File IO is injected in tests. - Added fake_async dev dep to unit-test the debounce with a virtual clock. 151 tests, 100% line coverage. Co-Authored-By: Claude Opus 4.8 --- lib/sync/local_backup.dart | 56 +++++++++++++++++++ lib/ui/capture_screen.dart | 65 +++++++++++++++++++++- pubspec.lock | 2 +- pubspec.yaml | 3 + test/capture_screen_test.dart | 97 ++++++++++++++++++++++++++++++++- test/local_backup_test.dart | 100 ++++++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 lib/sync/local_backup.dart create mode 100644 test/local_backup_test.dart diff --git a/lib/sync/local_backup.dart b/lib/sync/local_backup.dart new file mode 100644 index 0000000..87b881b --- /dev/null +++ b/lib/sync/local_backup.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import '../data/note.dart'; +import 'notes_markdown.dart'; + +/// Keeps an always-current Markdown backup of all notes on local disk, and +/// recovers from it on launch. +/// +/// This is a third durability layer alongside GitHub auto-sync and Android +/// Auto Backup: a plain human-readable file the user (or an LLM) can read +/// directly. File IO is injected ([reader]/[writer]) so the class is pure and +/// fully testable; the platform-specific path lives in the caller. +/// +/// Writes are debounced so a burst of keystrokes produces a single export. +class LocalBackup { + LocalBackup({ + required this.reader, + required this.writer, + this.debounce = const Duration(seconds: 2), + }); + + /// Reads the backup file's contents, or null if it does not exist. + final Future Function() reader; + + /// Writes the given Markdown to the backup file (overwriting it). + final Future Function(String markdown) writer; + + /// How long to wait after the last change before writing. + final Duration debounce; + + 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) { + _timer?.cancel(); + final markdown = NotesMarkdown.export(notes); + if (debounce == Duration.zero) { + writer(markdown); + } else { + _timer = Timer(debounce, () => writer(markdown)); + } + } + + /// Reads the backup file and parses it into notes for recovery. Returns an + /// empty list when there is no (usable) backup. + Future> recover() async { + final contents = await reader(); + if (contents == null || contents.trim().isEmpty) return const []; + return NotesMarkdown.parse(contents); + } + + /// Cancels any pending write. Call from the owner's dispose. + void dispose() => _timer?.cancel(); +} diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 082d1f7..eb3efe8 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -1,11 +1,16 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import '../data/note.dart'; import '../data/note_repository.dart'; import '../data/note_template.dart'; import '../sync/github_client.dart'; +import '../sync/local_backup.dart'; import '../sync/sync_service.dart'; import '../sync/sync_settings.dart'; import 'note_editor.dart'; @@ -20,7 +25,12 @@ import 'settings_screen.dart'; /// action finalises the current idea and clears the field for the next /// one (remote sync will hook in here later). class CaptureScreen extends StatefulWidget { - const CaptureScreen({required this.repository, this.httpClient, super.key}); + const CaptureScreen({ + required this.repository, + this.httpClient, + this.localBackup, + super.key, + }); final NoteRepository repository; @@ -29,6 +39,10 @@ class CaptureScreen extends StatefulWidget { /// sync flow can be exercised without real network access. final http.Client? httpClient; + /// Injectable local-disk backup. Production leaves this null (a platform + /// file-backed instance is created); tests pass a fake with in-memory IO. + final LocalBackup? localBackup; + @override State createState() => _CaptureScreenState(); } @@ -40,6 +54,11 @@ class _CaptureScreenState extends State /// Single-flight guard so a launch sync and a background sync never overlap. bool _autoSyncing = false; + /// 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; + /// Latest assembled text from the editor; persisted on change and re-saved /// when only priority/status change. String _draftText = ''; @@ -66,6 +85,13 @@ class _CaptureScreenState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _localBackup = widget.localBackup ?? _platformLocalBackup(); + // Recover from the local backup first (covers an empty DB after a wipe), + // then keep the backup current as notes change. + _recoverFromBackup(); + _notesSub = widget.repository.watchNotes().listen( + _localBackup.scheduleExport, + ); SyncSettings.load().then((s) { if (!mounted) return; setState(() => _settings = s); @@ -76,9 +102,46 @@ class _CaptureScreenState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _notesSub?.cancel(); + _localBackup.dispose(); super.dispose(); } + /// Restores notes from the local backup file, but only into an empty DB so a + /// stale backup never clobbers existing notes (merge-by-id stays safe too). + Future _recoverFromBackup() async { + final existing = await widget.repository.listNotes(); + if (existing.isNotEmpty) return; + final recovered = await _localBackup.recover(); + if (recovered.isNotEmpty) await widget.repository.importNotes(recovered); + } + + // coverage:ignore-start + // Platform file IO for the local backup: BACKLOG.md under ~/todo on desktop + // (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() { + Future backupFile() async { + if (Platform.isAndroid || Platform.isIOS) { + final dir = await getApplicationDocumentsDirectory(); + return File('${dir.path}/todo-backlog.md'); + } + final home = Platform.environment['HOME'] ?? Directory.current.path; + final dir = Directory('$home/todo')..createSync(recursive: true); + return File('${dir.path}/BACKLOG.md'); + } + + return LocalBackup( + reader: () async { + final file = await backupFile(); + return file.existsSync() ? file.readAsString() : null; + }, + writer: (markdown) async => (await backupFile()).writeAsString(markdown), + ); + } + // coverage:ignore-end + @override void didChangeAppLifecycleState(AppLifecycleState state) { // Push on background so the remote (the durable store) stays near-current. diff --git a/pubspec.lock b/pubspec.lock index b6284ac..d685aa9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -106,7 +106,7 @@ packages: source: hosted version: "1.1.8+2" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index ebbd943..dc04686 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,9 @@ dev_dependencies: flutter_test: sdk: flutter + # Virtual clock for unit-testing debounced timers (e.g. LocalBackup). + fake_async: ^1.3.3 + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index 5b8cab0..4ef9800 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -4,6 +4,8 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:todo/data/note.dart'; +import 'package:todo/sync/local_backup.dart'; +import 'package:todo/sync/notes_markdown.dart'; import 'package:todo/ui/capture_screen.dart'; import 'package:todo/ui/settings_screen.dart'; @@ -18,6 +20,8 @@ void main() { WidgetTester tester, { Map prefs = const {}, http.Client? httpClient, + List seed = const [], + LocalBackup? localBackup, }) async { SharedPreferences.setMockInitialValues(prefs); // Tall surface so a pushed settings screen builds its whole ListView. @@ -26,11 +30,24 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final repo = FakeNoteRepository(); + final repo = 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( + reader: () async => null, + writer: (_) async {}, + debounce: Duration.zero, + ); await tester.pumpWidget( MaterialApp( - home: CaptureScreen(repository: repo, httpClient: httpClient), + home: CaptureScreen( + repository: repo, + httpClient: httpClient, + localBackup: backup, + ), ), ); await tester.pump(); // flush initial stream + settings load @@ -274,4 +291,80 @@ void main() { expect(find.textContaining('Sync failed'), findsNothing); expect(find.textContaining('Synced'), findsNothing); }); + + testWidgets('recovers notes from the local backup into an empty DB', ( + tester, + ) async { + final markdown = NotesMarkdown.export([ + Note( + id: 'r1', + text: '# Recovered idea', + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime(2026, 6, 15), + updatedAt: DateTime(2026, 6, 15), + ), + ]); + final backup = LocalBackup( + reader: () async => markdown, + writer: (_) async {}, + debounce: Duration.zero, + ); + + final repo = await pumpCapture(tester, localBackup: backup); + await tester.pump(); // recover → import + + final notes = await repo.listNotes(); + expect(notes, hasLength(1)); + expect(notes.single.text, contains('Recovered idea')); + }); + + testWidgets('does not recover when the DB already has notes', (tester) async { + final backup = LocalBackup( + reader: () async => NotesMarkdown.export([ + Note( + id: 'r1', + text: '# From backup', + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime(2026, 6, 15), + updatedAt: DateTime(2026, 6, 15), + ), + ]), + writer: (_) async {}, + debounce: Duration.zero, + ); + final seeded = Note( + id: 'local', + text: '# Existing', + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime(2026, 6, 15), + updatedAt: DateTime(2026, 6, 15), + ); + + final repo = await pumpCapture(tester, seed: [seeded], localBackup: backup); + await tester.pump(); + + // The backup is ignored because the DB was not empty. + final notes = await repo.listNotes(); + expect(notes, hasLength(1)); + expect(notes.single.id, 'local'); + }); + + testWidgets('writes the local backup as notes change', (tester) async { + final writes = []; + final backup = LocalBackup( + reader: () async => null, + writer: (md) async => writes.add(md), + debounce: Duration.zero, + ); + + await pumpCapture(tester, localBackup: backup); + await tester.enterText(find.byType(TextField).first, 'Backed up idea'); + await tester.pump(); + + expect(writes, isNotEmpty); + expect(writes.last, contains('Backed up idea')); + }); } diff --git a/test/local_backup_test.dart b/test/local_backup_test.dart new file mode 100644 index 0000000..3641383 --- /dev/null +++ b/test/local_backup_test.dart @@ -0,0 +1,100 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/sync/local_backup.dart'; +import 'package:todo/sync/notes_markdown.dart'; + +void main() { + Note note(String id, String text) => Note( + id: id, + text: text, + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime(2026, 6, 15), + 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, + ); + + backup.scheduleExport([note('a', '# A')]); + expect(written, isNotNull); + expect(written, contains('# A')); + backup.dispose(); + }); + + test('debounced writes coalesce to the latest snapshot', () { + fakeAsync((async) { + final writes = []; + final backup = LocalBackup( + reader: () async => null, + writer: (md) async => writes.add(md), + debounce: const Duration(seconds: 2), + ); + + backup.scheduleExport([note('a', '# first')]); + async.elapse(const Duration(seconds: 1)); // not yet + backup.scheduleExport([note('a', '# second')]); // resets the timer + expect(writes, isEmpty); + + async.elapse(const Duration(seconds: 2)); + expect(writes, hasLength(1)); + expect(writes.single, contains('# second')); + backup.dispose(); + }); + }); + + test('dispose cancels a pending write', () { + fakeAsync((async) { + var calls = 0; + final backup = LocalBackup( + reader: () async => null, + writer: (_) async => calls++, + debounce: const Duration(seconds: 2), + ); + + backup.scheduleExport([note('a', '# x')]); + backup.dispose(); + async.elapse(const Duration(seconds: 5)); + + expect(calls, 0); // timer was cancelled + }); + }); + }); + + group('recover', () { + test('parses a backup file into notes', () async { + final markdown = NotesMarkdown.export([note('a', '# Recovered')]); + final backup = LocalBackup( + reader: () async => markdown, + writer: (_) async {}, + ); + + final recovered = await backup.recover(); + expect(recovered, hasLength(1)); + expect(recovered.single.text, contains('# Recovered')); + }); + + test('returns empty when there is no backup file', () async { + final backup = LocalBackup( + reader: () async => null, + writer: (_) async {}, + ); + expect(await backup.recover(), isEmpty); + }); + + test('returns empty when the backup file is blank', () async { + final backup = LocalBackup( + reader: () async => ' \n ', + writer: (_) async {}, + ); + expect(await backup.recover(), isEmpty); + }); + }); +}