diff --git a/app/lib/ui/cues.dart b/app/lib/ui/cues.dart new file mode 100644 index 0000000..8bc327e --- /dev/null +++ b/app/lib/ui/cues.dart @@ -0,0 +1,197 @@ +/// Text-free status cues: tinted icon + shake + haptic instead of sentences. +/// +/// Every cue carries its meaning twice (icon shape AND colour) and exposes +/// the old sentence only as a screen-reader label, never as visible text. +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +export 'package:diet_guard_app/ui/icon_field.dart'; + +/// What a [StatusCue] reports. +enum CueKind { + /// Something worked. + ok(Icons.check_circle), + + /// Neutral information. + info(Icons.info), + + /// Caution, e.g. nothing to do or only partly done. + warn(Icons.warning_amber_rounded), + + /// Something failed or the input was rejected. + error(Icons.error); + + const CueKind(this.icon); + + /// Default glyph for this kind. + final IconData icon; + + /// Tint for this kind from the active theme. + Color tint(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final status = + Theme.of(context).extension() ?? AppStatusColors.dark; + return switch (this) { + CueKind.ok => status.success, + CueKind.info => scheme.onSurfaceVariant, + CueKind.warn => status.warning, + CueKind.error => scheme.error, + }; + } +} + +/// A tinted icon chip that replaces a status sentence. +/// +/// [CueKind.error] shakes (~3 swings, skipped when the platform asks for +/// reduced motion) and fires a heavy haptic; [CueKind.ok] fires a light one. +/// [semanticLabel] is announced as a live region and is never painted. +class StatusCue extends StatefulWidget { + /// Creates a cue of [kind]; [icon] overrides the kind's default glyph. + const StatusCue({ + required this.kind, + required this.semanticLabel, + this.icon, + super.key, + }); + + /// Meaning of the cue. + final CueKind kind; + + /// Spoken description (screen readers, tests); never painted. + final String semanticLabel; + + /// Optional glyph replacing [CueKind.icon]. + final IconData? icon; + + @override + State createState() => _StatusCueState(); +} + +class _StatusCueState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _shake = AnimationController( + vsync: this, + duration: AppDuration.slow * 2, + ); + + @override + void initState() { + super.initState(); + unawaited(_announce()); + } + + Future _announce() async { + switch (widget.kind) { + case CueKind.error: + await HapticFeedback.heavyImpact(); + if (mounted && !MediaQuery.disableAnimationsOf(context)) { + await _shake.forward(); + } + case CueKind.ok: + await HapticFeedback.lightImpact(); + case CueKind.info || CueKind.warn: + break; + } + } + + @override + void dispose() { + _shake.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tint = widget.kind.tint(context); + return Semantics( + liveRegion: true, + label: widget.semanticLabel, + child: ExcludeSemantics( + child: AnimatedBuilder( + animation: _shake, + builder: (context, child) => Transform.translate( + offset: Offset(math.sin(_shake.value * 6 * math.pi) * 8, 0), + child: child, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: tint.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: tint), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Icon(widget.icon ?? widget.kind.icon, color: tint), + ), + ), + ), + ), + ); + } +} + +/// Shows an icon-only snackbar; [onUndo] adds an undo arrow inside it. +/// +/// `SnackBarAction` can only carry text, so the undo is an [IconButton]. +void showCueSnack( + BuildContext context, { + required CueKind kind, + required String semanticLabel, + IconData? icon, + VoidCallback? onUndo, +}) { + final messenger = ScaffoldMessenger.of(context)..hideCurrentSnackBar(); + final tint = kind.tint(context); + messenger.showSnackBar( + SnackBar( + content: Semantics( + liveRegion: true, + label: semanticLabel, + child: Row( + children: [ + Icon(icon ?? kind.icon, color: tint), + const Spacer(), + if (onUndo != null) + IconButton( + icon: const Icon(Icons.undo, semanticLabel: 'Undo'), + onPressed: () { + messenger.hideCurrentSnackBar(); + onUndo(); + }, + ), + ], + ), + ), + ), + ); +} + +/// 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) + : seq = (previous?.seq ?? 0) + 1; + + /// Meaning. + final CueKind kind; + + /// Spoken description; never painted. + final String label; + + /// 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), + ); +} diff --git a/app/lib/ui/icon_field.dart b/app/lib/ui/icon_field.dart new file mode 100644 index 0000000..8e41166 --- /dev/null +++ b/app/lib/ui/icon_field.dart @@ -0,0 +1,129 @@ +/// An icon-captioned text field: the glyph is painted, the words are only a +/// spoken label (see [IconField]). +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// A bordered text field whose caption is an icon plus a spoken label. +/// +/// Replaces `InputDecoration(labelText: ...)`: the glyph is painted, the +/// words exist only for screen readers, tests and flutter-web-drive. +class IconField extends StatelessWidget { + /// Creates a field captioned by [icon]. + const IconField({ + required this.icon, + required this.semanticLabel, + this.controller, + this.keyboardType, + this.obscure = false, + this.suffix, + this.onChanged, + this.dense = false, + this.fieldKey, + this.iconColor, + this.inputFormatters, + this.onSubmitted, + this.autofocus = false, + this.autocorrect = true, + super.key, + }); + + /// Input formatters, e.g. digits only. + final List? inputFormatters; + + /// Called when the user submits from the keyboard. + final ValueChanged? onSubmitted; + + /// Whether the field grabs focus when it first appears. + final bool autofocus; + + /// Whether the keyboard may autocorrect (false for e-mails and secrets). + final bool autocorrect; + + /// Painted caption. + final IconData icon; + + /// Tint of [icon]; the theme's default when null. + final Color? iconColor; + + /// Spoken caption; never painted. + final String semanticLabel; + + /// Backing controller. + final TextEditingController? controller; + + /// Keyboard type. + final TextInputType? keyboardType; + + /// Hides the typed characters. + final bool obscure; + + /// Trailing widget, e.g. a visibility toggle. + final Widget? suffix; + + /// Change callback. + final ValueChanged? onChanged; + + /// Compact vertical padding. + final bool dense; + + /// Key given to the inner [TextField]. + final Key? fieldKey; + + @override + Widget build(BuildContext context) { + // MergeSemantics folds the spoken label into the TextField's own node: + // a bare `Semantics(textField: true)` wrapper produced a second, + // disabled textbox beside the real one, so drivers tapped the dead one. + return MergeSemantics( + child: Semantics( + label: semanticLabel, + child: TextField( + key: fieldKey, + controller: controller, + keyboardType: keyboardType, + obscureText: obscure, + onChanged: onChanged, + onSubmitted: onSubmitted, + inputFormatters: inputFormatters, + autofocus: autofocus, + autocorrect: autocorrect, + enableSuggestions: autocorrect, + decoration: InputDecoration( + prefixIcon: ExcludeSemantics(child: Icon(icon, color: iconColor)), + prefixIconConstraints: dense + ? const BoxConstraints(minWidth: 32, minHeight: 32) + : null, + suffixIcon: suffix, + isDense: dense, + ), + ), + ), + ); + } +} + +/// Gives [child] a spoken name without painting any text. +/// +/// Wraps `Semantics(label:)`; keeping the string on a `semanticLabel:` line +/// lets line-based text scanners recognise it as accessibility metadata even +/// after the formatter splits `Semantics(` from its arguments. +class SpokenLabel extends StatelessWidget { + /// Creates a labelled wrapper around [child]. + const SpokenLabel({ + required this.semanticLabel, + required this.child, + super.key, + }); + + /// Spoken name; never painted. + final String semanticLabel; + + /// The widget being named. + final Widget child; + + @override + Widget build(BuildContext context) => + Semantics(label: semanticLabel, container: true, child: child); +} diff --git a/app/lib/ui/macro_kind.dart b/app/lib/ui/macro_kind.dart new file mode 100644 index 0000000..69fc985 --- /dev/null +++ b/app/lib/ui/macro_kind.dart @@ -0,0 +1,80 @@ +/// Shape + colour vocabulary for the nutrition fields, so a field or figure +/// is recognised by its glyph instead of a "P"/"C"/"F"/"kcal" word. +/// +/// Protein is a green circle, carbs an amber triangle, fat a blue square; +/// colour never carries the meaning alone (shape does too). +library; + +import 'package:flutter/material.dart'; + +/// One nutrition field and its non-text identity. +enum MacroKind { + /// Reference weight the typed macros are stated for. + perGrams(Icons.scale, Color(0xFFAAA09A), 'per (g)', 'per'), + + /// Energy. + kcal(Icons.bolt, Color(0xFFE08A3C), 'kcal', 'kcal'), + + /// Protein. + protein(Icons.circle, Color(0xFF8A9A3C), 'protein g', 'P'), + + /// Carbohydrate. + carbs(Icons.change_history, Color(0xFFE0A63C), 'carbs g', 'C'), + + /// Fat. + fat(Icons.square, Color(0xFF6C9BD1), 'fat g', 'F'), + + /// Portion weight actually eaten. + eaten(Icons.rice_bowl, Color(0xFFAAA09A), 'eaten (g)', 'eaten'); + + const MacroKind(this.icon, this.color, this._long, this._short); + + /// Painted glyph. + final IconData icon; + + /// Glyph tint. + final Color color; + + final String _long; + final String _short; + + /// Spoken name; the long form unless [compact]. + String semanticLabel({required bool compact}) => compact ? _short : _long; +} + +/// A glyph + number pair, e.g. a green circle and `12`. +class MacroFigure extends StatelessWidget { + /// Creates a figure for [kind] showing [grams] (or [value] verbatim). + const MacroFigure({ + required this.kind, + required this.value, + this.size = 12, + super.key, + }); + + /// Which nutrient. + final MacroKind kind; + + /// The number to show, already formatted. + final String value; + + /// Glyph size. + final double size; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + kind.icon, + color: kind.color, + size: size, + semanticLabel: kind.semanticLabel(compact: false), + ), + const SizedBox(width: 3), + Text(value), + ], + ); + } +} diff --git a/app/lib/ui/source_icon.dart b/app/lib/ui/source_icon.dart new file mode 100644 index 0000000..a15cf88 --- /dev/null +++ b/app/lib/ui/source_icon.dart @@ -0,0 +1,33 @@ +/// Glyph for where a log entry came from, replacing the stored source word +/// (`manual`, `food bank`, `catering`) that used to be printed on screen. +library; + +import 'package:flutter/material.dart'; + +/// Icon for a stored entry `source` value; unknown values get a neutral dot. +IconData sourceIcon(String source) => switch (source) { + 'manual' => Icons.edit, + 'food bank' => Icons.restaurant_menu, + 'catering' => Icons.local_shipping, + _ => Icons.circle_outlined, +}; + +/// A small painted [sourceIcon] that still speaks the stored source value. +class SourceMark extends StatelessWidget { + /// Creates a mark for [source]. + const SourceMark(this.source, {this.size = 14, super.key}); + + /// Stored source value. + final String source; + + /// Glyph size. + final double size; + + @override + Widget build(BuildContext context) => Icon( + sourceIcon(source), + size: size, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: source, + ); +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8e38656..ccd75c6 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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.138+1 +version: 1.0.139+1 environment: sdk: ^3.12.2 diff --git a/app/test/label_finders.dart b/app/test/label_finders.dart new file mode 100644 index 0000000..c76e672 --- /dev/null +++ b/app/test/label_finders.dart @@ -0,0 +1,46 @@ +/// Finders for text-free controls: they match the spoken label that icons and +/// [Semantics] carry, so tests keep addressing widgets by name without any +/// visible text. +library; + +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Finds an [Icon] with this `semanticLabel`. +Finder byIconLabel(String label) => + find.byWidgetPredicate((w) => w is Icon && w.semanticLabel == label); + +/// Finds a [Semantics] node with this label. +Finder bySemLabel(String label) => find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == label, +); + +/// Finds the [TextField] captioned [label] (an [IconField] or a bare +/// [Semantics] wrapper). +Finder fieldByLabel(String label) => + find.descendant(of: bySemLabel(label), matching: find.byType(TextField)); + +/// Finds the tappable control whose icon or semantics carries [label]. +Finder controlByLabel(String label) => find + .ancestor( + of: byIconLabel(label), + matching: find.byWidgetPredicate( + (w) => w is IconButton || w is ButtonStyleButton || w is InkResponse, + ), + ) + .first; + +/// Finds a [StatusCue] of [kind]. +Finder cueOfKind(CueKind kind) => + find.byWidgetPredicate((w) => w is StatusCue && w.kind == kind); + +/// Finds a [StatusCue] whose spoken label is [label]. +Finder cueLabelled(String label) => + find.byWidgetPredicate((w) => w is StatusCue && w.semanticLabel == label); + +/// Finds the chip ([FilterChip] or [ChoiceChip]) whose icon carries [label]. +Finder chipByLabel(String label) => find.ancestor( + of: byIconLabel(label), + matching: find.byWidgetPredicate((w) => w is FilterChip || w is ChoiceChip), +); diff --git a/app/test/ui/cues_test.dart b/app/test/ui/cues_test.dart new file mode 100644 index 0000000..4b88268 --- /dev/null +++ b/app/test/ui/cues_test.dart @@ -0,0 +1,110 @@ +/// Text-free status cues: kind -> icon/tint, haptics, snackbar undo. +library; + +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../label_finders.dart'; + +void main() { + final haptics = []; + + setUp(() { + haptics.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments as String); + } + return null; + }); + }); + + Widget host(Widget child, {bool reduceMotion = false}) => MaterialApp( + theme: buildAppTheme(), + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold(body: child), + ), + ); + + testWidgets('error cue: red error icon, heavy haptic, shakes', (t) async { + await t.pumpWidget( + host(const StatusCue(kind: CueKind.error, semanticLabel: 'bad input')), + ); + final icon = t.widget(find.byIcon(Icons.error)); + expect(icon.color, buildAppTheme().colorScheme.error); + expect(haptics, ['HapticFeedbackType.heavyImpact']); + await t.pump(const Duration(milliseconds: 100)); + final shifted = t.getTopLeft(find.byIcon(Icons.error)).dx; + await t.pumpAndSettle(); + expect(t.getTopLeft(find.byIcon(Icons.error)).dx, isNot(shifted)); + expect(cueLabelled('bad input'), findsOneWidget); + }); + + testWidgets('reduced motion skips the shake but keeps the haptic', (t) async { + await t.pumpWidget( + host( + const StatusCue(kind: CueKind.error, semanticLabel: 'bad input'), + reduceMotion: true, + ), + ); + final start = t.getTopLeft(find.byIcon(Icons.error)); + await t.pump(const Duration(milliseconds: 100)); + expect(t.getTopLeft(find.byIcon(Icons.error)), start); + expect(haptics, ['HapticFeedbackType.heavyImpact']); + }); + + testWidgets('ok cue taps lightly, info and warn stay silent', (t) async { + await t.pumpWidget( + host(const StatusCue(kind: CueKind.ok, semanticLabel: 'done')), + ); + expect(haptics, ['HapticFeedbackType.lightImpact']); + haptics.clear(); + for (final kind in [CueKind.info, CueKind.warn]) { + await t.pumpWidget(host(StatusCue(kind: kind, semanticLabel: 'x'))); + expect(find.byIcon(kind.icon), findsOneWidget); + } + expect(haptics, isEmpty); + }); + + testWidgets('CueState.next bumps the sequence and rebuilds the cue', ( + t, + ) async { + final first = CueState.next(null, CueKind.info, 'a'); + final second = CueState.next(first, CueKind.info, 'a'); + expect(second.seq, first.seq + 1); + await t.pumpWidget(host(second.widget)); + expect(cueLabelled('a'), findsOneWidget); + }); + + testWidgets('snackbar shows only a glyph and undo fires once', (t) async { + var undone = 0; + await t.pumpWidget( + host( + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.add), + onPressed: () => showCueSnack( + context, + kind: CueKind.warn, + semanticLabel: 'gone', + onUndo: () => undone++, + ), + ), + ), + ), + ); + await t.tap(find.byIcon(Icons.add)); + await t.pump(); + await t.pump(const Duration(milliseconds: 500)); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.byType(Text), findsNothing); + await t.tap(controlByLabel('Undo')); + await t.pump(); + expect(undone, 1); + }); +} diff --git a/app/test/ui/source_icon_test.dart b/app/test/ui/source_icon_test.dart new file mode 100644 index 0000000..38c152c --- /dev/null +++ b/app/test/ui/source_icon_test.dart @@ -0,0 +1,26 @@ +import 'package:diet_guard_app/ui/source_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'every stored source has its own glyph, unknown ones a neutral ring', + () { + expect(sourceIcon('manual'), Icons.edit); + expect(sourceIcon('food bank'), Icons.restaurant_menu); + expect(sourceIcon('catering'), Icons.local_shipping); + expect(sourceIcon('legacy'), Icons.circle_outlined); + }, + ); + + testWidgets('the mark paints no text and speaks the stored source', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SourceMark('catering'))), + ); + expect(find.byType(Text), findsNothing); + final icon = tester.widget(find.byType(Icon)); + expect(icon.semanticLabel, 'catering'); + }); +}