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.
155 lines
5.7 KiB
Dart
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'));
|
|
}
|