feat(seed-saving): per-crop 'how to save this seed' guidance

A bundled, curated knowledge layer — the differentiator of a serious
seed app. For a variety's crop it shows how the plant reproduces and what
it takes to keep the variety true: life cycle, pollination (self/cross +
insect/wind), isolation distance, how many plants to save from, dry/wet
processing, a difficulty pill, and a short tip — all in human words.

- assets/catalog/seed_saving.json: curated public-domain data (Seed to
  Seed / Seed Savers), keyed by botanical FAMILY (defaults) with
  per-species/genus OVERRIDES that win — so e.g. Vicia faba is correctly
  insect-cross-pollinated despite the selfing Fabaceae default, and maize
  is wind-pollinated with a large population. Notes are locale-keyed
  (es/en), extensible to any language — a starter set, not a ceiling.
- domain/seed_saving.dart (pure): enums + SeedSavingGuide (merge, note
  fallback) + SeedSavingData.guideFor (species → genus → family).
- data/seed_saving_catalog.dart: loads the asset once into an in-memory
  singleton (no DB table, no migration); injector loads it at startup.
- VarietyDetail gains  (from the linked species — the reliable
  key, vs the editable category); detail screen shows a _SeedSavingView
  when a guide resolves.
- i18n seedSaving block (labels + enum values + params) in en/es/pt/ast.
- Tests: domain lookup/merge, the real asset's key corrections, and the
  detail section shows/hides. 314 suite tests green; analyze clean.
This commit is contained in:
vjrj 2026-07-11 07:17:12 +02:00
parent 4ebfdca2fd
commit 4bb94db3e0
19 changed files with 954 additions and 2 deletions

View 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 23 días, lava y seca.", "en": "Almost always self-pollinates — easy to keep pure. Ferment the seed in its pulp for 23 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" }
}
}

View file

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

View file

@ -436,6 +436,7 @@ class VarietyDetail extends Equatable {
this.notes, this.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,

View file

@ -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(),

View 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'));
}

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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}.',

View file

@ -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}.',

View file

@ -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}.',

View file

@ -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}.',

View file

@ -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,

View file

@ -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

View 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));
});
}

View 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'});
});
}

View 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);
});
}