feat(seed-saving): source attribution + advisory note

Record where the guidance comes from (copyright/attribution) and make
clear it is general, not local gospel — kept light:
- seed_saving.json gains a top-level sources list (Seed to Seed / Seed
  Savers Exchange / Organic Seed Alliance); parsed into
  SeedSavingData.sources, exposed via SeedSavingCatalog.sources.
- The detail section shows a small muted footer: an italic 'advisory —
  adapt to your climate' line plus a 'Source: …' credit.
- i18n seedSaving.advisory + sourcePrefix (en/es/pt/ast).
- Tests: asset carries sources; the section renders advisory + credit.
This commit is contained in:
vjrj 2026-07-11 07:52:28 +02:00
parent d897d3934c
commit 89addb1ed7
15 changed files with 93 additions and 8 deletions

View file

@ -1,6 +1,11 @@
{ {
"version": 1, "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.", "note": "Curated seed-saving guidance. Values are widely-published horticultural FACTS (isolation distances, pollination habit, population sizes) — not copied text — drawn from the sources below and cross-checked for consistency. 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. Advisory only — adapt to local climate. A starter set, not a ceiling.",
"sources": [
{ "title": "Seed to Seed", "author": "Suzanne Ashworth", "publisher": "Seed Savers Exchange" },
{ "title": "Seed Savers Exchange", "url": "https://seedsavers.org" },
{ "title": "Organic Seed Alliance", "url": "https://seedalliance.org" }
],
"families": { "families": {
"Solanaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 15, "minPlants": 1, "recommendedPlants": 6, "processing": "wet", "difficulty": "easy" }, "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" }, "Fabaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 20, "minPlants": 1, "recommendedPlants": 10, "processing": "dry", "difficulty": "easy" },

View file

@ -29,4 +29,7 @@ class SeedSavingCatalog {
/// matches. See [SeedSavingData.guideFor]. /// matches. See [SeedSavingData.guideFor].
static SeedSavingGuide? guideFor({String? scientificName, String? family}) => static SeedSavingGuide? guideFor({String? scientificName, String? family}) =>
data?.guideFor(scientificName: scientificName, family: family); data?.guideFor(scientificName: scientificName, family: family);
/// The attributions for the guidance (empty before load).
static List<SeedSavingSource> get sources => data?.sources ?? const [];
} }

View file

@ -86,12 +86,21 @@ class SeedSavingGuide {
} }
} }
/// An attribution for the bundled guidance (the facts are drawn from these).
class SeedSavingSource {
const SeedSavingSource({required this.title, this.author, this.url});
final String title;
final String? author;
final String? url;
}
/// The whole bundled dataset: family defaults plus per-taxon overrides (keyed by /// The whole bundled dataset: family defaults plus per-taxon overrides (keyed by
/// scientific name OR bare genus). Lookups are case-insensitive. /// scientific name OR bare genus). Lookups are case-insensitive.
class SeedSavingData { class SeedSavingData {
SeedSavingData({ SeedSavingData({
required Map<String, SeedSavingGuide> families, required Map<String, SeedSavingGuide> families,
required Map<String, SeedSavingGuide> taxa, required Map<String, SeedSavingGuide> taxa,
this.sources = const [],
}) : _families = { }) : _families = {
for (final e in families.entries) e.key.toLowerCase().trim(): e.value, for (final e in families.entries) e.key.toLowerCase().trim(): e.value,
}, },
@ -102,6 +111,9 @@ class SeedSavingData {
final Map<String, SeedSavingGuide> _families; final Map<String, SeedSavingGuide> _families;
final Map<String, SeedSavingGuide> _taxa; final Map<String, SeedSavingGuide> _taxa;
/// Where the guidance comes from shown as a small credit under the section.
final List<SeedSavingSource> sources;
/// The best guide for a crop: the family default, overlaid by a per-species /// 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. /// (or per-genus) entry when one exists. Returns null when nothing matches.
SeedSavingGuide? guideFor({String? scientificName, String? family}) { SeedSavingGuide? guideFor({String? scientificName, String? family}) {
@ -151,5 +163,18 @@ SeedSavingData parseSeedSaving(String jsonString) {
); );
} }
return SeedSavingData(families: section('families'), taxa: section('species')); final sources = [
for (final s in (root['sources'] as List? ?? const []).cast<Map>())
SeedSavingSource(
title: s['title'] as String,
author: s['author'] as String?,
url: s['url'] as String?,
),
];
return SeedSavingData(
families: section('families'),
taxa: section('species'),
sources: sources,
);
} }

View file

@ -23,7 +23,9 @@
"difficulty": "Dificultá", "difficulty": "Dificultá",
"diffEasy": "Fácil", "diffEasy": "Fácil",
"diffMedium": "Media", "diffMedium": "Media",
"diffHard": "Difícil" "diffHard": "Difícil",
"advisory": "Orientativu — afaílu al to clima y variedá.",
"sourcePrefix": "Fonte"
}, },
"calendar": { "calendar": {
"title": "Esti mes", "title": "Esti mes",

View file

@ -23,7 +23,9 @@
"difficulty": "Difficulty", "difficulty": "Difficulty",
"diffEasy": "Easy", "diffEasy": "Easy",
"diffMedium": "Medium", "diffMedium": "Medium",
"diffHard": "Hard" "diffHard": "Hard",
"advisory": "General guidance — adapt it to your climate and variety.",
"sourcePrefix": "Source"
}, },
"calendar": { "calendar": {
"title": "This month", "title": "This month",

View file

@ -23,7 +23,9 @@
"difficulty": "Dificultad", "difficulty": "Dificultad",
"diffEasy": "Fácil", "diffEasy": "Fácil",
"diffMedium": "Media", "diffMedium": "Media",
"diffHard": "Difícil" "diffHard": "Difícil",
"advisory": "Orientativo — adáptalo a tu clima y variedad.",
"sourcePrefix": "Fuente"
}, },
"calendar": { "calendar": {
"title": "Este mes", "title": "Este mes",

View file

@ -23,7 +23,9 @@
"difficulty": "Dificuldade", "difficulty": "Dificuldade",
"diffEasy": "Fácil", "diffEasy": "Fácil",
"diffMedium": "Média", "diffMedium": "Média",
"diffHard": "Difícil" "diffHard": "Difícil",
"advisory": "Orientativo — adapte-o ao seu clima e variedade.",
"sourcePrefix": "Fonte"
}, },
"calendar": { "calendar": {
"title": "Este mês", "title": "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: 1960 (490 per locale) /// Strings: 1968 (492 per locale)
/// ///
/// Built on 2026-07-11 at 05:18 UTC /// Built on 2026-07-11 at 05:50 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -114,6 +114,8 @@ class _Translations$seedSaving$ast extends Translations$seedSaving$en {
@override String get diffEasy => 'Fácil'; @override String get diffEasy => 'Fácil';
@override String get diffMedium => 'Media'; @override String get diffMedium => 'Media';
@override String get diffHard => 'Difícil'; @override String get diffHard => 'Difícil';
@override String get advisory => 'Orientativu — afaílu al to clima y variedá.';
@override String get sourcePrefix => 'Fonte';
} }
// Path: calendar // Path: calendar
@ -1284,6 +1286,8 @@ extension on TranslationsAst {
'seedSaving.diffEasy' => 'Fácil', 'seedSaving.diffEasy' => 'Fácil',
'seedSaving.diffMedium' => 'Media', 'seedSaving.diffMedium' => 'Media',
'seedSaving.diffHard' => 'Difícil', 'seedSaving.diffHard' => 'Difícil',
'seedSaving.advisory' => 'Orientativu — afaílu al to clima y variedá.',
'seedSaving.sourcePrefix' => 'Fonte',
'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

@ -163,6 +163,12 @@ class Translations$seedSaving$en {
/// en: 'Hard' /// en: 'Hard'
String get diffHard => 'Hard'; String get diffHard => 'Hard';
/// en: 'General guidance — adapt it to your climate and variety.'
String get advisory => 'General guidance — adapt it to your climate and variety.';
/// en: 'Source'
String get sourcePrefix => 'Source';
} }
// Path: calendar // Path: calendar
@ -2251,6 +2257,8 @@ extension on Translations {
'seedSaving.diffEasy' => 'Easy', 'seedSaving.diffEasy' => 'Easy',
'seedSaving.diffMedium' => 'Medium', 'seedSaving.diffMedium' => 'Medium',
'seedSaving.diffHard' => 'Hard', 'seedSaving.diffHard' => 'Hard',
'seedSaving.advisory' => 'General guidance — adapt it to your climate and variety.',
'seedSaving.sourcePrefix' => 'Source',
'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

@ -114,6 +114,8 @@ class _Translations$seedSaving$es extends Translations$seedSaving$en {
@override String get diffEasy => 'Fácil'; @override String get diffEasy => 'Fácil';
@override String get diffMedium => 'Media'; @override String get diffMedium => 'Media';
@override String get diffHard => 'Difícil'; @override String get diffHard => 'Difícil';
@override String get advisory => 'Orientativo — adáptalo a tu clima y variedad.';
@override String get sourcePrefix => 'Fuente';
} }
// Path: calendar // Path: calendar
@ -1286,6 +1288,8 @@ extension on TranslationsEs {
'seedSaving.diffEasy' => 'Fácil', 'seedSaving.diffEasy' => 'Fácil',
'seedSaving.diffMedium' => 'Media', 'seedSaving.diffMedium' => 'Media',
'seedSaving.diffHard' => 'Difícil', 'seedSaving.diffHard' => 'Difícil',
'seedSaving.advisory' => 'Orientativo — adáptalo a tu clima y variedad.',
'seedSaving.sourcePrefix' => 'Fuente',
'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

@ -114,6 +114,8 @@ class _Translations$seedSaving$pt extends Translations$seedSaving$en {
@override String get diffEasy => 'Fácil'; @override String get diffEasy => 'Fácil';
@override String get diffMedium => 'Média'; @override String get diffMedium => 'Média';
@override String get diffHard => 'Difícil'; @override String get diffHard => 'Difícil';
@override String get advisory => 'Orientativo — adapte-o ao seu clima e variedade.';
@override String get sourcePrefix => 'Fonte';
} }
// Path: calendar // Path: calendar
@ -1283,6 +1285,8 @@ extension on TranslationsPt {
'seedSaving.diffEasy' => 'Fácil', 'seedSaving.diffEasy' => 'Fácil',
'seedSaving.diffMedium' => 'Média', 'seedSaving.diffMedium' => 'Média',
'seedSaving.diffHard' => 'Difícil', 'seedSaving.diffHard' => 'Difícil',
'seedSaving.advisory' => 'Orientativo — adapte-o ao seu clima e variedade.',
'seedSaving.sourcePrefix' => 'Fonte',
'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

@ -1097,6 +1097,21 @@ class _SeedSavingView extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text(note, style: const TextStyle(color: seedMuted, height: 1.35)), Text(note, style: const TextStyle(color: seedMuted, height: 1.35)),
], ],
// Advisory + a light source credit the figures are general, not local
// gospel, and drawn from the seed-saving literature.
const SizedBox(height: 10),
Text(t.seedSaving.advisory,
style: const TextStyle(
color: seedMuted, fontSize: 12, fontStyle: FontStyle.italic)),
if (SeedSavingCatalog.sources case final sources when sources.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'${t.seedSaving.sourcePrefix}: '
'${sources.map((s) => s.title).join(' · ')}',
style: const TextStyle(color: seedMuted, fontSize: 11),
),
),
], ],
); );
} }

View file

@ -48,4 +48,9 @@ void main() {
expect(g?.vector, PollinationVector.wind); expect(g?.vector, PollinationVector.wind);
expect(g?.recommendedPlants, greaterThanOrEqualTo(50)); expect(g?.recommendedPlants, greaterThanOrEqualTo(50));
}); });
test('carries source attributions', () {
expect(data.sources, isNotEmpty);
expect(data.sources.map((s) => s.title), contains('Seed to Seed'));
});
} }

View file

@ -13,6 +13,7 @@ void main() {
db = newTestDatabase(); db = newTestDatabase();
SeedSavingCatalog.data = parseSeedSaving(''' SeedSavingCatalog.data = parseSeedSaving('''
{ {
"sources": [ { "title": "Seed to Seed" } ],
"families": { "families": {
"Solanaceae": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 6, "processing": "wet", "difficulty": "easy" } "Solanaceae": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 6, "processing": "wet", "difficulty": "easy" }
}, },
@ -39,6 +40,9 @@ void main() {
// The pollination line is a Text.rich (label + value), so match the run. // The pollination line is a Text.rich (label + value), so match the run.
expect(find.textContaining('Self-pollinating'), findsOneWidget); expect(find.textContaining('Self-pollinating'), findsOneWidget);
expect(find.text('Easy'), findsOneWidget); // the difficulty pill expect(find.text('Easy'), findsOneWidget); // the difficulty pill
// Advisory note + a light source credit.
expect(find.textContaining('adapt it to your climate'), findsOneWidget);
expect(find.textContaining('Seed to Seed'), findsOneWidget);
await disposeTree(tester); await disposeTree(tester);
}); });