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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 09:34:47 +02:00
parent c1ad2e2406
commit c897279c8b
10 changed files with 572 additions and 19 deletions

View File

@ -41,6 +41,10 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
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<EditEntryScreen> {
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 [];

View File

@ -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<T extends StatefulWidget> on State<T> {
);
}
/// 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<String?> 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<void> 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.

View File

@ -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<LogMealScreen>
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -218,17 +218,11 @@ class _LogMealScreenState extends State<LogMealScreen>
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

View File

@ -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<void> 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";
}
}

View File

@ -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);

View File

@ -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

View File

@ -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);
});
});
}

View File

@ -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() {

View File

@ -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<String, String> documents = {};
@override
Future<String?> read(String name) async => documents[name];
@override
Future<void> 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<void> 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 = <String, TextEditingController>{
'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');
});
}

View File

@ -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<void> pump(
WidgetTester tester, {
int queued = 0,
bool busy = false,
bool canFetch = true,
Future<void> 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<OutlinedButton>(
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<OutlinedButton>(
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);
});
}