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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-15 22:02:39 +02:00
parent abd4ba3bd7
commit 085287861c
2 changed files with 102 additions and 2 deletions

View File

@ -33,9 +33,13 @@ class CaptureScreen extends StatefulWidget {
State<CaptureScreen> createState() => _CaptureScreenState(); State<CaptureScreen> createState() => _CaptureScreenState();
} }
class _CaptureScreenState extends State<CaptureScreen> { class _CaptureScreenState extends State<CaptureScreen>
with WidgetsBindingObserver {
static const _uuid = Uuid(); 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 /// 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 = '';
@ -61,11 +65,49 @@ class _CaptureScreenState extends State<CaptureScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
SyncSettings.load().then((s) { 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<void> _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. /// Opens the settings screen and adopts any saved configuration.
Future<void> _openSettings() async { Future<void> _openSettings() async {
final current = _settings ?? await SyncSettings.load(); final current = _settings ?? await SyncSettings.load();

View File

@ -216,4 +216,62 @@ void main() {
expect(find.text('Connect GitHub'), findsNothing); // back on capture expect(find.text('Connect GitHub'), findsNothing); // back on capture
expect(find.byTooltip('Sync settings'), findsOneWidget); 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<String> 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 = <String>[];
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 = <String>[];
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 = <String>[];
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);
});
} }