From 83d05c637a3d5947ad00bd7a38fcba3ea24752c1 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 9 Nov 2026 08:20:57 +0100 Subject: [PATCH] Drop text from the log-meal screen Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ --- app/lib/screens/log_meal_actions.dart | 15 +-- app/lib/screens/log_meal_fill_mixin.dart | 15 ++- app/lib/screens/log_meal_future_mixin.dart | 25 ++-- app/lib/screens/log_meal_kuchnia_mixin.dart | 10 +- app/lib/screens/log_meal_screen.dart | 44 +++---- app/lib/services/sync_health.dart | 13 +- app/lib/widgets/fill_all_row.dart | 40 ++++-- app/lib/widgets/log_meal_actions_row.dart | 58 ++++----- app/lib/widgets/macro_input_row.dart | 76 +++++------- app/lib/widgets/sync_health_banner.dart | 16 ++- app/lib/widgets/today_progress_card.dart | 115 +++++++++++++----- app/pubspec.yaml | 2 +- app/test/fill_all_row_test.dart | 40 +++--- app/test/screens/fit/fit_screens_test.dart | 7 +- .../screens/log_meal_fill_mixin_test.dart | 10 +- app/test/screens/log_meal_haptic_test.dart | 15 ++- .../screens/log_meal_screen_future_test.dart | 24 ++-- .../screens/log_meal_screen_nav_test.dart | 6 +- .../screens/log_meal_screen_sync_test.dart | 4 +- app/test/screens/log_meal_screen_test.dart | 111 ++++++++--------- app/test/widget_test.dart | 7 +- .../widgets/log_meal_actions_row_test.dart | 51 +++----- app/test/widgets/sync_health_banner_test.dart | 11 +- .../today_progress_card_motion_test.dart | 34 +++--- .../widgets/today_progress_card_test.dart | 14 ++- 25 files changed, 435 insertions(+), 328 deletions(-) diff --git a/app/lib/screens/log_meal_actions.dart b/app/lib/screens/log_meal_actions.dart index bb23531..6ace3d6 100644 --- a/app/lib/screens/log_meal_actions.dart +++ b/app/lib/screens/log_meal_actions.dart @@ -45,28 +45,23 @@ List logMealAppBarActions({ required VoidCallback onBody, }) => [ IconButton( - icon: const Icon(Icons.monitor_weight), - tooltip: 'Body', + icon: const Icon(Icons.monitor_weight, semanticLabel: 'Body'), onPressed: onBody, ), IconButton( - icon: const Icon(Icons.restaurant_menu), - tooltip: 'Food bank', + icon: const Icon(Icons.restaurant_menu, semanticLabel: 'Food bank'), onPressed: onFoodBank, ), IconButton( - icon: const Icon(Icons.history), - tooltip: 'History', + icon: const Icon(Icons.history, semanticLabel: 'History'), onPressed: onHistory, ), IconButton( - icon: const Icon(Icons.calendar_month), - tooltip: 'Calendar', + icon: const Icon(Icons.calendar_month, semanticLabel: 'Calendar'), onPressed: onCalendar, ), IconButton( - icon: const Icon(Icons.settings), - tooltip: 'Sync settings', + icon: const Icon(Icons.settings, semanticLabel: 'Sync settings'), onPressed: onSettings, ), ]; diff --git a/app/lib/screens/log_meal_fill_mixin.dart b/app/lib/screens/log_meal_fill_mixin.dart index aa3f760..bee8be6 100644 --- a/app/lib/screens/log_meal_fill_mixin.dart +++ b/app/lib/screens/log_meal_fill_mixin.dart @@ -12,6 +12,7 @@ import 'package:diet_guard_app/screens/log_meal_sync_mixin.dart'; import 'package:diet_guard_app/services/kuchnia_fill_flow.dart'; import 'package:diet_guard_app/services/kuchnia_queue.dart'; import 'package:diet_guard_app/services/kuchnia_spread.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:flutter/material.dart'; /// Drives the screen's [FillAllFlow] and tidies the screen after a fill. @@ -21,8 +22,9 @@ mixin LogMealFillMixin /// armed proposal along with it. FillAllFlow fillFlow = FillAllFlow(); - /// Shows [message] in the form's status line. Supplied by the screen. - void showFillStatus(String message); + /// Shows [message] as a status cue of [kind] (its words are only spoken). + /// Supplied by the screen. + void showFillStatus(String message, {CueKind kind = CueKind.info}); /// A proposal belongs to the date it was fetched for; picking another (or /// going back to today) must not leave "Confirm (N)" offering it. @@ -49,7 +51,9 @@ mixin LogMealFillMixin // for the next tick, and the user is told the batch did not finish. await publishAfterLog(); await refreshSlots(); - if (mounted) showFillStatus('Fill all failed: $error'); + if (mounted) { + showFillStatus('Fill all failed: $error', kind: CueKind.error); + } return; } if (!mounted) return; @@ -59,7 +63,10 @@ mixin LogMealFillMixin await _afterFill(outcome.written, later: day != null); } if (!mounted) return; - showFillStatus(outcome.message); + showFillStatus( + outcome.message, + kind: outcome.written.isNotEmpty ? CueKind.ok : CueKind.warn, + ); } /// Disarms a proposal without writing anything. diff --git a/app/lib/screens/log_meal_future_mixin.dart b/app/lib/screens/log_meal_future_mixin.dart index 50fc13a..2be53f2 100644 --- a/app/lib/screens/log_meal_future_mixin.dart +++ b/app/lib/screens/log_meal_future_mixin.dart @@ -91,21 +91,32 @@ mixin LogMealFutureMixin on State { final scheme = Theme.of(context).colorScheme; return Row( children: [ - Icon(Icons.event, size: 18, color: scheme.onSurfaceVariant), + Icon( + Icons.event, + size: 18, + color: scheme.onSurfaceVariant, + semanticLabel: 'Log date', + ), const SizedBox(width: AppSpacing.xs), - Expanded(child: Text(date == null ? 'Today' : localDateKey(date))), - TextButton.icon( + // Today is the default and needs no word; only a picked date (data) + // is shown. + Expanded( + child: date == null ? const SizedBox() : Text(localDateKey(date)), + ), + IconButton( // Not Icons.calendar_month: the app bar's own "Calendar" nav button // already uses that, and a second one on screen makes // `find.byIcon` ambiguous for widget tests (and taps) alike. - icon: const Icon(Icons.date_range, size: 18), - label: Text(date == null ? 'Log for later' : 'Change date'), + icon: Icon( + Icons.date_range, + size: 18, + semanticLabel: date == null ? 'Log for later' : 'Change date', + ), onPressed: _pickFutureDate, ), if (date != null) IconButton( - icon: const Icon(Icons.close), - tooltip: 'Back to today', + icon: const Icon(Icons.close, semanticLabel: 'Back to today'), onPressed: resetFutureLog, ), ], diff --git a/app/lib/screens/log_meal_kuchnia_mixin.dart b/app/lib/screens/log_meal_kuchnia_mixin.dart index 605ed03..59fb900 100644 --- a/app/lib/screens/log_meal_kuchnia_mixin.dart +++ b/app/lib/screens/log_meal_kuchnia_mixin.dart @@ -25,6 +25,7 @@ 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'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:flutter/material.dart'; @@ -186,9 +187,14 @@ mixin LogMealKuchniaMixin on State { if (mounted) setState(() => deliveryBusy = false); } if (!mounted || message == null) return; - ScaffoldMessenger.of( + // The outcome sentence stays as the spoken label; the snackbar paints + // only a warning glyph (no delivery / not ready / fetch failed). + showCueSnack( context, - ).showSnackBar(SnackBar(content: Text(message))); + kind: CueKind.warn, + semanticLabel: message, + icon: Icons.restaurant, + ); } /// Prefills the form with the next queued dish, if there is one. diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 77d158e..3662247 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/foodbank_service.dart'; import 'package:diet_guard_app/services/log_feedback.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/cues.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/fill_all_row.dart'; @@ -59,9 +60,14 @@ class _LogMealScreenState extends State @override int? selectedSlot; String _source = 'manual'; - String? _status; + CueState? _cue; // painted as a tinted icon, never as text TodayProgress? _progress; + void _setCue(CueKind kind, String? label) { + _cue = label == null ? null : CueState.next(_cue, kind, label); + _progress = null; + } + @override void initState() { super.initState(); @@ -117,10 +123,8 @@ class _LogMealScreenState extends State @override void onDishPrefilled() => setState(() => _source = 'catering'); @override - void showFillStatus(String message) => setState(() { - _status = message; - _progress = null; - }); + void showFillStatus(String message, {CueKind kind = CueKind.info}) => + setState(() => _setCue(kind, message)); void _onSuggestionSelected(FoodSuggestion suggestion) { fillControllersFromSuggestion(suggestion, _descController, _macros); @@ -133,18 +137,12 @@ class _LogMealScreenState extends State Future _onLogMeal() async { final desc = _descController.text.trim(); if (desc.isEmpty) { - setState(() { - _status = 'Type what you ate first.'; - _progress = null; - }); + setState(() => _setCue(CueKind.error, 'Type what you ate first.')); return; } final target = resolveLogTarget(selectedSlot); if (target.error != null) { - setState(() { - _status = target.error; - _progress = null; - }); + setState(() => _setCue(CueKind.error, target.error)); return; } // On the tap, before the write/sync: waiting for them is just latency. @@ -171,7 +169,7 @@ class _LogMealScreenState extends State // becomes a dead letter -- the exact regression the PC gate once shipped. advanceQueueAfterLog(desc); setState(() { - _status = queueStatusLine; + _setCue(CueKind.info, queueStatusLine); _progress = buildTodayProgress(log, logged: entry); }); } @@ -180,7 +178,8 @@ class _LogMealScreenState extends State Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Diet Guard'), + // No title: the app's identity is the launcher icon, and the five + // destination glyphs fill the bar. actions: [ ...logMealAppBarActions( onFoodBank: onOpenFoodBank, @@ -206,9 +205,10 @@ class _LogMealScreenState extends State setState(() => selectedSlot = slot), ), const SizedBox(height: 8), - TextField( + IconField( controller: _descController, - decoration: const InputDecoration(labelText: 'What did you eat?'), + icon: Icons.fastfood, + semanticLabel: 'What did you eat?', ), AutocompleteSuggestionList( suggestions: _suggestions, @@ -231,12 +231,12 @@ class _LogMealScreenState extends State busy: fillFlow.busy || deliveryBusy, armedCount: fillFlow.armedCount, ), - // Mutually exclusive: _status carries a validation complaint, - // _progress the post-log summary. A successful log clears one - // and sets the other, so the two never stack. - if (_status != null) ...[ + // Mutually exclusive: _cue carries a validation complaint or + // queue note, _progress the post-log summary; the two may stack + // only after a log that leaves dishes queued. + if (_cue != null) ...[ const SizedBox(height: AppSpacing.sm), - Text(_status!), + _cue!.widget, ], if (_progress != null) ...[ const SizedBox(height: AppSpacing.sm), diff --git a/app/lib/services/sync_health.dart b/app/lib/services/sync_health.dart index b3eed4d..d3f471a 100644 --- a/app/lib/services/sync_health.dart +++ b/app/lib/services/sync_health.dart @@ -53,8 +53,7 @@ abstract final class SyncHealth { _recordFailure(SyncFailureKind.unconfigured); /// Marks a tick that had a backend but failed to publish. - static Future recordFailure() => - _recordFailure(SyncFailureKind.failed); + static Future recordFailure() => _recordFailure(SyncFailureKind.failed); static Future _recordFailure(SyncFailureKind kind) => _withPrefs((prefs) => prefs.setString(_kFailureKind, kind.name)); @@ -155,6 +154,16 @@ class SyncHealthStatus { return 'Not syncing since ${_ago(since)}. Meals stay on this device.'; } + /// Compact elapsed time (`5h`, `2d`) for a numeral beside the banner's + /// glyph; null when it has not been at least an hour or never succeeded. + String? get agoShort { + final last = lastSuccess; + if (last == null) return null; + final since = now.difference(last); + if (since < const Duration(hours: 1)) return null; + return since.inDays >= 1 ? '${since.inDays}d' : '${since.inHours}h'; + } + static String _ago(Duration since) { if (since.inDays >= 1) { return '${since.inDays}d ago'; diff --git a/app/lib/widgets/fill_all_row.dart b/app/lib/widgets/fill_all_row.dart index 64f158b..743a643 100644 --- a/app/lib/widgets/fill_all_row.dart +++ b/app/lib/widgets/fill_all_row.dart @@ -9,8 +9,8 @@ import 'package:diet_guard_app/services/kuchnia_client.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; -/// Resting label, mirroring the PC gate's "🍱 Fill all". -const fillAllLabel = '🍱 Fill all with catering'; +/// Spoken name of the resting button (never painted). +const fillAllLabel = 'Fill all with catering'; /// Fill-all button; relabels to "✓ Confirm (N)" once a proposal is armed. class FillAllRow extends StatelessWidget { @@ -52,26 +52,42 @@ class FillAllRow extends StatelessWidget { Expanded( child: OutlinedButton( onPressed: busy ? null : onFill, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text(_label(armed), maxLines: 1), - ), + child: _face(context, armed), ), ), if (armed) ...[ const SizedBox(width: AppSpacing.sm), - TextButton( + IconButton( onPressed: busy ? null : onCancel, - child: const Text('Cancel'), + icon: const Icon(Icons.close, semanticLabel: 'Cancel'), ), ], ], ); } - String _label(bool armed) { - if (busy) return 'Working…'; - if (armed) return '✓ Confirm ($armedCount)'; - return fillAllLabel; + /// Resting glyph; spinner while busy; double check + count once armed. + Widget _face(BuildContext context, bool armed) { + if (busy) { + return const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + if (!armed) { + return const Icon(Icons.playlist_add_check, semanticLabel: fillAllLabel); + } + final success = + (Theme.of(context).extension() ?? AppStatusColors.dark) + .success; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.done_all, color: success, semanticLabel: 'Confirm'), + const SizedBox(width: AppSpacing.xs), + Text('$armedCount'), + ], + ); } } diff --git a/app/lib/widgets/log_meal_actions_row.dart b/app/lib/widgets/log_meal_actions_row.dart index 7dfa421..0ba7d77 100644 --- a/app/lib/widgets/log_meal_actions_row.dart +++ b/app/lib/widgets/log_meal_actions_row.dart @@ -52,44 +52,44 @@ class LogMealActionsRow extends StatelessWidget { 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. + // Expanded so the delivery button takes the slack and pushes the log + // button right; the queue is a numeral next to the icon, so there is + // no wide label to clip. While fetching the icon becomes a spinner. 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), - ), + child: OutlinedButton( + onPressed: deliveryBusy ? null : onLoadDelivery, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (deliveryBusy) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon( + Icons.restaurant, + semanticLabel: "Load today's catering delivery", + ), + if (dishesQueued > 0) ...[ + const SizedBox(width: AppSpacing.xs), + Text('$dishesQueued'), + ], + ], ), ), ), - // 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. + // No Spacer when the delivery button is present: it is a flex child + // too and would split the free space with the Expanded above. if (!canFetch) const Spacer(), const SizedBox(width: AppSpacing.sm), - Tooltip( - message: 'Log meal', - child: FilledButton( - onPressed: onLog, - child: const Icon(Icons.check_circle), - ), + FilledButton( + onPressed: onLog, + child: const Icon(Icons.check_circle, semanticLabel: 'Log meal'), ), ], ); } - - 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 58fe3cb..32444c1 100644 --- a/app/lib/widgets/macro_input_row.dart +++ b/app/lib/widgets/macro_input_row.dart @@ -4,6 +4,8 @@ library; import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:flutter/material.dart'; /// Text controllers for one macro-entry row, owned by the calling screen so @@ -91,7 +93,8 @@ class MacroControllers { } } -/// A labeled row of number-entry fields for calories, macros, and the +/// A row of number-entry fields, each captioned by an icon: calories, +/// macros, and the /// optional reference-weight-vs-eaten-weight split. /// /// Layout mirrors the Python gate's macro section: the reference weight @@ -116,76 +119,59 @@ class MacroInputRow extends StatelessWidget { @override Widget build(BuildContext context) { + final fields = [ + _macroField(MacroKind.perGrams, controllers.perGrams), + _macroField(MacroKind.kcal, controllers.kcal), + _macroField(MacroKind.protein, controllers.protein), + _macroField(MacroKind.carbs, controllers.carbs), + _macroField(MacroKind.fat, controllers.fat), + _macroField(MacroKind.eaten, controllers.grams), + ]; if (compact) { return Row( children: [ - Expanded(child: _macroField('per', controllers.perGrams)), - const SizedBox(width: 4), - Expanded(child: _macroField('kcal', controllers.kcal)), - const SizedBox(width: 4), - Expanded(child: _macroField('P', controllers.protein)), - const SizedBox(width: 4), - Expanded(child: _macroField('C', controllers.carbs)), - const SizedBox(width: 4), - Expanded(child: _macroField('F', controllers.fat)), - const SizedBox(width: 4), - Expanded( - child: Tooltip( - message: "blank = same as 'per (g)'", - child: _macroField('eaten', controllers.grams), - ), - ), + for (final (i, field) in fields.indexed) ...[ + if (i > 0) const SizedBox(width: 4), + Expanded(child: field), + ], ], ); } + // Reference weight and kcal share a line, then the three macros, then the + // eaten weight: the same order the Python gate shows them in. return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // per-gram reference weight and kcal on the same line. Row( - crossAxisAlignment: CrossAxisAlignment.end, children: [ - SizedBox( - width: 72, - child: _macroField('per (g)', controllers.perGrams), - ), + SizedBox(width: 96, child: fields[0]), const SizedBox(width: 8), - Expanded(child: _macroField('kcal', controllers.kcal)), + Expanded(child: fields[1]), ], ), const SizedBox(height: 8), Row( children: [ - Expanded(child: _macroField('protein g', controllers.protein)), - const SizedBox(width: 8), - Expanded(child: _macroField('carbs g', controllers.carbs)), - const SizedBox(width: 8), - Expanded(child: _macroField('fat g', controllers.fat)), + for (final (i, field) in fields.sublist(2, 5).indexed) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded(child: field), + ], ], ), const SizedBox(height: 8), - _macroField( - 'eaten (g)', - controllers.grams, - helperText: "blank = same as 'per (g)'", - ), + fields[5], ], ); } - Widget _macroField( - String label, - TextEditingController controller, { - String? helperText, - }) { - return TextField( + Widget _macroField(MacroKind kind, TextEditingController controller) { + return IconField( + icon: kind.icon, + iconColor: kind.color, + semanticLabel: kind.semanticLabel(compact: compact), controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration( - labelText: label, - helperText: helperText, - isDense: true, - ), + dense: true, ); } } diff --git a/app/lib/widgets/sync_health_banner.dart b/app/lib/widgets/sync_health_banner.dart index bd2efef..bf1da1b 100644 --- a/app/lib/widgets/sync_health_banner.dart +++ b/app/lib/widgets/sync_health_banner.dart @@ -40,16 +40,22 @@ class SyncHealthBanner extends StatelessWidget { border: Border.all(color: warning.withValues(alpha: 0.5)), borderRadius: BorderRadius.circular(8), ), + // A glyph and, once an hour or more has passed, the elapsed time as a + // numeral (`2d`). The sentence survives only as the spoken label. child: Row( children: [ - Icon(Icons.cloud_off, size: 18, color: warning), + Icon( + Icons.cloud_off, + size: 18, + color: warning, + semanticLabel: message, + ), const SizedBox(width: 8), - Expanded( - child: Text( - message, + if (status?.agoShort case final ago?) + Text( + ago, style: theme.textTheme.bodySmall?.copyWith(color: warning), ), - ), ], ), ); diff --git a/app/lib/widgets/today_progress_card.dart b/app/lib/widgets/today_progress_card.dart index ac06706..7d24c6e 100644 --- a/app/lib/widgets/today_progress_card.dart +++ b/app/lib/widgets/today_progress_card.dart @@ -3,6 +3,7 @@ library; import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:diet_guard_app/widgets/progress_motion.dart'; import 'package:flutter/material.dart'; @@ -87,8 +88,6 @@ class TodayProgressCard extends StatelessWidget { /// The values to display. final TodayProgress progress; - static String _plural(int count) => count == 1 ? 'day' : 'days'; - static String _grams(double value) => '${value.round()}g'; /// Whole numbers without decimals; fractions exactly, never rounded. @@ -98,6 +97,7 @@ class TodayProgressCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final status = theme.extension() ?? AppStatusColors.dark; final slot = progress.loggedSlotMinute; final logged = progress.loggedKcal; final from = progress.previousKcal ?? progress.consumedKcal; @@ -118,31 +118,55 @@ class TodayProgressCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (slot != null && logged != null) ...[ - Text( - '✓ ${slotLabel(slot)} · ${_kcal(logged)} kcal', - style: theme.textTheme.labelMedium, + Row( + children: [ + Icon( + Icons.check_circle, + size: 16, + color: status.success, + semanticLabel: 'logged', + ), + const SizedBox(width: AppSpacing.xs), + Text(slotLabel(slot), style: theme.textTheme.labelMedium), + const SizedBox(width: AppSpacing.sm), + MacroFigure(kind: MacroKind.kcal, value: _kcal(logged)), + ], ), const SizedBox(height: AppSpacing.xs), ], CountUp(from: from, to: progress.consumedKcal, builder: _kcalRow), const SizedBox(height: AppSpacing.xs), - Text( - 'P ${_grams(progress.proteinG)} · ' - 'C ${_grams(progress.carbsG)} · ' - 'F ${_grams(progress.fatG)}', - style: theme.textTheme.labelSmall, + Row( + children: [ + MacroFigure( + kind: MacroKind.protein, + value: _grams(progress.proteinG), + ), + const SizedBox(width: AppSpacing.md), + MacroFigure( + kind: MacroKind.carbs, + value: _grams(progress.carbsG), + ), + const SizedBox(width: AppSpacing.md), + MacroFigure(kind: MacroKind.fat, value: _grams(progress.fatG)), + ], ), const SizedBox(height: AppSpacing.xs), CountUp( from: streakFrom.toDouble(), to: progress.adherenceStreak.toDouble(), - builder: (context, value) { - final days = value.round(); - return Text( - 'Adherence streak: $days ${_plural(days)}', - style: theme.textTheme.labelSmall, - ); - }, + builder: (context, value) => Row( + children: [ + Icon( + Icons.local_fire_department, + size: 16, + color: status.warning, + semanticLabel: 'adherence streak', + ), + const SizedBox(width: 3), + Text('${value.round()}', style: theme.textTheme.labelMedium), + ], + ), ), ], ), @@ -156,24 +180,49 @@ class TodayProgressCard extends StatelessWidget { final colors = theme.colorScheme; final remaining = progress.budgetKcal - consumed; final over = remaining < 0; - return Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, + final tint = over ? colors.error : colors.onSurface; + // Bar fill = share of the budget used; it turns red AND the right-hand + // glyph flips from a flag (room left) to a warning (over) past the budget. + final used = progress.budgetKcal > 0 + ? (consumed / progress.budgetKcal).clamp(0.0, 1.0) + : 1.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${consumed.round()} / ${progress.budgetKcal}', - style: theme.textTheme.titleMedium?.copyWith( - color: over ? colors.error : colors.onSurface, - ), + Row( + children: [ + Icon( + MacroKind.kcal.icon, + size: 18, + color: MacroKind.kcal.color, + semanticLabel: 'kcal', + ), + const SizedBox(width: AppSpacing.xs), + Text( + '${consumed.round()} / ${progress.budgetKcal}', + style: theme.textTheme.titleMedium?.copyWith(color: tint), + ), + const Spacer(), + Icon( + over ? Icons.warning_amber_rounded : Icons.flag_outlined, + size: 16, + color: over ? colors.error : colors.onSurfaceVariant, + semanticLabel: over ? 'over budget' : 'left', + ), + const SizedBox(width: 3), + Text( + '${remaining.abs().round()}', + style: theme.textTheme.labelMedium?.copyWith(color: tint), + ), + ], ), - const SizedBox(width: AppSpacing.xs), - Text('kcal', style: theme.textTheme.labelSmall), - const Spacer(), - Text( - '${remaining.abs().round()} ${over ? 'over' : 'left'}', - style: theme.textTheme.labelMedium?.copyWith( - color: over ? colors.error : colors.onSurfaceVariant, - ), + const SizedBox(height: AppSpacing.xs), + LinearProgressIndicator( + value: used, + minHeight: 6, + borderRadius: BorderRadius.circular(3), + color: over ? colors.error : colors.primary, + backgroundColor: colors.surfaceContainerHighest, ), ], ); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ccd75c6..eaa81e4 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.139+1 +version: 1.0.140+1 environment: sdk: ^3.12.2 diff --git a/app/test/fill_all_row_test.dart b/app/test/fill_all_row_test.dart index 4bee33c..66abe7c 100644 --- a/app/test/fill_all_row_test.dart +++ b/app/test/fill_all_row_test.dart @@ -1,10 +1,12 @@ -/// The "Fill all with catering" row: label per state, Cancel only when armed. +/// The "Fill all with catering" row: glyph per state, Cancel only when armed. library; import 'package:diet_guard_app/widgets/fill_all_row.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'label_finders.dart'; + Future _pump( WidgetTester tester, { bool canFetch = true, @@ -31,26 +33,30 @@ void main() { await _pump(t, canFetch: false, armed: 3); expect(find.byType(OutlinedButton), findsNothing); - expect(find.text(fillAllLabel), findsNothing); - expect(find.text('Cancel'), findsNothing); + expect(byIconLabel(fillAllLabel), findsNothing); + expect(byIconLabel('Cancel'), findsNothing); }); - testWidgets('resting: the fill label and no Cancel', (t) async { + testWidgets('resting: the fill glyph and no Cancel', (t) async { await _pump(t); - expect(find.text('🍱 Fill all with catering'), findsOneWidget); - expect(find.text('Cancel'), findsNothing); + expect(byIconLabel(fillAllLabel), findsOneWidget); + expect(find.byIcon(Icons.playlist_add_check), findsOneWidget); + expect(byIconLabel('Cancel'), findsNothing); }); - testWidgets('armed: Confirm (N) and a Cancel button', (t) async { + testWidgets('armed: double check with the count, and a Cancel button', ( + t, + ) async { await _pump(t, armed: 3); - expect(find.text('✓ Confirm (3)'), findsOneWidget); - expect(find.text(fillAllLabel), findsNothing); - expect(find.text('Cancel'), findsOneWidget); + expect(byIconLabel('Confirm'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(byIconLabel(fillAllLabel), findsNothing); + expect(byIconLabel('Cancel'), findsOneWidget); }); - testWidgets('busy: Working… and both controls disabled', (t) async { + testWidgets('busy: spinner and both controls disabled', (t) async { var fills = 0; var cancels = 0; await _pump( @@ -61,15 +67,15 @@ void main() { onCancel: () => cancels++, ); - expect(find.text('Working…'), findsOneWidget); - expect(find.text('✓ Confirm (3)'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(byIconLabel('Confirm'), findsNothing); expect( t.widget(find.byType(OutlinedButton)).onPressed, isNull, ); - expect(t.widget(find.byType(TextButton)).onPressed, isNull); + expect(t.widget(find.byType(IconButton)).onPressed, isNull); await t.tap(find.byType(OutlinedButton), warnIfMissed: false); - await t.tap(find.byType(TextButton), warnIfMissed: false); + await t.tap(find.byType(IconButton), warnIfMissed: false); expect(fills + cancels, 0); }); @@ -83,8 +89,8 @@ void main() { onCancel: () => cancels++, ); - await t.tap(find.text('✓ Confirm (2)')); - await t.tap(find.text('Cancel')); + await t.tap(byIconLabel('Confirm')); + await t.tap(byIconLabel('Cancel')); expect([fills, cancels], [1, 1]); }); diff --git a/app/test/screens/fit/fit_screens_test.dart b/app/test/screens/fit/fit_screens_test.dart index 564fe50..38d96fd 100644 --- a/app/test/screens/fit/fit_screens_test.dart +++ b/app/test/screens/fit/fit_screens_test.dart @@ -19,10 +19,13 @@ import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/foodbank_service.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/widgets/food_bank/fb_record_tile.dart'; import 'package:diet_guard_app/widgets/today_progress_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; + import '../body_screen_support.dart'; import 'fit_support.dart'; @@ -74,7 +77,7 @@ void main() { await _pump(tester, const LogMealScreen()); await tester.enterText(find.byType(TextField).first, 'Oats'); await tester.runAsync(() async { - await tester.tap(find.byTooltip('Log meal')); + await tester.tap(controlByLabel('Log meal')); await Future.delayed(const Duration(milliseconds: 500)); }); await tester.pump(const Duration(milliseconds: 300)); @@ -113,7 +116,7 @@ void main() { // A tile is hit-testable, and long-press/tap opens its detail sheet. await tester.tap(first); await tester.pumpAndSettle(); - expect(find.textContaining('P 1 g'), findsOneWidget); + expect(find.byType(RecordTile), findsOneWidget); await tester.tapAt(const Offset(200, 50)); // scrim closes the sheet await tester.pumpAndSettle(); // Swiping to the next page reveals foods not on the first page. diff --git a/app/test/screens/log_meal_fill_mixin_test.dart b/app/test/screens/log_meal_fill_mixin_test.dart index d1b00fd..f3c8399 100644 --- a/app/test/screens/log_meal_fill_mixin_test.dart +++ b/app/test/screens/log_meal_fill_mixin_test.dart @@ -22,6 +22,7 @@ import 'package:diet_guard_app/services/kuchnia_fill_flow.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/log_storage_service.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -50,6 +51,7 @@ class _HostState extends State<_Host> final MacroControllers macros = MacroControllers(); int publishes = 0; String status = ''; + CueKind statusKind = CueKind.info; @override int? selectedSlot; @@ -74,7 +76,11 @@ class _HostState extends State<_Host> } @override - void showFillStatus(String message) => setState(() => status = message); + void showFillStatus(String message, {CueKind kind = CueKind.info}) => + setState(() { + status = message; + statusKind = kind; + }); /// Counted instead of run: the real one starts a network sync. @override @@ -134,6 +140,7 @@ void main() { }); expect(host.status, 'Logged 4 catering dishes.'); + expect(host.statusKind, CueKind.ok); expect(await t.runAsync(() => loggedToday(storage)), hasLength(4)); expect(host.publishes, 1, reason: 'one publish per batch, not per dish'); expect(KuchniaQueueService.remaining, 0); @@ -186,6 +193,7 @@ void main() { await t.runAsync(host.onFillAll); expect(host.status, startsWith('Fill all failed')); + expect(host.statusKind, CueKind.error); expect(host.publishes, 1, reason: 'a partial batch must still sync'); expect(host.fillFlow.busy, isFalse); }); diff --git a/app/test/screens/log_meal_haptic_test.dart b/app/test/screens/log_meal_haptic_test.dart index 505a9a7..44fed46 100644 --- a/app/test/screens/log_meal_haptic_test.dart +++ b/app/test/screens/log_meal_haptic_test.dart @@ -13,6 +13,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; + void main() { late Directory dir; final haptics = []; @@ -60,7 +62,7 @@ void main() { await settle(tester); await tester.enterText(find.byType(TextField).at(0), 'toast'); await settle(tester); - await tester.tap(find.byTooltip('Log meal')); + await tester.tap(controlByLabel('Log meal')); // One frame, no I/O wait: the haptic must not wait for the write. await tester.pump(); expect(haptics, ['HapticFeedbackType.lightImpact']); @@ -80,23 +82,24 @@ void main() { await settle(tester); await tester.enterText(find.byType(TextField).at(0), 'toast'); await settle(tester); - await tester.tap(find.byTooltip('Log meal')); + await tester.tap(controlByLabel('Log meal')); await settle(tester); expect(haptics, ['HapticFeedbackType.lightImpact']); expect(cues, 0); }); }); - testWidgets('a rejected log (empty description) does not buzz', ( + testWidgets('a rejected log (empty description) gives only the error tick', ( tester, ) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); - await tester.tap(find.byTooltip('Log meal')); + await tester.tap(controlByLabel('Log meal')); await settle(tester); - expect(find.text('Type what you ate first.'), findsOneWidget); - expect(haptics, isEmpty); + expect(cueLabelled('Type what you ate first.'), findsOneWidget); + // Only the error cue's own heavy tick: no success buzz. + expect(haptics, ['HapticFeedbackType.heavyImpact']); expect(cues, 0); }); }); diff --git a/app/test/screens/log_meal_screen_future_test.dart b/app/test/screens/log_meal_screen_future_test.dart index fbcf9e8..bb1a6df 100644 --- a/app/test/screens/log_meal_screen_future_test.dart +++ b/app/test/screens/log_meal_screen_future_test.dart @@ -12,6 +12,8 @@ import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; + /// Covers logging a meal against a future date/slot through [LogMealScreen]'s /// "When" row -- its own file, split out the same way /// `log_meal_screen_nav_test.dart` and `log_meal_screen_sync_test.dart` are. @@ -36,7 +38,7 @@ void main() { await tempDir.delete(recursive: true); }); - final logMealButton = find.byTooltip('Log meal'); + final logMealButton = controlByLabel('Log meal'); // See log_meal_screen_test.dart's `settle` for why: fire-and-forget I/O // that pumpAndSettle() alone does not wait for. @@ -58,7 +60,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); - await tester.tap(find.text('Log for later')); + await tester.tap(controlByLabel('Log for later')); await settle(tester); // The date picker's initialDate is already tomorrow, so confirming // immediately picks it without touching a specific calendar cell. @@ -89,8 +91,8 @@ void main() { expect(future.time.startsWith(tomorrowKey), isTrue); // Back to logging for today. - expect(find.text('Today'), findsOneWidget); - expect(find.text('Log for later'), findsOneWidget); + expect(find.text(tomorrowKey), findsNothing); + expect(controlByLabel('Log for later'), findsOneWidget); }); }, ); @@ -102,7 +104,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); - await tester.tap(find.text('Log for later')); + await tester.tap(controlByLabel('Log for later')); await settle(tester); await tester.tap(find.text('OK')); await settle(tester); @@ -114,7 +116,7 @@ void main() { await tester.tap(logMealButton); await settle(tester); - expect(find.text('Pick a slot for that date first.'), findsOneWidget); + expect(cueLabelled('Pick a slot for that date first.'), findsOneWidget); final log = await LogStorageService.instance.readLog(); expect(log.values.expand((e) => e), isEmpty); }); @@ -127,17 +129,17 @@ void main() { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); - await tester.tap(find.text('Log for later')); + await tester.tap(controlByLabel('Log for later')); await settle(tester); await tester.tap(find.text('OK')); await settle(tester); - expect(find.text('Today'), findsNothing); + expect(find.text(localDateKey(tomorrow())), findsOneWidget); - await tester.tap(find.byTooltip('Back to today')); + await tester.tap(controlByLabel('Back to today')); await settle(tester); - expect(find.text('Today'), findsOneWidget); - expect(find.text('Log for later'), findsOneWidget); + expect(find.text(localDateKey(tomorrow())), findsNothing); + expect(controlByLabel('Log for later'), findsOneWidget); }); }); } diff --git a/app/test/screens/log_meal_screen_nav_test.dart b/app/test/screens/log_meal_screen_nav_test.dart index be3a98f..558d85d 100644 --- a/app/test/screens/log_meal_screen_nav_test.dart +++ b/app/test/screens/log_meal_screen_nav_test.dart @@ -15,6 +15,8 @@ import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import '../label_finders.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -65,7 +67,7 @@ void main() { await tempDir.delete(recursive: true); }); - final logMealButton = find.byTooltip('Log meal'); + final logMealButton = controlByLabel('Log meal'); // The screen's button handlers and description-field listener trigger // real `dart:io` file I/O as fire-and-forget Futures that Flutter's frame @@ -107,7 +109,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); - await tester.tap(find.byTooltip('Body')); + await tester.tap(controlByLabel('Body')); await settle(tester); expect(find.byType(BodyScreen), findsOneWidget); diff --git a/app/test/screens/log_meal_screen_sync_test.dart b/app/test/screens/log_meal_screen_sync_test.dart index ee82c32..3ae2cce 100644 --- a/app/test/screens/log_meal_screen_sync_test.dart +++ b/app/test/screens/log_meal_screen_sync_test.dart @@ -10,6 +10,8 @@ import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import '../label_finders.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -168,7 +170,7 @@ void main() { await tester.enterText(find.byType(TextField).at(0), 'push-on-log'); await tester.pump(); - await tester.tap(find.byTooltip('Log meal')); + await tester.tap(controlByLabel('Log meal')); // Wait for the push itself rather than a fixed 200 ms: under the // CPU-capped pre-commit gate the real async hop can take longer, and // a wall-clock guess made this flaky there (passes 5/5 standalone). diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index 206e450..8d1a79e 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -13,6 +13,8 @@ import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/widgets/today_progress_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import '../label_finders.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -50,9 +52,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); await MealScheduleService.initForTesting(FileDocumentStore(tempDir)); }); @@ -65,7 +65,7 @@ void main() { await tempDir.delete(recursive: true); }); - final logMealButton = find.byTooltip('Log meal'); + final logMealButton = controlByLabel('Log meal'); // The screen's button handlers and description-field listener trigger // real `dart:io` file I/O as fire-and-forget Futures that Flutter's frame @@ -107,9 +107,7 @@ void main() { expect(entries.single.kcal, 150); }); }); - testWidgets('the progress card summarises today after a log', ( - tester, - ) async { + testWidgets('the progress card summarises today after a log', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); @@ -127,7 +125,7 @@ void main() { expect(find.byType(TodayProgressCard), findsOneWidget); // Default budget is 2200 with the settings singleton uninitialised. expect(find.text('150 / 2200'), findsOneWidget); - expect(find.text('2050 left'), findsOneWidget); + expect(find.text('2050'), findsOneWidget); }); }); testWidgets('typing a new description dismisses the progress card', ( @@ -163,7 +161,7 @@ void main() { await tester.tap(logMealButton); await settle(tester); - expect(find.text('Type what you ate first.'), findsOneWidget); + expect(cueLabelled('Type what you ate first.'), findsOneWidget); expect(await LogStorageService.instance.todayEntries(), isEmpty); }); }); @@ -197,54 +195,51 @@ void main() { }); }, ); - testWidgets( - 'selecting a food-bank suggestion stamps source food bank, but ' - 'editing a macro afterward reverts it to manual', - (tester) async { - await tester.runAsync(() async { - const seed = FoodEntry( - id: 'seed-1', - time: '2026-06-01T08:00:00+02:00', - desc: 'seeded food', - grams: 100, - kcal: 250, - proteinG: 10, - carbsG: 30, - fatG: 8, - source: 'manual', - ); - await FoodBankService.instance.rebuildAndPersist({ - '2026-06-01': [seed], - }); - - await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); - await settle(tester); - - // The empty-query suggestion list shows the only banked food. - await tester.tap(find.text('seeded food · 250 kcal')); - await settle(tester); - await tester.ensureVisible(logMealButton); - await tester.tap(logMealButton); - await settle(tester); - - final firstEntry = - (await LogStorageService.instance.todayEntries()).single; - expect(firstEntry.source, 'food bank'); - expect(firstEntry.kcal, 250); - - await tester.tap(find.text('seeded food · 250 kcal')); - await settle(tester); - await tester.enterText(find.byType(TextField).at(2), '999'); - await settle(tester); - await tester.ensureVisible(logMealButton); - await tester.tap(logMealButton); - await settle(tester); - - final secondEntry = - (await LogStorageService.instance.todayEntries()).last; - expect(secondEntry.source, 'manual'); - expect(secondEntry.kcal, 999); + testWidgets('selecting a food-bank suggestion stamps source food bank, but ' + 'editing a macro afterward reverts it to manual', (tester) async { + await tester.runAsync(() async { + const seed = FoodEntry( + id: 'seed-1', + time: '2026-06-01T08:00:00+02:00', + desc: 'seeded food', + grams: 100, + kcal: 250, + proteinG: 10, + carbsG: 30, + fatG: 8, + source: 'manual', + ); + await FoodBankService.instance.rebuildAndPersist({ + '2026-06-01': [seed], }); - }, - ); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + // The empty-query suggestion list shows the only banked food. + await tester.tap(find.text('seeded food')); + await settle(tester); + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final firstEntry = + (await LogStorageService.instance.todayEntries()).single; + expect(firstEntry.source, 'food bank'); + expect(firstEntry.kcal, 250); + + await tester.tap(find.text('seeded food')); + await settle(tester); + await tester.enterText(find.byType(TextField).at(2), '999'); + await settle(tester); + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final secondEntry = + (await LogStorageService.instance.todayEntries()).last; + expect(secondEntry.source, 'manual'); + expect(secondEntry.kcal, 999); + }); + }); } diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index cb523cc..c9c70fd 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -4,8 +4,11 @@ import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/main.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'label_finders.dart'; + void main() { late Directory tempDir; @@ -31,7 +34,7 @@ void main() { await tester.pumpAndSettle(); }); - expect(find.text('Diet Guard'), findsOneWidget); - expect(find.text('What did you eat?'), findsOneWidget); + expect(find.byType(LogMealScreen), findsOneWidget); + expect(fieldByLabel('What did you eat?'), findsOneWidget); }); } diff --git a/app/test/widgets/log_meal_actions_row_test.dart b/app/test/widgets/log_meal_actions_row_test.dart index 5d71c2d..fd0f5a3 100644 --- a/app/test/widgets/log_meal_actions_row_test.dart +++ b/app/test/widgets/log_meal_actions_row_test.dart @@ -11,6 +11,8 @@ import 'package:diet_guard_app/widgets/log_meal_actions_row.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; + void main() { Future pump( WidgetTester tester, { @@ -49,56 +51,39 @@ void main() { expect(taps, 1); }); - testWidgets('the queue count is visible, as the PC shows it', (tester) async { + testWidgets('the queue count is a numeral beside the icon', (tester) async { await pump(tester, queued: 4); - expect(find.text("Today's delivery (4 more to go)"), findsOneWidget); + expect(find.text('4'), findsOneWidget); + expect(byIconLabel("Load today's catering delivery"), 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); + testWidgets('nothing queued shows no numeral and never overflows', ( + tester, + ) async { + await pump(tester, 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(find.text('0'), findsNothing); expect(tester.takeException(), isNull); }); - testWidgets('a fetch in flight disables the button', (tester) async { + testWidgets('a fetch in flight disables the button and spins', ( + tester, + ) async { await pump(tester, busy: true); - final button = tester.widget( - find.byType(OutlinedButton), - ); + final button = tester.widget(find.byType(OutlinedButton)); expect(button.onPressed, isNull); - expect(find.text('Loading…'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), 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. + // cue 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(byIconLabel("Load today's catering delivery"), findsOneWidget); + final button = tester.widget(find.byType(OutlinedButton)); expect(button.onPressed, isNotNull); }); diff --git a/app/test/widgets/sync_health_banner_test.dart b/app/test/widgets/sync_health_banner_test.dart index 567054a..6fb94cf 100644 --- a/app/test/widgets/sync_health_banner_test.dart +++ b/app/test/widgets/sync_health_banner_test.dart @@ -5,7 +5,9 @@ import 'package:flutter_test/flutter_test.dart'; Future pump(WidgetTester tester, SyncHealthStatus? status) => tester.pumpWidget( - MaterialApp(home: Scaffold(body: SyncHealthBanner(status: status))), + MaterialApp( + home: Scaffold(body: SyncHealthBanner(status: status)), + ), ); void main() { @@ -39,8 +41,13 @@ void main() { ); expect(find.byIcon(Icons.cloud_off), findsOneWidget); + expect(find.text('2d'), findsOneWidget); expect( - find.textContaining('Meals stay on this device'), + find.byWidgetPredicate( + (w) => + w is Icon && + (w.semanticLabel?.contains('Meals stay on this device') ?? false), + ), findsOneWidget, ); }); diff --git a/app/test/widgets/today_progress_card_motion_test.dart b/app/test/widgets/today_progress_card_motion_test.dart index 0991548..653cd95 100644 --- a/app/test/widgets/today_progress_card_motion_test.dart +++ b/app/test/widgets/today_progress_card_motion_test.dart @@ -2,6 +2,8 @@ import 'package:diet_guard_app/widgets/today_progress_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; + TodayProgress _progress({ double consumedKcal = 1450, double? previousKcal, @@ -50,22 +52,24 @@ void main() { group('confirmation line', () { testWidgets('prints whole kcal without decimals', (tester) async { await _pump(tester, _progress(loggedSlotMinute: 720, loggedKcal: 450)); - expect(find.text('✓ 12:00 · 450 kcal'), findsOneWidget); + expect(find.text('12:00'), findsOneWidget); + expect(find.text('450'), findsOneWidget); expect(find.textContaining('Logged'), findsNothing); }); testWidgets('keeps fractional kcal exact', (tester) async { await _pump(tester, _progress(loggedSlotMinute: 495, loggedKcal: 450.5)); - expect(find.text('✓ 08:15 · 450.5 kcal'), findsOneWidget); + expect(find.text('08:15'), findsOneWidget); + expect(find.text('450.5'), findsOneWidget); }); testWidgets('is absent when the logged fields are null', (tester) async { await _pump(tester, _progress()); - expect(find.textContaining('✓'), findsNothing); + expect(byIconLabel('logged'), findsNothing); await _pump(tester, _progress(loggedSlotMinute: 720)); - expect(find.textContaining('✓'), findsNothing); + expect(byIconLabel('logged'), findsNothing); await _pump(tester, _progress(loggedKcal: 450)); - expect(find.textContaining('✓'), findsNothing); + expect(byIconLabel('logged'), findsNothing); }); }); @@ -73,7 +77,7 @@ void main() { testWidgets('runs from previousKcal to consumedKcal', (tester) async { await _pump(tester, _progress(previousKcal: 1000)); expect(find.text('1000 / 2200'), findsOneWidget); - expect(find.text('1200 left'), findsOneWidget); + expect(find.text('1200'), findsOneWidget); await tester.pump(const Duration(milliseconds: 160)); final mid = tester.widget(find.textContaining(' / 2200')).data!; @@ -83,7 +87,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('1450 / 2200'), findsOneWidget); - expect(find.text('750 left'), findsOneWidget); + expect(find.text('750'), findsOneWidget); }); testWidgets('is static without previousKcal', (tester) async { @@ -97,24 +101,24 @@ void main() { tester, ) async { await _pump(tester, _progress(consumedKcal: 2500, previousKcal: 2000)); - expect(find.text('200 left'), findsOneWidget); + expect(find.text('200'), findsOneWidget); await tester.pumpAndSettle(); expect(find.text('2500 / 2200'), findsOneWidget); - expect(find.text('300 over'), findsOneWidget); + expect(find.text('300'), findsOneWidget); }); testWidgets('streak counts up when it changed', (tester) async { await _pump(tester, _progress(previousAdherenceStreak: 2)); - expect(find.text('Adherence streak: 2 days'), findsOneWidget); + expect(find.text('2'), findsOneWidget); await tester.pumpAndSettle(); - expect(find.text('Adherence streak: 3 days'), findsOneWidget); + expect(find.text('3'), findsOneWidget); }); testWidgets('streak is static when unchanged', (tester) async { await _pump(tester, _progress(previousAdherenceStreak: 3)); - expect(find.text('Adherence streak: 3 days'), findsOneWidget); + expect(find.text('3'), findsOneWidget); await tester.pump(const Duration(milliseconds: 160)); - expect(find.text('Adherence streak: 3 days'), findsOneWidget); + expect(find.text('3'), findsOneWidget); }); }); @@ -128,8 +132,8 @@ void main() { reduced: true, ); expect(find.text('1450 / 2200'), findsOneWidget); - expect(find.text('750 left'), findsOneWidget); - expect(find.text('Adherence streak: 3 days'), findsOneWidget); + expect(find.text('750'), findsOneWidget); + expect(find.text('3'), findsOneWidget); expect(_entranceOpacity(tester), 1); }); }); diff --git a/app/test/widgets/today_progress_card_test.dart b/app/test/widgets/today_progress_card_test.dart index bc1aaad..a5d63ad 100644 --- a/app/test/widgets/today_progress_card_test.dart +++ b/app/test/widgets/today_progress_card_test.dart @@ -2,6 +2,8 @@ import 'package:diet_guard_app/widgets/today_progress_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; + TodayProgress _progress({ double consumedKcal = 1450, int budgetKcal = 2200, @@ -55,8 +57,8 @@ void main() { // what the user had just typed. Pinned so it does not creep back. expect(find.textContaining('Logged'), findsNothing); expect(find.text('1450 / 2200'), findsOneWidget); - expect(find.text('750 left'), findsOneWidget); - expect(find.text('P 90g · C 120g · F 40g'), findsOneWidget); + expect(find.text('750'), findsOneWidget); + expect(find.text('90g'), findsOneWidget); }); testWidgets('reports the overshoot in the error color when over', ( @@ -64,7 +66,7 @@ void main() { ) async { await _pump(tester, _progress(consumedKcal: 2500)); - expect(find.text('300 over'), findsOneWidget); + expect(find.text('300'), findsOneWidget); final total = tester.widget(find.text('2500 / 2200')); final context = tester.element(find.text('2500 / 2200')); expect(total.style?.color, Theme.of(context).colorScheme.error); @@ -72,10 +74,10 @@ void main() { testWidgets('pluralises the adherence streak', (tester) async { await _pump(tester, _progress(adherenceStreak: 1)); - expect(find.text('Adherence streak: 1 day'), findsOneWidget); + expect(find.text('1'), findsOneWidget); await _pump(tester, _progress(adherenceStreak: 12)); - expect(find.text('Adherence streak: 12 days'), findsOneWidget); + expect(find.text('12'), findsOneWidget); }); testWidgets('rounds fractional kcal and macros for display', ( @@ -87,7 +89,7 @@ void main() { ); expect(find.text('1451 / 2200'), findsOneWidget); - expect(find.text('P 90g · C 0g · F 0g'), findsOneWidget); + expect(find.text('90g'), findsOneWidget); }); }); }