mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
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:
parent
07e5d2149c
commit
83d05c637a
@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user