Glyph and count for catering cues

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 18:11:23 +01:00
parent ff12f4e171
commit c9c30b1ee5
6 changed files with 193 additions and 35 deletions

View File

@ -15,6 +15,20 @@ import 'package:diet_guard_app/services/kuchnia_spread.dart';
import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/ui/cues.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// One fill tap's status (see [CountCue]).
typedef FillCue = CountCue;
/// The cue for a tap's [outcome]: dishes logged (ok + N), a proposal of
/// [armed] dishes (info + N), or nothing done (warn + 0: no delivery, every
/// slot taken, or the fetch failed).
FillCue fillCue(FillOutcome outcome, {required int armed}) {
final written = outcome.written.length;
final label = outcome.message;
if (written > 0) return (label: label, kind: CueKind.ok, count: written);
if (armed > 0) return (label: label, kind: CueKind.info, count: armed);
return (label: label, kind: CueKind.warn, count: 0);
}
/// Drives the screen's [FillAllFlow] and tidies the screen after a fill. /// Drives the screen's [FillAllFlow] and tidies the screen after a fill.
mixin LogMealFillMixin<T extends StatefulWidget> mixin LogMealFillMixin<T extends StatefulWidget>
on LogMealSyncMixin<T>, LogMealKuchniaMixin<T>, LogMealFutureMixin<T> { on LogMealSyncMixin<T>, LogMealKuchniaMixin<T>, LogMealFutureMixin<T> {
@ -22,9 +36,9 @@ mixin LogMealFillMixin<T extends StatefulWidget>
/// armed proposal along with it. /// armed proposal along with it.
FillAllFlow fillFlow = FillAllFlow(); FillAllFlow fillFlow = FillAllFlow();
/// Shows [message] as a status cue of [kind] (its words are only spoken). /// Shows [cue] as a status cue (its label is only spoken). Supplied by the
/// Supplied by the screen. /// screen.
void showFillStatus(String message, {CueKind kind = CueKind.info}); void showFillStatus(FillCue cue);
/// A proposal belongs to the date it was fetched for; picking another (or /// A proposal belongs to the date it was fetched for; picking another (or
/// going back to today) must not leave "Confirm (N)" offering it. /// going back to today) must not leave "Confirm (N)" offering it.
@ -52,7 +66,11 @@ mixin LogMealFillMixin<T extends StatefulWidget>
await publishAfterLog(); await publishAfterLog();
await refreshSlots(); await refreshSlots();
if (mounted) { if (mounted) {
showFillStatus('Fill all failed: $error', kind: CueKind.error); showFillStatus((
label: 'Fill all failed: $error',
kind: CueKind.error,
count: null,
));
} }
return; return;
} }
@ -63,16 +81,17 @@ mixin LogMealFillMixin<T extends StatefulWidget>
await _afterFill(outcome.written, later: day != null); await _afterFill(outcome.written, later: day != null);
} }
if (!mounted) return; if (!mounted) return;
showFillStatus( showFillStatus(fillCue(outcome, armed: fillFlow.armedCount));
outcome.message,
kind: outcome.written.isNotEmpty ? CueKind.ok : CueKind.warn,
);
} }
/// Disarms a proposal without writing anything. /// Disarms a proposal without writing anything.
void onFillCancel() { void onFillCancel() {
fillFlow.cancel(); fillFlow.cancel();
showFillStatus('Fill all cancelled — nothing logged.'); showFillStatus((
label: 'Fill all cancelled — nothing logged.',
kind: CueKind.info,
count: null,
));
} }
/// Publishes the batch once and drops the filled dishes from the delivery /// Publishes the batch once and drops the filled dishes from the delivery

View File

