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."
|
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
|
||||||
|
|||||||
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