mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:07:40 +01:00
The largest consumer moves last on purpose: diet_guard was 71% of all sync traffic, and both its surfaces (the PC timer and the phone app) share one prefix, so they move together. Firebase becomes the primary backend when it is set up, with GitHub kept as a mirror so a device that has not moved yet still converges. An unconfigured device keeps syncing exactly as before, which is also the rollback. Revision tracking is new here. The PC hand-rolled its own pull-merge-push instead of calling sync_log, so it had none: every tick re-downloaded every peer's whole food log -- hundreds of KB, 96 times a day -- and re-pushed its own regardless of change. It now reads one small revision map first, skips any peer whose hash is unchanged, and suppresses a no-op push. Every sub-sync (log, budget, food bank, manual bank) takes the client as a parameter, so one swap covers all four. The app could not have reached Firebase at all: nothing called saveAccount(), so loadAccount() always returned null and openFirebase() always returned null. Settings now has a Firebase section that stores the account in the OS keystore and signs in immediately, so a typo surfaces on the screen where it can be fixed rather than as a silently failed background sync hours later. The email and password never touch the repo -- it is public. Platform reaches now degrade instead of failing: an unavailable keystore or application-support directory costs one tick of extra traffic, where before it would have taken sync down entirely. Caught by `on Object` rather than `on Exception`, because an uninitialised binding raises an Error. Verified end to end against the real database, not just in tests: a live PC sync merged 57 days / 339 entries and put 221 KB of food log plus 97 food-bank records into Firebase; a second unchanged tick issued zero pushes; and the Dart client signed in and read back all 339 meals and 97 foods, then completed a push / publish-revision / suppress-no-op round trip under a throwaway prefix. diet_guard: 618 tests at 100% branch coverage. diet_guard_app: 477 tests. Two assertions moved from 4 pushes to 5 -- the extra one is this device's revision, published after the log so a peer can never cache "seen rev X" against a log it never received. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
58 lines
2.8 KiB
Dart
58 lines
2.8 KiB
Dart
/// Connectivity-gated background push: the offline backstop for the
|
|
/// immediate-push-on-log behaviour. When a meal is logged while the device is
|
|
/// offline the in-process auto-sync fails silently; a WorkManager one-off task
|
|
/// with a `NetworkType.connected` constraint (enqueued on every log) fires on
|
|
/// reconnect and uploads the log without the app being reopened.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/services/app_settings_service.dart';
|
|
import 'package:diet_guard_app/services/budget_history_service.dart';
|
|
import 'package:diet_guard_app/services/firebase_backend.dart';
|
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
|
import 'package:diet_guard_app/services/github_client_factory.dart';
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
import 'package:diet_guard_app/services/sync_service.dart';
|
|
import 'package:diet_guard_app/services/sync_settings.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
/// Unique WorkManager task name for the connectivity-gated push.
|
|
const String syncPushTaskName = 'diet_guard.sync_push';
|
|
|
|
/// Loads sync settings and pushes the local log, returning WorkManager's
|
|
/// success flag: `true` when there was nothing to push or the push
|
|
/// succeeded, `false` to ask WorkManager to retry (with backoff) after a
|
|
/// transient failure -- an offline moment or a GitHub hiccup.
|
|
///
|
|
/// Extracted from the WorkManager dispatcher so it is unit-testable without
|
|
/// the real plugin (which only runs as a background isolate on-device),
|
|
/// exactly like [checkAndNotify]. [httpClient] is injectable for the same
|
|
/// reason. The service singletons are (re)initialised here because a fresh
|
|
/// background isolate has none; [LogStorageService.init] is idempotent, so
|
|
/// this is a no-op when a test has already pointed it at a temp dir.
|
|
/// [AppSettingsService.init] must run too: [runSync] now also syncs the
|
|
/// budget, and applies a merge winner via `AppSettingsService.instance`,
|
|
/// which throws if the singleton was never initialised in this isolate.
|
|
Future<bool> backgroundSyncPush({http.Client? httpClient}) async {
|
|
await LogStorageService.init();
|
|
await FoodBankService.init();
|
|
await AppSettingsService.init();
|
|
// runSync also merges the budget history through this singleton.
|
|
await BudgetHistoryService.init();
|
|
final SyncSettings settings;
|
|
try {
|
|
settings = await SyncSettings.load();
|
|
} on Exception {
|
|
return false; // couldn't read config in-isolate; let WorkManager retry
|
|
}
|
|
if (!settings.isConfigured) return true; // nothing to push; don't retry
|
|
final client = createGitHubClient(settings, httpClient: httpClient);
|
|
try {
|
|
await runSync(await syncBackend(client));
|
|
return true;
|
|
} on Exception {
|
|
return false; // offline / transient GitHub error -> retry with backoff
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|