@ -29,6 +29,8 @@ import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
CountCue _cue(String l, CueKind k, [int? n]) => (label: l, kind: k, count: n);
/// Writes [dish]'s name and macros into the log form's controllers. /// Writes [dish]'s name and macros into the log form's controllers.
/// ///
/// Free function rather than a mixin method so the numeric formatting is /// Free function rather than a mixin method so the numeric formatting is
@ -139,16 +141,20 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
/// not be re-offered) and still records the fetch, so a successful tap /// not be re-offered) and still records the fetch, so a successful tap
/// also spares the automatic path a second walk. /// also spares the automatic path a second walk.
/// ///
/// Returns a line to show the user, or null when a dish was prefilled and /// Returns the cue to show (a spoken line, a glyph kind and a dish count),
/// the form speaks for itself. /// or null when a dish was prefilled and the form speaks for itself.
Future<String?> loadDeliveryOnTap() async { Future<CountCue?> loadDeliveryOnTap() async {
if (!KuchniaQueueService.isInitialized) return 'Catering is not ready yet.'; if (!KuchniaQueueService.isInitialized) {
return _cue('Catering is not ready yet.', CueKind.warn);
}
final logged = await LogStorageService.instance.todayEntries(); final logged = await LogStorageService.instance.todayEntries();
if (!mounted) return null; if (!mounted) return null;
final result = await refreshDelivery(DateTime.now()); final result = await refreshDelivery(DateTime.now());
if (!mounted) return null; if (!mounted) return null;
if (!result.ok) return result.reason; if (!result.ok) return _cue(result.reason!, CueKind.error);
if (result.dishes.isEmpty) return 'No delivery today.'; if (result.dishes.isEmpty) {
return _cue('No delivery today.', CueKind.warn, 0);
}
await KuchniaQueueService.instance.recordFetched(DateTime.now()); await KuchniaQueueService.instance.recordFetched(DateTime.now());
if (!mounted) return null; if (!mounted) return null;
final ordered = dishesInSlotOrder( final ordered = dishesInSlotOrder(
@ -162,7 +168,7 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
}, },
); );
if (KuchniaQueueService.next == null) { if (KuchniaQueueService.next == null) {
return 'All delivered dishes are already logged.'; return _cue('All delivered dishes are already logged.', CueKind.ok, 0);
} }
prefillNextDish(); prefillNextDish();
return null; return null;
@ -180,20 +186,21 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
/// the 250-line cap, and this is the mixin's own behaviour anyway. /// the 250-line cap, and this is the mixin's own behaviour anyway.
Future<void> loadDeliveryAndReport() async { Future<void> loadDeliveryAndReport() async {
setState(() => deliveryBusy = true); setState(() => deliveryBusy = true);
String? message; CountCue? cue;
try { try {
message = await loadDeliveryOnTap(); cue = await loadDeliveryOnTap();
} finally { } finally {
if (mounted) setState(() => deliveryBusy = false); if (mounted) setState(() => deliveryBusy = false);
} }
if (!mounted || message == null) return; if (!mounted || cue == null) return;
// The outcome sentence stays as the spoken label; the snackbar paints // The outcome sentence stays as the spoken label; the snackbar paints
// only a warning glyph (no delivery / not ready / fetch failed). // the dish glyph in the kind's tint plus the count (0 = none to log).
showCueSnack( showCueSnack(
context, context,
kind: CueKind.warn, kind: cue.kind,
semanticLabel: message, semanticLabel: cue.label,
icon: Icons.restaurant, icon: Icons.restaurant,
count: cue.count,
); );
} }

View File

