Auto-export a local Markdown backup and recover from it on launch

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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-15 22:11:08 +02:00
parent f9794512b4
commit 6db9ee11d0
6 changed files with 319 additions and 4 deletions

View File

@ -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<String?> Function() reader;
/// Writes the given Markdown to the backup file (overwriting it).
final Future<void> 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<Note> 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<List<Note>> 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();
}

View File

@ -1,11 +1,16 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../data/note.dart'; import '../data/note.dart';
import '../data/note_repository.dart'; import '../data/note_repository.dart';
import '../data/note_template.dart'; import '../data/note_template.dart';
import '../sync/github_client.dart'; import '../sync/github_client.dart';
import '../sync/local_backup.dart';
import '../sync/sync_service.dart'; import '../sync/sync_service.dart';
import '../sync/sync_settings.dart'; import '../sync/sync_settings.dart';
import 'note_editor.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 /// action finalises the current idea and clears the field for the next
/// one (remote sync will hook in here later). /// one (remote sync will hook in here later).
class CaptureScreen extends StatefulWidget { 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; final NoteRepository repository;
@ -29,6 +39,10 @@ class CaptureScreen extends StatefulWidget {
/// sync flow can be exercised without real network access. /// sync flow can be exercised without real network access.
final http.Client? httpClient; 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 @override
State<CaptureScreen> createState() => _CaptureScreenState(); State<CaptureScreen> createState() => _CaptureScreenState();
} }
@ -40,6 +54,11 @@ class _CaptureScreenState extends State<CaptureScreen>
/// Single-flight guard so a launch sync and a background sync never overlap. /// Single-flight guard so a launch sync and a background sync never overlap.
bool _autoSyncing = false; 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<List<Note>>? _notesSub;
/// Latest assembled text from the editor; persisted on change and re-saved /// Latest assembled text from the editor; persisted on change and re-saved
/// when only priority/status change. /// when only priority/status change.
String _draftText = ''; String _draftText = '';
@ -66,6 +85,13 @@ class _CaptureScreenState extends State<CaptureScreen>
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); 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) { SyncSettings.load().then((s) {
if (!mounted) return; if (!mounted) return;
setState(() => _settings = s); setState(() => _settings = s);
@ -76,9 +102,46 @@ class _CaptureScreenState extends State<CaptureScreen>
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_notesSub?.cancel();
_localBackup.dispose();
super.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<void> _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<File> 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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
// Push on background so the remote (the durable store) stays near-current. // Push on background so the remote (the durable store) stays near-current.

View File

@ -106,7 +106,7 @@ packages:
source: hosted source: hosted
version: "1.1.8+2" version: "1.1.8+2"
fake_async: fake_async:
dependency: transitive dependency: "direct dev"
description: description:
name: fake_async name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"

View File

@ -48,6 +48,9 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # 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 # activated in the `analysis_options.yaml` file located at the root of your

View File

@ -4,6 +4,8 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart'; import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/data/note.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/capture_screen.dart';
import 'package:todo/ui/settings_screen.dart'; import 'package:todo/ui/settings_screen.dart';
@ -18,6 +20,8 @@ void main() {
WidgetTester tester, { WidgetTester tester, {
Map<String, Object> prefs = const {}, Map<String, Object> prefs = const {},
http.Client? httpClient, http.Client? httpClient,
List<Note> seed = const [],
LocalBackup? localBackup,
}) async { }) async {
SharedPreferences.setMockInitialValues(prefs); SharedPreferences.setMockInitialValues(prefs);
// Tall surface so a pushed settings screen builds its whole ListView. // Tall surface so a pushed settings screen builds its whole ListView.
@ -26,11 +30,24 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
final repo = FakeNoteRepository(); final repo = FakeNoteRepository(seed);
addTearDown(repo.close); 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( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: CaptureScreen(repository: repo, httpClient: httpClient), home: CaptureScreen(
repository: repo,
httpClient: httpClient,
localBackup: backup,
),
), ),
); );
await tester.pump(); // flush initial stream + settings load await tester.pump(); // flush initial stream + settings load
@ -274,4 +291,80 @@ void main() {
expect(find.textContaining('Sync failed'), findsNothing); expect(find.textContaining('Sync failed'), findsNothing);
expect(find.textContaining('Synced'), 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 = <String>[];
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'));
});
} }

100
test/local_backup_test.dart Normal file
View File

@ -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 = <String>[];
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);
});
});
}