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.
180 lines
6.3 KiB
Dart
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,
|
|
);
|
|
}
|