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: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.
mixin LogMealFillMixin<T extends StatefulWidget>
on LogMealSyncMixin<T>, LogMealKuchniaMixin<T>, LogMealFutureMixin<T> {
@ -22,9 +36,9 @@ mixin LogMealFillMixin<T extends StatefulWidget>
/// armed proposal along with it.
FillAllFlow fillFlow = FillAllFlow();
/// 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});
/// Shows [cue] as a status cue (its label is only spoken). Supplied by the
/// screen.
void showFillStatus(FillCue cue);
/// A proposal belongs to the date it was fetched for; picking another (or
/// going back to today) must not leave "Confirm (N)" offering it.
@ -52,7 +66,11 @@ mixin LogMealFillMixin<T extends StatefulWidget>
await publishAfterLog();
await refreshSlots();
if (mounted) {
showFillStatus('Fill all failed: $error', kind: CueKind.error);
showFillStatus((
label: 'Fill all failed: $error',
kind: CueKind.error,
count: null,
));
}
return;
}
@ -63,16 +81,17 @@ mixin LogMealFillMixin<T extends StatefulWidget>
await _afterFill(outcome.written, later: day != null);
}
if (!mounted) return;
showFillStatus(
outcome.message,
kind: outcome.written.isNotEmpty ? CueKind.ok : CueKind.warn,
);
showFillStatus(fillCue(outcome, armed: fillFlow.armedCount));
}
/// Disarms a proposal without writing anything.
void onFillCancel() {
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

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: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.
///
/// 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
/// also spares the automatic path a second walk.
///
/// Returns a line to show the user, or null when a dish was prefilled and
/// the form speaks for itself.
Future<String?> loadDeliveryOnTap() async {
if (!KuchniaQueueService.isInitialized) return 'Catering is not ready yet.';
/// Returns the cue to show (a spoken line, a glyph kind and a dish count),
/// or null when a dish was prefilled and the form speaks for itself.
Future<CountCue?> loadDeliveryOnTap() async {
if (!KuchniaQueueService.isInitialized) {
return _cue('Catering is not ready yet.', CueKind.warn);
}
final logged = await LogStorageService.instance.todayEntries();
if (!mounted) return null;
final result = await refreshDelivery(DateTime.now());
if (!mounted) return null;
if (!result.ok) return result.reason;
if (result.dishes.isEmpty) return 'No delivery today.';
if (!result.ok) return _cue(result.reason!, CueKind.error);
if (result.dishes.isEmpty) {
return _cue('No delivery today.', CueKind.warn, 0);
}
await KuchniaQueueService.instance.recordFetched(DateTime.now());
if (!mounted) return null;
final ordered = dishesInSlotOrder(
@ -162,7 +168,7 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
},
);
if (KuchniaQueueService.next == null) {
return 'All delivered dishes are already logged.';
return _cue('All delivered dishes are already logged.', CueKind.ok, 0);
}
prefillNextDish();
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.
Future<void> loadDeliveryAndReport() async {
setState(() => deliveryBusy = true);
String? message;
CountCue? cue;
try {
message = await loadDeliveryOnTap();
cue = await loadDeliveryOnTap();
} finally {
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
// 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(
context,
kind: CueKind.warn,
semanticLabel: message,
kind: cue.kind,
semanticLabel: cue.label,
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
TodayProgress? _progress;
void _setCue(CueKind kind, String? label) {
_cue = label == null ? null : CueState.next(_cue, kind, label);
void _setCue(CueKind kind, String? label, [int? count]) {
_cue = label == null ? null : CueState.next(_cue, kind, label, count);
_progress = null;
}
@ -123,8 +123,8 @@ class _LogMealScreenState extends State<LogMealScreen>
@override
void onDishPrefilled() => setState(() => _source = 'catering');
@override
void showFillStatus(String message, {CueKind kind = CueKind.info}) =>
setState(() => _setCue(kind, message));
void showFillStatus(FillCue cue) =>
setState(() => _setCue(cue.kind, cue.label, cue.count));
void _onSuggestionSelected(FoodSuggestion suggestion) {
fillControllersFromSuggestion(suggestion, _descController, _macros);

View File

@ -57,6 +57,7 @@ class StatusCue extends StatefulWidget {
required this.kind,
required this.semanticLabel,
this.icon,
this.count,
super.key,
});
@ -69,6 +70,9 @@ class StatusCue extends StatefulWidget {
/// Optional glyph replacing [CueKind.icon].
final IconData? icon;
/// Optional numeral painted beside the glyph (e.g. dishes logged).
final int? count;
@override
State<StatusCue> createState() => _StatusCueState();
}
@ -127,7 +131,11 @@ class _StatusCueState extends State<StatusCue>
),
child: Padding(
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 String semanticLabel,
IconData? icon,
int? count,
VoidCallback? onUndo,
}) {
final messenger = ScaffoldMessenger.of(context)..hideCurrentSnackBar();
@ -155,7 +164,9 @@ void showCueSnack(
label: semanticLabel,
child: Row(
children: [
Icon(icon ?? kind.icon, color: tint),
ExcludeSemantics(
child: _GlyphCount(icon ?? kind.icon, tint, count),
),
const Spacer(),
if (onUndo != null)
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
/// consecutive status still shakes/pulses again.
@immutable
class CueState {
/// 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;
/// Meaning.
@ -186,12 +201,46 @@ class CueState {
/// Spoken description; never painted.
final String label;
/// Optional numeral painted beside the glyph.
final int? count;
/// Monotonic counter used as the widget key.
final int seq;
/// The left-aligned cue widget for this state.
Widget get widget => Align(
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
void showFillStatus(String message, {CueKind kind = CueKind.info}) =>
setState(() {
status = message;
statusKind = kind;
});
void showFillStatus(FillCue cue) => setState(() {
status = cue.label;
statusKind = cue.kind;
statusCount = cue.count;
});
/// Counted instead of run: the real one starts a network sync.
@override
@ -123,6 +125,8 @@ void main() {
await t.runAsync(host.onFillAll);
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(host.publishes, 0);
expect(KuchniaQueueService.remaining, 4);
@ -141,6 +145,7 @@ void main() {
expect(host.status, 'Logged 4 catering dishes.');
expect(host.statusKind, CueKind.ok);
expect(host.statusCount, 4);
expect(await t.runAsync(() => loggedToday(storage)), hasLength(4));
expect(host.publishes, 1, reason: 'one publish per batch, not per dish');
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.
expect(host.status, 'Logged 3 catering dishes.');
expect(host.statusCount, 3);
expect(KuchniaQueueService.remaining, 1);
expect(host.desc.text, 'D1');
});