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.
This commit is contained in:
vjrj 2026-07-08 10:51:02 +02:00
parent 48e9d15772
commit 3942975dba
26 changed files with 4220 additions and 315 deletions

View file

@ -2,46 +2,49 @@ import 'package:commons_core/commons_core.dart';
import '../i18n/strings.g.dart';
/// Localized display label for a [QuantityKind]. The enum name is the stable
/// storage key; the label is always resolved through i18n (never hardcoded).
String quantityKindLabel(Translations t, QuantityKind kind) {
final q = t.quantityKind;
switch (kind) {
case QuantityKind.aFew:
return q.aFew;
case QuantityKind.some:
return q.some;
case QuantityKind.plenty:
return q.plenty;
case QuantityKind.handful:
return q.handful;
case QuantityKind.pinch:
return q.pinch;
case QuantityKind.jar:
return q.jar;
case QuantityKind.packet:
return q.packet;
case QuantityKind.cob:
return q.cob;
case QuantityKind.head:
return q.head;
case QuantityKind.pod:
return q.pod;
case QuantityKind.ear:
return q.ear;
case QuantityKind.fruit:
return q.fruit;
case QuantityKind.bulb:
return q.bulb;
case QuantityKind.tuber:
return q.tuber;
case QuantityKind.seedHead:
return q.seedHead;
case QuantityKind.bunch:
return q.bunch;
case QuantityKind.grams:
return q.grams;
case QuantityKind.count:
return q.count;
/// Singular + plural label for a [QuantityKind]. For uncountable *vibe* kinds
/// (a few, some) both are the same word.
(String, String) _unit(Translations t, QuantityKind kind) => switch (kind) {
QuantityKind.aFew => (t.unit.aFew, t.unit.aFew),
QuantityKind.some => (t.unit.some, t.unit.some),
QuantityKind.plenty => (t.unit.plenty, t.unit.plenty),
QuantityKind.pinch => (t.unit.pinch, t.unit.pinch),
QuantityKind.handful => (t.unit.handful.singular, t.unit.handful.plural),
QuantityKind.teaspoon => (t.unit.teaspoon.singular, t.unit.teaspoon.plural),
QuantityKind.spoon => (t.unit.spoon.singular, t.unit.spoon.plural),
QuantityKind.cup => (t.unit.cup.singular, t.unit.cup.plural),
QuantityKind.jar => (t.unit.jar.singular, t.unit.jar.plural),
QuantityKind.sack => (t.unit.sack.singular, t.unit.sack.plural),
QuantityKind.packet => (t.unit.packet.singular, t.unit.packet.plural),
QuantityKind.cob => (t.unit.cob.singular, t.unit.cob.plural),
QuantityKind.pod => (t.unit.pod.singular, t.unit.pod.plural),
QuantityKind.ear => (t.unit.ear.singular, t.unit.ear.plural),
QuantityKind.head => (t.unit.head.singular, t.unit.head.plural),
QuantityKind.fruit => (t.unit.fruit.singular, t.unit.fruit.plural),
QuantityKind.bulb => (t.unit.bulb.singular, t.unit.bulb.plural),
QuantityKind.tuber => (t.unit.tuber.singular, t.unit.tuber.plural),
QuantityKind.seedHead => (t.unit.seedHead.singular, t.unit.seedHead.plural),
QuantityKind.bunch => (t.unit.bunch.singular, t.unit.bunch.plural),
QuantityKind.plant => (t.unit.plant.singular, t.unit.plant.plural),
QuantityKind.pot => (t.unit.pot.singular, t.unit.pot.plural),
QuantityKind.tray => (t.unit.tray.singular, t.unit.tray.plural),
QuantityKind.grams => (t.unit.grams.singular, t.unit.grams.plural),
QuantityKind.count => (t.unit.count.singular, t.unit.count.plural),
};
/// The unit's singular name — for chips/scale items ("cob", "packet").
String unitLabel(Translations t, QuantityKind kind) => _unit(t, kind).$1;
/// A full, human quantity: "3 cobs", "2 packets", "a few".
String quantityDisplay(Translations t, Quantity q) {
final n = q.count;
final (one, other) = _unit(t, q.kind);
if (!q.kind.countable || n == null) {
// Vibe amount, or a countable unit with no number bare unit name.
return (n != null && n != 1) ? other : one;
}
return '${_formatCount(n)} ${n == 1 ? one : other}';
}
String _formatCount(num n) =>
n == n.roundToDouble() ? n.toInt().toString() : n.toString();

View file

@ -0,0 +1,256 @@
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),
),
],
),
),
);
}
}

View file

