From 7c2e3f207fb60377dd78c9e78461de0cf982575c Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 07:17:12 +0200 Subject: [PATCH] feat(seed-saving): per-crop 'how to save this seed' guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app_seeds/assets/catalog/seed_saving.json | 47 ++++++ .../lib/data/seed_saving_catalog.dart | 32 ++++ .../lib/data/variety_repository.dart | 10 ++ apps/app_seeds/lib/di/injector.dart | 5 + apps/app_seeds/lib/domain/seed_saving.dart | 155 ++++++++++++++++++ apps/app_seeds/lib/i18n/ast.i18n.json | 26 +++ apps/app_seeds/lib/i18n/en.i18n.json | 26 +++ apps/app_seeds/lib/i18n/es.i18n.json | 26 +++ apps/app_seeds/lib/i18n/pt.i18n.json | 26 +++ apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 58 +++++++ apps/app_seeds/lib/i18n/strings_en.g.dart | 106 ++++++++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 58 +++++++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 58 +++++++ .../lib/ui/variety_detail_screen.dart | 145 ++++++++++++++++ apps/app_seeds/pubspec.yaml | 2 + .../test/data/seed_saving_asset_test.dart | 51 ++++++ .../test/domain/seed_saving_test.dart | 67 ++++++++ .../test/ui/seed_saving_section_test.dart | 54 ++++++ 19 files changed, 954 insertions(+), 2 deletions(-) create mode 100644 apps/app_seeds/assets/catalog/seed_saving.json create mode 100644 apps/app_seeds/lib/data/seed_saving_catalog.dart create mode 100644 apps/app_seeds/lib/domain/seed_saving.dart create mode 100644 apps/app_seeds/test/data/seed_saving_asset_test.dart create mode 100644 apps/app_seeds/test/domain/seed_saving_test.dart create mode 100644 apps/app_seeds/test/ui/seed_saving_section_test.dart diff --git a/apps/app_seeds/assets/catalog/seed_saving.json b/apps/app_seeds/assets/catalog/seed_saving.json new file mode 100644 index 0000000..a8b7568 --- /dev/null +++ b/apps/app_seeds/assets/catalog/seed_saving.json @@ -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" } + } +} diff --git a/apps/app_seeds/lib/data/seed_saving_catalog.dart b/apps/app_seeds/lib/data/seed_saving_catalog.dart new file mode 100644 index 0000000..f27b68a --- /dev/null +++ b/apps/app_seeds/lib/data/seed_saving_catalog.dart @@ -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 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 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); +} diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 104581e..102707d 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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, diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 7a8209c..97658e7 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -9,6 +9,7 @@ import 'package:get_it/get_it.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../data/seed_saving_catalog.dart'; import '../data/species_catalog.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; @@ -141,6 +142,10 @@ Future configureDependencies() async { 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( database, idGen: IdGen(), diff --git a/apps/app_seeds/lib/domain/seed_saving.dart b/apps/app_seeds/lib/domain/seed_saving.dart new file mode 100644 index 0000000..2b86a68 --- /dev/null +++ b/apps/app_seeds/lib/domain/seed_saving.dart @@ -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 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 families, + required Map 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 _families; + final Map _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(List 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 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? ?? 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; + Map section(String key) { + final raw = root[key] as Map? ?? const {}; + return raw.map( + (k, v) => MapEntry(k, parseGuide((v as Map).cast())), + ); + } + + return SeedSavingData(families: section('families'), taxa: section('species')); +} diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 03ded97..6e4c487 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -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": { "title": "Esti mes", "filterChip": "Esti mes", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 31f878c..8b09e7e 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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": { "title": "This month", "filterChip": "This month", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 8201646..2913bc4 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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": { "title": "Este mes", "filterChip": "Este mes", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 4f4154e..4f9fb7d 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -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": { "title": "Este mês", "filterChip": "Este mês", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index a792f80..94b2ec3 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// 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 // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 641be76..67e9a3d 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -39,6 +39,7 @@ class TranslationsAst extends Translations with BaseTranslations? meta}) => TranslationsAst(meta: meta ?? this.$meta); // 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$app$ast app = _Translations$app$ast._(_root); @override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root); @@ -82,6 +83,39 @@ class TranslationsAst extends Translations with BaseTranslations '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 class _Translations$calendar$ast extends Translations$calendar$en { _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 { dynamic _flatMapFunction(String 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.filterChip' => 'Esti mes', 'calendar.nothing' => ({required Object month}) => 'Nada anotao pa ${month}.', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 56a333b..eb45b38 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -40,6 +40,7 @@ class Translations with BaseTranslations { Translations $copyWith({TranslationMetadata? meta}) => Translations(meta: meta ?? this.$meta); // 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$app$en app = Translations$app$en.internal(_root); late final Translations$bootstrap$en bootstrap = Translations$bootstrap$en.internal(_root); @@ -83,6 +84,87 @@ class Translations with BaseTranslations { 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 class Translations$calendar$en { Translations$calendar$en.internal(this._root); @@ -2145,6 +2227,30 @@ class Translations$intro$slides$plantare$en { extension on Translations { dynamic _flatMapFunction(String 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.filterChip' => 'This month', 'calendar.nothing' => ({required Object month}) => 'Nothing noted for ${month}.', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 61ff03f..f79d52d 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -39,6 +39,7 @@ class TranslationsEs extends Translations with BaseTranslations? meta}) => TranslationsEs(meta: meta ?? this.$meta); // 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$app$es app = _Translations$app$es._(_root); @override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root); @@ -82,6 +83,39 @@ class TranslationsEs extends Translations with BaseTranslations '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 class _Translations$calendar$es extends Translations$calendar$en { _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 { dynamic _flatMapFunction(String 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.filterChip' => 'Este mes', 'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index dad90dc..27068d1 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -39,6 +39,7 @@ class TranslationsPt extends Translations with BaseTranslations? meta}) => TranslationsPt(meta: meta ?? this.$meta); // 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$app$pt app = _Translations$app$pt._(_root); @override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root); @@ -82,6 +83,39 @@ class TranslationsPt extends Translations with BaseTranslations '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 class _Translations$calendar$pt extends Translations$calendar$en { _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 { dynamic _flatMapFunction(String 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.filterChip' => 'Este mês', 'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.', diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 3edc414..8472b77 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -6,10 +6,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../data/seed_saving_catalog.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; import '../db/enums.dart'; import '../domain/crop_calendar.dart'; +import '../domain/seed_saving.dart'; import '../domain/seed_viability.dart'; import '../domain/species_reference_links.dart'; import '../i18n/strings.g.dart'; @@ -147,6 +149,14 @@ class _DetailView extends StatelessWidget { _SectionTitle(t.cropCalendar.title), _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 when references.isNotEmpty) ...[ 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 = []; + + 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. String abundanceLabel(Translations t, Abundance a) => switch (a) { Abundance.plentyToShare => t.abundance.plentyToShare, diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 75ee26e..4976f64 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -130,6 +130,8 @@ flutter: # Seed strings for slang live under lib/i18n (not a Flutter asset; compiled). assets: - 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 # founding identities are curated; users bootstrap by adding their own. - assets/trust/referents.json diff --git a/apps/app_seeds/test/data/seed_saving_asset_test.dart b/apps/app_seeds/test/data/seed_saving_asset_test.dart new file mode 100644 index 0000000..4f0bda9 --- /dev/null +++ b/apps/app_seeds/test/data/seed_saving_asset_test.dart @@ -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)); + }); +} diff --git a/apps/app_seeds/test/domain/seed_saving_test.dart b/apps/app_seeds/test/domain/seed_saving_test.dart new file mode 100644 index 0000000..5265464 --- /dev/null +++ b/apps/app_seeds/test/domain/seed_saving_test.dart @@ -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'}); + }); +} diff --git a/apps/app_seeds/test/ui/seed_saving_section_test.dart b/apps/app_seeds/test/ui/seed_saving_section_test.dart new file mode 100644 index 0000000..1a05d4a --- /dev/null +++ b/apps/app_seeds/test/ui/seed_saving_section_test.dart @@ -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 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); + }); +}