diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index f0d6b73..63a4b6c 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -592,6 +592,7 @@ "amount": "Importe", "currency": "Moneda", "currencyHint": "€, Ğ1, hores… (opcional)", + "hours": "hores", "note": "Nota (opcional)", "save": "Guardar", "delete": "Quitar", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 329c8a3..76e1ea9 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -595,6 +595,7 @@ "amount": "Amount", "currency": "Currency", "currencyHint": "€, Ğ1, hours… (optional)", + "hours": "hours", "note": "Note (optional)", "save": "Save", "delete": "Remove", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 0f2fc09..32b8ee2 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -594,6 +594,7 @@ "amount": "Importe", "currency": "Moneda", "currencyHint": "€, Ğ1, horas… (opcional)", + "hours": "horas", "note": "Nota (opcional)", "save": "Guardar", "delete": "Quitar", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 650cb3a..827311f 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -591,6 +591,7 @@ "amount": "Montante", "currency": "Moeda", "currencyHint": "€, Ğ1, horas… (opcional)", + "hours": "horas", "note": "Nota (opcional)", "save": "Guardar", "delete": "Remover", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index edcc89a..ff201f4 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// 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 // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index e8f643d..98355bc 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -860,6 +860,7 @@ class _Translations$sale$ast extends Translations$sale$en { @override String get amount => 'Importe'; @override String get currency => 'Moneda'; @override String get currencyHint => '€, Ğ1, hores… (opcional)'; + @override String get hours => 'hores'; @override String get note => 'Nota (opcional)'; @override String get save => 'Guardar'; @override String get delete => 'Quitar'; @@ -1659,6 +1660,7 @@ extension on TranslationsAst { 'sale.amount' => 'Importe', 'sale.currency' => 'Moneda', 'sale.currencyHint' => '€, Ğ1, hores… (opcional)', + 'sale.hours' => 'hores', 'sale.note' => 'Nota (opcional)', 'sale.save' => 'Guardar', 'sale.delete' => 'Quitar', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 60af4ee..6256889 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1638,6 +1638,9 @@ class Translations$sale$en { /// en: '€, Ğ1, hours… (optional)' String get currencyHint => '€, Ğ1, hours… (optional)'; + /// en: 'hours' + String get hours => 'hours'; + /// en: 'Note (optional)' String get note => 'Note (optional)'; @@ -2563,6 +2566,7 @@ extension on Translations { 'sale.amount' => 'Amount', 'sale.currency' => 'Currency', 'sale.currencyHint' => '€, Ğ1, hours… (optional)', + 'sale.hours' => 'hours', 'sale.note' => 'Note (optional)', 'sale.save' => 'Save', 'sale.delete' => 'Remove', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 8a0a9f3..f9fb9ab 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -862,6 +862,7 @@ class _Translations$sale$es extends Translations$sale$en { @override String get amount => 'Importe'; @override String get currency => 'Moneda'; @override String get currencyHint => '€, Ğ1, horas… (opcional)'; + @override String get hours => 'horas'; @override String get note => 'Nota (opcional)'; @override String get save => 'Guardar'; @override String get delete => 'Quitar'; @@ -1663,6 +1664,7 @@ extension on TranslationsEs { 'sale.amount' => 'Importe', 'sale.currency' => 'Moneda', 'sale.currencyHint' => '€, Ğ1, horas… (opcional)', + 'sale.hours' => 'horas', 'sale.note' => 'Nota (opcional)', 'sale.save' => 'Guardar', 'sale.delete' => 'Quitar', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 29144e5..5bbb396 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -859,6 +859,7 @@ class _Translations$sale$pt extends Translations$sale$en { @override String get amount => 'Montante'; @override String get currency => 'Moeda'; @override String get currencyHint => '€, Ğ1, horas… (opcional)'; + @override String get hours => 'horas'; @override String get note => 'Nota (opcional)'; @override String get save => 'Guardar'; @override String get delete => 'Remover'; @@ -1657,6 +1658,7 @@ extension on TranslationsPt { 'sale.amount' => 'Montante', 'sale.currency' => 'Moeda', 'sale.currencyHint' => '€, Ğ1, horas… (opcional)', + 'sale.hours' => 'horas', 'sale.note' => 'Nota (opcional)', 'sale.save' => 'Guardar', 'sale.delete' => 'Remover', diff --git a/apps/app_seeds/lib/ui/category_palette.dart b/apps/app_seeds/lib/ui/category_palette.dart new file mode 100644 index 0000000..ce1f225 --- /dev/null +++ b/apps/app_seeds/lib/ui/category_palette.dart @@ -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(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)), +}; diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index aaddcb4..70de27b 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -9,6 +9,7 @@ import '../domain/seed_viability.dart'; import '../i18n/strings.g.dart'; import '../services/share_catalog_service.dart'; import '../state/inventory_cubit.dart'; +import 'category_palette.dart'; import 'draft_triage.dart'; import 'label_print_sheet.dart'; import 'quantity_kind_l10n.dart'; @@ -293,7 +294,10 @@ class _FilterBar extends StatelessWidget { return const SizedBox.shrink(); } - final chips = [ + // 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 = [ if (hasShared) FilterChip( key: const Key('inventory.filter.sharing'), @@ -330,19 +334,26 @@ class _FilterBar extends StatelessWidget { selected: state.needsReproductionOnly, onSelected: (_) => cubit.toggleNeedsReproductionOnly(), ), - for (final category in categories) - FilterChip( - key: Key('inventory.filter.category.$category'), - label: Text(category), - selected: state.categoryFilter.contains(category), - onSelected: (_) => cubit.toggleCategory(category), - ), + ]; + final formChips = [ for (final form in forms) - FilterChip( + _SwatchFilterChip( 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), - onSelected: (_) => cubit.toggleType(form), + onSelected: () => cubit.toggleType(form), + ), + ]; + final categoryChips = [ + 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 = @@ -352,25 +363,80 @@ class _FilterBar extends StatelessWidget { state.needsReproductionOnly || 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 = []; + 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( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - for (final chip in chips) - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: chip, - ), - if (hasActiveFilter) - TextButton.icon( - key: const Key('inventory.filter.clear'), - onPressed: cubit.clearFilters, - icon: const Icon(Icons.clear, size: 18), - label: Text(t.inventory.clearFilters), - ), - ], - ), + child: Row(children: row), + ); + } +} + +/// A thin vertical rule separating filter groups in the horizontal chip row. +class _FilterGroupDivider extends StatelessWidget { + const _FilterGroupDivider(); + + @override + Widget build(BuildContext context) => Container( + width: 1, + 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 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( padding: const EdgeInsets.fromLTRB(16, 18, 16, 6), - // 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.w500, - color: seedGreen, - ), + child: Row( + children: [ + Container( + width: 10, + height: 10, + 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, + ), + ), + ), + ], ), ); } diff --git a/apps/app_seeds/lib/ui/quantity_picker.dart b/apps/app_seeds/lib/ui/quantity_picker.dart index 7a88cc9..848940f 100644 --- a/apps/app_seeds/lib/ui/quantity_picker.dart +++ b/apps/app_seeds/lib/ui/quantity_picker.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../db/enums.dart'; import '../i18n/strings.g.dart'; +import 'category_palette.dart'; import 'quantity_kind_l10n.dart'; import 'seed_glyph.dart'; import 'theme.dart'; @@ -76,18 +77,28 @@ class LotTypeSelector extends StatelessWidget { runSpacing: 8, children: [ for (final type in LotType.values) - ChoiceChip( - 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)), - ), + _buildTypeChip(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 @@ -189,19 +200,29 @@ class _QuantityPickerState extends State { widget.onChanged(null); return; } - final n = num.tryParse(_countController.text.trim().replaceAll(',', '.')); - widget.onChanged(Quantity(kind: kind, count: kind.countable ? n : null)); + // A countable unit always carries a number ≥ 1 — "3 cobs", never an empty + // "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) { - 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(); } void _bump(int delta) { - final current = int.tryParse(_countController.text.trim()) ?? 0; - final next = (current + delta).clamp(0, 9999); - _countController.text = next == 0 ? '' : '$next'; + final current = int.tryParse(_countController.text.trim()) ?? 1; + final next = (current + delta).clamp(1, 9999); + _countController.text = '$next'; _emit(); } @@ -219,6 +240,7 @@ class _QuantityPickerState extends State { children: [ for (final kind in kinds) _ScaleItem( + key: Key('quantity.kind.${kind.name}'), kind: kind, label: unitLabel(t, kind), selected: _kind == kind, @@ -273,6 +295,7 @@ class _ScaleItem extends StatelessWidget { required this.label, required this.selected, required this.onTap, + super.key, }); final QuantityKind kind; diff --git a/apps/app_seeds/lib/ui/sale_sheet.dart b/apps/app_seeds/lib/ui/sale_sheet.dart index 0a18ba3..a197f1f 100644 --- a/apps/app_seeds/lib/ui/sale_sheet.dart +++ b/apps/app_seeds/lib/ui/sale_sheet.dart @@ -132,14 +132,31 @@ class _SaleSheetState extends State<_SaleSheet> { controller: _currency, decoration: InputDecoration( labelText: t.sale.currency, - helperText: t.sale.currencyHint, - helperMaxLines: 2, 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), TextField( key: const Key('sale.note'), diff --git a/apps/app_seeds/test/ui/category_palette_test.dart b/apps/app_seeds/test/ui/category_palette_test.dart new file mode 100644 index 0000000..04ee845 --- /dev/null +++ b/apps/app_seeds/test/ui/category_palette_test.dart @@ -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); + } + }); +} diff --git a/apps/app_seeds/test/ui/quantity_picker_test.dart b/apps/app_seeds/test/ui/quantity_picker_test.dart new file mode 100644 index 0000000..65b4eae --- /dev/null +++ b/apps/app_seeds/test/ui/quantity_picker_test.dart @@ -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 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); + }); +}