mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:47:38 +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
83 lines
3.2 KiB
Dart
83 lines
3.2 KiB
Dart
/// Shows/cancels the per-slot "meal not logged" notification, mirroring
|
|
/// diet_guard's `_gate.py` lock decision -- but as a notification rather
|
|
/// than a screen-grab, and re-evaluated on every background check tick
|
|
/// rather than fired once.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/models/slot.dart';
|
|
import 'package:diet_guard_app/services/notification_backend.dart';
|
|
import 'package:diet_guard_app/services/notification_backend_factory.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
const int _hoursPerDay = 24;
|
|
|
|
/// Owns the due-slot notification logic ([syncToSlots]) independently of the
|
|
/// platform surface that actually posts them: `flutter_local_notifications`
|
|
/// on Android, the browser's Notifications API in the desktop web build (see
|
|
/// `notification_backend.dart`).
|
|
class NotificationService {
|
|
NotificationService._(this._backend);
|
|
|
|
static NotificationService? _instance;
|
|
|
|
final NotificationBackend _backend;
|
|
|
|
bool _initialized = false;
|
|
|
|
/// Returns the initialized singleton; throws if [init] was not called.
|
|
static NotificationService get instance => _instance!;
|
|
|
|
/// Initializes the singleton with the platform backend (idempotent -- a
|
|
/// second call returns the already-initialized instance without
|
|
/// re-running platform setup).
|
|
static Future<NotificationService> init() async {
|
|
final svc = _instance ??= NotificationService._(openNotificationBackend());
|
|
if (!svc._initialized) {
|
|
await svc._backend.initialize();
|
|
svc._initialized = true;
|
|
}
|
|
return svc;
|
|
}
|
|
|
|
/// Resets the singleton so tests can inject a backend -- typically one over
|
|
/// a plugin pointed at a fake platform channel. A subsequent [init] call
|
|
/// drives that backend's `initialize` codepath, same as production.
|
|
@visibleForTesting
|
|
static void resetForTesting({NotificationBackend? backend}) {
|
|
_instance = backend == null ? null : NotificationService._(backend);
|
|
}
|
|
|
|
/// Requests the platform's notification permission.
|
|
///
|
|
/// Returns null where the concept doesn't apply; callers treat null and
|
|
/// false the same way.
|
|
Future<bool?> requestPermission() => _backend.requestPermission();
|
|
|
|
/// Shows a notification for every slot in [dueSlots] and cancels one for
|
|
/// every other known slot.
|
|
///
|
|
/// Idempotent and re-evaluated every tick: a slot logged after its
|
|
/// notification fired gets that notification cancelled on the very next
|
|
/// call, mirroring `_gate.gate_is_due()`'s re-evaluate-every-tick
|
|
/// behavior rather than firing once and forgetting.
|
|
Future<void> syncToSlots(List<int> dueSlots) async {
|
|
final due = dueSlots.toSet();
|
|
// Sweeps every hour of the day, not just the *current* schedule's slots.
|
|
// The slot hour doubles as the notification id, so a schedule change
|
|
// orphans the ids it no longer contains: going 08/12/16/20 -> 08/11/14/17
|
|
// used to leave 12 and 16 posted with nothing left to ever cancel them,
|
|
// nagging forever about checkpoints that no longer exist.
|
|
for (var slot = 0; slot < _hoursPerDay; slot++) {
|
|
if (due.contains(slot)) {
|
|
await _backend.show(
|
|
slot,
|
|
'Meal not logged',
|
|
"You haven't logged your ${slotLabel(slot)} meal yet.",
|
|
);
|
|
} else {
|
|
await _backend.cancel(slot);
|
|
}
|
|
}
|
|
}
|
|
}
|