Add text-free cue widgets and finders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 08:20:50 +01:00
parent 832f9ab7da
commit 07e5d2149c
8 changed files with 622 additions and 1 deletions

197
app/lib/ui/cues.dart Normal file
View File

@ -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>() ?? 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<StatusCue> createState() => _StatusCueState();
}
class _StatusCueState extends State<StatusCue>
with SingleTickerProviderStateMixin {
late final AnimationController _shake = AnimationController(
vsync: this,
duration: AppDuration.slow * 2,
);
@override
void initState() {
super.initState();
unawaited(_announce());
}
Future<void> _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),
);
}

129
app/lib/ui/icon_field.dart Normal file
View File

@ -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<TextInputFormatter>? inputFormatters;
/// Called when the user submits from the keyboard.
final ValueChanged<String>? 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<String>? 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);
}

View File

@ -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),
],
);
}
}

View File

@ -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,
);
}

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go." description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none' publish_to: 'none'
version: 1.0.138+1 version: 1.0.139+1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -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),
);

110
app/test/ui/cues_test.dart Normal file
View File

@ -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 = <String>[];
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<Icon>(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);
});
}

View File

@ -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<Icon>(find.byType(Icon));
expect(icon.semanticLabel, 'catering');
});
}