mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
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:
parent
f9794512b4
commit
6db9ee11d0
56
lib/sync/local_backup.dart
Normal file
56
lib/sync/local_backup.dart
Normal 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();
|
||||
}
|
||||
@ -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<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.
|
||||
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
|
||||
/// when only priority/status change.
|
||||
String _draftText = '';
|
||||
@ -66,6 +85,13 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
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<CaptureScreen>
|
||||
@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<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
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Push on background so the remote (the durable store) stays near-current.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<String, Object> prefs = const {},
|
||||
http.Client? httpClient,
|
||||
List<Note> 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 = <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
100
test/local_backup_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user