mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
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:
parent
abd4ba3bd7
commit
085287861c
@ -33,9 +33,13 @@ class CaptureScreen extends StatefulWidget {
|
||||
State<CaptureScreen> createState() => _CaptureScreenState();
|
||||
}
|
||||
|
||||
class _CaptureScreenState extends State<CaptureScreen> {
|
||||
class _CaptureScreenState extends State<CaptureScreen>
|
||||
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<CaptureScreen> {
|
||||
@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<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.
|
||||
Future<void> _openSettings() async {
|
||||
final current = _settings ?? await SyncSettings.load();
|
||||
|
||||
@ -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<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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user