mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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:
parent
832f9ab7da
commit
07e5d2149c
197
app/lib/ui/cues.dart
Normal file
197
app/lib/ui/cues.dart
Normal 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
129
app/lib/ui/icon_field.dart
Normal 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);
|
||||
}
|
||||
80
app/lib/ui/macro_kind.dart
Normal file
80
app/lib/ui/macro_kind.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
33
app/lib/ui/source_icon.dart
Normal file
33
app/lib/ui/source_icon.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
46
app/test/label_finders.dart
Normal file
46
app/test/label_finders.dart
Normal 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
110
app/test/ui/cues_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
26
app/test/ui/source_icon_test.dart
Normal file
26
app/test/ui/source_icon_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user