diff --git a/app/lib/screens/log_meal_fill_mixin.dart b/app/lib/screens/log_meal_fill_mixin.dart index bee8be6..88b8ce5 100644 --- a/app/lib/screens/log_meal_fill_mixin.dart +++ b/app/lib/screens/log_meal_fill_mixin.dart @@ -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 on LogMealSyncMixin, LogMealKuchniaMixin, LogMealFutureMixin { @@ -22,9 +36,9 @@ mixin LogMealFillMixin /// 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 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 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 diff --git a/app/lib/screens/log_meal_kuchnia_mixin.dart b/app/lib/screens/log_meal_kuchnia_mixin.dart index 59fb900..750571f 100644 --- a/app/lib/screens/log_meal_kuchnia_mixin.dart +++ b/app/lib/screens/log_meal_kuchnia_mixin.dart @@ -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 on State { /// 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 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 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 on State { }, ); 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 on State { /// the 250-line cap, and this is the mixin's own behaviour anyway. Future 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, ); } diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 3662247..84cf390 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -63,8 +63,8 @@ class _LogMealScreenState extends State 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 @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); diff --git a/app/lib/ui/cues.dart b/app/lib/ui/cues.dart index 8bc327e..2728721 100644 --- a/app/lib/ui/cues.dart +++ b/app/lib/ui/cues.dart @@ -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 createState() => _StatusCueState(); } @@ -127,7 +131,11 @@ class _StatusCueState extends State ), 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), + ), + ], + ); + } +} diff --git a/app/test/no_words_kuchnia_test.dart b/app/test/no_words_kuchnia_test.dart new file mode 100644 index 0000000..dbef620 --- /dev/null +++ b/app/test/no_words_kuchnia_test.dart @@ -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 [], + ), 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 [], + ), 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'); + }); +} diff --git a/app/test/screens/log_meal_fill_mixin_test.dart b/app/test/screens/log_meal_fill_mixin_test.dart index f3c8399..6ea5174 100644 --- a/app/test/screens/log_meal_fill_mixin_test.dart +++ b/app/test/screens/log_meal_fill_mixin_test.dart @@ -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'); });