From 085287861cc163432929cc50f1688b9b209fa65b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 15 Jun 2026 22:02:39 +0200 Subject: [PATCH] Auto-sync on launch and on app background Manual sync left a large window in which local edits had not reached the remote, and a reinstalled device only recovered after a manual sync. Make sync automatic so the GitHub repo (the durable store) stays near-current and recovery is near-automatic. - Pull on launch (once settings load) so a reinstalled device pulls its old changeset back without user action. - Push when the app is backgrounded (AppLifecycleState.paused) so edits leave the device promptly. Lifecycle-based, not per-keystroke, to keep CPU/battery and GitHub API usage low. - Best-effort: silent, skips when unconfigured, single-flight guard so a launch sync and a background sync never overlap; failures are swallowed (the manual Sync button still surfaces errors). 100% line coverage maintained. Co-Authored-By: Claude Opus 4.8 --- lib/ui/capture_screen.dart | 46 +++++++++++++++++++++++++-- test/capture_screen_test.dart | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 0521e2f..082d1f7 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -33,9 +33,13 @@ class CaptureScreen extends StatefulWidget { State createState() => _CaptureScreenState(); } -class _CaptureScreenState extends State { +class _CaptureScreenState extends State + with WidgetsBindingObserver { static const _uuid = Uuid(); + /// Single-flight guard so a launch sync and a background sync never overlap. + bool _autoSyncing = false; + /// Latest assembled text from the editor; persisted on change and re-saved /// when only priority/status change. String _draftText = ''; @@ -61,11 +65,49 @@ class _CaptureScreenState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); SyncSettings.load().then((s) { - if (mounted) setState(() => _settings = s); + if (!mounted) return; + setState(() => _settings = s); + _autoSync(); // pull on launch so a reinstalled device recovers its notes }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Push on background so the remote (the durable store) stays near-current. + if (state == AppLifecycleState.paused) _autoSync(); + } + + /// Best-effort background sync: silent, skips when unconfigured, and never + /// overlaps itself. Failures are swallowed — the manual Sync button is the + /// place that surfaces errors. + Future _autoSync() async { + final settings = _settings; + if (_autoSyncing || settings == null || !settings.isConfigured) return; + _autoSyncing = true; + final client = GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: settings.token, + httpClient: widget.httpClient, + ); + try { + await _syncService.sync(widget.repository, client); + } catch (_) { + // Best-effort: ignore (offline, transient GitHub errors, etc.). + } finally { + client.close(); + _autoSyncing = false; + } + } + /// Opens the settings screen and adopts any saved configuration. Future _openSettings() async { final current = _settings ?? await SyncSettings.load(); diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index 0766899..5b8cab0 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -216,4 +216,62 @@ void main() { expect(find.text('Connect GitHub'), findsNothing); // back on capture expect(find.byTooltip('Sync settings'), findsOneWidget); }); + + // A MockClient that records request methods and answers the sync flow: + // 404 for the (empty) changeset listing, 200 for the device's own PUT. + MockClient recordingMock(List methods) => MockClient((req) async { + methods.add(req.method); + if (req.method == 'PUT') return http.Response('{}', 200); + return http.Response('', 404); + }); + + testWidgets('auto-syncs on launch when configured', (tester) async { + final methods = []; + await pumpCapture( + tester, + prefs: configuredPrefs, + httpClient: recordingMock(methods), + ); + await tester.pump(); // settings load → auto-sync (pull) … + await tester.pump(); // … then push + + expect(methods, contains('PUT')); // this device pushed its changeset + }); + + testWidgets('does not auto-sync when unconfigured', (tester) async { + final methods = []; + await pumpCapture(tester, httpClient: recordingMock(methods)); // no token + await tester.pump(); + await tester.pump(); + + expect(methods, isEmpty); + }); + + testWidgets('auto-syncs again when the app is backgrounded', (tester) async { + final methods = []; + await pumpCapture( + tester, + prefs: configuredPrefs, + httpClient: recordingMock(methods), + ); + await tester.pump(); + await tester.pump(); + methods.clear(); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + await tester.pump(); + + expect(methods, contains('PUT')); + }); + + testWidgets('auto-sync failure is silent (no snackbar)', (tester) async { + final mock = MockClient((_) async => throw Exception('offline')); + await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining('Sync failed'), findsNothing); + expect(find.textContaining('Synced'), findsNothing); + }); }