tane/apps/app_seeds/lib/ui/quantity_picker.dart
vjrj 7e92410728 refine(quantity): drop handful, reorder scale, potted-plant icon, year dropdown
Feedback from the running app:
- Remove "handful" from the scale (redundant with spoon); keep the enum value.
- Reorder so "packet" (sobre) follows "spoon" (cuchara).
- Use the Material Symbols potted-plant for the plant lot type and plant unit
  (adds material_symbols_icons).
- Harvest year is now a dropdown of recent years instead of free text.

38 tests green; Linux build runs with the new icon font bundled.
2026-07-08 11:03:57 +02:00

257 lines
7 KiB
Dart

import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../db/enums.dart';
import '../i18n/strings.g.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,
];
/// Units for a plant/seedling lot.
const plantScaleKinds = <QuantityKind>[
QuantityKind.plant,
QuantityKind.pot,
QuantityKind.tray,
];
List<QuantityKind> kindsForType(LotType type) =>
type == LotType.seed ? seedScaleKinds : plantScaleKinds;
/// Seed vs plant/seedling segmented selector.
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 SegmentedButton<LotType>(
segments: [
ButtonSegment(
value: LotType.seed,
label: Text(t.lotType.seed),
icon: const SeedGlyph(SeedGlyphs.jars, size: 18),
),
ButtonSegment(
value: LotType.plant,
label: Text(t.lotType.plant),
icon: const Icon(Symbols.potted_plant, size: 18),
),
],
selected: {value},
showSelectedIcon: false,
onSelectionChanged: (s) => onChanged(s.first),
);
}
}
/// 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;
}
final n = num.tryParse(_countController.text.trim().replaceAll(',', '.'));
widget.onChanged(Quantity(kind: kind, count: kind.countable ? n : null));
}
void _select(QuantityKind kind) {
setState(() => _kind = kind);
_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';
_emit();
}
@override
Widget build(BuildContext context) {
final t = context.t;
final kinds = kindsForType(widget.type);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: kinds.length,
separatorBuilder: (_, _) => const SizedBox(width: 4),
itemBuilder: (context, i) {
final kind = kinds[i];
return _ScaleItem(
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,
});
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),
),
],
),
),
);
}
}