mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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
This commit is contained in:
parent
a6fcd3266e
commit
f83938bfe3
@ -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<Map<String, dynamic>>()
|
||||
@ -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<String, int> _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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<EditEntryScreen> {
|
||||
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();
|
||||
|
||||
@ -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<T extends StatefulWidget> on State<T> {
|
||||
DateTime? _futureDate;
|
||||
int? _futureSlot;
|
||||
Set<int> _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<T extends StatefulWidget> on State<T> {
|
||||
_futureDate = null;
|
||||
_futureSlot = null;
|
||||
_futureLoggedSlots = {};
|
||||
_futureSchedule = null;
|
||||
});
|
||||
}
|
||||
|
||||
@ -49,12 +55,18 @@ mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
|
||||
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 <FoodEntry>[])
|
||||
if (!entry.deleted) ?entry.slotMinute,
|
||||
], schedule);
|
||||
setState(() {
|
||||
_futureDate = picked;
|
||||
_futureSchedule = schedule;
|
||||
_futureLoggedSlots = logged;
|
||||
_futureSlot = null;
|
||||
});
|
||||
@ -107,6 +119,7 @@ mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
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<T extends StatefulWidget> on State<T> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,7 +149,7 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
await LogStorageService.instance.logMeal(
|
||||
desc,
|
||||
nutritionFromControllers(_macros, _source),
|
||||
slot: target.slot,
|
||||
slotMinute: target.slot,
|
||||
when: target.when,
|
||||
);
|
||||
resetFutureLog();
|
||||
|
||||
@ -179,7 +179,7 @@ mixin LogMealSyncMixin<T extends StatefulWidget> on State<T>
|
||||
/// 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
|
||||
|
||||
@ -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<SettingsScreen> {
|
||||
_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<int> 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<void> _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<int> hours,
|
||||
required ValueChanged<int> onChanged,
|
||||
}) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(labelText: label),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
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<Widget> _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<int>(
|
||||
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,
|
||||
List<Widget> _mealScheduleSection(BuildContext context) => [
|
||||
MealScheduleEditor(
|
||||
schedule: _schedule,
|
||||
onChanged: (next) => unawaited(_applySchedule(next)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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<List<SlottedDish>> 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 = <SlottedDish>[];
|
||||
for (final item in chosen) {
|
||||
@ -77,7 +87,7 @@ Future<List<SlottedDish>> 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<SlottedDish> plan) {
|
||||
final total = plan.fold<double>(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.';
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<FoodEntry> logMeal(
|
||||
String desc,
|
||||
Nutrition nutrition, {
|
||||
int? slot,
|
||||
int? slotMinute,
|
||||
List<MealComponent>? 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<Set<int>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, Object?> 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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,4 +25,12 @@ abstract class NotificationBackend {
|
||||
|
||||
/// Removes the notification for [slot], if one is showing.
|
||||
Future<void> 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<Set<int>> activeIds();
|
||||
}
|
||||
|
||||
@ -66,6 +66,17 @@ class LocalNotificationsBackend implements NotificationBackend {
|
||||
|
||||
@override
|
||||
Future<void> 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<Set<int>> activeIds() async => {
|
||||
for (final active in await plugin.getActiveNotifications())
|
||||
if (active.channelId == _channelId && active.id != null) active.id!,
|
||||
};
|
||||
}
|
||||
|
||||
/// Opens the platform notification backend.
|
||||
|
||||
@ -50,6 +50,13 @@ class WebNotificationBackend implements NotificationBackend {
|
||||
Future<void> 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<Set<int>> activeIds() async => _shown.keys.toSet();
|
||||
}
|
||||
|
||||
/// Opens the platform notification backend.
|
||||
|
||||
@ -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<bool?> 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<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)) {
|
||||
Iterable<int> 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.",
|
||||
);
|
||||
} else {
|
||||
await _backend.cancel(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,12 +47,10 @@ Hlc scheduleHlc(ScheduleEntry entry) {
|
||||
/// default over a peer's real value.
|
||||
Map<String, (dynamic, Hlc)> scheduleFields(List<ScheduleEntry> 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<ScheduleEntry> logToScheduleHistory(Log log) {
|
||||
final entries = <ScheduleEntry>[];
|
||||
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,
|
||||
|
||||
119
app/lib/widgets/meal_schedule_editor.dart
Normal file
119
app/lib/widgets/meal_schedule_editor.dart
Normal file
@ -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<MealSchedule> 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<int> 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<int>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/lib/widgets/meal_time_dropdown.dart
Normal file
126
app/lib/widgets/meal_time_dropdown.dart
Normal file
@ -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<int> onChanged;
|
||||
|
||||
/// The minutes listed, ascending: the grid marks in range plus [value].
|
||||
List<int> 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<void> _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<int>(
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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<int> 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<int?> 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>() ?? 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<MethodCall> installFakeAndroidNotifications() {
|
||||
List<MethodCall> installFakeAndroidNotifications({
|
||||
Map<int, String>? active,
|
||||
}) {
|
||||
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||
final log = <MethodCall>[];
|
||||
final shade = active ?? <int, String>{};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<List<String>> 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}',
|
||||
];
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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<String, Object?> entry(Map<String, Object?> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<State<LogMealScreen>>(
|
||||
find.byType(LogMealScreen),
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -163,7 +163,7 @@ void main() {
|
||||
carbsG: 40,
|
||||
fatG: 5,
|
||||
source: 'manual',
|
||||
slot: 8,
|
||||
slotMinute: 480,
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<Object?> 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(<int>{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)));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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));
|
||||
});
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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<List<MethodCall>> ready({Map<int, String>? 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<Object?> ids(List<MethodCall> 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(<int>{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(<int>{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(<int>[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 = <int>[];
|
||||
final cancelled = <int>[];
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<bool?> requestPermission() async => null;
|
||||
|
||||
@override
|
||||
Future<void> show(int slot, String title, String body) async =>
|
||||
shown.add(slot);
|
||||
|
||||
@override
|
||||
Future<void> cancel(int slot) async => cancelled.add(slot);
|
||||
|
||||
@override
|
||||
Future<Set<int>> activeIds() async {
|
||||
if (failLookup) throw StateError('no notification service');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
78
app/test/services/schedule_change_satisfaction_test.dart
Normal file
78
app/test/services/schedule_change_satisfaction_test.dart
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
168
app/test/widgets/meal_schedule_editor_test.dart
Normal file
168
app/test/widgets/meal_schedule_editor_test.dart
Normal file
@ -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<MealSchedule> 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<MealSchedule> edits;
|
||||
|
||||
setUp(() => edits = []);
|
||||
|
||||
Future<void> 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<void> 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<void> 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 _) {}
|
||||
Loading…
Reference in New Issue
Block a user