mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 21:13:53 +01:00
The phone/desktop half of the previous commit, so both devices now derive their slots the same way. Still no edit surface, so `MealScheduleService` returns the default and the derived hours are unchanged at 08/12/16/20. `slot.dart` takes the schedule as a required argument, mirroring `_slots.py`, and `slot_test.dart` gains the same every-hour x several-schedules sweep the Python suite runs. That sweep is the contract: the two must agree on which checkpoint a meal satisfies for every cell, not merely on the endpoints. Also fixes a real bug this feature would otherwise have shipped on top of. `syncToSlots` used the slot hour as the notification id but only cancelled ids in the *current* schedule, so changing 08/12/16/20 -> 08/11/14/17/20 would leave ids 12 and 16 posted with nothing that could ever cancel them: permanent reminders for checkpoints that no longer exist. It now sweeps the whole 0..23 id space. The regression test was confirmed to fail against the old loop (4 cancels vs 24) rather than passing vacuously. MealScheduleService.current degrades to the default when the singleton is absent, so `due_slot_check` deliberately does not init() it -- that would reach path_provider from widget tests and from the WorkManager isolate before main() runs. main.dart owns the real init, and background_sync_service.dart does its own, since a fresh isolate has its own static state and would otherwise nag against the default four meals. 609 Dart tests, 761 Python, both suites green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
101 lines
4.6 KiB
Dart
101 lines
4.6 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/meal_schedule_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();
|
|
// A fresh isolate has its own static state, so the schedule must be loaded
|
|
// here too -- otherwise the due-slot check in this isolate would fall back
|
|
// to the default four meals and nag for checkpoints the user's actual
|
|
// schedule does not have.
|
|
await MealScheduleService.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();
|
|
}
|
|
}
|