mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:43:38 +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();
|
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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user