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