mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:23:15 +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: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.
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
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