tane/apps/app_seeds/lib/ui/quantity_picker.dart
vjrj 3942975dba feat(inventory): informal quantity scale (number + unit) + seed/plant lots
Rework quantities the way seeds are actually described — a number of an informal
unit — and let a lot hold seeds or plants (plantel).

Quantity model (commons_core):
- Quantity is now { kind, count?, label } — "3 cobs", "2 packets", "a few".
  Count is optional; uncountable vibes (a few, some, pinch) carry no number.
- QuantityKind reorganised into an ascending scale: vibes → containers
  (teaspoon/spoon/cup/jar/sack) → seed & fruit forms → plant units, each tagged
  countable/not. Stored by name; count reuses the existing numeric column.

Lot type (schema v2):
- Lots gain a `type` (seed | plant); germination stays meaningful for seeds.
  schemaVersion 2 with a from1To2 migration (adds the column), drift_schema_v2
  exported, migration test covers v1→v2.

UI:
- New QuantityPicker: a horizontal scale of large seed-glyph icons + a number
  stepper (shown only for countable units), plus a seed/plant SegmentedButton.
  Used in quick-add and add-lot. Lot lines render "3 cobs" / "a few".
- i18n: unit singular/plural (ES/EN, Weblate-friendly), lot-type labels.

Build: slang moved to its CLI (disabled in build_runner to avoid a multi-file
collision); CI runs `dart run slang` before build_runner. 38 tests green,
Linux migrates the existing v1 DB and runs.
2026-07-08 10:51:02 +02:00

256 lines
6.9 KiB
Dart

import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.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.
const seedScaleKinds = <QuantityKind>[
QuantityKind.aFew,
QuantityKind.handful,
QuantityKind.teaspoon,
QuantityKind.spoon,
QuantityKind.cup,
QuantityKind.jar,
QuantityKind.sack,
QuantityKind.packet,
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(Icons.local_florist_outlined, 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),
),
],
),
),
);
}
}