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

@ -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 '../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 = <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)
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 = <Widget>[
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 = <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 =
@ -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 = <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(
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,
),
),
),
],
),
);
}

View file

@ -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<QuantityPicker> {
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<QuantityPicker> {
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;

View file

@ -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'),