@ -1,6 +1,5 @@
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
@ -8,24 +7,12 @@ import 'package:image_picker/image_picker.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
import '../state/quick_add_cubit.dart';
import 'quantity_kind_l10n.dart';
import 'seed_glyph.dart';
import 'quantity_picker.dart';
/// Picks a photo and returns its bytes (or null if cancelled). Injected so
/// widget tests can supply a fake instead of the camera plugin.
typedef PhotoPicker = Future<Uint8List?> Function();
/// A short, high-frequency subset of quantity units shown up front to keep the
/// add flow fast; the rest live behind "Add more…".
const _quickKinds = <QuantityKind>[
QuantityKind.aFew,
QuantityKind.handful,
QuantityKind.packet,
QuantityKind.pod,
QuantityKind.cob,
QuantityKind.grams,
];
Future<void> showQuickAddSheet(
BuildContext context, {
required VarietyRepository repository,
@ -94,22 +81,24 @@ class QuickAddSheet extends StatelessWidget {
onSubmitted: (_) => cubit.submit(),
),
const SizedBox(height: 16),
LotTypeSelector(
value: state.lotType,
onChanged: (type) {
cubit
..setLotType(type)
..setQuantity(null);
},
),
const SizedBox(height: 12),
Text(
t.quickAdd.quantity,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final kind in _quickKinds)
ChoiceChip(
avatar: QuantityKindIcon(kind, size: 18),
label: Text(quantityKindLabel(t, kind)),
selected: state.quantityKind == kind,
onSelected: (_) => cubit.selectQuantity(kind),
),
],
QuantityPicker(
type: state.lotType,
value: state.quantity,
onChanged: cubit.setQuantity,
),
const SizedBox(height: 8),
Align(

View file

@ -46,9 +46,13 @@ String? seedGlyphForKind(QuantityKind kind) => switch (kind) {
QuantityKind.aFew => SeedGlyphs.scattered,
QuantityKind.some => SeedGlyphs.smallSpoon,
QuantityKind.plenty => SeedGlyphs.jars,
QuantityKind.handful => SeedGlyphs.bigSpoon,
QuantityKind.pinch => SeedGlyphs.scattered,
QuantityKind.handful => SeedGlyphs.bigSpoon,
QuantityKind.teaspoon => SeedGlyphs.smallSpoon,
QuantityKind.spoon => SeedGlyphs.bigSpoon,
QuantityKind.cup => SeedGlyphs.mug,
QuantityKind.jar => SeedGlyphs.jar,
QuantityKind.sack => SeedGlyphs.sack,
QuantityKind.packet => SeedGlyphs.envelope,
_ => null,
};
@ -59,6 +63,9 @@ IconData _materialIconForKind(QuantityKind kind) => switch (kind) {
QuantityKind.cob || QuantityKind.ear => Icons.grass,
QuantityKind.head || QuantityKind.seedHead => Icons.filter_vintage_outlined,
QuantityKind.fruit => Icons.local_florist_outlined,
QuantityKind.plant => Icons.local_florist_outlined,
QuantityKind.pot => Icons.yard_outlined,
QuantityKind.tray => Icons.grid_on_outlined,
QuantityKind.bulb ||
QuantityKind.tuber ||
QuantityKind.bunch => Icons.spa_outlined,

View file

@ -4,11 +4,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart';
import 'seed_glyph.dart';
import 'theme.dart';
import 'quantity_kind_l10n.dart';
/// Read + edit view of a single variety: its photo, category, notes, other
/// names and lots. Edits go through [VarietyDetailCubit]; the view is reactive.
@ -144,20 +146,11 @@ String _lotSubtitle(Translations t, VarietyLot lot) {
t.detail.year(year: lot.harvestYear!)
else
t.detail.noYear,
if (lot.quantity != null) _quantityLabel(t, lot.quantity!),
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
];
return parts.join(' · ');
}
String _quantityLabel(Translations t, Quantity q) {
final kind = quantityKindLabel(t, q.kind);
if (q.precise != null) return '${_trimDouble(q.precise!)} $kind';
return kind;
}
String _trimDouble(double v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toString();
String _germinationPercent(Translations t, double rate) =>
t.germination.result(percent: (rate * 100).round());
@ -485,7 +478,8 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
final t = context.t;
final yearController = TextEditingController();
QuantityKind? selectedKind;
var selectedType = LotType.seed;
Quantity? selectedQuantity;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@ -506,6 +500,14 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
style: Theme.of(sheetContext).textTheme.titleLarge,
),
const SizedBox(height: 12),
LotTypeSelector(
value: selectedType,
onChanged: (type) => setState(() {
selectedType = type;
selectedQuantity = null;
}),
),
const SizedBox(height: 12),
TextField(
key: const Key('addLot.year'),
controller: yearController,
@ -521,17 +523,10 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final kind in _addLotKinds)
ChoiceChip(
avatar: QuantityKindIcon(kind, size: 18),
label: Text(quantityKindLabel(t, kind)),
selected: selectedKind == kind,
onSelected: (_) => setState(() => selectedKind = kind),
),
],
QuantityPicker(
type: selectedType,
value: selectedQuantity,
onChanged: (q) => selectedQuantity = q,
),
const SizedBox(height: 16),
Row(
@ -545,10 +540,9 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
key: const Key('addLot.save'),
onPressed: () {
cubit.addLot(
type: selectedType,
year: int.tryParse(yearController.text.trim()),
quantity: selectedKind == null
? null
: Quantity(kind: selectedKind!),
quantity: selectedQuantity,
);
Navigator.of(sheetContext).pop();
},
@ -563,15 +557,6 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
);
}
const _addLotKinds = <QuantityKind>[
QuantityKind.aFew,
QuantityKind.handful,
QuantityKind.packet,
QuantityKind.pod,
QuantityKind.cob,
QuantityKind.grams,
];
String? _nullIfBlank(String value) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;