feat(ux): usable amounts, colour-coded category/form chips, currency quick-picks

Four usability passes across the everyday flows:

- Quantity picker: a countable unit (cob/pod/ear…) now defaults to 1 and
  the stepper clamps at a minimum of 1 — you can never store 'a cob' with
  a blank/zero figure. Vibe units (a few) still carry no number.
- Category & form chips are colour-coded (new category_palette.dart): a
  small muted earthy palette maps each botanical family to a STABLE
  tonality (fill + readable ink), and each lot form gets its own tone.
  Applied to the inventory filter bar (now grouped attributes | forms |
  families, separated by a hairline), the list's category headers (a
  matching colour dot), and the lot-form selector.
- Sale currency: quick-pick chips (€ · Ğ1 · hours) fill the field in one
  tap — Ğ1 offered quietly among familiar options, never imposed — while
  free text still takes anything else. New i18n key sale.hours (en/es/pt/ast).

Tests: quantity_picker_test (default-1, clamp, vibe, clear-still-1),
category_palette_test (stable/distinct swatches); overflow guard + the
inventory widget tests stay green.
This commit is contained in:
vjrj 2026-07-11 06:06:04 +02:00
parent 6de039d518
commit 28e8318026
15 changed files with 364 additions and 53 deletions

View file

@ -592,6 +592,7 @@
"amount": "Importe", "amount": "Importe",
"currency": "Moneda", "currency": "Moneda",
"currencyHint": "€, Ğ1, hores… (opcional)", "currencyHint": "€, Ğ1, hores… (opcional)",
"hours": "hores",
"note": "Nota (opcional)", "note": "Nota (opcional)",
"save": "Guardar", "save": "Guardar",
"delete": "Quitar", "delete": "Quitar",

View file

@ -595,6 +595,7 @@
"amount": "Amount", "amount": "Amount",
"currency": "Currency", "currency": "Currency",
"currencyHint": "€, Ğ1, hours… (optional)", "currencyHint": "€, Ğ1, hours… (optional)",
"hours": "hours",
"note": "Note (optional)", "note": "Note (optional)",
"save": "Save", "save": "Save",
"delete": "Remove", "delete": "Remove",

View file

@ -594,6 +594,7 @@
"amount": "Importe", "amount": "Importe",
"currency": "Moneda", "currency": "Moneda",
"currencyHint": "€, Ğ1, horas… (opcional)", "currencyHint": "€, Ğ1, horas… (opcional)",
"hours": "horas",
"note": "Nota (opcional)", "note": "Nota (opcional)",
"save": "Guardar", "save": "Guardar",
"delete": "Quitar", "delete": "Quitar",

View file

