From c897279c8b3b7080e8614f6c99dcc40a2eaba585 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 23 Aug 2026 09:34:47 +0200 Subject: [PATCH] fix: clear every macro field before a prefill, add the phone's delivery button `fillControllersFromDish` set the dish's name, four macros and `grams` but left `perGrams` untouched. A `100` left there by an earlier food-bank pick made `nutritionForPortion` read the dish's per-portion macros as per-100g and rescale them: one real entry on 2026-08-23 logged 472 kcal as 1217.8 (258/100). The PC never had this -- `_gatelock_delivery.py` calls `_clear_inputs()` first -- so the fix is that same discipline, as one `MacroControllers.clear()` call rather than a `perGrams.clear()` line a seventh field could forget. `fillFrom` now clears too, and `edit_entry_screen` routes through it instead of six hand-written assignments; its `initState` fill had the same shape. The old tests asserted controller *text* and passed throughout the bug, so the new ones assert the computed `Nutrition` -- verified failing (400 -> 1200.0) before the fix. `clear()` gets an enumerated test so a new field fails until it is cleared too. Also adds the log screen's "Today's delivery" button, matching the PC lock screen's affordance: explicit taps are unguarded per the Triggers table, still skip dishes already logged, and show the queue count. The whole action row moved to its own widget, which put `log_meal_screen.dart` back under the cap. The label is a FittedBox in an Expanded -- measured, since a Row clips an oversized label without ever throwing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX --- app/lib/screens/edit_entry_screen.dart | 14 +- app/lib/screens/log_meal_kuchnia_mixin.dart | 80 ++++++++ app/lib/screens/log_meal_screen.dart | 18 +- app/lib/widgets/log_meal_actions_row.dart | 95 +++++++++ app/lib/widgets/macro_input_row.dart | 5 + app/pubspec.yaml | 2 +- .../edit_entry_screen_foodbank_test.dart | 47 +++++ .../screens/log_meal_kuchnia_mixin_test.dart | 24 +++ .../log_meal_prefill_nutrition_test.dart | 183 ++++++++++++++++++ .../widgets/log_meal_actions_row_test.dart | 123 ++++++++++++ 10 files changed, 572 insertions(+), 19 deletions(-) create mode 100644 app/lib/widgets/log_meal_actions_row.dart create mode 100644 app/test/screens/log_meal_prefill_nutrition_test.dart create mode 100644 app/test/widgets/log_meal_actions_row_test.dart diff --git a/app/lib/screens/edit_entry_screen.dart b/app/lib/screens/edit_entry_screen.dart index 6520ec9..b9c0e0b 100644 --- a/app/lib/screens/edit_entry_screen.dart +++ b/app/lib/screens/edit_entry_screen.dart @@ -41,6 +41,10 @@ class _EditEntryScreenState extends State { super.initState(); final e = widget.entry; _descController = TextEditingController(text: e.desc); + // Same clear-before-fill discipline as `fillControllersFromDish`: the + // entry's macros describe the whole eaten portion, so any reference + // weight left in `perGrams` would rescale them on save. + _macros.clear(); _macros.kcal.text = e.kcal.toStringAsFixed(0); _macros.protein.text = e.proteinG.toStringAsFixed(0); _macros.carbs.text = e.carbsG.toStringAsFixed(0); @@ -85,12 +89,10 @@ class _EditEntryScreenState extends State { void _onSuggestionSelected(FoodSuggestion suggestion) { _descController.text = suggestion.name; - _macros.kcal.text = suggestion.nutrition.kcal.toStringAsFixed(0); - _macros.protein.text = suggestion.nutrition.proteinG.toStringAsFixed(0); - _macros.carbs.text = suggestion.nutrition.carbsG.toStringAsFixed(0); - _macros.fat.text = suggestion.nutrition.fatG.toStringAsFixed(0); - _macros.perGrams.text = suggestion.nutrition.grams.toStringAsFixed(0); - _macros.grams.text = suggestion.nutrition.grams.toStringAsFixed(0); + // Routed through `fillFrom` rather than six hand-written assignments, so + // this path and the log screen's cannot drift and neither can forget a + // field. It clears first, so nothing survives from the previous pick. + _macros.fillFrom(suggestion.nutrition); setState(() { _source = 'food bank'; _suggestions = const []; diff --git a/app/lib/screens/log_meal_kuchnia_mixin.dart b/app/lib/screens/log_meal_kuchnia_mixin.dart index d569aad..605ed03 100644 --- a/app/lib/screens/log_meal_kuchnia_mixin.dart +++ b/app/lib/screens/log_meal_kuchnia_mixin.dart @@ -20,6 +20,7 @@ library; import 'package:diet_guard_app/models/kuchnia_dish.dart'; import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/services/kuchnia_import.dart'; import 'package:diet_guard_app/services/kuchnia_queue.dart'; import 'package:diet_guard_app/services/kuchnia_spread.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -31,11 +32,24 @@ import 'package:flutter/material.dart'; /// /// Free function rather than a mixin method so the numeric formatting is /// testable without pumping a widget. +/// +/// Clears every macro field first, mirroring `_gatelock_delivery.py`'s +/// `self._clear_inputs()`. Dropping that step is what corrupted a real log +/// entry on 2026-08-23: a dish carries *per-portion* macros and leaves +/// [MacroControllers.perGrams] untouched, so a `100` left behind by an +/// earlier food-bank pick made `nutritionForPortion` read them as *per-100g* +/// and scale them by `grams / 100` -- 472 kcal logged as 1217.8. +/// +/// The clear is a single call covering all six controllers rather than a +/// `perGrams.clear()` line here, so a seventh field added later cannot be +/// forgotten at this call site; `MacroControllers.clear()` is enumerated by +/// its own test. void fillControllersFromDish( KuchniaDish dish, TextEditingController desc, MacroControllers macros, ) { + macros.clear(); desc.text = dish.name; final (grams, values) = dishFieldValues(dish); macros.kcal.text = values.$1; @@ -111,6 +125,72 @@ mixin LogMealKuchniaMixin on State { ); } + /// Loads today's delivery on an **explicit** user tap, unguarded. + /// + /// Deliberately [refreshDelivery], not [KuchniaQueueService.refreshOnce]: + /// per `docs/kuchnia-wikinga.md`'s Triggers table, a user who asks gets a + /// look, the same as the CLI and the settings button. The guard exists to + /// rate-limit *automatic* triggers, and applying it here would make the + /// button silently do nothing for the rest of the day after the startup + /// load — the failure that has nothing to tap in the kitchen. + /// + /// Still skips dishes already logged today (a dish logged on the PC must + /// not be re-offered) and still records the fetch, so a successful tap + /// also spares the automatic path a second walk. + /// + /// Returns a line to show the user, or null when a dish was prefilled and + /// the form speaks for itself. + Future loadDeliveryOnTap() async { + if (!KuchniaQueueService.isInitialized) return 'Catering is not ready yet.'; + final logged = await LogStorageService.instance.todayEntries(); + if (!mounted) return null; + final result = await refreshDelivery(DateTime.now()); + if (!mounted) return null; + if (!result.ok) return result.reason; + if (result.dishes.isEmpty) return 'No delivery today.'; + await KuchniaQueueService.instance.recordFetched(DateTime.now()); + if (!mounted) return null; + final ordered = dishesInSlotOrder( + result.dishes, + daySlots(MealScheduleService.current), + ); + KuchniaQueueService.instance.offer( + ordered, + alreadyLogged: { + for (final entry in logged) entry.desc.trim().toLowerCase(), + }, + ); + if (KuchniaQueueService.next == null) { + return 'All delivered dishes are already logged.'; + } + prefillNextDish(); + return null; + } + + /// Whether an explicit delivery fetch is in flight. + /// + /// Only guards against a double tap opening two logins at once -- it is not + /// the daily guard, which an explicit tap deliberately bypasses. + bool deliveryBusy = false; + + /// Runs [loadDeliveryOnTap] and shows its outcome as a SnackBar. + /// + /// Lives here rather than on the screen because `log_meal_screen.dart` is at + /// the 250-line cap, and this is the mixin's own behaviour anyway. + Future loadDeliveryAndReport() async { + setState(() => deliveryBusy = true); + String? message; + try { + message = await loadDeliveryOnTap(); + } finally { + if (mounted) setState(() => deliveryBusy = false); + } + if (!mounted || message == null) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + /// Prefills the form with the next queued dish, if there is one. /// /// Safe to call when the queue is empty: it simply clears the offer. diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index acd371e..9952a5d 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -18,6 +18,7 @@ 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/autocomplete_suggestion_list.dart'; +import 'package:diet_guard_app/widgets/log_meal_actions_row.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:diet_guard_app/widgets/slot_selector_row.dart'; import 'package:diet_guard_app/widgets/sync_health_banner.dart'; @@ -178,7 +179,6 @@ class _LogMealScreenState extends State }); } - @override Widget build(BuildContext context) { return Scaffold( @@ -218,17 +218,11 @@ class _LogMealScreenState extends State const SizedBox(height: 8), MacroInputRow(controllers: _macros, compact: true), const SizedBox(height: 8), - Row( - children: [ - const Spacer(), - Tooltip( - message: 'Log meal', - child: FilledButton( - onPressed: _onLogMeal, - child: const Icon(Icons.check_circle), - ), - ), - ], + LogMealActionsRow( + onLoadDelivery: loadDeliveryAndReport, + onLog: _onLogMeal, + deliveryBusy: deliveryBusy, + dishesQueued: dishesStillQueued, ), // Mutually exclusive: _status carries a validation complaint, // _progress the post-log summary. A successful log clears one diff --git a/app/lib/widgets/log_meal_actions_row.dart b/app/lib/widgets/log_meal_actions_row.dart new file mode 100644 index 0000000..7dfa421 --- /dev/null +++ b/app/lib/widgets/log_meal_actions_row.dart @@ -0,0 +1,95 @@ +/// The log form's action row: load today's delivery, then log the meal. +/// +/// Its own file because `log_meal_screen.dart` sits at the repo's 250-line +/// ceiling; lifting the whole row rather than just the delivery button keeps +/// both controls' layout in one place and returns budget to the screen. +library; + +import 'package:diet_guard_app/services/kuchnia_client.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; + +/// A delivery button beside the log button. +/// +/// The delivery control mirrors the PC lock screen's "🍱 Today's delivery": +/// standing in the kitchen, there must be something to tap. It stays visible +/// and enabled even when there is nothing to load — the outcome arrives as a +/// message rather than as a disabled button, because a disabled control gives +/// no way to retry after a late delivery and a hidden one reads as broken. +class LogMealActionsRow extends StatelessWidget { + /// Creates the action row. + const LogMealActionsRow({ + required this.onLoadDelivery, + required this.onLog, + this.deliveryBusy = false, + this.dishesQueued = 0, + this.canFetchDelivery, + super.key, + }); + + /// Loads today's delivery and prefills the first/next dish. + final Future Function() onLoadDelivery; + + /// Writes the current form as a log entry. + final VoidCallback onLog; + + /// Whether a delivery fetch is in flight; disables the button meanwhile so + /// a second tap cannot start a concurrent login against the caterer. + final bool deliveryBusy; + + /// How many delivered dishes are still queued, shown as the PC's + /// "(N more to go)" so the user knows the walk is not finished. + final int dishesQueued; + + /// Whether the platform can fetch at all. Defaults to + /// [kuchniaFetchSupported] — false on web, where the desktop build runs and + /// the caterer's panel blocks browser requests, so the control is hidden + /// rather than shipped dead. + final bool? canFetchDelivery; + + @override + Widget build(BuildContext context) { + final canFetch = canFetchDelivery ?? kuchniaFetchSupported; + return Row( + children: [ + // Flexible, not Spacer-and-hope: `OutlinedButton.icon` lays its label + // out unbounded, so on a narrow phone the queue suffix renders wider + // than the row and is clipped without ever throwing an overflow. The + // Flexible caps the width and the FittedBox scales the text into it. + if (canFetch) + Expanded( + child: Tooltip( + message: "Load today's catering delivery", + child: OutlinedButton.icon( + onPressed: deliveryBusy ? null : onLoadDelivery, + icon: const Icon(Icons.restaurant), + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text(_label, maxLines: 1), + ), + ), + ), + ), + // No Spacer: it is a flex child too, so it would split the free space + // with the Flexible above and re-create the clipping this guards + // against. The Flexible takes the slack and pushes the log button + // right on its own. + if (!canFetch) const Spacer(), + const SizedBox(width: AppSpacing.sm), + Tooltip( + message: 'Log meal', + child: FilledButton( + onPressed: onLog, + child: const Icon(Icons.check_circle), + ), + ), + ], + ); + } + + String get _label { + if (deliveryBusy) return 'Loading…'; + if (dishesQueued > 0) return "Today's delivery ($dishesQueued more to go)"; + return "Today's delivery"; + } +} diff --git a/app/lib/widgets/macro_input_row.dart b/app/lib/widgets/macro_input_row.dart index 2cb8cf0..54455e4 100644 --- a/app/lib/widgets/macro_input_row.dart +++ b/app/lib/widgets/macro_input_row.dart @@ -43,7 +43,12 @@ class MacroControllers { /// /// Both [perGrams] and [grams] are set to the suggestion's own weight, so /// the row starts out describing exactly the portion that was banked. + /// + /// Clears first so no field can survive from a previous pick. This one + /// assignment set happens to cover all six today, but the clear is what + /// makes that non-load-bearing -- see [clear]. void fillFrom(Nutrition nutrition) { + clear(); kcal.text = nutrition.kcal.toStringAsFixed(0); protein.text = nutrition.proteinG.toStringAsFixed(0); carbs.text = nutrition.carbsG.toStringAsFixed(0); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3fa6910..3955957 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.67+1 +version: 1.0.68+1 environment: sdk: ^3.12.2 diff --git a/app/test/screens/edit_entry_screen_foodbank_test.dart b/app/test/screens/edit_entry_screen_foodbank_test.dart index 2ccfc29..a3fe822 100644 --- a/app/test/screens/edit_entry_screen_foodbank_test.dart +++ b/app/test/screens/edit_entry_screen_foodbank_test.dart @@ -147,4 +147,51 @@ void main() { expect(find.text('1200'), findsOneWidget); }); }); + + testWidgets('a stale per-grams does not rescale an edited entry on save', ( + tester, + ) async { + // The edit screen reaches the same `nutritionForPortion` as the log form, + // so it is exposed to the identical corruption: pick a suggestion (which + // sets `perGrams`), retype the macros as a whole portion, and the save + // would scale them by grams/perGrams. Asserting the *written entry* is + // what catches it -- the fields on screen look right either way. + await tester.runAsync(() async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'pizza obok pracy', + kcal: 1200, + proteinG: 52, + carbsG: 145, + fatG: 42, + grams: 400, + count: 1, + ), + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: _entry)), + ); + await settle(tester); + + await tester.tap(find.text('pizza obok pracy').last); + await settle(tester); + + await tester.tap(find.text('Save')); + await settle(tester); + + final saved = (await LogStorageService.instance.readLog())['2026-06-22']! + .first; + expect( + saved.kcal, + 1200, + reason: 'the banked portion must save as itself, not rescaled by ' + 'its own reference weight.', + ); + expect(saved.grams, 400); + }); + }); } diff --git a/app/test/screens/log_meal_kuchnia_mixin_test.dart b/app/test/screens/log_meal_kuchnia_mixin_test.dart index c23a6b8..1e4ff10 100644 --- a/app/test/screens/log_meal_kuchnia_mixin_test.dart +++ b/app/test/screens/log_meal_kuchnia_mixin_test.dart @@ -51,12 +51,36 @@ class _HostState extends State<_Host> with LogMealKuchniaMixin<_Host> { final TextEditingController desc = TextEditingController(); final MacroControllers macros = MacroControllers(); + @override + void initState() { + super.initState(); + for (final c in [ + macros.kcal, + macros.protein, + macros.carbs, + macros.fat, + macros.perGrams, + macros.grams, + ]) { + c.addListener(() { + if (source == 'food bank') source = 'manual'; + }); + } + } + @override TextEditingController get descController => desc; @override MacroControllers get macroControllers => macros; + /// Mirrors the screen: a macro edit demotes a food-bank pick to manual, + /// and a prefilled dish stamps catering. + String source = 'manual'; + + @override + void onDishPrefilled() => source = 'catering'; + /// Stands in for the screen's submit: the entry write is irrelevant here, /// the queue advance is what is under test. void submit() { diff --git a/app/test/screens/log_meal_prefill_nutrition_test.dart b/app/test/screens/log_meal_prefill_nutrition_test.dart new file mode 100644 index 0000000..73ab0f1 --- /dev/null +++ b/app/test/screens/log_meal_prefill_nutrition_test.dart @@ -0,0 +1,183 @@ +/// What a prefilled dish actually *logs*, as distinct from what it displays. +/// +/// Split from `log_meal_kuchnia_mixin_test.dart` (250-line cap) along the +/// seam the 2026-08-23 corruption exposed: that file asserts the queue's +/// mechanics and the controllers' text, and every one of its tests passed +/// while a delivered dish logged 2.58x its real calories. The bug lived in +/// the *computed* `Nutrition`, which nothing there looked at. +/// +/// So these tests assert the computed value, never the field text. +library; + +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/screens/log_meal_actions.dart'; +import 'package:diet_guard_app/screens/log_meal_kuchnia_mixin.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/kuchnia_queue.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// In-memory store, so these tests need no plugin channel. +class _MemoryStore implements DocumentStore { + final Map documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + } +} + +/// The smallest widget that uses the mixin the way the log screen does. +class _Host extends StatefulWidget { + const _Host(); + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with LogMealKuchniaMixin<_Host> { + final TextEditingController desc = TextEditingController(); + final MacroControllers macros = MacroControllers(); + + @override + void initState() { + super.initState(); + for (final c in [ + macros.kcal, + macros.protein, + macros.carbs, + macros.fat, + macros.perGrams, + macros.grams, + ]) { + c.addListener(() { + if (source == 'food bank') source = 'manual'; + }); + } + } + + @override + TextEditingController get descController => desc; + + @override + MacroControllers get macroControllers => macros; + + /// Mirrors the screen: a macro edit demotes a food-bank pick to manual, + /// and a prefilled dish stamps catering. + String source = 'manual'; + + @override + void onDishPrefilled() => source = 'catering'; + + /// Stands in for the screen's submit: the entry write is irrelevant here, + /// the queue advance is what is under test. + void submit() { + final logged = desc.text; + desc.clear(); + macros.clear(); + advanceQueueAfterLog(logged); + } + + @override + Widget build(BuildContext context) => + Text(queueStatusLine ?? '', textDirection: TextDirection.ltr); +} + +KuchniaDish _dish(String name, int priority) => KuchniaDish( + name: name, + kcal: 400, + proteinG: 25, + carbsG: 45.5, + fatG: 12, + grams: 300, + priority: priority, + slotLabel: 'Obiad', +); + +void main() { + late _HostState host; + + Future pumpHost(WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: _Host())); + host = tester.state(find.byType(_Host)); + } + + setUp(() async { + await KuchniaQueueService.initForTesting(_MemoryStore()); + }); + + tearDown(KuchniaQueueService.resetForTesting); + + testWidgets('a stale per-grams does not rescale the prefilled dish', ( + tester, + ) async { + // The 2026-08-23 corruption: a food-bank pick leaves `perGrams` at 100, + // the dish prefills over it without clearing, and `nutritionForPortion` + // reads the dish's *per-portion* macros as *per-100g* and multiplies them + // by 300/100. Asserting controller text passes throughout that bug -- + // only the computed Nutrition sees it. + await pumpHost(tester); + host.macros.perGrams.text = '100'; + + KuchniaQueueService.instance.offer([_dish('Pancakes', 1)]); + host.prefillNextDish(); + await tester.pump(); + + final nutrition = nutritionFromControllers(host.macros, 'kuchnia wikinga'); + expect( + nutrition.kcal, + 400, + reason: 'the dish must log its own calories. 1200 here is the stale ' + 'per-grams rescaling a per-portion dish by 300/100.', + ); + expect(nutrition.proteinG, 25); + expect(nutrition.carbsG, 45.5); + expect(nutrition.fatG, 12); + expect(nutrition.grams, 300); + }); + + testWidgets('clear() empties every macro controller', (tester) async { + // Enumerated deliberately: the bug was a *forgotten* field, so a new + // seventh controller must fail this until it is cleared too. + await pumpHost(tester); + final controllers = { + 'kcal': host.macros.kcal, + 'protein': host.macros.protein, + 'carbs': host.macros.carbs, + 'fat': host.macros.fat, + 'perGrams': host.macros.perGrams, + 'grams': host.macros.grams, + }; + for (final c in controllers.values) { + c.text = '99'; + } + + host.macros.clear(); + + controllers.forEach((name, c) { + expect(c.text, isEmpty, reason: '$name survived clear()'); + }); + }); + + testWidgets('the prefill stamps catering even though clearing demotes it', ( + tester, + ) async { + // Ordering is load-bearing and invisible: `clear()` fires the macro + // listener, which demotes a food-bank pick to 'manual'. `onDishPrefilled` + // runs after and must have the last word, or a delivered dish logs as a + // manual entry and the queue never advances. + await pumpHost(tester); + host.source = 'food bank'; + host.macros.perGrams.text = '100'; + + KuchniaQueueService.instance.offer([_dish('Pancakes', 1)]); + host.prefillNextDish(); + await tester.pump(); + + expect(host.source, 'catering'); + }); +} diff --git a/app/test/widgets/log_meal_actions_row_test.dart b/app/test/widgets/log_meal_actions_row_test.dart new file mode 100644 index 0000000..5d71c2d --- /dev/null +++ b/app/test/widgets/log_meal_actions_row_test.dart @@ -0,0 +1,123 @@ +/// The log form's action row, and the delivery button the phone lacked. +/// +/// The PC lock screen has had a "🍱 Today's delivery" button since the import +/// shipped; the phone had the queue but no visible control, so standing in +/// the kitchen there was nothing to tap. These tests pin the affordance and +/// the two ways it silently fails: a clipped label, and a dead control on the +/// web build. +library; + +import 'package:diet_guard_app/widgets/log_meal_actions_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future pump( + WidgetTester tester, { + int queued = 0, + bool busy = false, + bool canFetch = true, + Future Function()? onLoad, + VoidCallback? onLog, + double width = 360, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: width, + child: LogMealActionsRow( + onLoadDelivery: onLoad ?? () async {}, + onLog: onLog ?? () {}, + deliveryBusy: busy, + dishesQueued: queued, + canFetchDelivery: canFetch, + ), + ), + ), + ), + ); + } + + testWidgets('the delivery button is there to tap', (tester) async { + var taps = 0; + await pump(tester, onLoad: () async => taps++); + + await tester.tap(find.byIcon(Icons.restaurant)); + await tester.pump(); + + expect(taps, 1); + }); + + testWidgets('the queue count is visible, as the PC shows it', (tester) async { + await pump(tester, queued: 4); + expect(find.text("Today's delivery (4 more to go)"), findsOneWidget); + }); + + testWidgets('the long label is scaled, not silently clipped', (tester) async { + // A Row clips an oversized child without throwing, so "no overflow + // exception" proves nothing. Measure the rendered text instead. + await pump(tester, queued: 4, width: 300); + await tester.pumpAndSettle(); + + // `getSize` on the Text reports its *unscaled* intrinsic width, because + // FittedBox scales via a transform rather than by resizing its child. + // The painted extent is what the user sees, so measure the rect the + // FittedBox actually occupies. + final painted = tester.getRect( + find.ancestor( + of: find.text("Today's delivery (4 more to go)"), + matching: find.byType(FittedBox), + ), + ); + final row = tester.getRect(find.byType(LogMealActionsRow)); + expect( + painted.width, + lessThanOrEqualTo(row.width), + reason: 'the label must fit the row it is rendered in', + ); + expect(painted.right, lessThanOrEqualTo(row.right + 0.5)); + expect(tester.takeException(), isNull); + }); + + testWidgets('a fetch in flight disables the button', (tester) async { + await pump(tester, busy: true); + + final button = tester.widget( + find.byType(OutlinedButton), + ); + expect(button.onPressed, isNull); + expect(find.text('Loading…'), findsOneWidget); + }); + + testWidgets('the button stays visible with nothing queued', (tester) async { + // Never hidden or disabled on an empty queue: the outcome arrives as a + // SnackBar so a late delivery can still be retried. + await pump(tester); + + expect(find.text("Today's delivery"), findsOneWidget); + final button = tester.widget( + find.byType(OutlinedButton), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('web gets no delivery button at all', (tester) async { + // The desktop target *is* the web build, and the caterer's panel blocks + // browser requests -- an unguarded button would ship a dead control. + await pump(tester, canFetch: false); + + expect(find.byIcon(Icons.restaurant), findsNothing); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets('the log button still logs', (tester) async { + var logged = 0; + await pump(tester, onLog: () => logged++); + + await tester.tap(find.byIcon(Icons.check_circle)); + await tester.pump(); + + expect(logged, 1); + }); +}