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.
335 lines
9.8 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|