@ -63,8 +63,8 @@ class _LogMealScreenState extends State<LogMealScreen>
CueState? _cue; // painted as a tinted icon, never as text CueState? _cue; // painted as a tinted icon, never as text
TodayProgress? _progress; TodayProgress? _progress;
void _setCue(CueKind kind, String? label) { void _setCue(CueKind kind, String? label, [int? count]) {
_cue = label == null ? null : CueState.next(_cue, kind, label); _cue = label == null ? null : CueState.next(_cue, kind, label, count);
_progress = null; _progress = null;
} }
@ -123,8 +123,8 @@ class _LogMealScreenState extends State<LogMealScreen>
@override @override
void onDishPrefilled() => setState(() => _source = 'catering'); void onDishPrefilled() => setState(() => _source = 'catering');
@override @override
void showFillStatus(String message, {CueKind kind = CueKind.info}) => void showFillStatus(FillCue cue) =>
setState(() => _setCue(kind, message)); setState(() => _setCue(cue.kind, cue.label, cue.count));
void _onSuggestionSelected(FoodSuggestion suggestion) { void _onSuggestionSelected(FoodSuggestion suggestion) {
fillControllersFromSuggestion(suggestion, _descController, _macros); fillControllersFromSuggestion(suggestion, _descController, _macros);

View File

@ -57,6 +57,7 @@ class StatusCue extends StatefulWidget {
required this.kind, required this.kind,
required this.semanticLabel, required this.semanticLabel,
this.icon, this.icon,
this.count,
super.key, super.key,
}); });
@ -69,6 +70,9 @@ class StatusCue extends StatefulWidget {
/// Optional glyph replacing [CueKind.icon]. /// Optional glyph replacing [CueKind.icon].
final IconData? icon; final IconData? icon;
/// Optional numeral painted beside the glyph (e.g. dishes logged).
final int? count;
@override @override
State<StatusCue> createState() => _StatusCueState(); State<StatusCue> createState() => _StatusCueState();
} }
@ -127,7 +131,11 @@ class _StatusCueState extends State<StatusCue>
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm), padding: const EdgeInsets.all(AppSpacing.sm),
child: Icon(widget.icon ?? widget.kind.icon, color: tint), child: _GlyphCount(
widget.icon ?? widget.kind.icon,
tint,
widget.count,
),
), ),
), ),
), ),
@ -144,6 +152,7 @@ void showCueSnack(
required CueKind kind, required CueKind kind,
required String semanticLabel, required String semanticLabel,
IconData? icon, IconData? icon,
int? count,
VoidCallback? onUndo, VoidCallback? onUndo,
}) { }) {
final messenger = ScaffoldMessenger.of(context)..hideCurrentSnackBar(); final messenger = ScaffoldMessenger.of(context)..hideCurrentSnackBar();
@ -155,7 +164,9 @@ void showCueSnack(
label: semanticLabel, label: semanticLabel,
child: Row( child: Row(
children: [ children: [
Icon(icon ?? kind.icon, color: tint), ExcludeSemantics(
child: _GlyphCount(icon ?? kind.icon, tint, count),
),
const Spacer(), const Spacer(),
if (onUndo != null) if (onUndo != null)
IconButton( IconButton(
@ -172,12 +183,16 @@ void showCueSnack(
); );
} }
/// A status to show: `label` is only spoken; the `kind` glyph and the
/// `count` numeral (null = glyph only) are what is painted.
typedef CountCue = ({String label, CueKind kind, int? count});
/// One screen's current status cue; [seq] re-keys the widget so an identical /// One screen's current status cue; [seq] re-keys the widget so an identical
/// consecutive status still shakes/pulses again. /// consecutive status still shakes/pulses again.
@immutable @immutable
class CueState { class CueState {
/// Creates the successor of [previous] carrying [kind] and [label]. /// Creates the successor of [previous] carrying [kind] and [label].
CueState.next(CueState? previous, this.kind, this.label) CueState.next(CueState? previous, this.kind, this.label, [this.count])
: seq = (previous?.seq ?? 0) + 1; : seq = (previous?.seq ?? 0) + 1;
/// Meaning. /// Meaning.
@ -186,12 +201,46 @@ class CueState {
/// Spoken description; never painted. /// Spoken description; never painted.
final String label; final String label;
/// Optional numeral painted beside the glyph.
final int? count;
/// Monotonic counter used as the widget key. /// Monotonic counter used as the widget key.
final int seq; final int seq;
/// The left-aligned cue widget for this state. /// The left-aligned cue widget for this state.
Widget get widget => Align( Widget get widget => Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: StatusCue(key: ValueKey(seq), kind: kind, semanticLabel: label), child: StatusCue(
key: ValueKey(seq),
kind: kind,
semanticLabel: label,
count: count,
),
); );
} }
/// A tinted glyph, followed by [count] when there is one.
class _GlyphCount extends StatelessWidget {
const _GlyphCount(this.icon, this.tint, this.count);
final IconData icon;
final Color tint;
final int? count;
@override
Widget build(BuildContext context) {
final n = count;
if (n == null) return Icon(icon, color: tint);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: tint),
const SizedBox(width: AppSpacing.xs),
Text(
'$n',
style: TextStyle(color: tint, fontWeight: FontWeight.w700),
),
],
);
}
}

View File

@ -0,0 +1,77 @@
// Guard: a catering fetch / fill reports with a glyph and a dish count; its
// sentence is only the spoken label.
import 'package:diet_guard_app/screens/log_meal_fill_mixin.dart';
import 'package:diet_guard_app/services/kuchnia_spread.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'label_finders.dart';
import 'no_words_support.dart';
void main() {
group('fillCue', () {
test('nothing to fill paints a warning and 0', () {
final cue = fillCue((
message: 'No catering delivery today.',
written: const <SlottedDish>[],
), armed: 0);
expect(cue.kind, CueKind.warn);
expect(cue.count, 0);
expect(cue.label, 'No catering delivery today.');
});
test('a proposal paints its armed count', () {
final cue = fillCue((
message: 'Will log 3',
written: const <SlottedDish>[],
), armed: 3);
expect((cue.kind, cue.count), (CueKind.info, 3));
});
});
testWidgets('a counted cue paints only the glyph and the number', (
tester,
) async {
final state = CueState.next(
null,
CueKind.ok,
'Logged 4 catering dishes.',
4,
);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: state.widget)));
expect(find.text('4'), findsOneWidget);
expect(cueLabelled('Logged 4 catering dishes.'), findsOneWidget);
expectNoWords(tester, minStrings: 2, where: 'fill cue');
});
testWidgets('the delivery snackbar paints the dish glyph and 0', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showCueSnack(
context,
kind: CueKind.warn,
semanticLabel: 'No delivery today.',
icon: Icons.restaurant,
count: 0,
),
child: const Icon(Icons.local_shipping),
),
),
),
),
);
await tester.tap(find.byType(TextButton));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byIcon(Icons.restaurant), findsOneWidget);
expect(find.text('0'), findsOneWidget);
expectNoWords(tester, minStrings: 3, where: 'delivery snack');
});
}

