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.
This commit is contained in:
vjrj 2026-07-11 07:17:12 +02:00
parent bfff95fe8d
commit 7c2e3f207f
19 changed files with 954 additions and 2 deletions

View file

@ -0,0 +1,32 @@
import 'package:flutter/services.dart' show rootBundle;
import '../domain/seed_saving.dart';
const _asset = 'assets/catalog/seed_saving.json';
/// Loads and parses the bundled seed-saving catalog asset.
Future<SeedSavingData> loadBundledSeedSaving() async {
return parseSeedSaving(await rootBundle.loadString(_asset));
}
/// App-wide access to the bundled seed-saving guidance. It's small, read-only
/// reference data (not per-user, never synced), so it lives as a single
/// in-memory instance set once at startup no DB table, no migration. Tests
/// inject a parsed [SeedSavingData] directly via [data].
class SeedSavingCatalog {
SeedSavingCatalog._();
/// The loaded dataset, or null before [ensureLoaded] runs (then lookups just
/// return null and the UI hides the section).
static SeedSavingData? data;
/// Loads the asset once. Safe to call repeatedly.
static Future<void> ensureLoaded() async {
data ??= await loadBundledSeedSaving();
}
/// The guide for a crop, or null when the catalog isn't loaded or nothing
/// matches. See [SeedSavingData.guideFor].
static SeedSavingGuide? guideFor({String? scientificName, String? family}) =>
data?.guideFor(scientificName: scientificName, family: family);
}

View file

@ -436,6 +436,7 @@ class VarietyDetail extends Equatable {
this.notes,
this.speciesId,
this.scientificName,
this.family,
this.gbifKey,
this.wikidataQid,
this.viabilityYears,
@ -459,6 +460,11 @@ class VarietyDetail extends Equatable {
final String? speciesId;
final String? scientificName;
/// Botanical family of the linked species (Latin), the reliable key for
/// seed-saving guidance; null when no species is linked. (The editable
/// [category] is prefilled from this but may be changed by the grower.)
final String? family;
/// Bundled identifiers of the linked species, used to derive verified
/// "learn more" reference links (GBIF/Wikipedia/Wikispecies); null when no
/// species is linked or the catalog entry lacks the id.
@ -506,6 +512,7 @@ class VarietyDetail extends Equatable {
notes,
speciesId,
scientificName,
family,
gbifKey,
wikidataQid,
viabilityYears,
@ -1022,6 +1029,7 @@ class VarietyRepository {
if (v == null) return null;
String? scientificName;
String? family;
int? viabilityYears;
int? gbifKey;
String? wikidataQid;
@ -1030,6 +1038,7 @@ class VarietyRepository {
_db.species,
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
scientificName = species?.scientificName;
family = species?.family;
viabilityYears = species?.viabilityYears;
gbifKey = species?.gbifKey;
wikidataQid = species?.wikidataQid;
@ -1119,6 +1128,7 @@ class VarietyRepository {
notes: v.notes,
speciesId: v.speciesId,
scientificName: scientificName,
family: family,
gbifKey: gbifKey,
wikidataQid: wikidataQid,
viabilityYears: viabilityYears,