tane/apps/app_seeds/lib/ui/quantity_picker.dart
vjrj 5b487ba1ec 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.
2026-07-11 06:06:04 +02:00

335 lines
9.8 KiB
Dart

import 'package:commons_core/commons_core.dart';
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';
/// Informal seed amounts, ascending: vibes → containers → seed/fruit forms.
/// ([QuantityKind.handful] is intentionally omitted — it overlaps with spoon.)
const seedScaleKinds = <QuantityKind>[
QuantityKind.aFew,
QuantityKind.teaspoon,
QuantityKind.spoon,
QuantityKind.packet,
QuantityKind.cup,
QuantityKind.jar,
QuantityKind.sack,
QuantityKind.cob,
QuantityKind.pod,
QuantityKind.ear,
QuantityKind.head,
QuantityKind.fruit,
QuantityKind.bulb,
QuantityKind.tuber,
QuantityKind.bunch,
];
/// The amount units offered for each lot form. Living forms are counted as
/// whole individuals (their packaging is a separate [Presentation]); [bulb] also
/// allows grams. [pot]/[tray] are intentionally not offered anymore.
List<QuantityKind> kindsForType(LotType type) => switch (type) {
LotType.seed => seedScaleKinds,
LotType.seedling => const [QuantityKind.seedling],
LotType.plant => const [QuantityKind.plant],
LotType.tree => const [QuantityKind.tree],
LotType.bulb => const [
QuantityKind.bulb,
QuantityKind.tuber,
QuantityKind.grams,
],
LotType.cutting => const [QuantityKind.cutting],
};
/// The human label for a lot form.
String lotTypeLabel(Translations t, LotType type) => switch (type) {
LotType.seed => t.lotType.seed,
LotType.seedling => t.lotType.seedling,
LotType.plant => t.lotType.plant,
LotType.tree => t.lotType.tree,
LotType.bulb => t.lotType.bulb,
LotType.cutting => t.lotType.cutting,
};
/// Living forms whose packaging ([Presentation]) is worth asking about.
bool typeHasPresentation(LotType type) =>
type == LotType.seedling || type == LotType.plant || type == LotType.tree;
/// Form selector: a wrap of choice chips, one per [LotType].
class LotTypeSelector extends StatelessWidget {
const LotTypeSelector({
required this.value,
required this.onChanged,
super.key,
});
final LotType value;
final ValueChanged<LotType> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final type in LotType.values)
_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
/// clears it (packaging is optional).
class PresentationSelector extends StatelessWidget {
const PresentationSelector({
required this.value,
required this.onChanged,
super.key,
});
final Presentation? value;
final ValueChanged<Presentation?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final p in Presentation.values)
ChoiceChip(
key: Key('presentation.${p.name}'),
selected: value == p,
onSelected: (sel) => onChanged(sel ? p : null),
label: Text(presentationLabel(t, p)),
),
],
);
}
}
/// The human label for a packaging [Presentation].
String presentationLabel(Translations t, Presentation p) => switch (p) {
Presentation.pot => t.presentation.pot,
Presentation.tray => t.presentation.tray,
Presentation.plug => t.presentation.plug,
Presentation.bareRoot => t.presentation.bareRoot,
Presentation.rootBall => t.presentation.rootBall,
};
/// A horizontal scale of large unit icons + an optional number stepper. Emits a
/// [Quantity] (or null while nothing is picked) via [onChanged].
class QuantityPicker extends StatefulWidget {
const QuantityPicker({
required this.type,
required this.onChanged,
this.value,
super.key,
});
final LotType type;
final Quantity? value;
final ValueChanged<Quantity?> onChanged;
@override
State<QuantityPicker> createState() => _QuantityPickerState();
}
class _QuantityPickerState extends State<QuantityPicker> {
QuantityKind? _kind;
final _countController = TextEditingController();
@override
void initState() {
super.initState();
_kind = widget.value?.kind;
final c = widget.value?.count;
if (c != null) _countController.text = _fmt(c);
}
@override
void didUpdateWidget(QuantityPicker old) {
super.didUpdateWidget(old);
// Switching seed/plant, or the owner clearing the value, drops a stale
// selection. We only update local fields here (no onChanged during build);
// the owner clears its own value alongside the type change.
if (widget.value == null && _kind != null) {
_kind = null;
_countController.clear();
} else if (widget.type != old.type &&
_kind != null &&
!kindsForType(widget.type).contains(_kind)) {
_kind = null;
_countController.clear();
}
}
@override
void dispose() {
_countController.dispose();
super.dispose();
}
void _emit() {
final kind = _kind;
if (kind == null) {
widget.onChanged(null);
return;
}
// 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;
// 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()) ?? 1;
final next = (current + delta).clamp(1, 9999);
_countController.text = '$next';
_emit();
}
@override
Widget build(BuildContext context) {
final t = context.t;
final kinds = kindsForType(widget.type);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Wrap (not a horizontal scroll) so every unit stays visible.
Wrap(
spacing: 4,
runSpacing: 4,
children: [
for (final kind in kinds)
_ScaleItem(
key: Key('quantity.kind.${kind.name}'),
kind: kind,
label: unitLabel(t, kind),
selected: _kind == kind,
onTap: () => _select(kind),
),
],
),
if (_kind?.countable ?? false) ...[
const SizedBox(height: 8),
Row(
children: [
IconButton.filledTonal(
onPressed: () => _bump(-1),
icon: const Icon(Icons.remove),
),
SizedBox(
width: 64,
child: TextField(
key: const Key('quantity.count'),
controller: _countController,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
decoration: const InputDecoration(isDense: true),
onChanged: (_) => _emit(),
),
),
IconButton.filledTonal(
onPressed: () => _bump(1),
icon: const Icon(Icons.add),
),
const SizedBox(width: 12),
Expanded(
child: Text(
unitLabel(t, _kind!),
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
),
],
],
);
}
}
String _fmt(num n) =>
n == n.roundToDouble() ? n.toInt().toString() : n.toString();
class _ScaleItem extends StatelessWidget {
const _ScaleItem({
required this.kind,
required this.label,
required this.selected,
required this.onTap,
super.key,
});
final QuantityKind kind;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = selected ? seedGreenDark : Colors.black54;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
width: 68,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
decoration: BoxDecoration(
color: selected ? seedGreen.withValues(alpha: 0.15) : null,
borderRadius: BorderRadius.circular(10),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
QuantityKindIcon(kind, size: 30, color: color),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: color),
),
],
),
),
);
}
}