tane/apps/app_seeds/lib/domain/seed_saving.dart
vjrj 5ecdd1222a feat(seed-saving): source attribution + advisory note
Record where the guidance comes from (copyright/attribution) and make
clear it is general, not local gospel — kept light:
- seed_saving.json gains a top-level sources list (Seed to Seed / Seed
  Savers Exchange / Organic Seed Alliance); parsed into
  SeedSavingData.sources, exposed via SeedSavingCatalog.sources.
- The detail section shows a small muted footer: an italic 'advisory —
  adapt to your climate' line plus a 'Source: …' credit.
- i18n seedSaving.advisory + sourcePrefix (en/es/pt/ast).
- Tests: asset carries sources; the section renders advisory + credit.
2026-07-11 07:52:48 +02:00

180 lines
6.3 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;
}
}
/// An attribution for the bundled guidance (the facts are drawn from these).
class SeedSavingSource {
const SeedSavingSource({required this.title, this.author, this.url});
final String title;
final String? author;
final String? url;
}
/// 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,
this.sources = const [],
}) : _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;
/// Where the guidance comes from — shown as a small credit under the section.
final List<SeedSavingSource> sources;
/// 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>())),
);
}
final sources = [
for (final s in (root['sources'] as List? ?? const []).cast<Map>())
SeedSavingSource(
title: s['title'] as String,
author: s['author'] as String?,
url: s['url'] as String?,
),
];
return SeedSavingData(
families: section('families'),
taxa: section('species'),
sources: sources,
);
}