diet-guard/app/lib/services/background_sync_service.dart
Krzysztof kuhy Rudnicki c77e7d9acf Split the last four Dart lib files; file-length gate now reports zero
Final batch of the 250-line sweep. `check_file_length.sh --all` exits 0: every
file in the repo is at or under 250 lines.

  github_mirror_screen.dart  391 -> 205  (+ actions, client_id_dialog parts)
  foodbank_service.dart      314 -> 209  (+ foodbank_service_manual part)
  settings_screen.dart       286 -> 237  (+ settings_kcal_goal part)
  firebase_backend.dart      282 -> 100  (+ firebase_client.dart)

Three of the four are `part` files rather than separate libraries, and that is
forced rather than stylistic: the moved code drives private state
(`_busy`, `_status`, the text controllers) and calls private helpers
(`_readBank`, `_normalize`), none of which are visible across a library
boundary in Dart. Widening them to public just to split a file would leak
internals the rest of the app has no business calling.

Within those parts, mixins rather than extensions, for a concrete reason: an
extension is not a subclass of `State`, so calling `setState` from one is an
`invalid_use_of_protected_member`. Fields the State must own (the controllers
it creates and disposes) stay declared there and are reached through abstract
getters.

`firebase_client.dart` is a real library split -- opening an authenticated
client has no private coupling to the keystore-backed account record, only to
its public API. The one exception, dropping the account marker without setting
the opt-out flag, became `forgetAccountMarker()` rather than exposing the
secure store.

Verified: flutter test 555 (unchanged), flutter analyze clean, 707 Python
tests / 100% branch coverage, check_patch_targets 0, pre-commit clean,
check_file_length.sh --all exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 21:07:24 +02:00

95 lines
4.3 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 'dart:developer';
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_client.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_device_id.dart';
import 'package:diet_guard_app/services/sync_health.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 {
// Must run here too, and before anything stamps an HLC: a fresh isolate has
// its own static state, so without this `currentSyncDeviceId` falls back to
// the compile-time role constant ('phone') and this tick publishes to
// `devices/phone/` instead of the persisted uuid. That splits the device
// across two directories, and because `sync_state` holds a single
// `pushedRev` with no notion of which identity wrote it, the next
// *foreground* push then sees `unchanged` and skips writing the uuid
// directory entirely.
await initSyncDeviceId();
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 catch (error, stackTrace) {
log(
'diet_guard background sync: cannot read config in-isolate',
level: 900,
error: error,
stackTrace: stackTrace,
);
return false; // let WorkManager retry
}
// Either backend counts. `isConfigured` means "has a GitHub token", so
// gating on it alone silently skips every background push on a device
// connected only to Firebase.
if (!settings.isConfigured && await openFirebase() == null) {
log(
'diet_guard background sync: no backend configured; nothing to push',
level: 900,
);
await SyncHealth.recordUnconfigured();
return true; // nothing to push; don't retry
}
final client = createGitHubClient(settings, httpClient: httpClient);
try {
await runSync(await syncBackend(client));
await SyncHealth.recordSuccess();
return true;
} on Exception catch (error, stackTrace) {
// Never silent: this is the only signal that a background push failed.
log(
'diet_guard background sync failed',
level: 1000,
error: error,
stackTrace: stackTrace,
);
await SyncHealth.recordFailure();
return false; // offline / transient error -> retry with backoff
} finally {
client.close();
}
}