diet-guard/app/lib/screens/log_meal_sync_mixin.dart
Krzysztof kuhy Rudnicki f83938bfe3 app: minute-of-day slots (phase 2 of 3: every app consumer)
- FoodEntry.slot -> slotMinute: read via entrySlotMinute, written via
  slotFields (whole-hour stays byte-identical, else slot_min), kept by
  copyWithDeleted and the edit screen, so the phone never republishes a
  stripped copy of a PC entry. Out-of-range peer values echo, never throw.
- Satisfaction is nearest-slot snapping (satisfiedSlots) everywhere:
  loggedSlotsToday, due check, kuchnia occupancy, future-day logged set
  (that day's schedule), slot selector. Schedule edits no longer un-log.
- Future pre-log builds DateTime from slot ~/ 60, slot % 60.
- Schedule history + sync go through scheduleToWire/scheduleFromWire.
- Notifications: ids are slot minutes; syncToSlots cancels whatever the
  platform reports active (getActiveNotifications, reminder channel) that
  is not due, covering legacy hour ids; full 0..1439 sweep only if the
  lookup throws.
- Settings: 15-minute dropdowns + "Custom..." (24h showTimePicker), off-grid
  values always selectable, counts capped by grid capacity; editor moved
  to widgets/meal_schedule_editor.dart + meal_time_dropdown.dart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:55:26 +02:00

195 lines
8.6 KiB
Dart

import 'dart:async';
import 'dart:developer';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/services/background_tasks.dart';
import 'package:diet_guard_app/services/due_slot_check.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/health_steps.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_health.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// Background-sync and lifecycle behaviour for [LogMealScreen]'s state.
///
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. It is a
/// mixin rather than a helper class because the behaviour is inseparable from
/// the State it drives: it observes the widget lifecycle, calls `setState`,
/// and reads `mounted`. The screen's own fields stay declared on the State and
/// are reached here through the abstract members below, so the split moves
/// behaviour without moving ownership of the widget's state.
mixin LogMealSyncMixin<T extends StatefulWidget> on State<T>
implements WidgetsBindingObserver {
/// Single-flight guard so a launch sync and a lifecycle sync never overlap.
bool autoSyncing = false;
/// Latest sync health, driving the "not syncing" banner. Null until read.
SyncHealthStatus? syncHealth;
/// The slots that already carry a logged meal today.
Set<int> loggedSlots = {};
/// The slot the screen is currently logging for.
///
/// Owned by the screen's own state; declared here so
/// [onScheduleMayHaveChanged] can re-derive it when the schedule changes.
abstract int? selectedSlot;
/// The screen's injected HTTP client, or null to use the default.
///
/// Declared abstract so the mixin stays independent of the widget's own
/// field: `widget` is typed `T` here, so `widget.httpClient` would not
/// resolve. The State supplies it.
http.Client? get syncHttpClient;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Pull on resume (catch up on what another device logged while this one
// was backgrounded) and push on pause (keep the remote near-current).
final isResumeOrPause =
state == AppLifecycleState.resumed || state == AppLifecycleState.paused;
if (isResumeOrPause) {
unawaited(autoSync());
}
}
/// Best-effort background sync: silent, skips when unconfigured, and never
/// overlaps itself. Failures are swallowed -- the Settings screen's manual
/// "Sync now" is where errors get surfaced. The try wraps even loading
/// [SyncSettings] itself: under `flutter test`, the shared_preferences and
/// secure-storage platform channels are unmocked by default and throw
/// [MissingPluginException], which must degrade exactly like "offline"
/// rather than crash every screen that mounts this widget.
///
/// [refreshSlots] only runs after an actual sync (not on the unconfigured
/// path, which every existing screen test takes): a fire-and-forget tail
/// await here can resolve after a *later* test's `tearDown` has already
/// reset [LogStorageService]'s singleton -- `mounted` alone doesn't bound
/// that, since widget disposal between tests isn't synchronized with a
/// still-pending Future from an earlier one.
Future<void> autoSync() async {
if (autoSyncing) return;
autoSyncing = true;
try {
final settings = await SyncSettings.load();
// Either backend counts. `isConfigured` means "has a GitHub token", so
// gating on it alone silently skips every sync on a device connected
// only to Firebase -- and once the mirror is retired that is every
// device. Same fix workout_app's push() already carries.
if (!settings.isConfigured && await openFirebase() == null) {
// Never a silent return: an unconfigured device looked exactly like a
// healthy one with nothing to send, which is how meals sat unpublished
// for days. Recorded so the banner can say so.
log(
'diet_guard auto-sync: no backend configured; nothing to push',
level: 900,
);
await SyncHealth.recordUnconfigured();
if (mounted) await refreshSyncHealth();
return;
}
final client = createGitHubClient(settings, httpClient: syncHttpClient);
try {
// Foreground only (this mixin is UI), and never prompts: fresh
// Health Connect steps ride along in this tick's body.json.
await refreshSteps();
await runSync(await syncBackend(client));
await SyncHealth.recordSuccess();
} finally {
client.close();
}
if (!mounted) return;
await refreshSlots();
await refreshSyncHealth();
} on Object catch (error, stackTrace) {
// Best-effort, but never silent: this swallowed the reason a desktop
// install could not publish at all, which looked identical to "nothing
// to sync". Offline and unmocked-platform-channel-under-test both land
// here too, so it stays non-fatal -- it just says why now.
log(
'diet_guard auto-sync failed',
level: 1000,
error: error,
stackTrace: stackTrace,
);
await SyncHealth.recordFailure();
if (mounted) await refreshSyncHealth();
} finally {
autoSyncing = false;
}
}
/// Re-reads sync health so the banner reflects the tick that just ran.
Future<void> refreshSyncHealth() async {
final status = await SyncHealth.read();
if (!mounted) return;
setState(() => syncHealth = status);
}
/// Queues the platform's offline push backstop so a meal logged while
/// offline still uploads on reconnect. The in-process [autoSync] covers
/// the online case; this is the backstop (a no-op on web, which has no
/// out-of-page scheduler -- see background_tasks.dart).
// coverage:ignore-start
/// Schedules the WorkManager backstop that syncs when the app is closed.
Future<void> enqueueSyncBackstopTask() => enqueueSyncBackstop();
// coverage:ignore-end
/// Everything that must follow a write to the food log, shared by the
/// manual submit and "Fill all" so a batch fill publishes exactly like a
/// typed meal. Call it once per batch, not per entry: [autoSync] is
/// single-flight, so a second call while the first is in flight is dropped
/// and the later entries would wait for the next tick.
///
/// Returns the log as read after the write.
Future<DayLog> publishAfterLog() async {
final log = await LogStorageService.instance.readLog();
await FoodBankService.instance.rebuildAndPersist(log);
// Push the new meal now instead of waiting for the next lifecycle event,
// so the PC gate can see it in seconds. Fire-and-forget and best-effort:
// autoSync is single-flight and swallows offline/transient failures.
unawaited(autoSync());
// Offline backstop: if the push above fails (no connectivity), a
// connectivity-gated WorkManager task uploads the meal on reconnect.
unawaited(enqueueSyncBackstopTask());
// Clears a reminder the meal just logged has now satisfied; without it a
// notification already on screen survives up to 15 min and reads as a
// false alarm. `pullWhenDue: false` because `autoSync` above already owns
// the network for this submit.
await checkAndNotify(pullWhenDue: false);
return log;
}
/// Re-reads which meal slots are already logged today.
Future<void> refreshSlots() async {
final logged = await LogStorageService.instance.loggedSlotsToday();
if (!mounted) return;
setState(() => loggedSlots = logged);
}
/// Rebuilds the slot row after the settings screen pops.
///
/// The meal-schedule editor lives in settings, and [SlotSelectorRow] reads
/// `MealScheduleService.current` at build time, so refreshing is enough to
/// re-derive the row -- but the selected slot also has to be recomputed,
/// since the minute it referred to may no longer be a checkpoint at all.
///
/// Found on the phone: without this the settings preview and the overdue
/// reminder both updated while the row behind them kept the old four
/// checkpoints until the app was restarted.
Future<void> onScheduleMayHaveChanged() async {
await refreshSlots();
if (!mounted) return;
setState(() {
selectedSlot = slotForLog(DateTime.now(), MealScheduleService.current);
});
}
}