@ -591,6 +591,7 @@
"amount": "Montante", "amount": "Montante",
"currency": "Moeda", "currency": "Moeda",
"currencyHint": "€, Ğ1, horas… (opcional)", "currencyHint": "€, Ğ1, horas… (opcional)",
"hours": "horas",
"note": "Nota (opcional)", "note": "Nota (opcional)",
"save": "Guardar", "save": "Guardar",
"delete": "Remover", "delete": "Remover",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 4 /// Locales: 4
/// Strings: 1828 (457 per locale) /// Strings: 1832 (458 per locale)
/// ///
/// Built on 2026-07-11 at 00:20 UTC /// Built on 2026-07-11 at 04:03 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -860,6 +860,7 @@ class _Translations$sale$ast extends Translations$sale$en {
@override String get amount => 'Importe'; @override String get amount => 'Importe';
@override String get currency => 'Moneda'; @override String get currency => 'Moneda';
@override String get currencyHint => '€, Ğ1, hores… (opcional)'; @override String get currencyHint => '€, Ğ1, hores… (opcional)';
@override String get hours => 'hores';
@override String get note => 'Nota (opcional)'; @override String get note => 'Nota (opcional)';
@override String get save => 'Guardar'; @override String get save => 'Guardar';
@override String get delete => 'Quitar'; @override String get delete => 'Quitar';
@ -1659,6 +1660,7 @@ extension on TranslationsAst {
'sale.amount' => 'Importe', 'sale.amount' => 'Importe',
'sale.currency' => 'Moneda', 'sale.currency' => 'Moneda',
'sale.currencyHint' => '€, Ğ1, hores… (opcional)', 'sale.currencyHint' => '€, Ğ1, hores… (opcional)',
'sale.hours' => 'hores',
'sale.note' => 'Nota (opcional)', 'sale.note' => 'Nota (opcional)',
'sale.save' => 'Guardar', 'sale.save' => 'Guardar',
'sale.delete' => 'Quitar', 'sale.delete' => 'Quitar',

View file

@ -1638,6 +1638,9 @@ class Translations$sale$en {
/// en: '€, Ğ1, hours… (optional)' /// en: '€, Ğ1, hours… (optional)'
String get currencyHint => '€, Ğ1, hours… (optional)'; String get currencyHint => '€, Ğ1, hours… (optional)';
/// en: 'hours'
String get hours => 'hours';
/// en: 'Note (optional)' /// en: 'Note (optional)'
String get note => 'Note (optional)'; String get note => 'Note (optional)';
@ -2563,6 +2566,7 @@ extension on Translations {
'sale.amount' => 'Amount', 'sale.amount' => 'Amount',
'sale.currency' => 'Currency', 'sale.currency' => 'Currency',
'sale.currencyHint' => '€, Ğ1, hours… (optional)', 'sale.currencyHint' => '€, Ğ1, hours… (optional)',
'sale.hours' => 'hours',
'sale.note' => 'Note (optional)', 'sale.note' => 'Note (optional)',
'sale.save' => 'Save', 'sale.save' => 'Save',
'sale.delete' => 'Remove', 'sale.delete' => 'Remove',

View file

@ -862,6 +862,7 @@ class _Translations$sale$es extends Translations$sale$en {
@override String get amount => 'Importe'; @override String get amount => 'Importe';
@override String get currency => 'Moneda'; @override String get currency => 'Moneda';
@override String get currencyHint => '€, Ğ1, horas… (opcional)'; @override String get currencyHint => '€, Ğ1, horas… (opcional)';
@override String get hours => 'horas';
@override String get note => 'Nota (opcional)'; @override String get note => 'Nota (opcional)';
@override String get save => 'Guardar'; @override String get save => 'Guardar';
@override String get delete => 'Quitar'; @override String get delete => 'Quitar';
@ -1663,6 +1664,7 @@ extension on TranslationsEs {
'sale.amount' => 'Importe', 'sale.amount' => 'Importe',
'sale.currency' => 'Moneda', 'sale.currency' => 'Moneda',
'sale.currencyHint' => '€, Ğ1, horas… (opcional)', 'sale.currencyHint' => '€, Ğ1, horas… (opcional)',
'sale.hours' => 'horas',
'sale.note' => 'Nota (opcional)', 'sale.note' => 'Nota (opcional)',
'sale.save' => 'Guardar', 'sale.save' => 'Guardar',
'sale.delete' => 'Quitar', 'sale.delete' => 'Quitar',

View file

@ -859,6 +859,7 @@ class _Translations$sale$pt extends Translations$sale$en {
@override String get amount => 'Montante'; @override String get amount => 'Montante';
@override String get currency => 'Moeda'; @override String get currency => 'Moeda';
@override String get currencyHint => '€, Ğ1, horas… (opcional)'; @override String get currencyHint => '€, Ğ1, horas… (opcional)';
@override String get hours => 'horas';
@override String get note => 'Nota (opcional)'; @override String get note => 'Nota (opcional)';
@override String get save => 'Guardar'; @override String get save => 'Guardar';
@override String get delete => 'Remover'; @override String get delete => 'Remover';
@ -1657,6 +1658,7 @@ extension on TranslationsPt {
'sale.amount' => 'Montante', 'sale.amount' => 'Montante',
'sale.currency' => 'Moeda', 'sale.currency' => 'Moeda',
'sale.currencyHint' => '€, Ğ1, horas… (opcional)', 'sale.currencyHint' => '€, Ğ1, horas… (opcional)',
'sale.hours' => 'horas',
'sale.note' => 'Nota (opcional)', 'sale.note' => 'Nota (opcional)',
'sale.save' => 'Guardar', 'sale.save' => 'Guardar',
'sale.delete' => 'Remover', 'sale.delete' => 'Remover',

View file

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../db/enums.dart';
import 'theme.dart';
/// A colour for a chip/label: a soft [fill] to tint the background and a darker,
/// same-hue [ink] readable on that fill and on white (text, dot, checkmark).
class Swatch {
const Swatch(this.fill, this.ink);
final Color fill;
final Color ink;
}
/// A small, muted, earthy palette one low-saturation key, different hues so
/// botanical families (categories) are distinguishable at a glance without
/// shouting over the green brand. Kept calm on purpose (soft fills, legible
/// ink). Order is stable; new entries append only.
const _familyPalette = <Swatch>[
Swatch(Color(0xFFE8EEDA), Color(0xFF556B2F)), // olive
Swatch(Color(0xFFF3E2D8), Color(0xFF9C5B3B)), // terracotta
Swatch(Color(0xFFF3EBD3), Color(0xFF8A6D1E)), // ochre
Swatch(Color(0xFFDDEAE6), Color(0xFF3E7168)), // sage teal
Swatch(Color(0xFFF0E1E4), Color(0xFF9B5566)), // dusty rose
Swatch(Color(0xFFE0E6EE), Color(0xFF4A6489)), // slate blue
Swatch(Color(0xFFE9E1EE), Color(0xFF6E5090)), // plum
Swatch(Color(0xFFEBE3D8), Color(0xFF7A5A3A)), // warm brown
];
/// A stable (launch-to-launch, platform-independent) hash of a category name.
/// `String.hashCode` is randomised per run, which would reshuffle colours on
/// every launch so fold the code units ourselves (FNV-1a-ish).
int _stableHash(String s) {
var h = 0x811c9dc5;
for (final c in s.codeUnits) {
h = (h ^ c) * 0x01000193;
h &= 0xffffffff;
}
return h;
}
/// The stable colour for a free-text category (a botanical family). The same
/// name always maps to the same tonality.
Swatch categorySwatch(String category) =>
_familyPalette[_stableHash(category) % _familyPalette.length];
/// A fixed tonality per lot form, so the form chips read as their own colour
/// group (distinct from the family chips). Greens for the leafy forms, warmer
/// tones for woody/underground ones.
Swatch lotTypeSwatch(LotType type) => switch (type) {
LotType.seed => const Swatch(seedPrimaryContainer, seedOnPrimaryContainer),
LotType.seedling => const Swatch(Color(0xFFDDEBCF), Color(0xFF4E7A2E)),
LotType.plant => const Swatch(Color(0xFFD9EAD9), Color(0xFF2F7D34)),
LotType.tree => const Swatch(Color(0xFFE3D9C8), Color(0xFF6B4F2A)),
LotType.bulb => const Swatch(Color(0xFFF3E9CF), Color(0xFF8A6D1E)),
LotType.cutting => const Swatch(Color(0xFFE6EAD3), Color(0xFF63702F)),
};

View file

@ -9,6 +9,7 @@ import '../domain/seed_viability.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/share_catalog_service.dart'; import '../services/share_catalog_service.dart';
import '../state/inventory_cubit.dart'; import '../state/inventory_cubit.dart';
import 'category_palette.dart';
import 'draft_triage.dart'; import 'draft_triage.dart';
import 'label_print_sheet.dart'; import 'label_print_sheet.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
@ -293,7 +294,10 @@ class _FilterBar extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final chips = <Widget>[ // Filters read as three colour-coded groups, separated by a thin rule:
// attributes (green icons) · forms (per-form tonality) · families (each a
// stable earthy tonality). Grouping + colour makes a long row scannable.
final attrChips = <Widget>[
if (hasShared) if (hasShared)
FilterChip( FilterChip(
key: const Key('inventory.filter.sharing'), key: const Key('inventory.filter.sharing'),
@ -330,19 +334,26 @@ class _FilterBar extends StatelessWidget {
selected: state.needsReproductionOnly, selected: state.needsReproductionOnly,
onSelected: (_) => cubit.toggleNeedsReproductionOnly(), onSelected: (_) => cubit.toggleNeedsReproductionOnly(),
), ),
for (final category in categories) ];
FilterChip( final formChips = <Widget>[
key: Key('inventory.filter.category.$category'),
label: Text(category),
selected: state.categoryFilter.contains(category),
onSelected: (_) => cubit.toggleCategory(category),
),
for (final form in forms) for (final form in forms)
FilterChip( _SwatchFilterChip(
key: Key('inventory.filter.type.${form.name}'), key: Key('inventory.filter.type.${form.name}'),
label: Text(lotTypeLabel(t, form)), swatch: lotTypeSwatch(form),
label: lotTypeLabel(t, form),
icon: iconForLotType(form),
selected: state.typeFilter.contains(form), selected: state.typeFilter.contains(form),
onSelected: (_) => cubit.toggleType(form), onSelected: () => cubit.toggleType(form),
),
];
final categoryChips = <Widget>[
for (final category in categories)
_SwatchFilterChip(
key: Key('inventory.filter.category.$category'),
swatch: categorySwatch(category),
label: category,
selected: state.categoryFilter.contains(category),
onSelected: () => cubit.toggleCategory(category),
), ),
]; ];
final hasActiveFilter = final hasActiveFilter =
@ -352,25 +363,80 @@ class _FilterBar extends StatelessWidget {
state.needsReproductionOnly || state.needsReproductionOnly ||
state.sharingOnly; state.sharingOnly;
// Lay out the groups in order, dropping a hairline divider between any two
// that both have chips.
final groups = [attrChips, formChips, categoryChips].where((g) => g.isNotEmpty);
final row = <Widget>[];
for (final group in groups) {
if (row.isNotEmpty) row.add(const _FilterGroupDivider());
for (final chip in group) {
row.add(Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: chip,
));
}
}
if (hasActiveFilter) {
row.add(TextButton.icon(
key: const Key('inventory.filter.clear'),
onPressed: cubit.clearFilters,
icon: const Icon(Icons.clear, size: 18),
label: Text(t.inventory.clearFilters),
));
}
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(children: row),
children: [ );
for (final chip in chips) }
Padding( }
padding: const EdgeInsetsDirectional.only(end: 8),
child: chip, /// A thin vertical rule separating filter groups in the horizontal chip row.
), class _FilterGroupDivider extends StatelessWidget {
if (hasActiveFilter) const _FilterGroupDivider();
TextButton.icon(
key: const Key('inventory.filter.clear'), @override
onPressed: cubit.clearFilters, Widget build(BuildContext context) => Container(
icon: const Icon(Icons.clear, size: 18), width: 1,
label: Text(t.inventory.clearFilters), height: 22,
), margin: const EdgeInsetsDirectional.only(end: 8),
], color: seedOutline,
), );
}
/// A [FilterChip] tinted with a [Swatch]: a soft fill when idle, a stronger
/// same-hue fill when selected, with matching ink for the label, border,
/// checkmark and optional leading [icon].
class _SwatchFilterChip extends StatelessWidget {
const _SwatchFilterChip({
required this.swatch,
required this.label,
required this.selected,
required this.onSelected,
this.icon,
super.key,
});
final Swatch swatch;
final String label;
final bool selected;
final VoidCallback onSelected;
final IconData? icon;
@override
Widget build(BuildContext context) {
return FilterChip(
label: Text(label),
avatar: icon == null ? null : Icon(icon, size: 18, color: swatch.ink),
selected: selected,
onSelected: (_) => onSelected(),
backgroundColor: swatch.fill,
selectedColor: swatch.ink.withValues(alpha: 0.24),
checkmarkColor: swatch.ink,
side: BorderSide(color: swatch.ink.withValues(alpha: 0.35)),
labelStyle: TextStyle(color: swatch.ink, fontWeight: FontWeight.w500),
); );
} }
} }
@ -451,15 +517,30 @@ class _CategoryHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Same tonality as this family's filter chip, so header and chip read as a
// pair. The uncategorised bucket keeps the plain green.
final swatch = categorySwatch(title);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6), padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
// Base on the text theme so it honours the system font-scale factor. child: Row(
child: Text( children: [
title, Container(
style: Theme.of(context).textTheme.titleMedium?.copyWith( width: 10,
fontWeight: FontWeight.w500, height: 10,
color: seedGreen, margin: const EdgeInsetsDirectional.only(end: 8),
), decoration: BoxDecoration(color: swatch.ink, shape: BoxShape.circle),
),
Flexible(
// Base on the text theme so it honours the system font-scale factor.
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: swatch.ink,
),
),
),
],
), ),
); );
} }

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../db/enums.dart'; import '../db/enums.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import 'category_palette.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
import 'seed_glyph.dart'; import 'seed_glyph.dart';
import 'theme.dart'; import 'theme.dart';
@ -76,18 +77,28 @@ class LotTypeSelector extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (final type in LotType.values) for (final type in LotType.values)
ChoiceChip( _buildTypeChip(t, type),
key: Key('lotType.${type.name}'),
selected: value == type,
onSelected: (_) => onChanged(type),
avatar: type == LotType.seed
? const SeedGlyph(SeedGlyphs.jars, size: 18)
: Icon(iconForLotType(type), size: 18),
label: Text(lotTypeLabel(t, type)),
),
], ],
); );
} }
Widget _buildTypeChip(Translations t, LotType type) {
final swatch = lotTypeSwatch(type);
final selected = value == type;
return ChoiceChip(
key: Key('lotType.${type.name}'),
selected: selected,
onSelected: (_) => onChanged(type),
avatar: type == LotType.seed
? const SeedGlyph(SeedGlyphs.jars, size: 18)
: Icon(iconForLotType(type), size: 18, color: swatch.ink),
label: Text(lotTypeLabel(t, type)),
backgroundColor: swatch.fill,
selectedColor: swatch.ink.withValues(alpha: 0.24),
side: BorderSide(color: swatch.ink.withValues(alpha: 0.35)),
labelStyle: TextStyle(color: swatch.ink, fontWeight: FontWeight.w500),
);
}
} }
/// Optional packaging selector for living lots. Tapping the selected chip again /// Optional packaging selector for living lots. Tapping the selected chip again
@ -189,19 +200,29 @@ class _QuantityPickerState extends State<QuantityPicker> {
widget.onChanged(null); widget.onChanged(null);
return; return;
} }
final n = num.tryParse(_countController.text.trim().replaceAll(',', '.')); // A countable unit always carries a number 1 "3 cobs", never an empty
widget.onChanged(Quantity(kind: kind, count: kind.countable ? n : null)); // "cob". Blank/zero/garbage while typing falls back to 1, so you can never
// save a countable amount with no figure.
final parsed = num.tryParse(_countController.text.trim().replaceAll(',', '.'));
final count = kind.countable ? ((parsed == null || parsed < 1) ? 1 : parsed) : null;
widget.onChanged(Quantity(kind: kind, count: count));
} }
void _select(QuantityKind kind) { void _select(QuantityKind kind) {
setState(() => _kind = kind); setState(() {
_kind = kind;
// Selecting a countable unit with no number yet defaults it to 1.
if (kind.countable && (num.tryParse(_countController.text.trim()) == null)) {
_countController.text = '1';
}
});
_emit(); _emit();
} }
void _bump(int delta) { void _bump(int delta) {
final current = int.tryParse(_countController.text.trim()) ?? 0; final current = int.tryParse(_countController.text.trim()) ?? 1;
final next = (current + delta).clamp(0, 9999); final next = (current + delta).clamp(1, 9999);
_countController.text = next == 0 ? '' : '$next'; _countController.text = '$next';
_emit(); _emit();
} }
@ -219,6 +240,7 @@ class _QuantityPickerState extends State<QuantityPicker> {
children: [ children: [
for (final kind in kinds) for (final kind in kinds)
_ScaleItem( _ScaleItem(
key: Key('quantity.kind.${kind.name}'),
kind: kind, kind: kind,
label: unitLabel(t, kind), label: unitLabel(t, kind),
selected: _kind == kind, selected: _kind == kind,
@ -273,6 +295,7 @@ class _ScaleItem extends StatelessWidget {
required this.label, required this.label,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
super.key,
}); });
final QuantityKind kind; final QuantityKind kind;

View file

@ -132,14 +132,31 @@ class _SaleSheetState extends State<_SaleSheet> {
controller: _currency, controller: _currency,
decoration: InputDecoration( decoration: InputDecoration(
labelText: t.sale.currency, labelText: t.sale.currency,
helperText: t.sale.currencyHint,
helperMaxLines: 2,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
onChanged: (_) => setState(() {}),
), ),
), ),
], ],
), ),
const SizedBox(height: 8),
// Quick picks so a currency is one tap. Ğ1 sits quietly among the
// familiar ones offered, never imposed and free text still takes
// anything else.
Wrap(
spacing: 8,
children: [
for (final c in ['', 'Ğ1', t.sale.hours])
ChoiceChip(
key: Key('sale.currencyChip.$c'),
label: Text(c),
selected: _currency.text.trim() == c,
onSelected: (sel) => setState(() {
_currency.text = sel ? c : '';
}),
),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
key: const Key('sale.note'), key: const Key('sale.note'),

View file

@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/ui/category_palette.dart';
/// Category/form colours must be STABLE (same name same tonality every
/// launch) so a family's chip and list header always match.
void main() {
test('a category always maps to the same swatch', () {
final a = categorySwatch('Solanáceas');
final b = categorySwatch('Solanáceas');
expect(a.fill, b.fill);
expect(a.ink, b.ink);
});
test('different families generally get different tonalities', () {
final names = [
'Solanáceas',
'Leguminosas',
'Cucurbitáceas',
'Brasicáceas',
'Asteráceas',
'Amarantáceas',
];
final inks = {for (final n in names) categorySwatch(n).ink};
// Not required to be all-distinct (palette wraps), but a fixed input set
// must not collapse to a single colour.
expect(inks.length, greaterThan(1));
});
test('every lot form has its own swatch', () {
for (final type in LotType.values) {
final s = lotTypeSwatch(type);
expect(s.fill, isNotNull);
expect(s.ink, isNotNull);
}
});
}

View file

@ -0,0 +1,83 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/ui/quantity_picker.dart';
import '../support/test_support.dart';
/// A countable amount must never be blank: picking cob/pod/ defaults to 1 and
/// the stepper can't drop below 1 — you can't save "a cob" with no figure.
void main() {
Quantity? emitted;
Future<void> pumpPicker(WidgetTester tester,
{LotType type = LotType.seed}) async {
emitted = null;
final db = newTestDatabase();
addTearDown(db.close);
await tester.pumpWidget(wrapScreen(
repository: newTestRepository(db),
child: Scaffold(
body: QuantityPicker(
type: type,
onChanged: (q) => emitted = q,
),
),
));
await tester.pump();
}
testWidgets('selecting a countable unit defaults the count to 1',
(tester) async {
await pumpPicker(tester);
await tester.tap(find.byKey(const Key('quantity.kind.cob')));
await tester.pump();
expect(emitted, isNotNull);
expect(emitted!.kind, QuantityKind.cob);
expect(emitted!.count, 1);
expect(
find.widgetWithText(TextField, '1'),
findsOneWidget,
reason: 'the count field shows 1, not an empty box',
);
});
testWidgets('the minus button never takes a countable amount below 1',
(tester) async {
await pumpPicker(tester);
await tester.tap(find.byKey(const Key('quantity.kind.cob')));
await tester.pump();
// Two decrements from 1 must clamp at 1, never 0 or blank.
await tester.tap(find.byIcon(Icons.remove));
await tester.pump();
await tester.tap(find.byIcon(Icons.remove));
await tester.pump();
expect(emitted!.count, 1);
});
testWidgets('a vibe unit (a few) carries no number and shows no stepper',
(tester) async {
await pumpPicker(tester);
await tester.tap(find.byKey(const Key('quantity.kind.aFew')));
await tester.pump();
expect(emitted!.kind, QuantityKind.aFew);
expect(emitted!.count, isNull);
expect(find.byKey(const Key('quantity.count')), findsNothing);
});
testWidgets('typing then clearing the box still stores 1, never empty',
(tester) async {
await pumpPicker(tester);
await tester.tap(find.byKey(const Key('quantity.kind.pod')));
await tester.pump();
await tester.enterText(find.byKey(const Key('quantity.count')), '');
await tester.pump();
expect(emitted!.kind, QuantityKind.pod);
expect(emitted!.count, 1);
});
}