View File

@ -75,12 +75,14 @@ class _HostState extends State<_Host>
); );
} }
int? statusCount;
@override @override
void showFillStatus(String message, {CueKind kind = CueKind.info}) => void showFillStatus(FillCue cue) => setState(() {
setState(() { status = cue.label;
status = message; statusKind = cue.kind;
statusKind = kind; statusCount = cue.count;
}); });
/// Counted instead of run: the real one starts a network sync. /// Counted instead of run: the real one starts a network sync.
@override @override
@ -123,6 +125,8 @@ void main() {
await t.runAsync(host.onFillAll); await t.runAsync(host.onFillAll);
expect(host.status, startsWith('Will log 4')); expect(host.status, startsWith('Will log 4'));
// Painted: a glyph + the number of dishes the confirm would log.
expect(host.statusCount, 4);
expect(await t.runAsync(storage.todayEntries), isEmpty); expect(await t.runAsync(storage.todayEntries), isEmpty);
expect(host.publishes, 0); expect(host.publishes, 0);
expect(KuchniaQueueService.remaining, 4); expect(KuchniaQueueService.remaining, 4);
@ -141,6 +145,7 @@ void main() {
expect(host.status, 'Logged 4 catering dishes.'); expect(host.status, 'Logged 4 catering dishes.');
expect(host.statusKind, CueKind.ok); expect(host.statusKind, CueKind.ok);
expect(host.statusCount, 4);
expect(await t.runAsync(() => loggedToday(storage)), hasLength(4)); expect(await t.runAsync(() => loggedToday(storage)), hasLength(4));
expect(host.publishes, 1, reason: 'one publish per batch, not per dish'); expect(host.publishes, 1, reason: 'one publish per batch, not per dish');
expect(KuchniaQueueService.remaining, 0); expect(KuchniaQueueService.remaining, 0);
@ -162,6 +167,7 @@ void main() {
// D1's slot was taken, so only D2..D4 landed and D1 is still on offer. // D1's slot was taken, so only D2..D4 landed and D1 is still on offer.
expect(host.status, 'Logged 3 catering dishes.'); expect(host.status, 'Logged 3 catering dishes.');
expect(host.statusCount, 3);
expect(KuchniaQueueService.remaining, 1); expect(KuchniaQueueService.remaining, 1);
expect(host.desc.text, 'D1'); expect(host.desc.text, 'D1');
}); });