Drop text from the log-meal screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 08:20:57 +01:00
parent 07e5d2149c
commit 83d05c637a
25 changed files with 435 additions and 328 deletions

View File

@ -45,28 +45,23 @@ List<Widget> 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,
),
];

View File

@ -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<T extends StatefulWidget>
/// 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<T extends StatefulWidget>
// 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<T extends StatefulWidget>
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.

View File

@ -91,21 +91,32 @@ mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
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,
),
],

View File

@ -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<T extends StatefulWidget> on State<T> {
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.

View File

@ -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<LogMealScreen>
@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<LogMealScreen>
@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<LogMealScreen>
Future<void> _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<LogMealScreen>
// 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<LogMealScreen>
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<LogMealScreen>
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<LogMealScreen>
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),

View File

@ -53,8 +53,7 @@ abstract final class SyncHealth {
_recordFailure(SyncFailureKind.unconfigured);
/// Marks a tick that had a backend but failed to publish.
static Future<void> recordFailure() =>
_recordFailure(SyncFailureKind.failed);
static Future<void> recordFailure() => _recordFailure(SyncFailureKind.failed);
static Future<void> _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';

View File

@ -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>() ?? AppStatusColors.dark)
.success;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.done_all, color: success, semanticLabel: 'Confirm'),
const SizedBox(width: AppSpacing.xs),
Text('$armedCount'),
],
);
}
}

View File

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

View File

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

View File

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

View File

@ -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>() ?? 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,
),
],
);

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.139+1
version: 1.0.140+1
environment:
sdk: ^3.12.2

View File

@ -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<void> _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<OutlinedButton>(find.byType(OutlinedButton)).onPressed,
isNull,
);
expect(t.widget<TextButton>(find.byType(TextButton)).onPressed, isNull);
expect(t.widget<IconButton>(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]);
});

View File

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

View File

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

View File

@ -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 = <String>[];
@ -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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> 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<OutlinedButton>(
find.byType(OutlinedButton),
);
final button = tester.widget<OutlinedButton>(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<OutlinedButton>(
find.byType(OutlinedButton),
);
expect(byIconLabel("Load today's catering delivery"), findsOneWidget);
final button = tester.widget<OutlinedButton>(find.byType(OutlinedButton));
expect(button.onPressed, isNotNull);
});

View File

@ -5,7 +5,9 @@ import 'package:flutter_test/flutter_test.dart';
Future<void> 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,
);
});

View File

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

View File

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