tane/apps/app_seeds/lib/domain/seed_saving.dart
vjrj 7c2e3f207f feat(seed-saving): per-crop 'how to save this seed' guidance
A bundled, curated knowledge layer — the differentiator of a serious
seed app. For a variety's crop it shows how the plant reproduces and what
it takes to keep the variety true: life cycle, pollination (self/cross +
insect/wind), isolation distance, how many plants to save from, dry/wet
processing, a difficulty pill, and a short tip — all in human words.

- assets/catalog/seed_saving.json: curated public-domain data (Seed to
  Seed / Seed Savers), keyed by botanical FAMILY (defaults) with
  per-species/genus OVERRIDES that win — so e.g. Vicia faba is correctly
  insect-cross-pollinated despite the selfing Fabaceae default, and maize
  is wind-pollinated with a large population. Notes are locale-keyed
  (es/en), extensible to any language — a starter set, not a ceiling.
- domain/seed_saving.dart (pure): enums + SeedSavingGuide (merge, note
  fallback) + SeedSavingData.guideFor (species → genus → family).
- data/seed_saving_catalog.dart: loads the asset once into an in-memory
  singleton (no DB table, no migration); injector loads it at startup.
- VarietyDetail gains  (from the linked species — the reliable
  key, vs the editable category); detail screen shows a _SeedSavingView
  when a guide resolves.
- i18n seedSaving block (labels + enum values + params) in en/es/pt/ast.
- Tests: domain lookup/merge, the real asset's key corrections, and the
  detail section shows/hides. 314 suite tests green; analyze clean.
2026-07-11 07:18:39 +02:00

155 lines
5.7 KiB
Dart

/// Seed-saving know-how per crop: how a plant reproduces and what it takes to
/// keep a variety true when saving its seed. Bundled reference data (curated
/// from public seed-saving literature), keyed by botanical family with
/// per-species overrides. Pure Dart (only `dart:convert`) — no Flutter binding
/// — so it is trivially testable.
library;
import 'dart:convert';
/// How long the plant takes to make seed.
enum LifeCycle { annual, biennial, perennial }
/// Whether a plant fertilises itself or needs another plant.
enum Pollination { self, cross, mixed }
/// What carries the pollen when it does cross.
enum PollinationVector { self, insect, wind }
/// How the seed is cleaned once ripe.
enum SeedProcessing { dry, wet }
/// Roughly how hard it is to save true seed at home.
enum SavingDifficulty { easy, medium, hard }
/// The saving guidance for one crop. Every field is optional — a starter entry
/// may only know some of them. Distances are in metres; plant counts are how
/// many plants to save seed from to keep enough genetic diversity.
class SeedSavingGuide {
const SeedSavingGuide({
this.lifeCycle,
this.pollination,
this.vector,
this.isolationMinM,
this.isolationMaxM,
this.minPlants,
this.recommendedPlants,
this.processing,
this.difficulty,
this.note = const {},
});
final LifeCycle? lifeCycle;
final Pollination? pollination;
final PollinationVector? vector;
final int? isolationMinM;
final int? isolationMaxM;
final int? minPlants;
final int? recommendedPlants;
final SeedProcessing? processing;
final SavingDifficulty? difficulty;
/// Locale-keyed short tip (`{"es": "…", "en": "…"}`), extensible to any
/// language — never assume es/en only.
final Map<String, String> note;
/// True when there's at least one fact worth showing.
bool get hasAny =>
lifeCycle != null ||
pollination != null ||
vector != null ||
isolationMinM != null ||
minPlants != null ||
processing != null ||
difficulty != null ||
note.isNotEmpty;
/// This guide with [other]'s non-null fields laid over it (species over
/// family). Notes merge, with [other]'s languages winning.
SeedSavingGuide mergedWith(SeedSavingGuide other) => SeedSavingGuide(
lifeCycle: other.lifeCycle ?? lifeCycle,
pollination: other.pollination ?? pollination,
vector: other.vector ?? vector,
isolationMinM: other.isolationMinM ?? isolationMinM,
isolationMaxM: other.isolationMaxM ?? isolationMaxM,
minPlants: other.minPlants ?? minPlants,
recommendedPlants: other.recommendedPlants ?? recommendedPlants,
processing: other.processing ?? processing,
difficulty: other.difficulty ?? difficulty,
note: {...note, ...other.note},
);
/// The tip in [lang], falling back to English then any language.
String? noteFor(String lang) {
if (note.isEmpty) return null;
return note[lang] ?? note['en'] ?? note.values.first;
}
}
/// The whole bundled dataset: family defaults plus per-taxon overrides (keyed by
/// scientific name OR bare genus). Lookups are case-insensitive.
class SeedSavingData {
SeedSavingData({
required Map<String, SeedSavingGuide> families,
required Map<String, SeedSavingGuide> taxa,
}) : _families = {
for (final e in families.entries) e.key.toLowerCase().trim(): e.value,
},
_taxa = {
for (final e in taxa.entries) e.key.toLowerCase().trim(): e.value,
};
final Map<String, SeedSavingGuide> _families;
final Map<String, SeedSavingGuide> _taxa;
/// The best guide for a crop: the family default, overlaid by a per-species
/// (or per-genus) entry when one exists. Returns null when nothing matches.
SeedSavingGuide? guideFor({String? scientificName, String? family}) {
SeedSavingGuide? base =
family == null ? null : _families[family.toLowerCase().trim()];
if (scientificName != null) {
final sci = scientificName.toLowerCase().trim();
final genus = sci.split(RegExp(r'\s+')).first;
final species = _taxa[sci] ?? (genus.isEmpty ? null : _taxa[genus]);
if (species != null) base = base == null ? species : base.mergedWith(species);
}
return base;
}
}
T? _enumByName<T extends Enum>(List<T> values, Object? name) {
if (name is! String) return null;
for (final v in values) {
if (v.name == name) return v;
}
return null;
}
/// Parses one guide from a JSON map (already decoded).
SeedSavingGuide parseGuide(Map<String, dynamic> m) => SeedSavingGuide(
lifeCycle: _enumByName(LifeCycle.values, m['lifeCycle']),
pollination: _enumByName(Pollination.values, m['pollination']),
vector: _enumByName(PollinationVector.values, m['vector']),
isolationMinM: (m['isolationMinM'] as num?)?.toInt(),
isolationMaxM: (m['isolationMaxM'] as num?)?.toInt(),
minPlants: (m['minPlants'] as num?)?.toInt(),
recommendedPlants: (m['recommendedPlants'] as num?)?.toInt(),
processing: _enumByName(SeedProcessing.values, m['processing']),
difficulty: _enumByName(SavingDifficulty.values, m['difficulty']),
note: (m['note'] as Map<String, dynamic>? ?? const {})
.map((k, v) => MapEntry(k, v as String)),
);
/// Parses the bundled seed-saving catalog JSON into [SeedSavingData].
SeedSavingData parseSeedSaving(String jsonString) {
final root = jsonDecode(jsonString) as Map<String, dynamic>;
Map<String, SeedSavingGuide> section(String key) {
final raw = root[key] as Map<String, dynamic>? ?? const {};
return raw.map(
(k, v) => MapEntry(k, parseGuide((v as Map).cast<String, dynamic>())),
);
}
return SeedSavingData(families: section('families'), taxa: section('species'));
}