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

@ -1,67 +1,77 @@
import 'package:equatable/equatable.dart';
/// Coarse groups used by the UI to suggest relevant [QuantityKind]s for a
/// species/family. Purely presentational never forces a choice.
enum QuantityGroup { informal, plantForm, precise }
/// Groups used to lay out the quantity picker as an ascending, informal scale.
enum QuantityGroup { vibe, size, seedForm, plant, precise }
/// A stable, extensible, i18n vocabulary for *rough, human* seed amounts.
/// A stable, extensible, i18n vocabulary for *rough, human* amounts.
///
/// Values go deliberately beyond kitchen measures: seeds are counted in the
/// forms the plant gives them (a cob, a pod, a flower head). The display label
/// is localized from [name] so the enum name is the stable storage key and
/// must never be renumbered or reused (see data-model §5 migration rules).
/// Amounts are a [count] of one of these units "3 cobs", "2 packets" never
/// a weight. Some units are pure *vibes* ("a few", "a handful") that carry no
/// number. The enum name is the stable storage key: values may be appended but
/// never renamed or repurposed (data-model §5).
enum QuantityKind {
// Informal / generic
aFew(QuantityGroup.informal),
some(QuantityGroup.informal),
plenty(QuantityGroup.informal),
handful(QuantityGroup.informal),
pinch(QuantityGroup.informal),
jar(QuantityGroup.informal),
packet(QuantityGroup.informal),
// Plant-form natural units
cob(QuantityGroup.plantForm),
head(QuantityGroup.plantForm),
pod(QuantityGroup.plantForm),
ear(QuantityGroup.plantForm),
fruit(QuantityGroup.plantForm),
bulb(QuantityGroup.plantForm),
tuber(QuantityGroup.plantForm),
seedHead(QuantityGroup.plantForm),
bunch(QuantityGroup.plantForm),
// Precise
grams(QuantityGroup.precise),
count(QuantityGroup.precise);
// Vibe rough amounts, mostly uncountable.
aFew(QuantityGroup.vibe, countable: false),
some(QuantityGroup.vibe, countable: false),
plenty(QuantityGroup.vibe, countable: false),
pinch(QuantityGroup.vibe, countable: false),
handful(QuantityGroup.vibe, countable: true),
// Size scale informal containers, ascending.
teaspoon(QuantityGroup.size, countable: true),
spoon(QuantityGroup.size, countable: true),
cup(QuantityGroup.size, countable: true),
jar(QuantityGroup.size, countable: true),
sack(QuantityGroup.size, countable: true),
// The forms the plant gives seeds/fruit in.
packet(QuantityGroup.seedForm, countable: true),
cob(QuantityGroup.seedForm, countable: true),
pod(QuantityGroup.seedForm, countable: true),
ear(QuantityGroup.seedForm, countable: true),
head(QuantityGroup.seedForm, countable: true),
fruit(QuantityGroup.seedForm, countable: true),
bulb(QuantityGroup.seedForm, countable: true),
tuber(QuantityGroup.seedForm, countable: true),
seedHead(QuantityGroup.seedForm, countable: true),
bunch(QuantityGroup.seedForm, countable: true),
// Seedlings / plants (for a plant lot).
plant(QuantityGroup.plant, countable: true),
pot(QuantityGroup.plant, countable: true),
tray(QuantityGroup.plant, countable: true),
// Precise (optional).
grams(QuantityGroup.precise, countable: true),
count(QuantityGroup.precise, countable: true);
const QuantityKind(this.group);
const QuantityKind(this.group, {required this.countable});
final QuantityGroup group;
/// Whether a number reads naturally ("3 cobs"). Vibes like [aFew] don't.
final bool countable;
}
/// A quantity held or moved: a qualitative [kind], plus an optional [precise]
/// amount (grams / seed count) and an optional free-text [label] for rough
/// amounts no [kind] captures. Shared value type used by both Lot and Movement.
/// A held or moved amount: a [count] of an informal [kind]. Shared value type
/// used by both Lot and Movement. No weights deliberately human.
class Quantity extends Equatable {
const Quantity({required this.kind, this.precise, this.label});
const Quantity({required this.kind, this.count, this.label});
final QuantityKind kind;
/// Optional precise amount, meaningful for [QuantityGroup.precise] kinds.
final double? precise;
/// How many of [kind] (e.g. 3 cobs, 2 packets). Null for an uncountable vibe
/// ("a few") or when unspecified.
final num? count;
/// Optional free text ("half a jam jar") when no [kind] fits well.
/// Free text fallback for a rough amount no [kind] captures.
final String? label;
bool get isPrecise => precise != null;
bool get hasCount => count != null;
Quantity copyWith({QuantityKind? kind, double? precise, String? label}) {
return Quantity(
kind: kind ?? this.kind,
precise: precise ?? this.precise,
label: label ?? this.label,
);
}
Quantity copyWith({QuantityKind? kind, num? count, String? label}) =>
Quantity(
kind: kind ?? this.kind,
count: count ?? this.count,
label: label ?? this.label,
);
@override
List<Object?> get props => [kind, precise, label];
List<Object?> get props => [kind, count, label];
}