diff --git a/app/lib/models/food_entry.dart b/app/lib/models/food_entry.dart index a35e898..cd9fef7 100644 --- a/app/lib/models/food_entry.dart +++ b/app/lib/models/food_entry.dart @@ -2,6 +2,8 @@ library; import 'package:diet_guard_app/models/meal_component.dart'; +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/models/slot.dart'; /// One logged meal, as stored in `food_log.json` under its date key. /// @@ -20,7 +22,7 @@ class FoodEntry { required this.fatG, required this.source, this.id, - this.slot, + this.slotMinute, this.hmac, this.components, this.deleted = false, @@ -40,7 +42,7 @@ class FoodEntry { carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0, fatG: (json['fat_g'] as num?)?.toDouble() ?? 0, source: json['source'] as String? ?? 'manual', - slot: json['slot'] as int?, + slotMinute: entrySlotMinute(json), hmac: json['hmac'] as String?, components: (json['components'] as List?) ?.cast>() @@ -79,16 +81,26 @@ class FoodEntry { /// Provenance label (e.g. `"manual"`, `"food bank"`, `"meal"`). final String source; - /// The meal-slot hour this entry satisfies (8/12/16/20), or null for a - /// snack that counts toward calories but satisfies no slot. + /// The meal-slot minute of day this entry was logged against (480 for + /// 08:00, 435 for 07:15), or null for a snack that satisfies no slot. + /// + /// A *recorded* minute, not a live checkpoint: which slot it satisfies is + /// decided at read time by `satisfiedSlots`, so an entry written under an + /// older schedule still counts after the schedule moves. + /// + /// Read through `entrySlotMinute` (`slot_min` wins, else `slot` x 60) and + /// written back through `slotFields`, so a PC entry carrying `slot_min` + /// survives a round trip through this device. Keeping only the hour would + /// make every edit or tombstone republish a stripped copy, and the merge + /// would spread that lossy copy back to the PC. /// /// Nothing writes null any more -- the "Snack" chip was removed on - /// 2026-08-14 and every remaining writer resolves a concrete hour through + /// 2026-08-14 and every remaining writer resolves a concrete slot through /// `slotForLog`/`slot_for_log` -- but entries already on disk and arriving /// over sync still carry no `slot` key, so the null case and every /// `slot != null` filter built on it are load-bearing. Dropping them would /// make those historical entries retroactively satisfy meal slots. - final int? slot; + final int? slotMinute; /// HMAC signature, present on entries that have passed through the PC's /// signing step. Never computed on the phone -- it never holds the key. @@ -122,7 +134,7 @@ class FoodEntry { 'carbs_g': carbsG, 'fat_g': fatG, 'source': source, - if (slot != null) 'slot': slot, + ..._slotJson(slotMinute), if (components != null) 'components': components!.map((c) => c.toJson()).toList(), if (deleted) 'deleted': true, @@ -139,9 +151,26 @@ class FoodEntry { carbsG: carbsG, fatG: fatG, source: source, - slot: slot, + slotMinute: slotMinute, hmac: hmac, components: components, deleted: true, ); } + +/// Returns the wire fields for [minute], total over every value it can hold. +/// +/// [slotFields] throws outside the day, but [entrySlotMinute] does not +/// range-check what a peer sent. A single `slot: 24` arriving over sync would +/// otherwise make every later `writeLog` throw -- one bad entry blocking all +/// logging. Such a value is echoed back unrepaired, by the same whole-hour +/// rule (so a legacy `slot: 24` stays byte-identical), with `~/` only on a +/// non-negative operand -- a negative value travels as `slot_min` alone. +Map _slotJson(int? minute) { + if (minute == null) return const {}; + if (minute >= 0 && minute < kMinutesPerDay) return slotFields(minute); + return { + if (minute >= 0) 'slot': minute ~/ 60, + if (minute < 0 || minute % 60 != 0) 'slot_min': minute, + }; +} diff --git a/app/lib/screens/edit_entry_screen.dart b/app/lib/screens/edit_entry_screen.dart index b9c0e0b..7216689 100644 --- a/app/lib/screens/edit_entry_screen.dart +++ b/app/lib/screens/edit_entry_screen.dart @@ -15,7 +15,7 @@ import 'package:uuid/uuid.dart'; /// Edit screen for an existing [FoodEntry]. /// -/// Preserves [FoodEntry.id], [FoodEntry.time], [FoodEntry.slot], +/// Preserves [FoodEntry.id], [FoodEntry.time], [FoodEntry.slotMinute], /// and [FoodEntry.deleted]. All nutritional fields and /// the description are editable. class EditEntryScreen extends StatefulWidget { @@ -130,7 +130,7 @@ class _EditEntryScreenState extends State { carbsG: nutrition.carbsG, fatG: nutrition.fatG, source: _source, - slot: e.slot, + slotMinute: e.slotMinute, ); await LogStorageService.instance.updateEntry(e, updated); final log = await LogStorageService.instance.readLog(); diff --git a/app/lib/screens/log_meal_future_mixin.dart b/app/lib/screens/log_meal_future_mixin.dart index 7a4527b..a12fc2e 100644 --- a/app/lib/screens/log_meal_future_mixin.dart +++ b/app/lib/screens/log_meal_future_mixin.dart @@ -9,8 +9,12 @@ /// a day that hasn't started. library; +import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/models/slot.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/ui/theme.dart'; import 'package:diet_guard_app/widgets/slot_selector_row.dart'; import 'package:flutter/material.dart'; @@ -20,6 +24,7 @@ mixin LogMealFutureMixin on State { DateTime? _futureDate; int? _futureSlot; Set _futureLoggedSlots = {}; + MealSchedule? _futureSchedule; /// The date currently chosen for a future log, or null when logging for /// today through the screen's normal flow. @@ -34,6 +39,7 @@ mixin LogMealFutureMixin on State { _futureDate = null; _futureSlot = null; _futureLoggedSlots = {}; + _futureSchedule = null; }); } @@ -49,12 +55,18 @@ mixin LogMealFutureMixin on State { if (picked == null || !mounted) return; final log = await LogStorageService.instance.readLog(); if (!mounted) return; - final logged = (log[localDateKey(picked)] ?? const []) - .where((entry) => !entry.deleted && entry.slot != null) - .map((entry) => entry.slot!) - .toSet(); + final day = localDateKey(picked); + final schedule = MealScheduleService.history.forDay(day); + // Snapped against the schedule for *that* day, the same rule as + // `loggedSlotsToday`: an exact match would show a pre-logged meal's slot + // as open again after a schedule edit, inviting a duplicate. + final logged = satisfiedSlots([ + for (final entry in log[day] ?? const []) + if (!entry.deleted) ?entry.slotMinute, + ], schedule); setState(() { _futureDate = picked; + _futureSchedule = schedule; _futureLoggedSlots = logged; _futureSlot = null; }); @@ -107,6 +119,7 @@ mixin LogMealFutureMixin on State { } return SlotSelectorRow( now: DateTime(date.year, date.month, date.day), + schedule: _futureSchedule, loggedSlots: _futureLoggedSlots, selectedSlot: _futureSlot, onSlotSelected: (slot) => setState(() => _futureSlot = slot), @@ -132,7 +145,9 @@ mixin LogMealFutureMixin on State { return ( error: null, slot: slot, - when: DateTime(date.year, date.month, date.day, slot), + // The slot is a minute of day, not an hour: passing it as the hour + // argument would roll 720 (12:00) forward a whole month. + when: DateTime(date.year, date.month, date.day, slot ~/ 60, slot % 60), ); } } diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index cf656c4..aab3de2 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -149,7 +149,7 @@ class _LogMealScreenState extends State await LogStorageService.instance.logMeal( desc, nutritionFromControllers(_macros, _source), - slot: target.slot, + slotMinute: target.slot, when: target.when, ); resetFutureLog(); diff --git a/app/lib/screens/log_meal_sync_mixin.dart b/app/lib/screens/log_meal_sync_mixin.dart index fe4199e..36404a7 100644 --- a/app/lib/screens/log_meal_sync_mixin.dart +++ b/app/lib/screens/log_meal_sync_mixin.dart @@ -179,7 +179,7 @@ mixin LogMealSyncMixin on State /// 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 hour it referred to may no longer be a checkpoint at all. + /// 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 diff --git a/app/lib/screens/settings_meal_schedule.dart b/app/lib/screens/settings_meal_schedule.dart index 54c7ffd..b2afd46 100644 --- a/app/lib/screens/settings_meal_schedule.dart +++ b/app/lib/screens/settings_meal_schedule.dart @@ -1,5 +1,5 @@ -// The meal-schedule editor: first meal, last meal, how many meals, and a -// live preview of the checkpoint times they derive. +// The meal-schedule section: owns the schedule state and its persistence; +// the widgets themselves live in `widgets/meal_schedule_editor.dart`. // // Split out of `settings_screen.dart` for the repo's 250-line cap. A `part` // mixin for the same reason as `_SettingsKcalGoal`: it drives the screen's @@ -14,134 +14,22 @@ mixin _SettingsMealSchedule on State { _schedule = MealScheduleService.current; } - /// The meal counts selectable for the current window. - /// - /// Capped at the number of whole hours the window holds, so the count - /// dropdown can never offer a value that would round two meals onto the - /// same hour. Invalid states are made unrepresentable here rather than - /// validated after the fact -- `MealSchedule.normalized` still clamps, but - /// that is the defence against corrupt synced data, not the input contract. - List get _selectableCounts { - final span = _schedule.last - _schedule.first; - final maxCount = span + 1 < kMaxMealCount ? span + 1 : kMaxMealCount; - return [for (var n = kMinMealCount; n <= maxCount; n++) n]; - } - Future _applySchedule(MealSchedule next) async { final normalized = next.normalized(); setState(() => _schedule = normalized); if (!MealScheduleService.isInitialized) return; await MealScheduleService.instance.recordChange(normalized); - // The reminder ids are slot hours, so a schedule change orphans the ones - // it no longer contains. Re-running the check cancels them immediately - // rather than leaving a stale nag until the next background tick. + // The reminder ids are slot minutes, so a schedule change leaves posted + // reminders for checkpoints that no longer exist. Re-running the check + // cancels them immediately rather than leaving a stale nag until the + // next background tick. await checkAndNotify(pullWhenDue: false); } - /// Dropdowns rather than a time picker: `showTimePicker` returns minutes, - /// and slots are whole hours on both devices by design. - Widget _hourDropdown({ - required String label, - required int value, - required List hours, - required ValueChanged onChanged, - }) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: AppWidth.field), - child: InputDecorator( - decoration: InputDecoration(labelText: label), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isDense: true, - isExpanded: true, - items: [ - for (final hour in hours) - DropdownMenuItem(value: hour, child: Text(slotLabel(hour))), - ], - onChanged: (hour) => hour == null ? null : onChanged(hour), - ), - ), + List _mealScheduleSection(BuildContext context) => [ + MealScheduleEditor( + schedule: _schedule, + onChanged: (next) => unawaited(_applySchedule(next)), ), - ); - - List _mealScheduleSection(BuildContext context) { - final theme = Theme.of(context); - final counts = _selectableCounts; - return [ - Text('Meal times', style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text( - 'Checkpoints are spread evenly between your first and last meal.', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 12), - _hourDropdown( - label: 'First meal', - value: _schedule.first, - // Leaves at least one hour of window, so `last` always has a value - // strictly after `first` to offer. - hours: [for (var h = kFirstHour; h < kLastHour; h++) h], - onChanged: (hour) => unawaited( - _applySchedule( - MealSchedule( - first: hour, - last: _schedule.last, - count: _schedule.count, - ), - ), - ), - ), - const SizedBox(height: 12), - _hourDropdown( - label: 'Last meal', - value: _schedule.last, - hours: [for (var h = _schedule.first + 1; h <= kLastHour; h++) h], - onChanged: (hour) => unawaited( - _applySchedule( - MealSchedule( - first: _schedule.first, - last: hour, - count: _schedule.count, - ), - ), - ), - ), - const SizedBox(height: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: AppWidth.field), - child: InputDecorator( - decoration: const InputDecoration(labelText: 'Meals per day'), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: counts.contains(_schedule.count) - ? _schedule.count - : counts.last, - isDense: true, - isExpanded: true, - items: [ - for (final count in counts) - DropdownMenuItem(value: count, child: Text('$count')), - ], - onChanged: (count) => count == null - ? null - : unawaited( - _applySchedule( - MealSchedule( - first: _schedule.first, - last: _schedule.last, - count: count, - ), - ), - ), - ), - ), - ), - ), - const SizedBox(height: 12), - Text( - _schedule.slots().map(slotLabel).join(' · '), - style: theme.textTheme.bodyMedium, - ), - ]; - } + ]; } diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index e305d20..b911ab7 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -12,7 +12,6 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/meal_schedule.dart'; -import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/screens/github_mirror_screen.dart'; import 'package:diet_guard_app/screens/settings_kuchnia.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; @@ -22,6 +21,7 @@ import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/google_sign_in_backend.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/ui/theme.dart'; +import 'package:diet_guard_app/widgets/meal_schedule_editor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/app/lib/services/kuchnia_fill.dart b/app/lib/services/kuchnia_fill.dart index d1da9b3..97e9bd3 100644 --- a/app/lib/services/kuchnia_fill.dart +++ b/app/lib/services/kuchnia_fill.dart @@ -11,6 +11,7 @@ library; import 'package:diet_guard_app/models/kuchnia_dish.dart'; import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/services/kuchnia_spread.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -20,6 +21,11 @@ const kuchniaSource = 'kuchnia wikinga'; /// Keeps only the dishes whose slot has nothing logged in it today. /// +/// [occupied] holds slot minutes *satisfied* today (see +/// `LogStorageService.loggedSlotsToday`), i.e. entries already snapped to +/// the nearest current slot -- not raw recorded minutes, or a meal logged +/// under yesterday's schedule would leave its slot looking empty. +/// /// Fills *empty* slots: a slot the user already logged something into keeps /// that meal rather than gaining a second one. [occupied] is read once, before /// the batch, so two dishes the spread put in the same empty slot (5 dishes on @@ -48,8 +54,12 @@ Nutrition dishNutrition(KuchniaDish dish) => nutritionForPortion( source: kuchniaSource, ); -String _dedupKey(String desc, int? slot) => - '${desc.trim().toLowerCase()}\u0000$slot'; +/// Keyed by the entry's *recorded* slot minute, unsnapped, mirroring Python's +/// `_already_logged` exact comparison. Deliberately not snapped: occupancy +/// ([fillPlan]) already snaps, so this only has to catch the exact dish this +/// batch or an earlier fill wrote into the same slot. +String _dedupKey(String desc, int? slotMinute) => + '${desc.trim().toLowerCase()}\u0000$slotMinute'; /// Logs each chosen dish, skipping any already logged today in that slot. /// @@ -68,7 +78,7 @@ Future> logDishes( final store = storage ?? LogStorageService.instance; final seen = { for (final entry in await store.todayEntries()) - _dedupKey(entry.desc, entry.slot), + _dedupKey(entry.desc, entry.slotMinute), }; final written = []; for (final item in chosen) { @@ -77,7 +87,7 @@ Future> logDishes( await store.logMeal( item.dish.name, dishNutrition(item.dish), - slot: item.slot, + slotMinute: item.slot, ); written.add(item); } @@ -95,11 +105,7 @@ String _g(double value) { String planSummary(List plan) { final total = plan.fold(0, (sum, item) => sum + item.dish.kcal); final listing = plan - .map( - (item) => - '${item.slot.toString().padLeft(2, '0')}:00 ' - '${item.dish.name}', - ) + .map((item) => '${slotLabel(item.slot)} ${item.dish.name}') .join(', '); return 'Will log ${plan.length} (${_g(total)} kcal): $listing' ' — tap Confirm.'; diff --git a/app/lib/services/kuchnia_spread.dart b/app/lib/services/kuchnia_spread.dart index 615ca84..10f759b 100644 --- a/app/lib/services/kuchnia_spread.dart +++ b/app/lib/services/kuchnia_spread.dart @@ -27,19 +27,19 @@ library; import 'package:diet_guard_app/models/kuchnia_dish.dart'; -/// A dish paired with the meal-slot hour it was assigned to. +/// A dish paired with the meal-slot minute it was assigned to. class SlottedDish { - /// Pairs [dish] with the [slot] hour it should be logged against. + /// Pairs [dish] with the [slot] minute it should be logged against. const SlottedDish({required this.dish, required this.slot}); /// The dish itself. final KuchniaDish dish; - /// The slot hour, e.g. `12` for the 12:00 checkpoint. + /// The slot's minute of day, e.g. `720` for the 12:00 checkpoint. final int slot; } -/// Pairs each dish with a slot hour, following the provider's meal order. +/// Pairs each dish with a slot minute, following the provider's meal order. /// /// Returns an empty list when either input is empty -- a day with no delivery /// and a schedule with no slots are both "nothing to assign", not errors. diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart index 51b4cdf..fcf5615 100644 --- a/app/lib/services/log_storage_service.dart +++ b/app/lib/services/log_storage_service.dart @@ -7,8 +7,10 @@ import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/local_time.dart'; import 'package:diet_guard_app/models/meal_component.dart'; import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/services/document_store.dart'; import 'package:diet_guard_app/services/document_store_factory.dart'; +import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; @@ -102,11 +104,12 @@ class LogStorageService { /// Mirrors `_state.log_meal`: always assigns a fresh `id`, never computes /// an `hmac` (the phone never holds the shared key -- the PC re-signs on /// merge, see Milestone 3). [when] lets a caller pre-log a meal against a - /// future date/slot; must not be in the past. + /// future date/slot; must not be in the past. [slotMinute] is the slot's + /// minute of day (`slotForLog`'s result), never an hour. Future logMeal( String desc, Nutrition nutrition, { - int? slot, + int? slotMinute, List? components, DateTime? when, }) async { @@ -124,7 +127,7 @@ class LogStorageService { carbsG: nutrition.carbsG, fatG: nutrition.fatG, source: nutrition.source, - slot: slot, + slotMinute: slotMinute, components: components, ); final log = await readLog(); @@ -229,10 +232,17 @@ class LogStorageService { } } - /// Returns the slot hours already satisfied today, mirrors + /// Returns today's slot minutes already satisfied, mirrors /// `_state.logged_slots_today`. + /// + /// Each entry satisfies the slot *nearest* its recorded minute under the + /// schedule in force now. Stored minutes are never rewritten, so an exact + /// match would let a schedule edit (07:00 -> 07:15) un-log a meal already + /// eaten and re-nag for it -- and disagree with the PC, which snaps too. Future> loggedSlotsToday() async { final entries = await todayEntries(); - return entries.where((e) => e.slot != null).map((e) => e.slot!).toSet(); + return satisfiedSlots([ + for (final entry in entries) ?entry.slotMinute, + ], MealScheduleService.current); } } diff --git a/app/lib/services/meal_schedule_history.dart b/app/lib/services/meal_schedule_history.dart index 81cb116..f49476b 100644 --- a/app/lib/services/meal_schedule_history.dart +++ b/app/lib/services/meal_schedule_history.dart @@ -50,33 +50,28 @@ class ScheduleEntry { } /// Returns the wire/disk form of one entry's value. +/// +/// Goes through [scheduleToWire], so a whole-hour schedule is stored exactly +/// as before minutes existed (`{f, l, n, t}`) and an older build still reads +/// it; `fm`/`lm` appear only for an off-the-hour endpoint. Map scheduleEntryToJson(ScheduleEntry entry) => { - 'f': entry.schedule.first, - 'l': entry.schedule.last, - 'n': entry.schedule.count, + ...scheduleToWire(entry.schedule), 't': entry.editedAt, }; /// Returns an entry parsed from its stored value, or null if unusable. /// /// Never throws: a malformed entry is skipped so one bad field from a peer -/// cannot take out the whole history. +/// cannot take out the whole history. [scheduleFromWire] normalises on the +/// way in, so a peer running a future version with a wider range cannot hand +/// us a schedule we would derive differently. ScheduleEntry? scheduleEntryFromJson(String effectiveFrom, Object? raw) { - if (raw is! Map) return null; - final first = raw['f']; - final last = raw['l']; - final count = raw['n']; - if (first is! int || last is! int || count is! int) return null; + final schedule = scheduleFromWire(raw); + if (schedule == null || raw is! Map) return null; final editedAt = raw['t']; return ScheduleEntry( effectiveFrom: effectiveFrom, - // Normalising on the way in means a peer running a future version with a - // wider range cannot hand us a schedule we would derive differently. - schedule: MealSchedule( - first: first, - last: last, - count: count, - ).normalized(), + schedule: schedule, editedAt: editedAt is String ? editedAt : _epochIso, ); } diff --git a/app/lib/services/notification_backend.dart b/app/lib/services/notification_backend.dart index 438aa6e..1f6e021 100644 --- a/app/lib/services/notification_backend.dart +++ b/app/lib/services/notification_backend.dart @@ -25,4 +25,12 @@ abstract class NotificationBackend { /// Removes the notification for [slot], if one is showing. Future cancel(int slot); + + /// Returns the ids of the meal reminders currently showing. + /// + /// Must include reminders posted by *earlier* builds and runs (on Android + /// the hour-keyed ids an older version left behind), because this is what + /// lets `NotificationService.syncToSlots` cancel orphans without sweeping + /// every possible id. May throw; the caller then falls back to a sweep. + Future> activeIds(); } diff --git a/app/lib/services/notification_backend_io.dart b/app/lib/services/notification_backend_io.dart index d69eac8..bae7ec2 100644 --- a/app/lib/services/notification_backend_io.dart +++ b/app/lib/services/notification_backend_io.dart @@ -66,6 +66,17 @@ class LocalNotificationsBackend implements NotificationBackend { @override Future cancel(int slot) => plugin.cancel(id: slot); + + /// Asks Android which notifications this app has in the shade (API 23+; + /// minSdk is 26), restricted to the reminder channel so nothing else the + /// app might ever post is swept up. Older builds posted on this same + /// channel id, so their hour-keyed reminders are included -- which is what + /// makes the upgrade orphan-free. + @override + Future> activeIds() async => { + for (final active in await plugin.getActiveNotifications()) + if (active.channelId == _channelId && active.id != null) active.id!, + }; } /// Opens the platform notification backend. diff --git a/app/lib/services/notification_backend_web.dart b/app/lib/services/notification_backend_web.dart index 0f00186..dfb0695 100644 --- a/app/lib/services/notification_backend_web.dart +++ b/app/lib/services/notification_backend_web.dart @@ -50,6 +50,13 @@ class WebNotificationBackend implements NotificationBackend { Future cancel(int slot) async { _shown.remove(slot)?.close(); } + + /// The page's own live notifications. Complete for this surface: a browser + /// notification cannot outlive the page that raised it in any way this + /// page could still close, so nothing posted by an earlier load (or an + /// older, hour-keyed build) is reachable to orphan. + @override + Future> activeIds() async => _shown.keys.toSet(); } /// Opens the platform notification backend. diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart index cb61923..be70982 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -4,13 +4,12 @@ /// rather than fired once. library; +import 'package:diet_guard_app/models/meal_schedule.dart'; 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 @@ -53,30 +52,45 @@ class NotificationService { /// false the same way. Future requestPermission() => _backend.requestPermission(); - /// Shows a notification for every slot in [dueSlots] and cancels one for - /// every other known slot. + /// Shows a notification for every slot minute in [dueSlots] and cancels + /// every other reminder this app has showing. /// /// 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. + /// + /// The slot minute doubles as the notification id, so a schedule change + /// leaves ids no current slot names -- and so does the upgrade itself, + /// because older builds used the slot *hour* (0..23) as the id. Iterating + /// only today's slots would leave those posted forever, nagging about + /// checkpoints that no longer exist. Rather than sweeping all 1440 possible + /// ids (a platform call each, every ~15 min from a background isolate), + /// this asks the platform which reminders are actually showing and cancels + /// those not due: one lookup plus one call per real notification, and + /// complete by construction, since an orphan is by definition something + /// showing. Only when that lookup fails does it fall back to the full + /// sweep, paying the cost on that one tick to keep the guarantee. Future syncToSlots(List 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); - } + Iterable stale; + try { + stale = (await _backend.activeIds()).difference(due); + } on Object { + stale = [ + for (var id = 0; id < kMinutesPerDay; id++) + if (!due.contains(id)) id, + ]; + } + for (final id in stale) { + await _backend.cancel(id); + } + for (final slot in due) { + await _backend.show( + slot, + 'Meal not logged', + "You haven't logged your ${slotLabel(slot)} meal yet.", + ); } } } diff --git a/app/lib/services/sync_merge_schedule.dart b/app/lib/services/sync_merge_schedule.dart index dcb50a4..3eb6062 100644 --- a/app/lib/services/sync_merge_schedule.dart +++ b/app/lib/services/sync_merge_schedule.dart @@ -47,12 +47,10 @@ Hlc scheduleHlc(ScheduleEntry entry) { /// default over a peer's real value. Map scheduleFields(List entries) => { for (final entry in entries) + // scheduleToWire keeps a whole-hour schedule byte-identical to the + // pre-minute `{f, l, n}`, so a peer that predates minutes still reads it. '$scheduleFieldPrefix${entry.effectiveFrom}': ( - { - 'f': entry.schedule.first, - 'l': entry.schedule.last, - 'n': entry.schedule.count, - }, + scheduleToWire(entry.schedule), scheduleHlc(entry), ), }; @@ -69,23 +67,15 @@ List logToScheduleHistory(Log log) { final entries = []; for (final field in record.fields.entries) { if (!field.key.startsWith(scheduleFieldPrefix)) continue; - final value = field.value.$1; - if (value is! Map) continue; - final first = value['f']; - final last = value['l']; - final count = value['n']; - if (first is! int || last is! int || count is! int) continue; + // Normalised on the way in (scheduleFromWire), so a peer running a future + // version with a wider range cannot hand us a schedule we would derive + // slots differently from. + final schedule = scheduleFromWire(field.value.$1); + if (schedule == null) continue; entries.add( ScheduleEntry( effectiveFrom: field.key.substring(scheduleFieldPrefix.length), - // Normalised on the way in, so a peer running a future version with a - // wider range cannot hand us a schedule we would derive slots - // differently from. - schedule: MealSchedule( - first: first, - last: last, - count: count, - ).normalized(), + schedule: schedule, editedAt: DateTime.fromMillisecondsSinceEpoch( field.value.$2.wallTimeMs, isUtc: true, diff --git a/app/lib/widgets/meal_schedule_editor.dart b/app/lib/widgets/meal_schedule_editor.dart new file mode 100644 index 0000000..a6bfd97 --- /dev/null +++ b/app/lib/widgets/meal_schedule_editor.dart @@ -0,0 +1,119 @@ +/// The meal-schedule editor: first meal, last meal, how many meals, and a +/// live preview of the checkpoint times they derive. +library; + +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:diet_guard_app/widgets/meal_time_dropdown.dart'; +import 'package:flutter/material.dart'; + +/// Latest minute the *grid* offers for the first meal: 23:30, leaving one +/// grid step for a grid-aligned last meal. "Custom…" can go later, up to the +/// 23:44 that [MealSchedule.normalized] allows. +const int _firstGridMax = kMinutesPerDay - 2 * kSlotGridMinutes; + +/// Latest first-meal minute [MealSchedule.normalized] keeps: one grid step +/// before the end of the day. +const int _firstMax = kMinutesPerDay - 1 - kSlotGridMinutes; + +/// Edits a [MealSchedule], reporting every change through [onChanged]. +/// +/// Stateless: the owner holds the schedule and persists it. Every offered +/// value is already legal, so `normalized()` in the owner is the defence +/// against corrupt synced data rather than the input contract. +class MealScheduleEditor extends StatelessWidget { + /// Creates a [MealScheduleEditor]. + const MealScheduleEditor({ + required this.schedule, + required this.onChanged, + super.key, + }); + + /// The schedule being shown. + final MealSchedule schedule; + + /// Called with the edited (not yet normalised) schedule. + final ValueChanged onChanged; + + /// The meal counts selectable for the current window. + /// + /// Capped at the number of grid-spaced checkpoints the window holds, so the + /// dropdown can never offer a count that would round two meals onto the + /// same quarter hour -- the same capacity rule `normalized` enforces. + List get selectableCounts { + final span = schedule.lastMinute - schedule.firstMinute; + final capacity = span ~/ kSlotGridMinutes + 1; + final maxCount = capacity < kMaxMealCount ? capacity : kMaxMealCount; + return [for (var n = kMinMealCount; n <= maxCount; n++) n]; + } + + MealSchedule _with({int? first, int? last, int? count}) => MealSchedule( + firstMinute: first ?? schedule.firstMinute, + lastMinute: last ?? schedule.lastMinute, + count: count ?? schedule.count, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final counts = selectableCounts; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Meal times', style: theme.textTheme.titleMedium), + const SizedBox(height: AppSpacing.xs), + Text( + 'Checkpoints are spread evenly between your first and last meal.', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 12), + MealTimeDropdown( + label: 'First meal', + value: schedule.firstMinute, + minMinute: 0, + maxMinute: _firstMax, + gridMaxMinute: _firstGridMax, + onChanged: (minute) => onChanged(_with(first: minute)), + ), + const SizedBox(height: 12), + MealTimeDropdown( + label: 'Last meal', + value: schedule.lastMinute, + // At least one grid step after the first meal: the shortest window + // that still holds two distinct checkpoints. + minMinute: schedule.firstMinute + kSlotGridMinutes, + maxMinute: kMinutesPerDay - 1, + onChanged: (minute) => onChanged(_with(last: minute)), + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: AppWidth.field), + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Meals per day'), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: counts.contains(schedule.count) + ? schedule.count + : counts.last, + isDense: true, + isExpanded: true, + items: [ + for (final count in counts) + DropdownMenuItem(value: count, child: Text('$count')), + ], + onChanged: (count) => + count == null ? null : onChanged(_with(count: count)), + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + schedule.slots().map(slotLabel).join(' · '), + style: theme.textTheme.bodyMedium, + ), + ], + ); + } +} diff --git a/app/lib/widgets/meal_time_dropdown.dart b/app/lib/widgets/meal_time_dropdown.dart new file mode 100644 index 0000000..71223df --- /dev/null +++ b/app/lib/widgets/meal_time_dropdown.dart @@ -0,0 +1,126 @@ +/// A minute-of-day picker: a 15-minute dropdown plus a "Custom…" escape. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; + +/// The dropdown value standing for "Custom…". Negative, so it can never +/// collide with a real minute of day (`0..1439`). +const int _customValue = -1; + +/// Picks a minute of day, offering the 15-minute grid within +/// [minMinute]..[maxMinute] and a "Custom…" item that opens a time picker +/// for any minute in that range. +/// +/// The grid is what most people want and keeps the list short; "Custom…" +/// exists because an endpoint may legitimately sit off the grid (07:23 to +/// match a train), and because the PC can set such a value and sync it here. +/// That second case is why the current [value] is always offered as an item +/// even when it is off-grid or outside the range: a [DropdownButton] whose +/// value matches no item asserts, which would take the settings screen down +/// over a value another device wrote. +class MealTimeDropdown extends StatelessWidget { + /// Creates a [MealTimeDropdown]. + const MealTimeDropdown({ + required this.label, + required this.value, + required this.minMinute, + required this.maxMinute, + required this.onChanged, + this.gridMaxMinute, + super.key, + }); + + /// The field label, e.g. "First meal". + final String label; + + /// The current minute of day. + final int value; + + /// Earliest minute offered (inclusive). + final int minMinute; + + /// Latest minute offered (inclusive), through "Custom…" if not the grid. + final int maxMinute; + + /// Latest grid mark listed, when it must stop short of [maxMinute] (the + /// first meal's grid ends at 23:30 so a grid-aligned last meal still fits, + /// while "Custom…" may go to 23:44). Defaults to [maxMinute]. + final int? gridMaxMinute; + + /// Called with the chosen minute, already clamped into + /// [minMinute]..[maxMinute]. Not called when the picker is dismissed. + final ValueChanged onChanged; + + /// The minutes listed, ascending: the grid marks in range plus [value]. + List get options { + final first = + (minMinute + kSlotGridMinutes - 1) ~/ + kSlotGridMinutes * + kSlotGridMinutes; + return { + for ( + var m = first; + m <= (gridMaxMinute ?? maxMinute); + m += kSlotGridMinutes + ) + m, + value, + }.toList()..sort(); + } + + int _clampToRange(int minute) => minute < minMinute + ? minMinute + : (minute > maxMinute ? maxMinute : minute); + + Future _pickCustom(BuildContext context) async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60), + helpText: label, + // The rest of the app writes times as HH:MM; an AM/PM dial here would + // be the one place that reads differently. + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child!, + ), + ); + if (picked == null) return; + // Clamped rather than rejected: the picker cannot be told the range, and + // the nearest legal minute is what `MealSchedule.normalized` would + // produce anyway -- doing it here keeps the dropdown's own contract. + onChanged(_clampToRange(picked.hour * 60 + picked.minute)); + } + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: AppWidth.field), + child: InputDecorator( + decoration: InputDecoration(labelText: label), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + items: [ + for (final minute in options) + DropdownMenuItem(value: minute, child: Text(slotLabel(minute))), + const DropdownMenuItem(value: _customValue, child: Text('Custom…')), + ], + onChanged: (minute) { + if (minute == null) return; + if (minute == _customValue) { + unawaited(_pickCustom(context)); + } else { + onChanged(minute); + } + }, + ), + ), + ), + ); +} diff --git a/app/lib/widgets/slot_selector_row.dart b/app/lib/widgets/slot_selector_row.dart index 71bf309..8b91c0b 100644 --- a/app/lib/widgets/slot_selector_row.dart +++ b/app/lib/widgets/slot_selector_row.dart @@ -3,15 +3,16 @@ /// used to be three separate stacked elements. library; +import 'package:diet_guard_app/models/meal_schedule.dart'; import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; -/// One row of [ChoiceChip]s, one per today's slot hour. Each chip is -/// simultaneously selectable (tap to log for that slot) and status-colored -/// (green+check = logged, red = due, grey = upcoming), so no separate status -/// bar or caption text is needed. +/// One row of [ChoiceChip]s, one per today's slot (labelled `HH:MM`). Each +/// chip is simultaneously selectable (tap to log for that slot) and +/// status-colored (green+check = logged, red = due, grey = upcoming), so no +/// separate status bar or caption text is needed. /// /// There used to be a fifth "Snack" chip that selected no slot at all, /// removed 2026-08-14. Entries with a null slot still exist in storage and @@ -24,20 +25,27 @@ class SlotSelectorRow extends StatelessWidget { required this.loggedSlots, required this.selectedSlot, required this.onSlotSelected, + this.schedule, super.key, }); /// Reference time used to decide which slots are due. final DateTime now; - /// Slot hours already satisfied by today's log. + /// The schedule whose slots to show; defaults to the one in force today. + /// + /// Passed explicitly for a future day so the chips and [loggedSlots] (which + /// the caller snapped against that day's schedule) can never disagree. + final MealSchedule? schedule; + + /// Slot minutes already satisfied by the day's log (`satisfiedSlots`). final Set loggedSlots; /// The slot currently chosen to log for. Nullable for historical reasons /// (see the class doc); no chip in this row selects null any more. final int? selectedSlot; - /// Called with the tapped slot's hour. Never called with null. + /// Called with the tapped slot's minute. Never called with null. final ValueChanged onSlotSelected; @override @@ -49,7 +57,7 @@ class SlotSelectorRow extends StatelessWidget { // matches production exactly when the extension is genuinely absent. final statusColors = Theme.of(context).extension() ?? AppStatusColors.dark; - final schedule = MealScheduleService.current; + final schedule = this.schedule ?? MealScheduleService.current; final elapsed = elapsedSlots(now, schedule).toSet(); // One row, always. A Wrap dropped the later pills onto a second line as // soon as the row outgrew the width, which it does even at four: a logged diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c33caf7..463443c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.106+1 +version: 1.0.107+1 environment: sdk: ^3.12.2 diff --git a/app/test/fake_notifications.dart b/app/test/fake_notifications.dart index d3da36f..3ca9186 100644 --- a/app/test/fake_notifications.dart +++ b/app/test/fake_notifications.dart @@ -3,28 +3,57 @@ import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_test/flutter_test.dart'; +/// The channel id `LocalNotificationsBackend` posts reminders on -- and the +/// one older, hour-keyed builds used too. +const reminderChannelId = 'diet_guard_due_slot'; + /// Mocks the raw `dexterous.com/flutter/local_notifications` MethodChannel /// the way the package's own test suite does /// (`android_flutter_local_notifications_test.dart`), so /// [NotificationService] can be exercised end-to-end (init/show/cancel) /// without a real Android plugin. /// +/// Stateful, like the real notification shade: `show` adds an id, `cancel` +/// removes it, and `getActiveNotifications` reports what is left. Without +/// that, the active-id lookup `syncToSlots` relies on would always see an +/// empty shade and every orphan test would pass vacuously. Seed [active] +/// (id -> channel id) to simulate reminders a previous run or an older build +/// left behind. +/// /// Returns the call log so a test can assert which slots were shown vs. /// cancelled. -List installFakeAndroidNotifications() { +List installFakeAndroidNotifications({ + Map? active, +}) { AndroidFlutterLocalNotificationsPlugin.registerWith(); debugDefaultTargetPlatformOverride = TargetPlatform.android; const channel = MethodChannel('dexterous.com/flutter/local_notifications'); final log = []; + final shade = active ?? {}; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { log.add(call); + final args = call.arguments; switch (call.method) { case 'initialize': return true; case 'requestNotificationsPermission': return true; + case 'show': + final map = args as Map; + final details = map['platformSpecifics'] as Map?; + shade[map['id'] as int] = + details?['channelId'] as String? ?? reminderChannelId; + return null; + case 'cancel': + shade.remove((args as Map)['id']); + return null; + case 'getActiveNotifications': + return [ + for (final entry in shade.entries) + {'id': entry.key, 'channelId': entry.value}, + ]; default: return null; } diff --git a/app/test/kuchnia_fill_flow_test.dart b/app/test/kuchnia_fill_flow_test.dart index 91c9e5a..ae0be4f 100644 --- a/app/test/kuchnia_fill_flow_test.dart +++ b/app/test/kuchnia_fill_flow_test.dart @@ -76,7 +76,7 @@ void main() { final outcome = await flow.tap(); expect(outcome!.message, 'Logged 4 catering dishes.'); - expect(await loggedToday(storage), ['D1@8', 'D2@12', 'D3@16', 'D4@20']); + expect(await loggedToday(storage), ['D1@480', 'D2@720', 'D3@960', 'D4@1200']); expect( (await storage.todayEntries()).map((e) => e.source), everyElement(kuchniaSource), @@ -94,18 +94,18 @@ void main() { await storage.logMeal( 'my own lunch', dishNutrition(dish('x', 1)), - slot: 12, + slotMinute: 720, ); - occupied = {12}; + occupied = {720}; final outcome = await flow.tap(); expect(outcome!.message, 'Logged 3 catering dishes.'); expect(await loggedToday(storage), [ - 'my own lunch@12', - 'D1@8', - 'D3@16', - 'D4@20', + 'my own lunch@720', + 'D1@480', + 'D3@960', + 'D4@1200', ]); expect(occupancyReads, 2, reason: 'once to propose, once to confirm'); }, @@ -113,7 +113,7 @@ void main() { test('a confirm into a now-full day writes nothing', () async { await flow.tap(); - occupied = {8, 12, 16, 20}; + occupied = {480, 720, 960, 1200}; final outcome = await flow.tap(); @@ -131,11 +131,11 @@ void main() { await flow.tap(); expect(await loggedToday(storage), [ - 'D1@8', - 'D2@8', - 'D3@12', - 'D4@16', - 'D5@20', + 'D1@480', + 'D2@480', + 'D3@720', + 'D4@960', + 'D5@1200', ]); }, ); @@ -193,7 +193,7 @@ void main() { }); test('all slots full arms nothing', () async { - occupied = {8, 12, 16, 20}; + occupied = {480, 720, 960, 1200}; final outcome = await flow.tap(); expect(outcome!.message, nothingToFillMessage); diff --git a/app/test/kuchnia_fill_parity_test.dart b/app/test/kuchnia_fill_parity_test.dart index 5c1e909..ce92d90 100644 --- a/app/test/kuchnia_fill_parity_test.dart +++ b/app/test/kuchnia_fill_parity_test.dart @@ -97,13 +97,15 @@ void main() { await storage.logMeal( row['desc'] as String, _seedNutrition, - slot: row['slot'] as int, + // Fixture slots are resolved minutes: seeded through logMeal, so + // storage writes them via slotFields, never as a raw hour. + slotMinute: row['slot'] as int, ); } final returned = await logDishes(spread(testCase), storage: storage); final logged = [ for (final e in (await storage.todayEntries()).skip(seeded.length)) - [e.desc, e.slot], + [e.desc, e.slotMinute], ]; final reason = 'logDishes diverged for ${entry.key}'; expect(logged, equals(testCase['expected']), reason: reason); diff --git a/app/test/kuchnia_fill_support.dart b/app/test/kuchnia_fill_support.dart index 2eebaa9..7049579 100644 --- a/app/test/kuchnia_fill_support.dart +++ b/app/test/kuchnia_fill_support.dart @@ -21,7 +21,7 @@ class MemoryStore implements DocumentStore { } /// The default four-slot day. -const defaultSlots = [8, 12, 16, 20]; +const defaultSlots = [480, 720, 960, 1200]; /// A dish with the given [priority]; macros are distinct per priority. KuchniaDish dish(String name, int priority, {double grams = 300}) => @@ -53,5 +53,5 @@ LogStorageService freshStorage() { /// `desc@slot` for every live entry logged today, in write order. Future> loggedToday(LogStorageService storage) async => [ - for (final e in await storage.todayEntries()) '${e.desc}@${e.slot}', + for (final e in await storage.todayEntries()) '${e.desc}@${e.slotMinute}', ]; diff --git a/app/test/kuchnia_fill_test.dart b/app/test/kuchnia_fill_test.dart index 1b2dfd8..2b7ce0d 100644 --- a/app/test/kuchnia_fill_test.dart +++ b/app/test/kuchnia_fill_test.dart @@ -23,40 +23,40 @@ void main() { test('an empty day keeps every dish', () { final slotted = assignSlots(dishes(4), defaultSlots); expect(_names(fillPlan(slotted, {})), [ - 'D1@8', - 'D2@12', - 'D3@16', - 'D4@20', + 'D1@480', + 'D2@720', + 'D3@960', + 'D4@1200', ]); }); test('an occupied slot is skipped, the others stay', () { final slotted = assignSlots(dishes(4), defaultSlots); - expect(_names(fillPlan(slotted, {12})), ['D1@8', 'D3@16', 'D4@20']); + expect(_names(fillPlan(slotted, {720})), ['D1@480', 'D3@960', 'D4@1200']); }); test('5 dishes on 4 slots: both dishes of one empty slot stay', () { final slotted = assignSlots(dishes(5), defaultSlots); // The spread doubles the first slot; occupancy is read once, so the // first dish must not shadow the second. - expect(_names(slotted).take(2), ['D1@8', 'D2@8']); + expect(_names(slotted).take(2), ['D1@480', 'D2@480']); expect(_names(fillPlan(slotted, {})), hasLength(5)); - expect(_names(fillPlan(slotted, {16})), [ - 'D1@8', - 'D2@8', - 'D3@12', - 'D5@20', + expect(_names(fillPlan(slotted, {960})), [ + 'D1@480', + 'D2@480', + 'D3@720', + 'D5@1200', ]); }); test('an occupied doubled slot drops both of its dishes', () { final slotted = assignSlots(dishes(5), defaultSlots); - expect(_names(fillPlan(slotted, {8})), ['D3@12', 'D4@16', 'D5@20']); + expect(_names(fillPlan(slotted, {480})), ['D3@720', 'D4@960', 'D5@1200']); }); test('a full day yields an empty plan', () { final slotted = assignSlots(dishes(4), defaultSlots); - expect(fillPlan(slotted, {8, 12, 16, 20}), isEmpty); + expect(fillPlan(slotted, {480, 720, 960, 1200}), isEmpty); }); }); @@ -79,16 +79,16 @@ void main() { () async { final storage = freshStorage(); final chosen = [ - at(dish('D1', 1, grams: 250), 8), - at(dish('D3', 3), 16), + at(dish('D1', 1, grams: 250), 480), + at(dish('D3', 3), 960), ]; final written = await logDishes(chosen, storage: storage); - expect(_names(written), ['D1@8', 'D3@16']); + expect(_names(written), ['D1@480', 'D3@960']); final entries = await storage.todayEntries(); expect(entries.map((e) => e.desc), ['D1', 'D3']); - expect(entries.map((e) => e.slot), [8, 16]); + expect(entries.map((e) => e.slotMinute), [480, 960]); expect(entries.map((e) => e.source), everyElement('kuchnia wikinga')); expect(entries.map((e) => e.grams), [250, 300]); expect(entries.map((e) => e.kcal), [100, 300]); @@ -97,15 +97,15 @@ void main() { test('skips a dish already logged in that slot', () async { final storage = freshStorage(); - await storage.logMeal(' d1 ', dishNutrition(dish('D1', 1)), slot: 8); + await storage.logMeal(' d1 ', dishNutrition(dish('D1', 1)), slotMinute: 480); final written = await logDishes([ - at(dish('D1', 1), 8), - at(dish('D2', 2), 12), + at(dish('D1', 1), 480), + at(dish('D2', 2), 720), ], storage: storage); // Case and surrounding whitespace do not make it a different meal. - expect(_names(written), ['D2@12']); + expect(_names(written), ['D2@720']); expect(await storage.todayEntries(), hasLength(2)); }); @@ -114,30 +114,30 @@ void main() { final twin = dish('Twin', 1); final written = await logDishes([ - at(twin, 8), - at(twin, 8), + at(twin, 480), + at(twin, 480), ], storage: storage); - expect(_names(written), ['Twin@8']); - expect(await loggedToday(storage), ['Twin@8']); + expect(_names(written), ['Twin@480']); + expect(await loggedToday(storage), ['Twin@480']); }); test('the same dish in a different slot is not a duplicate', () async { final storage = freshStorage(); - await storage.logMeal('D1', dishNutrition(dish('D1', 1)), slot: 8); + await storage.logMeal('D1', dishNutrition(dish('D1', 1)), slotMinute: 480); final written = await logDishes([ - at(dish('D1', 1), 12), + at(dish('D1', 1), 720), ], storage: storage); - expect(_names(written), ['D1@12']); - expect(await loggedToday(storage), ['D1@8', 'D1@12']); + expect(_names(written), ['D1@720']); + expect(await loggedToday(storage), ['D1@480', 'D1@720']); }); }); group('planSummary', () { test('lists slot, dish and the kcal total', () { - final plan = [at(dish('D1', 1), 8), at(dish('D2', 2), 12)]; + final plan = [at(dish('D1', 1), 480), at(dish('D2', 2), 720)]; expect( planSummary(plan), 'Will log 2 (300 kcal): 08:00 D1, 12:00 D2 — tap Confirm.', diff --git a/app/test/models/food_entry_test.dart b/app/test/models/food_entry_test.dart index ec121e2..677fcb5 100644 --- a/app/test/models/food_entry_test.dart +++ b/app/test/models/food_entry_test.dart @@ -32,7 +32,7 @@ void main() { expect(entry.id, 'abc-123'); expect(entry.desc, 'label_food'); expect(entry.kcal, 300.0); - expect(entry.slot, 16); + expect(entry.slotMinute, 960); expect(entry.hmac, 'deadbeef'); expect(entry.components, hasLength(1)); expect(entry.components!.first.name, 'rice'); @@ -45,7 +45,7 @@ void main() { expect(entry.kcal, 0); expect(entry.proteinG, 0); expect(entry.source, 'manual'); - expect(entry.slot, isNull); + expect(entry.slotMinute, isNull); expect(entry.deleted, isFalse); expect(entry.components, isNull); }); @@ -67,6 +67,7 @@ void main() { ); final local = entry.toLocalJson(); final sync = entry.toSyncJson(); + expect(local, sync); expect(sync.containsKey('hmac'), isFalse); expect(sync['desc'], 'toast'); }); @@ -136,4 +137,50 @@ void main() { expect(tombstoned.components, base.components); }); }); + + group('slot minute round trip', () { + Map entry(Map slot) => { + 'id': 'id-1', + 'time': '2026-06-22T07:15:00+02:00', + 'desc': 'toast', + 'grams': 50.0, + 'kcal': 120.0, + 'protein_g': 3.0, + 'carbs_g': 20.0, + 'fat_g': 2.0, + 'source': 'manual', + ...slot, + }; + + test('a PC entry with slot_min survives a phone round trip', () { + // The regression this guards: keeping only the hour made every phone + // edit or tombstone republish a stripped copy of the PC's entry. + final raw = entry({'slot': 7, 'slot_min': 435}); + final parsed = FoodEntry.fromJson(raw); + expect(parsed.slotMinute, 435); + expect(parsed.toSyncJson(), raw); + expect(parsed.copyWithDeleted().slotMinute, 435); + expect(parsed.copyWithDeleted().toSyncJson()['slot_min'], 435); + }); + + test('a legacy whole-hour entry stays byte-identical', () { + final raw = entry({'slot': 8}); + final parsed = FoodEntry.fromJson(raw); + expect(parsed.slotMinute, 480); + expect(parsed.toSyncJson(), raw); + expect(parsed.toSyncJson().containsKey('slot_min'), isFalse); + }); + + test('an out-of-range peer slot is echoed, never thrown on', () { + // slotFields throws outside the day; writeLog encodes every entry, so a + // throw here would block all logging behind one bad synced entry. + expect(FoodEntry.fromJson(entry({'slot': 24})).toSyncJson()['slot'], 24); + final late = FoodEntry.fromJson(entry({'slot_min': 1510})).toSyncJson(); + expect(late['slot'], 25); + expect(late['slot_min'], 1510); + final negative = FoodEntry.fromJson(entry({'slot': -1})).toSyncJson(); + expect(negative.containsKey('slot'), isFalse); + expect(negative['slot_min'], -60); + }); + }); } diff --git a/app/test/screens/log_meal_fill_mixin_test.dart b/app/test/screens/log_meal_fill_mixin_test.dart index 46a9d9b..0da9a3c 100644 --- a/app/test/screens/log_meal_fill_mixin_test.dart +++ b/app/test/screens/log_meal_fill_mixin_test.dart @@ -134,7 +134,7 @@ void main() { expect(await t.runAsync(() => loggedToday(storage)), hasLength(4)); expect(host.publishes, 1, reason: 'one publish per batch, not per dish'); expect(KuchniaQueueService.remaining, 0); - expect(host.loggedSlots, {8, 12, 16, 20}); + expect(host.loggedSlots, {480, 720, 960, 1200}); expect(host.desc.text, isEmpty, reason: 'D1 was logged by the fill'); }); @@ -144,7 +144,7 @@ void main() { await storage.logMeal( 'own breakfast', dishNutrition(dish('own', 1)), - slot: 8, + slotMinute: 480, ); await host.onFillAll(); await host.onFillAll(); diff --git a/app/test/screens/log_meal_schedule_test.dart b/app/test/screens/log_meal_schedule_test.dart index 29ebe9b..6ceadf0 100644 --- a/app/test/screens/log_meal_schedule_test.dart +++ b/app/test/screens/log_meal_schedule_test.dart @@ -56,7 +56,7 @@ void main() { expect(find.text('12:00'), findsOneWidget); await MealScheduleService.instance.recordChange( - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); final state = tester.state>( find.byType(LogMealScreen), diff --git a/app/test/screens/log_meal_screen_future_test.dart b/app/test/screens/log_meal_screen_future_test.dart index d7605f7..fbcf9e8 100644 --- a/app/test/screens/log_meal_screen_future_test.dart +++ b/app/test/screens/log_meal_screen_future_test.dart @@ -85,7 +85,7 @@ void main() { final log = await LogStorageService.instance.readLog(); final future = log[tomorrowKey]!.single; expect(future.desc, 'future meal'); - expect(future.slot, firstSlot); + expect(future.slotMinute, firstSlot); expect(future.time.startsWith(tomorrowKey), isTrue); // Back to logging for today. diff --git a/app/test/screens/log_meal_screen_nav_test.dart b/app/test/screens/log_meal_screen_nav_test.dart index 4481a4f..be3a98f 100644 --- a/app/test/screens/log_meal_screen_nav_test.dart +++ b/app/test/screens/log_meal_screen_nav_test.dart @@ -163,7 +163,7 @@ void main() { carbsG: 40, fatG: 5, source: 'manual', - slot: 8, + slotMinute: 480, ), ], }); diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index 18ba9cc..7ba0c40 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -90,7 +90,7 @@ void main() { ) async { await tester.runAsync(() async { await MealScheduleService.instance.recordChange( - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); diff --git a/app/test/services/due_slot_check_test.dart b/app/test/services/due_slot_check_test.dart index 265196b..59a6d60 100644 --- a/app/test/services/due_slot_check_test.dart +++ b/app/test/services/due_slot_check_test.dart @@ -75,37 +75,42 @@ void main() { }); test( - 'shows due-and-unlogged slots, cancels logged and upcoming ones', + 'shows due-and-unlogged slots, cancels one once its meal is logged', () async { - await LogStorageService.instance.logMeal('lunch', _manual, slot: 12); + await checkAndNotify(now: DateTime(2026, 1, 1, 16)); + Set ids(String method) => { + for (final call in notificationLog) + if (call.method == method) (call.arguments as Map)['id'], + }; + // Ids are slot minutes: 08:00, 12:00 and 16:00 are due by 16:00. + expect(ids('show'), {480, 720, 960}); + notificationLog.clear(); + await LogStorageService.instance.logMeal( + 'lunch', + _manual, + slotMinute: 720, + ); await checkAndNotify(now: DateTime(2026, 1, 1, 16)); - final shown = notificationLog - .where((c) => c.method == 'show') - .map((c) => (c.arguments as Map)['id']) - .toSet(); - final cancelled = notificationLog - .where((c) => c.method == 'cancel') - .map((c) => (c.arguments as Map)['id']) - .toSet(); - expect(shown, {8, 16}); - // Every other id in the 0..23 space is cancelled, not just the other - // slots of the current schedule -- see syncToSlots' comment. - expect(cancelled, containsAll({12, 20})); - expect(cancelled.intersection(shown), isEmpty); + expect(ids('show'), {480, 960}); + expect(ids('cancel'), {720}); }, ); - test('cancels everything when every due slot is logged', () async { - await LogStorageService.instance.logMeal('breakfast', _manual, slot: 8); + test('a tick with every due slot logged posts and cancels nothing', () async { + await LogStorageService.instance.logMeal( + 'breakfast', + _manual, + slotMinute: 480, + ); await checkAndNotify(now: DateTime(2026, 1, 1, 8)); expect(notificationLog.where((c) => c.method == 'show'), isEmpty); - // 24, not 4: syncToSlots sweeps the whole id space so a schedule change - // cannot orphan the ids it no longer contains. - expect(notificationLog.where((c) => c.method == 'cancel'), hasLength(24)); + // Nothing is showing, so nothing is cancelled: the active-id lookup + // replaced a sweep of the whole id space on every tick. + expect(notificationLog.where((c) => c.method == 'cancel'), isEmpty); }); test('uses the real clock when now is omitted', () async { @@ -119,7 +124,11 @@ void main() { }); test('still syncs when nothing is due', () async { - await LogStorageService.instance.logMeal('breakfast', _manual, slot: 8); + await LogStorageService.instance.logMeal( + 'breakfast', + _manual, + slotMinute: 480, + ); SharedPreferences.setMockInitialValues({ 'sync.owner': 'o', 'sync.repo': 'r', @@ -216,6 +225,6 @@ void main() { .where((c) => c.method == 'show') .map((c) => (c.arguments as Map)['id']) .toSet(); - expect(shown, isNot(contains(12))); + expect(shown, isNot(contains(720))); }); } diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart index 745bb62..6ae5fd8 100644 --- a/app/test/services/log_storage_service_test.dart +++ b/app/test/services/log_storage_service_test.dart @@ -59,11 +59,11 @@ void main() { final entry = await LogStorageService.instance.logMeal( 'toast', _manual, - slot: 8, + slotMinute: 480, ); expect(entry.id, isNotEmpty); expect(entry.hmac, isNull); - expect(entry.slot, 8); + expect(entry.slotMinute, 480); expect(entry.desc, 'toast'); }); @@ -100,7 +100,7 @@ void main() { final entry = await LogStorageService.instance.logMeal( 'future meal', _manual, - slot: 12, + slotMinute: 720, when: future, ); final log = await LogStorageService.instance.readLog(); @@ -195,9 +195,17 @@ void main() { }); group('loggedSlotsToday', () { test('returns only the slots with a logged entry', () async { - await LogStorageService.instance.logMeal('a', _manual, slot: 8); + await LogStorageService.instance.logMeal('a', _manual, slotMinute: 480); await LogStorageService.instance.logMeal('b', _manual); - expect(await LogStorageService.instance.loggedSlotsToday(), {8}); + expect(await LogStorageService.instance.loggedSlotsToday(), {480}); + }); + + test('snaps an off-slot minute to the nearest slot', () async { + // 07:30 under the default 08/12/16/20 schedule satisfies 08:00, and + // two meals snapping onto one slot satisfy only that slot. + await LogStorageService.instance.logMeal('a', _manual, slotMinute: 450); + await LogStorageService.instance.logMeal('b', _manual, slotMinute: 500); + expect(await LogStorageService.instance.loggedSlotsToday(), {480}); }); }); } diff --git a/app/test/services/meal_schedule_history_test.dart b/app/test/services/meal_schedule_history_test.dart index 9f12c25..89849ef 100644 --- a/app/test/services/meal_schedule_history_test.dart +++ b/app/test/services/meal_schedule_history_test.dart @@ -17,7 +17,7 @@ void main() { test('round trips', () { const entry = ScheduleEntry( effectiveFrom: '2026-08-16', - schedule: MealSchedule(first: 8, last: 20, count: 5), + schedule: MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), editedAt: '2026-08-16T12:00:00.000', ); expect( @@ -32,7 +32,7 @@ void main() { 'l': 20, 'n': 99, }); - expect(entry!.schedule, const MealSchedule(first: 8, last: 20, count: 6)); + expect(entry!.schedule, const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 6)); }); test('falls back to the epoch when the stamp is missing', () { @@ -86,7 +86,7 @@ void main() { final history = MealScheduleHistory(const [ ScheduleEntry( effectiveFrom: '2026-08-16', - schedule: MealSchedule(first: 8, last: 20, count: 5), + schedule: MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), editedAt: 't', ), ]); @@ -97,7 +97,7 @@ void main() { final history = MealScheduleHistory(const [ ScheduleEntry( effectiveFrom: '2026-08-16', - schedule: MealSchedule(first: 8, last: 20, count: 5), + schedule: MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), editedAt: 't1', ), ScheduleEntry( @@ -108,30 +108,30 @@ void main() { ]); expect( history.forDay('2026-08-16'), - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); expect(history.forDay('2026-05-01'), kDefaultSchedule); }); test('upsert replaces a same-day re-edit', () { final once = MealScheduleHistory.empty.upsert( - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), when: _at('2026-08-16'), ); final twice = once.upsert( - const MealSchedule(first: 9, last: 21, count: 3), + const MealSchedule(firstMinute: 9 * 60, lastMinute: 21 * 60, count: 3), when: _at('2026-08-16'), ); expect(twice.entries, hasLength(1)); expect( twice.entries.single.schedule, - const MealSchedule(first: 9, last: 21, count: 3), + const MealSchedule(firstMinute: 9 * 60, lastMinute: 21 * 60, count: 3), ); }); test('upsert defaults to now', () { final history = MealScheduleHistory.empty.upsert( - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); expect(history.entries, hasLength(1)); }); diff --git a/app/test/services/meal_schedule_service_test.dart b/app/test/services/meal_schedule_service_test.dart index bd2ba58..29a1f8b 100644 --- a/app/test/services/meal_schedule_service_test.dart +++ b/app/test/services/meal_schedule_service_test.dart @@ -56,13 +56,13 @@ void main() { () async { await MealScheduleService.initForTesting(store); await MealScheduleService.instance.recordChange( - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); await MealScheduleService.initForTesting(store); expect( MealScheduleService.current, - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); // The whole point of the history: a day before the edit keeps the // four-meal schedule it was actually judged against. @@ -103,7 +103,7 @@ void main() { () async { await MealScheduleService.initForTesting(store); await MealScheduleService.instance.recordChange( - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); // A pre-feature peer contributes no `sched:` fields; writing that back @@ -111,20 +111,20 @@ void main() { await MealScheduleService.instance.applyMerged(const []); expect( MealScheduleService.current, - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); final stamp = DateTime.parse('2026-08-16T09:00:00.000'); await MealScheduleService.instance.applyMerged([ ScheduleEntry( effectiveFrom: kScheduleEpochDay, - schedule: const MealSchedule(first: 7, last: 19, count: 3), + schedule: const MealSchedule(firstMinute: 7 * 60, lastMinute: 19 * 60, count: 3), editedAt: stamp.toIso8601String(), ), ], updatedAt: stamp); expect( MealScheduleService.current, - const MealSchedule(first: 7, last: 19, count: 3), + const MealSchedule(firstMinute: 7 * 60, lastMinute: 19 * 60, count: 3), ); // The winner's stamp is kept verbatim, so re-syncing is idempotent. expect(MealScheduleService.updatedAt, stamp); @@ -134,7 +134,7 @@ void main() { test('the stored document is plain readable JSON', () async { await MealScheduleService.initForTesting(store); await MealScheduleService.instance.recordChange( - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), when: _at('2026-08-16'), ); final decoded = diff --git a/app/test/services/notification_service_test.dart b/app/test/services/notification_service_test.dart index 9ec7eb8..a3c106a 100644 --- a/app/test/services/notification_service_test.dart +++ b/app/test/services/notification_service_test.dart @@ -1,5 +1,8 @@ +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/services/notification_backend.dart'; import 'package:diet_guard_app/services/notification_backend_io.dart'; import 'package:diet_guard_app/services/notification_service.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -41,98 +44,134 @@ void main() { expect(await NotificationService.instance.requestPermission(), isTrue); }); - test('syncToSlots shows due slots and cancels the rest', () async { - final log = installFakeAndroidNotifications(); + Future> ready({Map? active}) async { + final log = installFakeAndroidNotifications(active: active); NotificationService.resetForTesting( backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), ); await NotificationService.init(); log.clear(); + return log; + } - await NotificationService.instance.syncToSlots([12, 20]); + Set ids(List log, String method) => { + for (final call in log) + if (call.method == method) (call.arguments as Map)['id'], + }; - final shown = log - .where((c) => c.method == 'show') - .map((c) => (c.arguments as Map)['id']) - .toSet(); - final cancelled = log - .where((c) => c.method == 'cancel') - .map((c) => (c.arguments as Map)['id']) - .toSet(); - expect(shown, {12, 20}); - // Every other id in the 0..23 space is cancelled, not just the other - // slots of the current schedule -- see syncToSlots' comment. - expect(cancelled, containsAll({8, 16})); - expect(cancelled.intersection(shown), isEmpty); + test('syncToSlots shows due slots and cancels other reminders', () async { + final log = await ready( + active: {480: reminderChannelId, 720: reminderChannelId}, + ); + + await NotificationService.instance.syncToSlots([720, 1200]); + + expect(ids(log, 'show'), {720, 1200}); + expect(ids(log, 'cancel'), {480}); }); - test('syncToSlots with no due slots cancels every id in the day', () async { - final log = installFakeAndroidNotifications(); - NotificationService.resetForTesting( - backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), - ); - await NotificationService.init(); - log.clear(); + test('a quiet tick costs one lookup and no cancels', () async { + // The cost bound: the old design swept every id each tick, which at + // minute resolution would be 1440 platform calls per background run. + final log = await ready(); await NotificationService.instance.syncToSlots(const []); - expect(log.where((c) => c.method == 'show'), isEmpty); - // 24, not 4: the slot hour doubles as the notification id, so a - // schedule change would otherwise orphan the ids it no longer contains. - expect(log.where((c) => c.method == 'cancel'), hasLength(24)); + expect(log.map((c) => c.method), ['getActiveNotifications']); + }); + + test('the upgrade cancels reminders keyed by the old slot hours', () async { + // Older builds used the slot *hour* as the id. Nothing in the minute + // schedule names 8/12/16, so only the active lookup can find them. + final log = await ready( + active: { + 8: reminderChannelId, + 12: reminderChannelId, + 16: reminderChannelId, + }, + ); + + await NotificationService.instance.syncToSlots([720]); + + expect(ids(log, 'cancel'), {8, 12, 16}); + expect(ids(log, 'show'), {720}); }); test('syncToSlots cancels ids orphaned by a schedule change', () async { - // The regression this guards: the slot hour *is* the notification id, - // and syncToSlots used to iterate only the current schedule's slots. - // Switching 08/12/16/20 -> 08/11/14/17/20 therefore left ids 12 and 16 - // posted with nothing that would ever cancel them, so the phone nagged - // forever about checkpoints that no longer existed. - final log = installFakeAndroidNotifications(); - NotificationService.resetForTesting( - backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), - ); - await NotificationService.init(); - - // Due under the old four-meal schedule. - await NotificationService.instance.syncToSlots([12, 16]); + // The regression this guards: the slot minute *is* the notification + // id, so iterating only the current schedule's slots would leave the + // old schedule's reminders posted forever. + final log = await ready(); + await NotificationService.instance.syncToSlots([720, 960]); log.clear(); - // Now due under a five-meal schedule, which has neither 12 nor 16. - await NotificationService.instance.syncToSlots([11]); - final cancelled = log - .where((c) => c.method == 'cancel') - .map((c) => (c.arguments as Map)['id']) - .toSet(); - expect(cancelled, containsAll({12, 16})); + // Now due under a schedule that has neither 720 nor 960. + await NotificationService.instance.syncToSlots([660]); + + expect(ids(log, 'cancel'), {720, 960}); + }); + + test('leaves notifications on other channels alone', () async { + final log = await ready(active: {5: 'some_other_channel'}); + + await NotificationService.instance.syncToSlots(const []); + + expect(ids(log, 'cancel'), isEmpty); }); test( 'syncToSlots cancels a slot whose meal was logged after it fired', () async { - final log = installFakeAndroidNotifications(); - NotificationService.resetForTesting( - backend: LocalNotificationsBackend( - FlutterLocalNotificationsPlugin(), - ), - ); - await NotificationService.init(); - - await NotificationService.instance.syncToSlots([12]); + final log = await ready(); + await NotificationService.instance.syncToSlots([720]); log.clear(); await NotificationService.instance.syncToSlots(const []); // logged - expect( - log - .where((c) => c.method == 'cancel') - .map((c) => (c.arguments as Map)['id']), - contains(12), - ); + expect(ids(log, 'cancel'), {720}); }, ); }); + test('a failed active lookup falls back to sweeping every id', () async { + final backend = _FakeBackend()..failLookup = true; + NotificationService.resetForTesting(backend: backend); + await NotificationService.init(); + + await NotificationService.instance.syncToSlots([435]); + + expect(backend.shown, [435]); + expect(backend.cancelled, hasLength(kMinutesPerDay - 1)); + expect(backend.cancelled, isNot(contains(435))); + expect(backend.cancelled, containsAll([0, 8, 23, 720, 1439])); + }); + test('instance throws before init has ever been called', () { expect(() => NotificationService.instance, throwsA(anything)); }); } + +/// A platform-free backend for the paths the channel fake cannot reach. +class _FakeBackend implements NotificationBackend { + bool failLookup = false; + final shown = []; + final cancelled = []; + + @override + Future initialize() async {} + + @override + Future requestPermission() async => null; + + @override + Future show(int slot, String title, String body) async => + shown.add(slot); + + @override + Future cancel(int slot) async => cancelled.add(slot); + + @override + Future> activeIds() async { + if (failLookup) throw StateError('no notification service'); + return {}; + } +} diff --git a/app/test/services/schedule_change_satisfaction_test.dart b/app/test/services/schedule_change_satisfaction_test.dart new file mode 100644 index 0000000..f28a86c --- /dev/null +++ b/app/test/services/schedule_change_satisfaction_test.dart @@ -0,0 +1,78 @@ +// The user-approved acceptance scenario for minute slots: editing the +// schedule must never un-log a meal already eaten, on this device or in the +// reminders it posts. + +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/services/due_slot_check.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/notification_backend_io.dart'; +import 'package:diet_guard_app/services/notification_service.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fake_notifications.dart'; +import 'body_test_support.dart'; + +const _manual = Nutrition( + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 5, + grams: 100, + source: 'manual', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + LogStorageService.resetForTesting(store: MemoryDocStore()); + await MealScheduleService.initForTesting(MemoryDocStore()); + }); + + tearDown(() { + LogStorageService.resetForTesting(); + MealScheduleService.resetForTesting(); + NotificationService.resetForTesting(); + }); + + test('moving the first meal 07:00 -> 07:15 keeps both meals logged', () async { + const before = MealSchedule(firstMinute: 420, lastMinute: 1140, count: 5); + const after = MealSchedule(firstMinute: 435, lastMinute: 1140, count: 5); + expect(before.slots(), [420, 600, 780, 960, 1140]); + expect(after.slots(), [435, 615, 795, 960, 1140]); + + final schedule = MealScheduleService.instance; + await schedule.recordChange(before); + final today = DateTime.now(); + DateTime at(int hour) => DateTime(today.year, today.month, today.day, hour); + // Logged at 07:00 and 10:00 under the old schedule, so recorded as the + // slots slotForLog attributed them to then. + final storage = LogStorageService.instance; + for (final hour in [7, 10]) { + await storage.logMeal( + 'meal', + _manual, + slotMinute: slotForLog(at(hour), MealScheduleService.current), + ); + } + expect(await storage.loggedSlotsToday(), {420, 600}); + + await schedule.recordChange(after); + + // Nothing stored was rewritten; each entry now snaps to its nearest slot. + final logged = await storage.loggedSlotsToday(); + expect(logged, {435, 615}); + expect(missingSlots(at(11), logged, MealScheduleService.current), isEmpty); + + final log = installFakeAndroidNotifications(); + NotificationService.resetForTesting( + backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), + ); + await checkAndNotify(now: at(11), pullWhenDue: false); + expect(log.where((c) => c.method == 'show'), isEmpty); + }); +} diff --git a/app/test/services/sync_merge_schedule_test.dart b/app/test/services/sync_merge_schedule_test.dart index 8390c9d..65f2ad0 100644 --- a/app/test/services/sync_merge_schedule_test.dart +++ b/app/test/services/sync_merge_schedule_test.dart @@ -36,7 +36,7 @@ void main() { final fields = scheduleFields([ _entry( '2026-08-16', - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), '2026-08-16T10:00:00+02:00', ), ]); @@ -51,7 +51,7 @@ void main() { test('the Hlc is deterministic', () { final entry = _entry( '2026-08-16', - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), '2026-08-16T10:00:00+02:00', ); expect( @@ -63,7 +63,7 @@ void main() { test('an unparsable timestamp falls back to the epoch', () { final entry = _entry( '2026-08-16', - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), 'not-a-timestamp', ); expect(scheduleHlc(entry).wallTimeMs, 0); @@ -80,7 +80,7 @@ void main() { budgetToLog(_record, const [], [ _entry( '2026-08-16', - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), '2026-08-16T10:00:00+02:00', ), ]), @@ -89,7 +89,7 @@ void main() { expect(back.single.effectiveFrom, '2026-08-16'); expect( back.single.schedule, - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ); }); @@ -103,7 +103,7 @@ void main() { ); expect( logToScheduleHistory(log).single.schedule, - const MealSchedule(first: 8, last: 20, count: 6), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 6), ); }); @@ -127,7 +127,7 @@ void main() { _entry('1970-01-01', kDefaultSchedule, '1970-01-01T00:00:00.000Z'), _entry( '2026-08-16', - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), '2026-08-16T10:00:00+02:00', ), ]); @@ -141,7 +141,7 @@ void main() { expect(logToScheduleHistory(merged).map((e) => e.schedule), [ kDefaultSchedule, - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), ]); }); @@ -149,14 +149,14 @@ void main() { final older = budgetToLog(_record, const [], [ _entry( '2026-08-16', - const MealSchedule(first: 8, last: 20, count: 5), + const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5), '2026-08-16T10:00:00+02:00', ), ]); final newer = budgetToLog(_record, const [], [ _entry( '2026-08-16', - const MealSchedule(first: 7, last: 21, count: 3), + const MealSchedule(firstMinute: 7 * 60, lastMinute: 21 * 60, count: 3), '2026-08-16T18:00:00+02:00', ), ]); @@ -164,7 +164,7 @@ void main() { final merged = mergeLogs(parseRemoteBudget(_wire(older)), newer); expect( logToScheduleHistory(merged).single.schedule, - const MealSchedule(first: 7, last: 21, count: 3), + const MealSchedule(firstMinute: 7 * 60, lastMinute: 21 * 60, count: 3), ); }); }); diff --git a/app/test/widgets/meal_schedule_editor_test.dart b/app/test/widgets/meal_schedule_editor_test.dart new file mode 100644 index 0000000..4dc18d4 --- /dev/null +++ b/app/test/widgets/meal_schedule_editor_test.dart @@ -0,0 +1,168 @@ +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/widgets/meal_schedule_editor.dart'; +import 'package:diet_guard_app/widgets/meal_time_dropdown.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Holds the schedule the way `_SettingsMealSchedule` does: every edit is +/// normalised and fed back, so the preview reflects what would be saved. +class _Harness extends StatefulWidget { + const _Harness(this.initial, this.edits); + + final MealSchedule initial; + final List edits; + + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + late MealSchedule _schedule = widget.initial; + + @override + Widget build(BuildContext context) => MaterialApp( + home: Scaffold( + body: ListView( + children: [ + MealScheduleEditor( + schedule: _schedule, + onChanged: (next) { + widget.edits.add(next); + setState(() => _schedule = next.normalized()); + }, + ), + ], + ), + ), + ); +} + +const _fiveMeals = MealSchedule(firstMinute: 480, lastMinute: 1200, count: 5); + +void main() { + late List edits; + + setUp(() => edits = []); + + Future pump(WidgetTester tester, MealSchedule schedule) async { + await tester.binding.setSurfaceSize(const Size(500, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_Harness(schedule, edits)); + } + + Future openDropdown(WidgetTester tester, String label) async { + await tester.tap(find.widgetWithText(InputDecorator, label)); + await tester.pumpAndSettle(); + } + + /// Picks "Custom…" from the open menu, typing [hhmm] in the time picker's + /// keyboard mode. + Future pickCustom(WidgetTester tester, String hh, String mm) async { + await tester.scrollUntilVisible( + find.text('Custom…'), + 300, + scrollable: find.byType(Scrollable).last, + ); + await tester.tap(find.text('Custom…').last); + await tester.pumpAndSettle(); + expect(find.byType(TimePickerDialog), findsOneWidget); + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pumpAndSettle(); + final fields = find.descendant( + of: find.byType(TimePickerDialog), + matching: find.byType(TextField), + ); + await tester.enterText(fields.at(0), hh); + await tester.enterText(fields.at(1), mm); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + } + + testWidgets('an off-grid value from another device renders', (tester) async { + // A dropdown whose value matches no item asserts; a PC-set 07:23 must + // show as itself rather than take the settings screen down. + await pump( + tester, + const MealSchedule(firstMinute: 443, lastMinute: 1140, count: 5), + ); + + expect(find.text('07:23'), findsWidgets); + expect(tester.takeException(), isNull); + await openDropdown(tester, 'First meal'); + expect(find.text('07:23'), findsWidgets); + expect(find.text('07:15'), findsWidgets); + }); + + testWidgets('choosing a 15-minute value updates the preview', (tester) async { + await pump(tester, _fiveMeals); + expect(find.text('08:00 · 11:00 · 14:00 · 17:00 · 20:00'), findsOne); + + await openDropdown(tester, 'First meal'); + await tester.tap(find.text('07:45').last); + await tester.pumpAndSettle(); + + expect(edits.last.firstMinute, 465); + expect(find.text('07:45 · 10:45 · 14:00 · 17:00 · 20:00'), findsOne); + }); + + testWidgets('Custom… opens the time picker and applies 07:23', ( + tester, + ) async { + await pump(tester, _fiveMeals); + + await openDropdown(tester, 'First meal'); + await pickCustom(tester, '07', '23'); + + expect(edits.last.firstMinute, 443); + expect(edits.last.lastMinute, 1200); + expect(find.text('07:23'), findsWidgets); + }); + + testWidgets('a custom last meal before the first is clamped', (tester) async { + await pump(tester, _fiveMeals); + + await openDropdown(tester, 'Last meal'); + await pickCustom(tester, '06', '00'); + + // One grid step after 08:00: the shortest legal window, not a rejection. + expect(edits.last.lastMinute, 495); + }); + + testWidgets('Last meal only offers times after the first', (tester) async { + await pump(tester, _fiveMeals); + + MealTimeDropdown field(String label) => tester.widget( + find.byWidgetPredicate((w) => w is MealTimeDropdown && w.label == label), + ); + final last = field('Last meal').options; + expect(last.first, 495, reason: '08:15, one grid step after 08:00'); + expect(last.last, 1425, reason: '23:45, the last grid mark'); + final first = field('First meal').options; + expect(first.first, 0); + expect(first.last, 1410, reason: '23:30 leaves a grid-aligned last meal'); + expect(first.every((m) => m % 15 == 0), isTrue); + }); + + testWidgets('meal counts shrink to what a narrow window holds', ( + tester, + ) async { + const narrow = MealSchedule(firstMinute: 480, lastMinute: 510, count: 2); + expect( + const MealScheduleEditor(schedule: narrow, onChanged: _ignore) + .selectableCounts, + [2, 3], + ); + expect( + const MealScheduleEditor(schedule: _fiveMeals, onChanged: _ignore) + .selectableCounts, + [2, 3, 4, 5, 6], + ); + + await pump(tester, narrow); + await openDropdown(tester, 'Meals per day'); + expect(find.text('3'), findsWidgets); + expect(find.text('4'), findsNothing); + }); +} + +void _ignore(MealSchedule _) {}