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:
parent
bfff95fe8d
commit
7c2e3f207f
19 changed files with 954 additions and 2 deletions
47
apps/app_seeds/assets/catalog/seed_saving.json
Normal file
47
apps/app_seeds/assets/catalog/seed_saving.json
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"note": "Curated seed-saving guidance (public-domain horticultural knowledge, e.g. Ashworth 'Seed to Seed', Seed Savers Exchange). Keyed by botanical family (defaults) with per-species/genus overrides that win. Distances in metres are conservative home-garden ranges for keeping a variety reasonably true; plant counts keep enough genetic diversity. `note` is a locale-keyed map, extensible to any language. This is a starter set, not a ceiling.",
|
||||||
|
"families": {
|
||||||
|
"Solanaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 15, "minPlants": 1, "recommendedPlants": 6, "processing": "wet", "difficulty": "easy" },
|
||||||
|
"Fabaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 20, "minPlants": 1, "recommendedPlants": 10, "processing": "dry", "difficulty": "easy" },
|
||||||
|
"Cucurbitaceae": { "lifeCycle": "annual", "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 12, "processing": "wet", "difficulty": "medium" },
|
||||||
|
"Brassicaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||||
|
"Asteraceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 8, "minPlants": 1, "recommendedPlants": 12, "processing": "dry", "difficulty": "easy" },
|
||||||
|
"Apiaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 5, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||||
|
"Amaranthaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||||
|
"Chenopodiaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||||
|
"Amaryllidaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Alliaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Poaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 20, "minPlants": 20, "recommendedPlants": 40, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Lamiaceae": { "lifeCycle": "perennial", "pollination": "cross", "vector": "insect", "isolationMinM": 45, "isolationMaxM": 150, "minPlants": 5, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Malvaceae": { "lifeCycle": "annual", "pollination": "mixed", "vector": "insect", "isolationMinM": 150, "isolationMaxM": 500, "minPlants": 5, "recommendedPlants": 12, "processing": "dry", "difficulty": "easy" }
|
||||||
|
},
|
||||||
|
"species": {
|
||||||
|
"Solanum lycopersicum": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 6, "minPlants": 1, "recommendedPlants": 5, "processing": "wet", "difficulty": "easy", "note": { "es": "Casi siempre se autopoliniza: fácil de mantener pura. Fermenta la semilla con su pulpa 2–3 días, lava y seca.", "en": "Almost always self-pollinates — easy to keep pure. Ferment the seed in its pulp for 2–3 days, then rinse and dry." } },
|
||||||
|
"Capsicum annuum": { "pollination": "mixed", "vector": "insect", "isolationMinM": 8, "isolationMaxM": 150, "minPlants": 2, "recommendedPlants": 6, "processing": "dry", "difficulty": "easy", "note": { "es": "Se autopoliniza pero los insectos cruzan algo: separa los picantes de los dulces.", "en": "Self-pollinates but insects cross it somewhat — keep hot and sweet types apart." } },
|
||||||
|
"Solanum melongena": { "pollination": "self", "isolationMinM": 6, "isolationMaxM": 15, "processing": "wet", "difficulty": "easy" },
|
||||||
|
"Phaseolus vulgaris": { "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 6, "minPlants": 1, "recommendedPlants": 10, "processing": "dry", "difficulty": "easy" },
|
||||||
|
"Pisum sativum": { "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 6, "minPlants": 1, "recommendedPlants": 10, "processing": "dry", "difficulty": "easy" },
|
||||||
|
"Vicia faba": { "pollination": "cross", "vector": "insect", "isolationMinM": 180, "isolationMaxM": 1000, "minPlants": 10, "recommendedPlants": 50, "processing": "dry", "difficulty": "medium", "note": { "es": "¡Ojo! El haba SÍ se cruza por insectos, al revés que otras leguminosas: aíslala o cultiva una sola variedad.", "en": "Careful — unlike most legumes, faba beans DO cross via insects. Isolate them or grow a single variety." } },
|
||||||
|
"Cucumis sativus": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||||
|
"Cucurbita pepo": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "minPlants": 6, "processing": "wet", "difficulty": "medium", "note": { "es": "Se cruza con calabacines, calabazas de verano y algunas de invierno de su especie. Poliniza a mano y cierra la flor para mantenerla pura.", "en": "Crosses with courgettes, summer squash and some winter squash of its species. Hand-pollinate and tape the flower to keep it pure." } },
|
||||||
|
"Cucurbita maxima": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||||
|
"Cucurbita moschata": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||||
|
"Cucumis melo": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||||
|
"Citrullus lanatus": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||||
|
"Lactuca sativa": { "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 8, "minPlants": 1, "recommendedPlants": 12, "processing": "dry", "difficulty": "easy", "note": { "es": "Se autopoliniza: muy fácil. Deja subir a flor y recoge la semilla con vilano cuando seque.", "en": "Self-pollinating and very easy. Let it bolt and collect the fluffy seed as it dries." } },
|
||||||
|
"Helianthus annuus": { "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 5, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Daucus carota": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 5, "recommendedPlants": 40, "processing": "dry", "difficulty": "hard", "note": { "es": "Bienal: da semilla el segundo año. Se cruza con la zanahoria silvestre (encaje de la reina Ana).", "en": "Biennial — seeds in its second year. Crosses with wild carrot (Queen Anne's lace)." } },
|
||||||
|
"Beta vulgaris": { "lifeCycle": "biennial", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 3200, "minPlants": 6, "recommendedPlants": 30, "processing": "dry", "difficulty": "hard", "note": { "es": "Polen por viento a mucha distancia: remolacha y acelga se cruzan entre sí. Cultiva solo una para semilla.", "en": "Wind-borne pollen travels far — beet and chard cross each other. Grow only one for seed." } },
|
||||||
|
"Spinacia oleracea": { "lifeCycle": "annual", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 30, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Allium cepa": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Brassica oleracea": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard", "note": { "es": "Coles, brócoli, coliflor, col rizada… todos son la MISMA especie y se cruzan entre sí. Aísla bien y guarda de varias plantas.", "en": "Cabbage, broccoli, cauliflower, kale… all the SAME species and cross freely. Isolate well and save from several plants." } },
|
||||||
|
"Brassica rapa": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "processing": "dry", "difficulty": "hard" },
|
||||||
|
"Raphanus sativus": { "lifeCycle": "annual", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 800, "processing": "dry", "difficulty": "medium" },
|
||||||
|
"Zea mays": { "lifeCycle": "annual", "pollination": "cross", "vector": "wind", "isolationMinM": 200, "isolationMaxM": 800, "minPlants": 20, "recommendedPlants": 100, "processing": "dry", "difficulty": "medium", "note": { "es": "Polen por viento: se cruza con cualquier otro maíz cercano. Guarda semilla de muchas plantas (100+) para que no pierda vigor.", "en": "Wind-pollinated — crosses with any nearby maize. Save seed from many plants (100+) so it doesn't lose vigour." } },
|
||||||
|
"Ocimum basilicum": { "lifeCycle": "annual", "pollination": "mixed", "vector": "insect", "isolationMinM": 45, "isolationMaxM": 150, "minPlants": 5, "processing": "dry", "difficulty": "easy" },
|
||||||
|
"Coriandrum sativum": { "lifeCycle": "annual", "pollination": "cross", "vector": "insect", "isolationMinM": 45, "isolationMaxM": 400, "processing": "dry", "difficulty": "easy" },
|
||||||
|
"Petroselinum crispum": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 800, "processing": "dry", "difficulty": "hard" },
|
||||||
|
"Abelmoschus esculentus": { "pollination": "mixed", "vector": "insect", "isolationMinM": 150, "isolationMaxM": 500, "processing": "dry", "difficulty": "easy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/app_seeds/lib/data/seed_saving_catalog.dart
Normal file
32
apps/app_seeds/lib/data/seed_saving_catalog.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -436,6 +436,7 @@ class VarietyDetail extends Equatable {
|
||||||
this.notes,
|
this.notes,
|
||||||
this.speciesId,
|
this.speciesId,
|
||||||
this.scientificName,
|
this.scientificName,
|
||||||
|
this.family,
|
||||||
this.gbifKey,
|
this.gbifKey,
|
||||||
this.wikidataQid,
|
this.wikidataQid,
|
||||||
this.viabilityYears,
|
this.viabilityYears,
|
||||||
|
|
@ -459,6 +460,11 @@ class VarietyDetail extends Equatable {
|
||||||
final String? speciesId;
|
final String? speciesId;
|
||||||
final String? scientificName;
|
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
|
/// Bundled identifiers of the linked species, used to derive verified
|
||||||
/// "learn more" reference links (GBIF/Wikipedia/Wikispecies); null when no
|
/// "learn more" reference links (GBIF/Wikipedia/Wikispecies); null when no
|
||||||
/// species is linked or the catalog entry lacks the id.
|
/// species is linked or the catalog entry lacks the id.
|
||||||
|
|
@ -506,6 +512,7 @@ class VarietyDetail extends Equatable {
|
||||||
notes,
|
notes,
|
||||||
speciesId,
|
speciesId,
|
||||||
scientificName,
|
scientificName,
|
||||||
|
family,
|
||||||
gbifKey,
|
gbifKey,
|
||||||
wikidataQid,
|
wikidataQid,
|
||||||
viabilityYears,
|
viabilityYears,
|
||||||
|
|
@ -1022,6 +1029,7 @@ class VarietyRepository {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
|
|
||||||
String? scientificName;
|
String? scientificName;
|
||||||
|
String? family;
|
||||||
int? viabilityYears;
|
int? viabilityYears;
|
||||||
int? gbifKey;
|
int? gbifKey;
|
||||||
String? wikidataQid;
|
String? wikidataQid;
|
||||||
|
|
@ -1030,6 +1038,7 @@ class VarietyRepository {
|
||||||
_db.species,
|
_db.species,
|
||||||
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
|
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
|
||||||
scientificName = species?.scientificName;
|
scientificName = species?.scientificName;
|
||||||
|
family = species?.family;
|
||||||
viabilityYears = species?.viabilityYears;
|
viabilityYears = species?.viabilityYears;
|
||||||
gbifKey = species?.gbifKey;
|
gbifKey = species?.gbifKey;
|
||||||
wikidataQid = species?.wikidataQid;
|
wikidataQid = species?.wikidataQid;
|
||||||
|
|
@ -1119,6 +1128,7 @@ class VarietyRepository {
|
||||||
notes: v.notes,
|
notes: v.notes,
|
||||||
speciesId: v.speciesId,
|
speciesId: v.speciesId,
|
||||||
scientificName: scientificName,
|
scientificName: scientificName,
|
||||||
|
family: family,
|
||||||
gbifKey: gbifKey,
|
gbifKey: gbifKey,
|
||||||
wikidataQid: wikidataQid,
|
wikidataQid: wikidataQid,
|
||||||
viabilityYears: viabilityYears,
|
viabilityYears: viabilityYears,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:get_it/get_it.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../data/seed_saving_catalog.dart';
|
||||||
import '../data/species_catalog.dart';
|
import '../data/species_catalog.dart';
|
||||||
import '../data/species_repository.dart';
|
import '../data/species_repository.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
|
|
@ -141,6 +142,10 @@ Future<void> configureDependencies() async {
|
||||||
loadSeeds: loadBundledSpecies,
|
loadSeeds: loadBundledSpecies,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Small read-only reference data — kept in memory, not the DB. Loaded before
|
||||||
|
// the UI so the variety detail can show seed-saving guidance synchronously.
|
||||||
|
await SeedSavingCatalog.ensureLoaded();
|
||||||
|
|
||||||
final varietyRepository = VarietyRepository(
|
final varietyRepository = VarietyRepository(
|
||||||
database,
|
database,
|
||||||
idGen: IdGen(),
|
idGen: IdGen(),
|
||||||
|
|
|
||||||
155
apps/app_seeds/lib/domain/seed_saving.dart
Normal file
155
apps/app_seeds/lib/domain/seed_saving.dart
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/// 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'));
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,30 @@
|
||||||
{
|
{
|
||||||
|
"seedSaving": {
|
||||||
|
"title": "Guardar la so semiente",
|
||||||
|
"subtitle": "Lo que fai falta pa caltener la variedá fiel",
|
||||||
|
"lifeCycle": "Ciclu",
|
||||||
|
"cycleAnnual": "Añal",
|
||||||
|
"cycleBiennial": "Bienal — da semiente el 2.u añu",
|
||||||
|
"cyclePerennial": "Perenne",
|
||||||
|
"pollination": "Polinización",
|
||||||
|
"pollSelf": "Autopolinízase",
|
||||||
|
"pollCross": "Crúciase con otres",
|
||||||
|
"pollMixed": "Autopolinízase, pero crúciase dacuando",
|
||||||
|
"byInsect": "por inseutos",
|
||||||
|
"byWind": "col vientu",
|
||||||
|
"isolation": "Xébrala",
|
||||||
|
"isolationRange": "{min}–{max} m d'otres variedaes",
|
||||||
|
"isolationSingle": "{min} m d'otres variedaes",
|
||||||
|
"plants": "Guarda de delles plantes",
|
||||||
|
"plantsValue": "D'a lo menos {n} plantes",
|
||||||
|
"processing": "Cómo llimpiala",
|
||||||
|
"procDry": "Semiente seca (mayar)",
|
||||||
|
"procWet": "Semiente húmeda (fermentar y llavar)",
|
||||||
|
"difficulty": "Dificultá",
|
||||||
|
"diffEasy": "Fácil",
|
||||||
|
"diffMedium": "Media",
|
||||||
|
"diffHard": "Difícil"
|
||||||
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Esti mes",
|
"title": "Esti mes",
|
||||||
"filterChip": "Esti mes",
|
"filterChip": "Esti mes",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,30 @@
|
||||||
{
|
{
|
||||||
|
"seedSaving": {
|
||||||
|
"title": "Saving its seed",
|
||||||
|
"subtitle": "What it takes to keep the variety true",
|
||||||
|
"lifeCycle": "Cycle",
|
||||||
|
"cycleAnnual": "Annual",
|
||||||
|
"cycleBiennial": "Biennial — seeds in year 2",
|
||||||
|
"cyclePerennial": "Perennial",
|
||||||
|
"pollination": "Pollination",
|
||||||
|
"pollSelf": "Self-pollinating",
|
||||||
|
"pollCross": "Crosses with others",
|
||||||
|
"pollMixed": "Self-pollinates, sometimes crosses",
|
||||||
|
"byInsect": "by insects",
|
||||||
|
"byWind": "on the wind",
|
||||||
|
"isolation": "Keep apart",
|
||||||
|
"isolationRange": "{min}–{max} m from other varieties",
|
||||||
|
"isolationSingle": "{min} m from other varieties",
|
||||||
|
"plants": "Save from several plants",
|
||||||
|
"plantsValue": "From at least {n} plants",
|
||||||
|
"processing": "Cleaning the seed",
|
||||||
|
"procDry": "Dry seed (thresh)",
|
||||||
|
"procWet": "Wet seed (ferment and rinse)",
|
||||||
|
"difficulty": "Difficulty",
|
||||||
|
"diffEasy": "Easy",
|
||||||
|
"diffMedium": "Medium",
|
||||||
|
"diffHard": "Hard"
|
||||||
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "This month",
|
"title": "This month",
|
||||||
"filterChip": "This month",
|
"filterChip": "This month",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,30 @@
|
||||||
{
|
{
|
||||||
|
"seedSaving": {
|
||||||
|
"title": "Conservar su semilla",
|
||||||
|
"subtitle": "Lo que hace falta para mantener la variedad fiel",
|
||||||
|
"lifeCycle": "Ciclo",
|
||||||
|
"cycleAnnual": "Anual",
|
||||||
|
"cycleBiennial": "Bienal — da semilla el 2.º año",
|
||||||
|
"cyclePerennial": "Perenne",
|
||||||
|
"pollination": "Polinización",
|
||||||
|
"pollSelf": "Se autopoliniza",
|
||||||
|
"pollCross": "Se cruza con otras",
|
||||||
|
"pollMixed": "Se autopoliniza, pero se cruza a veces",
|
||||||
|
"byInsect": "por insectos",
|
||||||
|
"byWind": "por el viento",
|
||||||
|
"isolation": "Sepárala",
|
||||||
|
"isolationRange": "{min}–{max} m de otras variedades",
|
||||||
|
"isolationSingle": "{min} m de otras variedades",
|
||||||
|
"plants": "Guarda de varias plantas",
|
||||||
|
"plantsValue": "De al menos {n} plantas",
|
||||||
|
"processing": "Cómo limpiarla",
|
||||||
|
"procDry": "Semilla seca (trillar)",
|
||||||
|
"procWet": "Semilla húmeda (fermentar y lavar)",
|
||||||
|
"difficulty": "Dificultad",
|
||||||
|
"diffEasy": "Fácil",
|
||||||
|
"diffMedium": "Media",
|
||||||
|
"diffHard": "Difícil"
|
||||||
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Este mes",
|
"title": "Este mes",
|
||||||
"filterChip": "Este mes",
|
"filterChip": "Este mes",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,30 @@
|
||||||
{
|
{
|
||||||
|
"seedSaving": {
|
||||||
|
"title": "Guardar a sua semente",
|
||||||
|
"subtitle": "O que é preciso para manter a variedade fiel",
|
||||||
|
"lifeCycle": "Ciclo",
|
||||||
|
"cycleAnnual": "Anual",
|
||||||
|
"cycleBiennial": "Bienal — dá semente no 2.º ano",
|
||||||
|
"cyclePerennial": "Perene",
|
||||||
|
"pollination": "Polinização",
|
||||||
|
"pollSelf": "Autopoliniza-se",
|
||||||
|
"pollCross": "Cruza-se com outras",
|
||||||
|
"pollMixed": "Autopoliniza-se, mas às vezes cruza",
|
||||||
|
"byInsect": "por insetos",
|
||||||
|
"byWind": "pelo vento",
|
||||||
|
"isolation": "Separe-a",
|
||||||
|
"isolationRange": "{min}–{max} m de outras variedades",
|
||||||
|
"isolationSingle": "{min} m de outras variedades",
|
||||||
|
"plants": "Guarde de várias plantas",
|
||||||
|
"plantsValue": "De pelo menos {n} plantas",
|
||||||
|
"processing": "Como limpá-la",
|
||||||
|
"procDry": "Semente seca (debulhar)",
|
||||||
|
"procWet": "Semente húmida (fermentar e lavar)",
|
||||||
|
"difficulty": "Dificuldade",
|
||||||
|
"diffEasy": "Fácil",
|
||||||
|
"diffMedium": "Média",
|
||||||
|
"diffHard": "Difícil"
|
||||||
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Este mês",
|
"title": "Este mês",
|
||||||
"filterChip": "Este mês",
|
"filterChip": "Este mês",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// Locales: 4
|
||||||
/// Strings: 1864 (466 per locale)
|
/// Strings: 1960 (490 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-11 at 05:13 UTC
|
/// Built on 2026-07-11 at 05:18 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
||||||
TranslationsAst $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsAst(meta: meta ?? this.$meta);
|
TranslationsAst $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsAst(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
|
@override late final _Translations$seedSaving$ast seedSaving = _Translations$seedSaving$ast._(_root);
|
||||||
@override late final _Translations$calendar$ast calendar = _Translations$calendar$ast._(_root);
|
@override late final _Translations$calendar$ast calendar = _Translations$calendar$ast._(_root);
|
||||||
@override late final _Translations$app$ast app = _Translations$app$ast._(_root);
|
@override late final _Translations$app$ast app = _Translations$app$ast._(_root);
|
||||||
@override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root);
|
@override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root);
|
||||||
|
|
@ -82,6 +83,39 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
||||||
@override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root);
|
@override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: seedSaving
|
||||||
|
class _Translations$seedSaving$ast extends Translations$seedSaving$en {
|
||||||
|
_Translations$seedSaving$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsAst _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get title => 'Guardar la so semiente';
|
||||||
|
@override String get subtitle => 'Lo que fai falta pa caltener la variedá fiel';
|
||||||
|
@override String get lifeCycle => 'Ciclu';
|
||||||
|
@override String get cycleAnnual => 'Añal';
|
||||||
|
@override String get cycleBiennial => 'Bienal — da semiente el 2.u añu';
|
||||||
|
@override String get cyclePerennial => 'Perenne';
|
||||||
|
@override String get pollination => 'Polinización';
|
||||||
|
@override String get pollSelf => 'Autopolinízase';
|
||||||
|
@override String get pollCross => 'Crúciase con otres';
|
||||||
|
@override String get pollMixed => 'Autopolinízase, pero crúciase dacuando';
|
||||||
|
@override String get byInsect => 'por inseutos';
|
||||||
|
@override String get byWind => 'col vientu';
|
||||||
|
@override String get isolation => 'Xébrala';
|
||||||
|
@override String isolationRange({required Object min, required Object max}) => '${min}–${max} m d\'otres variedaes';
|
||||||
|
@override String isolationSingle({required Object min}) => '${min} m d\'otres variedaes';
|
||||||
|
@override String get plants => 'Guarda de delles plantes';
|
||||||
|
@override String plantsValue({required Object n}) => 'D\'a lo menos ${n} plantes';
|
||||||
|
@override String get processing => 'Cómo llimpiala';
|
||||||
|
@override String get procDry => 'Semiente seca (mayar)';
|
||||||
|
@override String get procWet => 'Semiente húmeda (fermentar y llavar)';
|
||||||
|
@override String get difficulty => 'Dificultá';
|
||||||
|
@override String get diffEasy => 'Fácil';
|
||||||
|
@override String get diffMedium => 'Media';
|
||||||
|
@override String get diffHard => 'Difícil';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: calendar
|
// Path: calendar
|
||||||
class _Translations$calendar$ast extends Translations$calendar$en {
|
class _Translations$calendar$ast extends Translations$calendar$en {
|
||||||
_Translations$calendar$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
_Translations$calendar$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1226,6 +1260,30 @@ class _Translations$intro$slides$plantare$ast extends Translations$intro$slides$
|
||||||
extension on TranslationsAst {
|
extension on TranslationsAst {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
|
'seedSaving.title' => 'Guardar la so semiente',
|
||||||
|
'seedSaving.subtitle' => 'Lo que fai falta pa caltener la variedá fiel',
|
||||||
|
'seedSaving.lifeCycle' => 'Ciclu',
|
||||||
|
'seedSaving.cycleAnnual' => 'Añal',
|
||||||
|
'seedSaving.cycleBiennial' => 'Bienal — da semiente el 2.u añu',
|
||||||
|
'seedSaving.cyclePerennial' => 'Perenne',
|
||||||
|
'seedSaving.pollination' => 'Polinización',
|
||||||
|
'seedSaving.pollSelf' => 'Autopolinízase',
|
||||||
|
'seedSaving.pollCross' => 'Crúciase con otres',
|
||||||
|
'seedSaving.pollMixed' => 'Autopolinízase, pero crúciase dacuando',
|
||||||
|
'seedSaving.byInsect' => 'por inseutos',
|
||||||
|
'seedSaving.byWind' => 'col vientu',
|
||||||
|
'seedSaving.isolation' => 'Xébrala',
|
||||||
|
'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m d\'otres variedaes',
|
||||||
|
'seedSaving.isolationSingle' => ({required Object min}) => '${min} m d\'otres variedaes',
|
||||||
|
'seedSaving.plants' => 'Guarda de delles plantes',
|
||||||
|
'seedSaving.plantsValue' => ({required Object n}) => 'D\'a lo menos ${n} plantes',
|
||||||
|
'seedSaving.processing' => 'Cómo llimpiala',
|
||||||
|
'seedSaving.procDry' => 'Semiente seca (mayar)',
|
||||||
|
'seedSaving.procWet' => 'Semiente húmeda (fermentar y llavar)',
|
||||||
|
'seedSaving.difficulty' => 'Dificultá',
|
||||||
|
'seedSaving.diffEasy' => 'Fácil',
|
||||||
|
'seedSaving.diffMedium' => 'Media',
|
||||||
|
'seedSaving.diffHard' => 'Difícil',
|
||||||
'calendar.title' => 'Esti mes',
|
'calendar.title' => 'Esti mes',
|
||||||
'calendar.filterChip' => 'Esti mes',
|
'calendar.filterChip' => 'Esti mes',
|
||||||
'calendar.nothing' => ({required Object month}) => 'Nada anotao pa ${month}.',
|
'calendar.nothing' => ({required Object month}) => 'Nada anotao pa ${month}.',
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
|
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
|
late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root);
|
||||||
late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root);
|
late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root);
|
||||||
late final Translations$app$en app = Translations$app$en.internal(_root);
|
late final Translations$app$en app = Translations$app$en.internal(_root);
|
||||||
late final Translations$bootstrap$en bootstrap = Translations$bootstrap$en.internal(_root);
|
late final Translations$bootstrap$en bootstrap = Translations$bootstrap$en.internal(_root);
|
||||||
|
|
@ -83,6 +84,87 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
late final Translations$sale$en sale = Translations$sale$en.internal(_root);
|
late final Translations$sale$en sale = Translations$sale$en.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: seedSaving
|
||||||
|
class Translations$seedSaving$en {
|
||||||
|
Translations$seedSaving$en.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
|
||||||
|
/// en: 'Saving its seed'
|
||||||
|
String get title => 'Saving its seed';
|
||||||
|
|
||||||
|
/// en: 'What it takes to keep the variety true'
|
||||||
|
String get subtitle => 'What it takes to keep the variety true';
|
||||||
|
|
||||||
|
/// en: 'Cycle'
|
||||||
|
String get lifeCycle => 'Cycle';
|
||||||
|
|
||||||
|
/// en: 'Annual'
|
||||||
|
String get cycleAnnual => 'Annual';
|
||||||
|
|
||||||
|
/// en: 'Biennial — seeds in year 2'
|
||||||
|
String get cycleBiennial => 'Biennial — seeds in year 2';
|
||||||
|
|
||||||
|
/// en: 'Perennial'
|
||||||
|
String get cyclePerennial => 'Perennial';
|
||||||
|
|
||||||
|
/// en: 'Pollination'
|
||||||
|
String get pollination => 'Pollination';
|
||||||
|
|
||||||
|
/// en: 'Self-pollinating'
|
||||||
|
String get pollSelf => 'Self-pollinating';
|
||||||
|
|
||||||
|
/// en: 'Crosses with others'
|
||||||
|
String get pollCross => 'Crosses with others';
|
||||||
|
|
||||||
|
/// en: 'Self-pollinates, sometimes crosses'
|
||||||
|
String get pollMixed => 'Self-pollinates, sometimes crosses';
|
||||||
|
|
||||||
|
/// en: 'by insects'
|
||||||
|
String get byInsect => 'by insects';
|
||||||
|
|
||||||
|
/// en: 'on the wind'
|
||||||
|
String get byWind => 'on the wind';
|
||||||
|
|
||||||
|
/// en: 'Keep apart'
|
||||||
|
String get isolation => 'Keep apart';
|
||||||
|
|
||||||
|
/// en: '{min}–{max} m from other varieties'
|
||||||
|
String isolationRange({required Object min, required Object max}) => '${min}–${max} m from other varieties';
|
||||||
|
|
||||||
|
/// en: '{min} m from other varieties'
|
||||||
|
String isolationSingle({required Object min}) => '${min} m from other varieties';
|
||||||
|
|
||||||
|
/// en: 'Save from several plants'
|
||||||
|
String get plants => 'Save from several plants';
|
||||||
|
|
||||||
|
/// en: 'From at least {n} plants'
|
||||||
|
String plantsValue({required Object n}) => 'From at least ${n} plants';
|
||||||
|
|
||||||
|
/// en: 'Cleaning the seed'
|
||||||
|
String get processing => 'Cleaning the seed';
|
||||||
|
|
||||||
|
/// en: 'Dry seed (thresh)'
|
||||||
|
String get procDry => 'Dry seed (thresh)';
|
||||||
|
|
||||||
|
/// en: 'Wet seed (ferment and rinse)'
|
||||||
|
String get procWet => 'Wet seed (ferment and rinse)';
|
||||||
|
|
||||||
|
/// en: 'Difficulty'
|
||||||
|
String get difficulty => 'Difficulty';
|
||||||
|
|
||||||
|
/// en: 'Easy'
|
||||||
|
String get diffEasy => 'Easy';
|
||||||
|
|
||||||
|
/// en: 'Medium'
|
||||||
|
String get diffMedium => 'Medium';
|
||||||
|
|
||||||
|
/// en: 'Hard'
|
||||||
|
String get diffHard => 'Hard';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: calendar
|
// Path: calendar
|
||||||
class Translations$calendar$en {
|
class Translations$calendar$en {
|
||||||
Translations$calendar$en.internal(this._root);
|
Translations$calendar$en.internal(this._root);
|
||||||
|
|
@ -2145,6 +2227,30 @@ class Translations$intro$slides$plantare$en {
|
||||||
extension on Translations {
|
extension on Translations {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
|
'seedSaving.title' => 'Saving its seed',
|
||||||
|
'seedSaving.subtitle' => 'What it takes to keep the variety true',
|
||||||
|
'seedSaving.lifeCycle' => 'Cycle',
|
||||||
|
'seedSaving.cycleAnnual' => 'Annual',
|
||||||
|
'seedSaving.cycleBiennial' => 'Biennial — seeds in year 2',
|
||||||
|
'seedSaving.cyclePerennial' => 'Perennial',
|
||||||
|
'seedSaving.pollination' => 'Pollination',
|
||||||
|
'seedSaving.pollSelf' => 'Self-pollinating',
|
||||||
|
'seedSaving.pollCross' => 'Crosses with others',
|
||||||
|
'seedSaving.pollMixed' => 'Self-pollinates, sometimes crosses',
|
||||||
|
'seedSaving.byInsect' => 'by insects',
|
||||||
|
'seedSaving.byWind' => 'on the wind',
|
||||||
|
'seedSaving.isolation' => 'Keep apart',
|
||||||
|
'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m from other varieties',
|
||||||
|
'seedSaving.isolationSingle' => ({required Object min}) => '${min} m from other varieties',
|
||||||
|
'seedSaving.plants' => 'Save from several plants',
|
||||||
|
'seedSaving.plantsValue' => ({required Object n}) => 'From at least ${n} plants',
|
||||||
|
'seedSaving.processing' => 'Cleaning the seed',
|
||||||
|
'seedSaving.procDry' => 'Dry seed (thresh)',
|
||||||
|
'seedSaving.procWet' => 'Wet seed (ferment and rinse)',
|
||||||
|
'seedSaving.difficulty' => 'Difficulty',
|
||||||
|
'seedSaving.diffEasy' => 'Easy',
|
||||||
|
'seedSaving.diffMedium' => 'Medium',
|
||||||
|
'seedSaving.diffHard' => 'Hard',
|
||||||
'calendar.title' => 'This month',
|
'calendar.title' => 'This month',
|
||||||
'calendar.filterChip' => 'This month',
|
'calendar.filterChip' => 'This month',
|
||||||
'calendar.nothing' => ({required Object month}) => 'Nothing noted for ${month}.',
|
'calendar.nothing' => ({required Object month}) => 'Nothing noted for ${month}.',
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsEs(meta: meta ?? this.$meta);
|
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsEs(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
|
@override late final _Translations$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root);
|
||||||
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
|
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
|
||||||
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
||||||
@override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root);
|
@override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root);
|
||||||
|
|
@ -82,6 +83,39 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
|
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: seedSaving
|
||||||
|
class _Translations$seedSaving$es extends Translations$seedSaving$en {
|
||||||
|
_Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsEs _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get title => 'Conservar su semilla';
|
||||||
|
@override String get subtitle => 'Lo que hace falta para mantener la variedad fiel';
|
||||||
|
@override String get lifeCycle => 'Ciclo';
|
||||||
|
@override String get cycleAnnual => 'Anual';
|
||||||
|
@override String get cycleBiennial => 'Bienal — da semilla el 2.º año';
|
||||||
|
@override String get cyclePerennial => 'Perenne';
|
||||||
|
@override String get pollination => 'Polinización';
|
||||||
|
@override String get pollSelf => 'Se autopoliniza';
|
||||||
|
@override String get pollCross => 'Se cruza con otras';
|
||||||
|
@override String get pollMixed => 'Se autopoliniza, pero se cruza a veces';
|
||||||
|
@override String get byInsect => 'por insectos';
|
||||||
|
@override String get byWind => 'por el viento';
|
||||||
|
@override String get isolation => 'Sepárala';
|
||||||
|
@override String isolationRange({required Object min, required Object max}) => '${min}–${max} m de otras variedades';
|
||||||
|
@override String isolationSingle({required Object min}) => '${min} m de otras variedades';
|
||||||
|
@override String get plants => 'Guarda de varias plantas';
|
||||||
|
@override String plantsValue({required Object n}) => 'De al menos ${n} plantas';
|
||||||
|
@override String get processing => 'Cómo limpiarla';
|
||||||
|
@override String get procDry => 'Semilla seca (trillar)';
|
||||||
|
@override String get procWet => 'Semilla húmeda (fermentar y lavar)';
|
||||||
|
@override String get difficulty => 'Dificultad';
|
||||||
|
@override String get diffEasy => 'Fácil';
|
||||||
|
@override String get diffMedium => 'Media';
|
||||||
|
@override String get diffHard => 'Difícil';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: calendar
|
// Path: calendar
|
||||||
class _Translations$calendar$es extends Translations$calendar$en {
|
class _Translations$calendar$es extends Translations$calendar$en {
|
||||||
_Translations$calendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$calendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1228,6 +1262,30 @@ class _Translations$intro$slides$plantare$es extends Translations$intro$slides$p
|
||||||
extension on TranslationsEs {
|
extension on TranslationsEs {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
|
'seedSaving.title' => 'Conservar su semilla',
|
||||||
|
'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel',
|
||||||
|
'seedSaving.lifeCycle' => 'Ciclo',
|
||||||
|
'seedSaving.cycleAnnual' => 'Anual',
|
||||||
|
'seedSaving.cycleBiennial' => 'Bienal — da semilla el 2.º año',
|
||||||
|
'seedSaving.cyclePerennial' => 'Perenne',
|
||||||
|
'seedSaving.pollination' => 'Polinización',
|
||||||
|
'seedSaving.pollSelf' => 'Se autopoliniza',
|
||||||
|
'seedSaving.pollCross' => 'Se cruza con otras',
|
||||||
|
'seedSaving.pollMixed' => 'Se autopoliniza, pero se cruza a veces',
|
||||||
|
'seedSaving.byInsect' => 'por insectos',
|
||||||
|
'seedSaving.byWind' => 'por el viento',
|
||||||
|
'seedSaving.isolation' => 'Sepárala',
|
||||||
|
'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m de otras variedades',
|
||||||
|
'seedSaving.isolationSingle' => ({required Object min}) => '${min} m de otras variedades',
|
||||||
|
'seedSaving.plants' => 'Guarda de varias plantas',
|
||||||
|
'seedSaving.plantsValue' => ({required Object n}) => 'De al menos ${n} plantas',
|
||||||
|
'seedSaving.processing' => 'Cómo limpiarla',
|
||||||
|
'seedSaving.procDry' => 'Semilla seca (trillar)',
|
||||||
|
'seedSaving.procWet' => 'Semilla húmeda (fermentar y lavar)',
|
||||||
|
'seedSaving.difficulty' => 'Dificultad',
|
||||||
|
'seedSaving.diffEasy' => 'Fácil',
|
||||||
|
'seedSaving.diffMedium' => 'Media',
|
||||||
|
'seedSaving.diffHard' => 'Difícil',
|
||||||
'calendar.title' => 'Este mes',
|
'calendar.title' => 'Este mes',
|
||||||
'calendar.filterChip' => 'Este mes',
|
'calendar.filterChip' => 'Este mes',
|
||||||
'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.',
|
'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.',
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
TranslationsPt $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsPt(meta: meta ?? this.$meta);
|
TranslationsPt $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsPt(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
|
@override late final _Translations$seedSaving$pt seedSaving = _Translations$seedSaving$pt._(_root);
|
||||||
@override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root);
|
@override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root);
|
||||||
@override late final _Translations$app$pt app = _Translations$app$pt._(_root);
|
@override late final _Translations$app$pt app = _Translations$app$pt._(_root);
|
||||||
@override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root);
|
@override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root);
|
||||||
|
|
@ -82,6 +83,39 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root);
|
@override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: seedSaving
|
||||||
|
class _Translations$seedSaving$pt extends Translations$seedSaving$en {
|
||||||
|
_Translations$seedSaving$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsPt _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get title => 'Guardar a sua semente';
|
||||||
|
@override String get subtitle => 'O que é preciso para manter a variedade fiel';
|
||||||
|
@override String get lifeCycle => 'Ciclo';
|
||||||
|
@override String get cycleAnnual => 'Anual';
|
||||||
|
@override String get cycleBiennial => 'Bienal — dá semente no 2.º ano';
|
||||||
|
@override String get cyclePerennial => 'Perene';
|
||||||
|
@override String get pollination => 'Polinização';
|
||||||
|
@override String get pollSelf => 'Autopoliniza-se';
|
||||||
|
@override String get pollCross => 'Cruza-se com outras';
|
||||||
|
@override String get pollMixed => 'Autopoliniza-se, mas às vezes cruza';
|
||||||
|
@override String get byInsect => 'por insetos';
|
||||||
|
@override String get byWind => 'pelo vento';
|
||||||
|
@override String get isolation => 'Separe-a';
|
||||||
|
@override String isolationRange({required Object min, required Object max}) => '${min}–${max} m de outras variedades';
|
||||||
|
@override String isolationSingle({required Object min}) => '${min} m de outras variedades';
|
||||||
|
@override String get plants => 'Guarde de várias plantas';
|
||||||
|
@override String plantsValue({required Object n}) => 'De pelo menos ${n} plantas';
|
||||||
|
@override String get processing => 'Como limpá-la';
|
||||||
|
@override String get procDry => 'Semente seca (debulhar)';
|
||||||
|
@override String get procWet => 'Semente húmida (fermentar e lavar)';
|
||||||
|
@override String get difficulty => 'Dificuldade';
|
||||||
|
@override String get diffEasy => 'Fácil';
|
||||||
|
@override String get diffMedium => 'Média';
|
||||||
|
@override String get diffHard => 'Difícil';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: calendar
|
// Path: calendar
|
||||||
class _Translations$calendar$pt extends Translations$calendar$en {
|
class _Translations$calendar$pt extends Translations$calendar$en {
|
||||||
_Translations$calendar$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_Translations$calendar$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1225,6 +1259,30 @@ class _Translations$intro$slides$plantare$pt extends Translations$intro$slides$p
|
||||||
extension on TranslationsPt {
|
extension on TranslationsPt {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (path) {
|
return switch (path) {
|
||||||
|
'seedSaving.title' => 'Guardar a sua semente',
|
||||||
|
'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel',
|
||||||
|
'seedSaving.lifeCycle' => 'Ciclo',
|
||||||
|
'seedSaving.cycleAnnual' => 'Anual',
|
||||||
|
'seedSaving.cycleBiennial' => 'Bienal — dá semente no 2.º ano',
|
||||||
|
'seedSaving.cyclePerennial' => 'Perene',
|
||||||
|
'seedSaving.pollination' => 'Polinização',
|
||||||
|
'seedSaving.pollSelf' => 'Autopoliniza-se',
|
||||||
|
'seedSaving.pollCross' => 'Cruza-se com outras',
|
||||||
|
'seedSaving.pollMixed' => 'Autopoliniza-se, mas às vezes cruza',
|
||||||
|
'seedSaving.byInsect' => 'por insetos',
|
||||||
|
'seedSaving.byWind' => 'pelo vento',
|
||||||
|
'seedSaving.isolation' => 'Separe-a',
|
||||||
|
'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m de outras variedades',
|
||||||
|
'seedSaving.isolationSingle' => ({required Object min}) => '${min} m de outras variedades',
|
||||||
|
'seedSaving.plants' => 'Guarde de várias plantas',
|
||||||
|
'seedSaving.plantsValue' => ({required Object n}) => 'De pelo menos ${n} plantas',
|
||||||
|
'seedSaving.processing' => 'Como limpá-la',
|
||||||
|
'seedSaving.procDry' => 'Semente seca (debulhar)',
|
||||||
|
'seedSaving.procWet' => 'Semente húmida (fermentar e lavar)',
|
||||||
|
'seedSaving.difficulty' => 'Dificuldade',
|
||||||
|
'seedSaving.diffEasy' => 'Fácil',
|
||||||
|
'seedSaving.diffMedium' => 'Média',
|
||||||
|
'seedSaving.diffHard' => 'Difícil',
|
||||||
'calendar.title' => 'Este mês',
|
'calendar.title' => 'Este mês',
|
||||||
'calendar.filterChip' => 'Este mês',
|
'calendar.filterChip' => 'Este mês',
|
||||||
'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.',
|
'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.',
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../data/seed_saving_catalog.dart';
|
||||||
import '../data/species_repository.dart';
|
import '../data/species_repository.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../db/enums.dart';
|
import '../db/enums.dart';
|
||||||
import '../domain/crop_calendar.dart';
|
import '../domain/crop_calendar.dart';
|
||||||
|
import '../domain/seed_saving.dart';
|
||||||
import '../domain/seed_viability.dart';
|
import '../domain/seed_viability.dart';
|
||||||
import '../domain/species_reference_links.dart';
|
import '../domain/species_reference_links.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
|
@ -147,6 +149,14 @@ class _DetailView extends StatelessWidget {
|
||||||
_SectionTitle(t.cropCalendar.title),
|
_SectionTitle(t.cropCalendar.title),
|
||||||
_CropCalendarView(detail: detail),
|
_CropCalendarView(detail: detail),
|
||||||
],
|
],
|
||||||
|
if (SeedSavingCatalog.guideFor(
|
||||||
|
scientificName: detail.scientificName,
|
||||||
|
family: detail.family ?? detail.category,
|
||||||
|
) case final guide? when guide.hasAny) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_SectionTitle(t.seedSaving.title),
|
||||||
|
_SeedSavingView(guide: guide),
|
||||||
|
],
|
||||||
if (_referenceLinks(context, detail) case final references
|
if (_referenceLinks(context, detail) case final references
|
||||||
when references.isNotEmpty) ...[
|
when references.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -1010,6 +1020,141 @@ class _CropCalendarView extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "How to save this seed" — the bundled seed-saving guidance for the crop
|
||||||
|
/// (pollination, isolation distance, how many plants, wet/dry, difficulty, a
|
||||||
|
/// tip), in human words. Shown only when a guide resolves for the variety.
|
||||||
|
class _SeedSavingView extends StatelessWidget {
|
||||||
|
const _SeedSavingView({required this.guide});
|
||||||
|
|
||||||
|
final SeedSavingGuide guide;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
final rows = <Widget>[];
|
||||||
|
|
||||||
|
final cycle = switch (guide.lifeCycle) {
|
||||||
|
LifeCycle.annual => t.seedSaving.cycleAnnual,
|
||||||
|
LifeCycle.biennial => t.seedSaving.cycleBiennial,
|
||||||
|
LifeCycle.perennial => t.seedSaving.cyclePerennial,
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
if (cycle != null) {
|
||||||
|
rows.add(_line(Icons.event_repeat, t.seedSaving.lifeCycle, cycle));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guide.pollination case final poll?) {
|
||||||
|
final base = switch (poll) {
|
||||||
|
Pollination.self => t.seedSaving.pollSelf,
|
||||||
|
Pollination.cross => t.seedSaving.pollCross,
|
||||||
|
Pollination.mixed => t.seedSaving.pollMixed,
|
||||||
|
};
|
||||||
|
final via = poll == Pollination.self
|
||||||
|
? ''
|
||||||
|
: switch (guide.vector) {
|
||||||
|
PollinationVector.insect => ' · ${t.seedSaving.byInsect}',
|
||||||
|
PollinationVector.wind => ' · ${t.seedSaving.byWind}',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
final icon = switch (guide.vector) {
|
||||||
|
PollinationVector.wind => Icons.air,
|
||||||
|
PollinationVector.insect => Icons.emoji_nature,
|
||||||
|
_ => Icons.lock_outline,
|
||||||
|
};
|
||||||
|
rows.add(_line(icon, t.seedSaving.pollination, '$base$via'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guide.isolationMinM case final min?) {
|
||||||
|
final max = guide.isolationMaxM;
|
||||||
|
final value = (max != null && max != min)
|
||||||
|
? t.seedSaving.isolationRange(min: min, max: max)
|
||||||
|
: t.seedSaving.isolationSingle(min: min);
|
||||||
|
rows.add(_line(Icons.social_distance, t.seedSaving.isolation, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((guide.recommendedPlants ?? guide.minPlants) case final n?) {
|
||||||
|
rows.add(_line(
|
||||||
|
Icons.groups_outlined, t.seedSaving.plants, t.seedSaving.plantsValue(n: n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guide.processing case final proc?) {
|
||||||
|
final wet = proc == SeedProcessing.wet;
|
||||||
|
rows.add(_line(wet ? Icons.water_drop_outlined : Icons.grass,
|
||||||
|
t.seedSaving.processing, wet ? t.seedSaving.procWet : t.seedSaving.procDry));
|
||||||
|
}
|
||||||
|
|
||||||
|
final note = guide.noteFor(LocaleSettings.currentLocale.languageCode);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
...rows,
|
||||||
|
if (guide.difficulty case final d?) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_DifficultyChip(difficulty: d),
|
||||||
|
],
|
||||||
|
if (note != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(note, style: const TextStyle(color: seedMuted, height: 1.35)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _line(IconData icon, String label, String value) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: seedGreen),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text.rich(TextSpan(children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$label · ',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
TextSpan(text: value),
|
||||||
|
])),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DifficultyChip extends StatelessWidget {
|
||||||
|
const _DifficultyChip({required this.difficulty});
|
||||||
|
|
||||||
|
final SavingDifficulty difficulty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
final (label, color) = switch (difficulty) {
|
||||||
|
SavingDifficulty.easy => (t.seedSaving.diffEasy, const Color(0xFF3B6D11)),
|
||||||
|
SavingDifficulty.medium =>
|
||||||
|
(t.seedSaving.diffMedium, const Color(0xFF8A6D1E)),
|
||||||
|
SavingDifficulty.hard => (t.seedSaving.diffHard, const Color(0xFFA14234)),
|
||||||
|
};
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.insights, size: 18, color: seedGreen),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text('${t.seedSaving.difficulty} · ',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(label,
|
||||||
|
style: TextStyle(color: color, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Human label for a coarse-abundance level.
|
/// Human label for a coarse-abundance level.
|
||||||
String abundanceLabel(Translations t, Abundance a) => switch (a) {
|
String abundanceLabel(Translations t, Abundance a) => switch (a) {
|
||||||
Abundance.plentyToShare => t.abundance.plentyToShare,
|
Abundance.plentyToShare => t.abundance.plentyToShare,
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ flutter:
|
||||||
# Seed strings for slang live under lib/i18n (not a Flutter asset; compiled).
|
# Seed strings for slang live under lib/i18n (not a Flutter asset; compiled).
|
||||||
assets:
|
assets:
|
||||||
- assets/catalog/species.json
|
- assets/catalog/species.json
|
||||||
|
# Curated seed-saving guidance (pollination, isolation, difficulty…).
|
||||||
|
- assets/catalog/seed_saving.json
|
||||||
# Web-of-trust bootstrap referents (Duniter "seeds"). Empty until real
|
# Web-of-trust bootstrap referents (Duniter "seeds"). Empty until real
|
||||||
# founding identities are curated; users bootstrap by adding their own.
|
# founding identities are curated; users bootstrap by adding their own.
|
||||||
- assets/trust/referents.json
|
- assets/trust/referents.json
|
||||||
|
|
|
||||||
51
apps/app_seeds/test/data/seed_saving_asset_test.dart
Normal file
51
apps/app_seeds/test/data/seed_saving_asset_test.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/domain/seed_saving.dart';
|
||||||
|
|
||||||
|
/// Guards the committed bundled seed-saving asset: it must parse, cover the
|
||||||
|
/// major families, and carry the notable per-species corrections the UI relies
|
||||||
|
/// on. Reads the file directly — no Flutter asset bundle — so it runs as a
|
||||||
|
/// plain VM test.
|
||||||
|
void main() {
|
||||||
|
final data =
|
||||||
|
parseSeedSaving(File('assets/catalog/seed_saving.json').readAsStringSync());
|
||||||
|
|
||||||
|
test('resolves the major families', () {
|
||||||
|
for (final family in const [
|
||||||
|
'Solanaceae',
|
||||||
|
'Fabaceae',
|
||||||
|
'Cucurbitaceae',
|
||||||
|
'Brassicaceae',
|
||||||
|
'Asteraceae',
|
||||||
|
'Apiaceae',
|
||||||
|
'Amaranthaceae',
|
||||||
|
'Poaceae',
|
||||||
|
]) {
|
||||||
|
expect(data.guideFor(family: family)?.hasAny, isTrue, reason: family);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('faba bean is corrected to insect cross-pollination', () {
|
||||||
|
// The whole point of species overrides: unlike other legumes, Vicia faba
|
||||||
|
// crosses — the guidance must not inherit Fabaceae "self".
|
||||||
|
final g = data.guideFor(scientificName: 'Vicia faba', family: 'Fabaceae');
|
||||||
|
expect(g?.pollination, Pollination.cross);
|
||||||
|
expect(g?.vector, PollinationVector.insect);
|
||||||
|
expect(g?.noteFor('es'), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tomato self-pollinates and is wet-processed', () {
|
||||||
|
final g = data.guideFor(
|
||||||
|
scientificName: 'Solanum lycopersicum', family: 'Solanaceae');
|
||||||
|
expect(g?.pollination, Pollination.self);
|
||||||
|
expect(g?.processing, SeedProcessing.wet);
|
||||||
|
expect(g?.difficulty, SavingDifficulty.easy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maize is wind-pollinated with a large population', () {
|
||||||
|
final g = data.guideFor(scientificName: 'Zea mays', family: 'Poaceae');
|
||||||
|
expect(g?.vector, PollinationVector.wind);
|
||||||
|
expect(g?.recommendedPlants, greaterThanOrEqualTo(50));
|
||||||
|
});
|
||||||
|
}
|
||||||
67
apps/app_seeds/test/domain/seed_saving_test.dart
Normal file
67
apps/app_seeds/test/domain/seed_saving_test.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/domain/seed_saving.dart';
|
||||||
|
|
||||||
|
/// Pure lookup/merge logic for the seed-saving guidance: a per-species entry
|
||||||
|
/// overrides its family default, a bare genus is a fallback, and unknown crops
|
||||||
|
/// resolve to null.
|
||||||
|
void main() {
|
||||||
|
final data = parseSeedSaving('''
|
||||||
|
{
|
||||||
|
"families": {
|
||||||
|
"Fabaceae": { "lifeCycle": "annual", "pollination": "self", "processing": "dry", "difficulty": "easy" },
|
||||||
|
"Solanaceae": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 15 }
|
||||||
|
},
|
||||||
|
"species": {
|
||||||
|
"Vicia faba": { "pollination": "cross", "vector": "insect", "isolationMinM": 180, "note": {"es": "aísla", "en": "isolate"} },
|
||||||
|
"Cucurbita": { "pollination": "cross", "vector": "insect" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''');
|
||||||
|
|
||||||
|
test('a species entry overrides its family default', () {
|
||||||
|
final g = data.guideFor(scientificName: 'Vicia faba', family: 'Fabaceae');
|
||||||
|
expect(g, isNotNull);
|
||||||
|
// Family said self+easy+annual; the species override flips pollination.
|
||||||
|
expect(g!.pollination, Pollination.cross);
|
||||||
|
expect(g.vector, PollinationVector.insect);
|
||||||
|
expect(g.isolationMinM, 180);
|
||||||
|
// Fields the species didn't set fall through to the family.
|
||||||
|
expect(g.lifeCycle, LifeCycle.annual);
|
||||||
|
expect(g.difficulty, SavingDifficulty.easy);
|
||||||
|
expect(g.processing, SeedProcessing.dry);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bare genus key is a fallback when the species is unlisted', () {
|
||||||
|
final g = data.guideFor(scientificName: 'Cucurbita maxima', family: null);
|
||||||
|
expect(g?.pollination, Pollination.cross);
|
||||||
|
expect(g?.vector, PollinationVector.insect);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('family-only lookup returns the family default', () {
|
||||||
|
final g = data.guideFor(family: 'Solanaceae');
|
||||||
|
expect(g?.pollination, Pollination.self);
|
||||||
|
expect(g?.isolationMaxM, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookup is case-insensitive', () {
|
||||||
|
expect(data.guideFor(family: 'fabaceae'), isNotNull);
|
||||||
|
expect(data.guideFor(scientificName: 'vicia faba'), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown crop resolves to null', () {
|
||||||
|
expect(data.guideFor(scientificName: 'Musa acuminata', family: 'Musaceae'),
|
||||||
|
isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('noteFor falls back to English then any language', () {
|
||||||
|
final g = data.guideFor(scientificName: 'Vicia faba')!;
|
||||||
|
expect(g.noteFor('es'), 'aísla');
|
||||||
|
expect(g.noteFor('fr'), 'isolate'); // no fr → en
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergedWith unions notes with the override winning', () {
|
||||||
|
const base = SeedSavingGuide(note: {'es': 'a', 'en': 'b'});
|
||||||
|
const over = SeedSavingGuide(note: {'es': 'c'});
|
||||||
|
expect(base.mergedWith(over).note, {'es': 'c', 'en': 'b'});
|
||||||
|
});
|
||||||
|
}
|
||||||
54
apps/app_seeds/test/ui/seed_saving_section_test.dart
Normal file
54
apps/app_seeds/test/ui/seed_saving_section_test.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/seed_saving_catalog.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/domain/seed_saving.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// The variety detail shows "how to save this seed" when the bundled catalog
|
||||||
|
/// has guidance for the crop's family, and hides it otherwise.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
setUp(() {
|
||||||
|
db = newTestDatabase();
|
||||||
|
SeedSavingCatalog.data = parseSeedSaving('''
|
||||||
|
{
|
||||||
|
"families": {
|
||||||
|
"Solanaceae": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 6, "processing": "wet", "difficulty": "easy" }
|
||||||
|
},
|
||||||
|
"species": {}
|
||||||
|
}
|
||||||
|
''');
|
||||||
|
});
|
||||||
|
tearDown(() {
|
||||||
|
SeedSavingCatalog.data = null;
|
||||||
|
return db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> pumpDetailFor(WidgetTester tester, String? category) async {
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
final id = await repo.addQuickVariety(label: 'Tomate', category: category);
|
||||||
|
await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('shows the seed-saving section for a known family', (tester) async {
|
||||||
|
await pumpDetailFor(tester, 'Solanaceae');
|
||||||
|
|
||||||
|
expect(find.text('Saving its seed'), findsOneWidget);
|
||||||
|
// The pollination line is a Text.rich (label + value), so match the run.
|
||||||
|
expect(find.textContaining('Self-pollinating'), findsOneWidget);
|
||||||
|
expect(find.text('Easy'), findsOneWidget); // the difficulty pill
|
||||||
|
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides the section when nothing matches', (tester) async {
|
||||||
|
await pumpDetailFor(tester, 'Unknownaceae');
|
||||||
|
|
||||||
|
expect(find.text('Saving its seed'), findsNothing);
|
||||||
|
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue