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:
parent
e2665c27d4
commit
5b487ba1ec
15 changed files with 364 additions and 53 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
56
apps/app_seeds/lib/ui/category_palette.dart
Normal file
56
apps/app_seeds/lib/ui/category_palette.dart
Normal 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)),
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
// Lay out the groups in order, dropping a hairline divider between any two
|
||||||
scrollDirection: Axis.horizontal,
|
// that both have chips.
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
final groups = [attrChips, formChips, categoryChips].where((g) => g.isNotEmpty);
|
||||||
child: Row(
|
final row = <Widget>[];
|
||||||
children: [
|
for (final group in groups) {
|
||||||
for (final chip in chips)
|
if (row.isNotEmpty) row.add(const _FilterGroupDivider());
|
||||||
Padding(
|
for (final chip in group) {
|
||||||
|
row.add(Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||||
child: chip,
|
child: chip,
|
||||||
),
|
));
|
||||||
if (hasActiveFilter)
|
}
|
||||||
TextButton.icon(
|
}
|
||||||
|
if (hasActiveFilter) {
|
||||||
|
row.add(TextButton.icon(
|
||||||
key: const Key('inventory.filter.clear'),
|
key: const Key('inventory.filter.clear'),
|
||||||
onPressed: cubit.clearFilters,
|
onPressed: cubit.clearFilters,
|
||||||
icon: const Icon(Icons.clear, size: 18),
|
icon: const Icon(Icons.clear, size: 18),
|
||||||
label: Text(t.inventory.clearFilters),
|
label: Text(t.inventory.clearFilters),
|
||||||
),
|
));
|
||||||
],
|
}
|
||||||
),
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
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,16 +517,31 @@ 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),
|
||||||
|
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.
|
// Base on the text theme so it honours the system font-scale factor.
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
color: seedGreen,
|
color: swatch.ink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,16 +77,26 @@ 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTypeChip(Translations t, LotType type) {
|
||||||
|
final swatch = lotTypeSwatch(type);
|
||||||
|
final selected = value == type;
|
||||||
|
return ChoiceChip(
|
||||||
key: Key('lotType.${type.name}'),
|
key: Key('lotType.${type.name}'),
|
||||||
selected: value == type,
|
selected: selected,
|
||||||
onSelected: (_) => onChanged(type),
|
onSelected: (_) => onChanged(type),
|
||||||
avatar: type == LotType.seed
|
avatar: type == LotType.seed
|
||||||
? const SeedGlyph(SeedGlyphs.jars, size: 18)
|
? const SeedGlyph(SeedGlyphs.jars, size: 18)
|
||||||
: Icon(iconForLotType(type), size: 18),
|
: Icon(iconForLotType(type), size: 18, color: swatch.ink),
|
||||||
label: Text(lotTypeLabel(t, type)),
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
37
apps/app_seeds/test/ui/category_palette_test.dart
Normal file
37
apps/app_seeds/test/ui/category_palette_test.dart
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
83
apps/app_seeds/test/ui/quantity_picker_test.dart
Normal file
83
apps/app_seeds/test/ui/quantity_picker_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue