diff --git a/apps/app_seeds/assets/catalog/species.json b/apps/app_seeds/assets/catalog/species.json index cc251f5..16b0bbe 100644 --- a/apps/app_seeds/assets/catalog/species.json +++ b/apps/app_seeds/assets/catalog/species.json @@ -1,90 +1,118 @@ { - "version": 2, - "note": "Curated starter set of common horticultural species — a seed, not a region-locked list. `common` is a locale-keyed map extensible to ANY language (add es/en/fr/de/ar/zh/…); the app resolves names by the user's locale and falls back gracefully, so this must never assume es/en only. Names are enriched later per-locale from the varilla (Wikidata CC0 + GBIF) work, which carries vernacular names in every language. viability_years is typical seed longevity under normal home storage (public-domain agricultural-extension figures); a conservative single value, used to warn about aging lots.", + "version": 3, + "note": "Curated starter set of common horticultural species — a seed, not a region-locked list. `common` is a locale-keyed map extensible to ANY language (add es/en/fr/de/ar/zh/…); the app resolves names by the user's locale and falls back gracefully, so this must never assume es/en only. Names are enriched later per-locale from the varilla (Wikidata CC0 + GBIF) work, which carries vernacular names in every language. viability_years is typical seed longevity under normal home storage (public-domain agricultural-extension figures); a conservative single value, used to warn about aging lots. `gbif_key` (GBIF backbone taxon key) and `wikidata_qid` (Wikidata item) are verified cross-checked identifiers (each QID carries GBIF ID P846 = the key here); the app derives locale-aware reference links (GBIF, Wikipedia via Wikidata GoToLinkedPage, Wikispecies) from them — no titles are bundled.", "species": [ { "scientific_name": "Solanum lycopersicum", "family": "Solanaceae", "common": { "es": ["Tomate"], "en": ["Tomato"] }, - "viability_years": 5 + "viability_years": 5, + "gbif_key": 2930137, + "wikidata_qid": "Q23501" }, { "scientific_name": "Solanum melongena", "family": "Solanaceae", "common": { "es": ["Berenjena"], "en": ["Aubergine", "Eggplant"] }, - "viability_years": 5 + "viability_years": 5, + "gbif_key": 2930617, + "wikidata_qid": "Q7540" }, { "scientific_name": "Capsicum annuum", "family": "Solanaceae", "common": { "es": ["Pimiento", "Guindilla"], "en": ["Pepper", "Chilli"] }, - "viability_years": 3 + "viability_years": 3, + "gbif_key": 2932944, + "wikidata_qid": "Q1380" }, { "scientific_name": "Phaseolus vulgaris", "family": "Fabaceae", "common": { "es": ["Judía", "Alubia", "Habichuela"], "en": ["Common bean"] }, - "viability_years": 3 + "viability_years": 3, + "gbif_key": 5350452, + "wikidata_qid": "Q42339" }, { "scientific_name": "Vicia faba", "family": "Fabaceae", "common": { "es": ["Haba"], "en": ["Broad bean", "Fava bean"] }, - "viability_years": 4 + "viability_years": 4, + "gbif_key": 2974832, + "wikidata_qid": "Q131342" }, { "scientific_name": "Pisum sativum", "family": "Fabaceae", "common": { "es": ["Guisante"], "en": ["Pea"] }, - "viability_years": 3 + "viability_years": 3, + "gbif_key": 5347845, + "wikidata_qid": "Q25237" }, { "scientific_name": "Zea mays", "family": "Poaceae", "common": { "es": ["Maíz"], "en": ["Maize", "Corn"] }, - "viability_years": 2 + "viability_years": 2, + "gbif_key": 5290052, + "wikidata_qid": "Q11575" }, { "scientific_name": "Cucurbita pepo", "family": "Cucurbitaceae", "common": { "es": ["Calabacín", "Calabaza"], "en": ["Courgette", "Zucchini", "Squash"] }, - "viability_years": 5 + "viability_years": 5, + "gbif_key": 2874508, + "wikidata_qid": "Q7229863" }, { "scientific_name": "Cucumis sativus", "family": "Cucurbitaceae", "common": { "es": ["Pepino"], "en": ["Cucumber"] }, - "viability_years": 5 + "viability_years": 5, + "gbif_key": 2874569, + "wikidata_qid": "Q23425" }, { "scientific_name": "Lactuca sativa", "family": "Asteraceae", "common": { "es": ["Lechuga"], "en": ["Lettuce"] }, - "viability_years": 3 + "viability_years": 3, + "gbif_key": 7403263, + "wikidata_qid": "Q83193" }, { "scientific_name": "Beta vulgaris", "family": "Amaranthaceae", "common": { "es": ["Acelga", "Remolacha"], "en": ["Chard", "Beetroot"] }, - "viability_years": 4 + "viability_years": 4, + "gbif_key": 8971274, + "wikidata_qid": "Q165191" }, { "scientific_name": "Allium cepa", "family": "Amaryllidaceae", "common": { "es": ["Cebolla"], "en": ["Onion"] }, - "viability_years": 1 + "viability_years": 1, + "gbif_key": 2857697, + "wikidata_qid": "Q23485" }, { "scientific_name": "Allium sativum", "family": "Amaryllidaceae", "common": { "es": ["Ajo"], "en": ["Garlic"] }, - "viability_years": 1 + "viability_years": 1, + "gbif_key": 2856681, + "wikidata_qid": "Q23400" }, { "scientific_name": "Daucus carota", "family": "Apiaceae", "common": { "es": ["Zanahoria"], "en": ["Carrot"] }, - "viability_years": 3 + "viability_years": 3, + "gbif_key": 3034742, + "wikidata_qid": "Q30056" } ] } diff --git a/apps/app_seeds/lib/data/species_catalog.dart b/apps/app_seeds/lib/data/species_catalog.dart index 3a082de..a2852c2 100644 --- a/apps/app_seeds/lib/data/species_catalog.dart +++ b/apps/app_seeds/lib/data/species_catalog.dart @@ -20,6 +20,8 @@ List parseSpeciesCatalog(String jsonString) { family: e['family'] as String?, commonNames: common, viabilityYears: (e['viability_years'] as num?)?.toInt(), + gbifKey: (e['gbif_key'] as num?)?.toInt(), + wikidataQid: e['wikidata_qid'] as String?, ); }).toList(); } diff --git a/apps/app_seeds/lib/data/species_repository.dart b/apps/app_seeds/lib/data/species_repository.dart index a5bd814..d91502b 100644 --- a/apps/app_seeds/lib/data/species_repository.dart +++ b/apps/app_seeds/lib/data/species_repository.dart @@ -10,6 +10,8 @@ class SpeciesSeed { this.family, this.commonNames = const {}, this.viabilityYears, + this.gbifKey, + this.wikidataQid, }); final String scientificName; @@ -20,6 +22,13 @@ class SpeciesSeed { /// Typical seed longevity in years (bundled reference data); null if unknown. final int? viabilityYears; + + /// GBIF backbone taxon key; null if unknown. Feeds the derived reference link. + final int? gbifKey; + + /// Wikidata item id (e.g. `Q23501`); null if unknown. Feeds the derived + /// Wikipedia (locale-aware) reference link. + final String? wikidataQid; } /// A catalog match surfaced to the UI (with a best common name for the locale). @@ -63,13 +72,28 @@ class SpeciesRepository { ..where((s) => s.scientificName.equals(seed.scientificName))) .getSingleOrNull(); if (existing != null) { - // Backfill bundled reference data added after the row was seeded. - if (existing.viabilityYears == null && seed.viabilityYears != null) { + // Backfill bundled reference data added after the row was seeded, so + // it reaches installs seeded before these fields were bundled. Each + // field is only written when the row still lacks it. + final backfill = SpeciesCompanion( + viabilityYears: + existing.viabilityYears == null && seed.viabilityYears != null + ? Value(seed.viabilityYears) + : const Value.absent(), + gbifKey: existing.gbifKey == null && seed.gbifKey != null + ? Value(seed.gbifKey) + : const Value.absent(), + wikidataQid: + existing.wikidataQid == null && seed.wikidataQid != null + ? Value(seed.wikidataQid) + : const Value.absent(), + ); + if (backfill.viabilityYears.present || + backfill.gbifKey.present || + backfill.wikidataQid.present) { await (_db.update( _db.species, - )..where((s) => s.id.equals(existing.id))).write( - SpeciesCompanion(viabilityYears: Value(seed.viabilityYears)), - ); + )..where((s) => s.id.equals(existing.id))).write(backfill); } continue; } @@ -87,6 +111,8 @@ class SpeciesRepository { family: Value(seed.family), isBundled: const Value(true), viabilityYears: Value(seed.viabilityYears), + gbifKey: Value(seed.gbifKey), + wikidataQid: Value(seed.wikidataQid), ), ); diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 496983b..32224e6 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -301,6 +301,8 @@ class VarietyDetail extends Equatable { this.notes, this.speciesId, this.scientificName, + this.gbifKey, + this.wikidataQid, this.viabilityYears, this.isOrganic = false, this.needsReproduction = false, @@ -322,6 +324,12 @@ class VarietyDetail extends Equatable { final String? speciesId; final String? scientificName; + /// 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. + final int? gbifKey; + final String? wikidataQid; + /// Grower-declared organic ("eco") provenance. final bool isOrganic; @@ -363,6 +371,8 @@ class VarietyDetail extends Equatable { notes, speciesId, scientificName, + gbifKey, + wikidataQid, viabilityYears, isOrganic, needsReproduction, @@ -804,12 +814,16 @@ class VarietyRepository { String? scientificName; int? viabilityYears; + int? gbifKey; + String? wikidataQid; if (v.speciesId != null) { final species = await (_db.select( _db.species, )..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull(); scientificName = species?.scientificName; viabilityYears = species?.viabilityYears; + gbifKey = species?.gbifKey; + wikidataQid = species?.wikidataQid; } final lots = @@ -896,6 +910,8 @@ class VarietyRepository { notes: v.notes, speciesId: v.speciesId, scientificName: scientificName, + gbifKey: gbifKey, + wikidataQid: wikidataQid, viabilityYears: viabilityYears, isOrganic: v.isOrganic, needsReproduction: v.needsReproduction, diff --git a/apps/app_seeds/lib/domain/species_reference_links.dart b/apps/app_seeds/lib/domain/species_reference_links.dart new file mode 100644 index 0000000..94fef62 --- /dev/null +++ b/apps/app_seeds/lib/domain/species_reference_links.dart @@ -0,0 +1,90 @@ +/// A verified external reference for a species, built deterministically from the +/// bundled catalog's identifiers — never hand-entered. These are read-only +/// "learn more" pointers, distinct from the links a grower adds by hand. +enum ReferenceSource { gbif, wikipedia, wikispecies } + +/// One reference pointer: which source it is (for the label/icon) and the URL. +class ReferenceLink { + const ReferenceLink({required this.source, required this.url}); + + final ReferenceSource source; + final String url; + + @override + bool operator ==(Object other) => + other is ReferenceLink && other.source == source && other.url == url; + + @override + int get hashCode => Object.hash(source, url); + + @override + String toString() => 'ReferenceLink($source, $url)'; +} + +/// Builds the verified reference links for a species from its bundled +/// identifiers. Deterministic and offline — no titles are bundled and no +/// network call is made; each URL is derived from a stable identifier: +/// +/// * **GBIF** — the taxon page for the backbone [gbifKey]. +/// * **Wikipedia** — locale-aware via Wikidata's `Special:GoToLinkedPage` +/// redirect, so we need only the [wikidataQid] and the user's [languageCode]: +/// the redirect resolves to the article in that language, falling back to the +/// Wikidata item when the language has no article. Works under RTL locales. +/// * **Wikispecies** — the canonical article keyed by [scientificName]. +/// +/// A source is only included when its required identifier is present, so a +/// species missing (say) a GBIF key simply yields fewer links. +List speciesReferenceLinks({ + required String scientificName, + int? gbifKey, + String? wikidataQid, + String languageCode = 'en', +}) { + final links = []; + + if (gbifKey != null) { + links.add( + ReferenceLink( + source: ReferenceSource.gbif, + url: 'https://www.gbif.org/species/$gbifKey', + ), + ); + } + + final qid = wikidataQid?.trim(); + if (qid != null && qid.isNotEmpty) { + final lang = _wikiLang(languageCode); + links.add( + ReferenceLink( + source: ReferenceSource.wikipedia, + url: + 'https://www.wikidata.org/wiki/Special:GoToLinkedPage/${lang}wiki/$qid', + ), + ); + } + + final title = scientificName.trim(); + if (title.isNotEmpty) { + links.add( + ReferenceLink( + source: ReferenceSource.wikispecies, + url: 'https://species.wikimedia.org/wiki/${_wikiTitle(title)}', + ), + ); + } + + return links; +} + +/// The bare Wikipedia language subtag: drops any region (`pt_BR` → `pt`) and +/// falls back to English for an empty/blank locale. +String _wikiLang(String languageCode) { + final base = languageCode.split(RegExp('[-_]')).first.trim().toLowerCase(); + return base.isEmpty ? 'en' : base; +} + +/// A MediaWiki page title: spaces become underscores, then non-ASCII is +/// percent-encoded (scientific names are Latin, but stay safe for hybrids and +/// diacritics). `_` is left intact by [Uri.encodeFull]. +String _wikiTitle(String scientificName) => + Uri.encodeFull(scientificName.replaceAll(' ', '_')); diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index ba71a1d..d304650 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -148,6 +148,10 @@ "addLink": "Add link", "linkUrl": "URL", "linkTitle": "Title (optional)", + "reference": "Learn more", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", "notes": "Notes", "addLot": "Add lot", "editLot": "Edit lot", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 8169033..059b2ab 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -148,6 +148,10 @@ "addLink": "Añadir enlace", "linkUrl": "URL", "linkTitle": "Título (opcional)", + "reference": "Saber más", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", "notes": "Notas", "addLot": "Añadir lote", "editLot": "Editar lote", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 0ee27f7..3cc245d 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: 2 -/// Strings: 554 (277 per locale) +/// Strings: 562 (281 per locale) /// -/// Built on 2026-07-09 at 20:14 UTC +/// Built on 2026-07-09 at 20:38 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 58b893e..578b2aa 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -495,6 +495,18 @@ class Translations$detail$en { /// en: 'Title (optional)' String get linkTitle => 'Title (optional)'; + /// en: 'Learn more' + String get reference => 'Learn more'; + + /// en: 'GBIF' + String get refGbif => 'GBIF'; + + /// en: 'Wikipedia' + String get refWikipedia => 'Wikipedia'; + + /// en: 'Wikispecies' + String get refWikispecies => 'Wikispecies'; + /// en: 'Notes' String get notes => 'Notes'; @@ -1564,6 +1576,10 @@ extension on Translations { 'detail.addLink' => 'Add link', 'detail.linkUrl' => 'URL', 'detail.linkTitle' => 'Title (optional)', + 'detail.reference' => 'Learn more', + 'detail.refGbif' => 'GBIF', + 'detail.refWikipedia' => 'Wikipedia', + 'detail.refWikispecies' => 'Wikispecies', 'detail.notes' => 'Notes', 'detail.addLot' => 'Add lot', 'detail.editLot' => 'Edit lot', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 96283b7..bc83071 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -288,6 +288,10 @@ class _Translations$detail$es extends Translations$detail$en { @override String get addLink => 'Añadir enlace'; @override String get linkUrl => 'URL'; @override String get linkTitle => 'Título (opcional)'; + @override String get reference => 'Saber más'; + @override String get refGbif => 'GBIF'; + @override String get refWikipedia => 'Wikipedia'; + @override String get refWikispecies => 'Wikispecies'; @override String get notes => 'Notas'; @override String get addLot => 'Añadir lote'; @override String get editLot => 'Editar lote'; @@ -1030,6 +1034,10 @@ extension on TranslationsEs { 'detail.addLink' => 'Añadir enlace', 'detail.linkUrl' => 'URL', 'detail.linkTitle' => 'Título (opcional)', + 'detail.reference' => 'Saber más', + 'detail.refGbif' => 'GBIF', + 'detail.refWikipedia' => 'Wikipedia', + 'detail.refWikispecies' => 'Wikispecies', 'detail.notes' => 'Notas', 'detail.addLot' => 'Añadir lote', 'detail.editLot' => 'Editar lote', diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index bba1e62..2debd50 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -10,6 +10,7 @@ import '../data/variety_repository.dart'; import '../db/enums.dart'; import '../domain/crop_calendar.dart'; import '../domain/seed_viability.dart'; +import '../domain/species_reference_links.dart'; import '../i18n/strings.g.dart'; import '../state/variety_detail_cubit.dart'; import 'harvest_date_picker.dart'; @@ -116,29 +117,54 @@ class _DetailView extends StatelessWidget { _SectionTitle(t.cropCalendar.title), _CropCalendarView(detail: detail), ], - const SizedBox(height: 16), - _SectionTitle(t.detail.links), - Wrap( - spacing: 8, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - for (final link in detail.links) - InputChip( - key: Key('link.${link.id}'), - avatar: const Icon(Icons.link, size: 18), - label: Text(link.display), - deleteIcon: const Icon(Icons.close, size: 18), - onDeleted: () => cubit.removeExternalLink(link.id), - onPressed: () => _openUrl(link.url), - ), - ActionChip( - key: const Key('link.add'), - avatar: const Icon(Icons.add, size: 18), - label: Text(t.detail.addLink), - onPressed: () => _showAddLinkDialog(context, cubit), - ), - ], + if (_referenceLinks(context, detail) case final references + when references.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionTitle(t.detail.reference), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final ref in references) + ActionChip( + key: Key('ref.${ref.source.name}'), + avatar: const Icon(Icons.open_in_new, size: 18), + label: Text(_referenceLabel(t, ref.source)), + onPressed: () => _openUrl(ref.url), + ), + ], + ), + ], + // The grower's own links: a rarer power move, so kept subtle — the + // section only appears once there are links, and adding one lives + // behind a low-emphasis button rather than a prominent chip. + if (detail.links.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionTitle(t.detail.links), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final link in detail.links) + InputChip( + key: Key('link.${link.id}'), + avatar: const Icon(Icons.link, size: 18), + label: Text(link.display), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => cubit.removeExternalLink(link.id), + onPressed: () => _openUrl(link.url), + ), + ], + ), + ], + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('link.add'), + onPressed: () => _showAddLinkDialog(context, cubit), + icon: const Icon(Icons.add_link, size: 18), + label: Text(t.detail.addLink), + ), ), const SizedBox(height: 16), Row( @@ -1497,6 +1523,30 @@ Future _showAddNameDialog( ); } +/// Verified reference links for the variety's linked species, localized to the +/// UI language (drives the locale-aware Wikipedia link). Empty when no species +/// is linked or the catalog entry carries no identifiers. +List _referenceLinks( + BuildContext context, + VarietyDetail detail, +) { + final name = detail.scientificName; + if (name == null) return const []; + return speciesReferenceLinks( + scientificName: name, + gbifKey: detail.gbifKey, + wikidataQid: detail.wikidataQid, + languageCode: Localizations.localeOf(context).languageCode, + ); +} + +String _referenceLabel(Translations t, ReferenceSource source) => + switch (source) { + ReferenceSource.gbif => t.detail.refGbif, + ReferenceSource.wikipedia => t.detail.refWikipedia, + ReferenceSource.wikispecies => t.detail.refWikispecies, + }; + Future _openUrl(String url) async { final normalized = url.contains('://') ? url : 'https://$url'; final uri = Uri.tryParse(normalized); diff --git a/apps/app_seeds/test/data/species_repository_test.dart b/apps/app_seeds/test/data/species_repository_test.dart index 6bbb705..9ec69cf 100644 --- a/apps/app_seeds/test/data/species_repository_test.dart +++ b/apps/app_seeds/test/data/species_repository_test.dart @@ -1,4 +1,5 @@ import 'package:commons_core/commons_core.dart'; +import 'package:drift/drift.dart' show Value; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/species_catalog.dart'; import 'package:tane/data/species_repository.dart'; @@ -49,6 +50,65 @@ void main() { }, ); + test('parseSpeciesCatalog reads gbif and wikidata identifiers', () { + const json = ''' + {"version":3,"species":[ + {"scientific_name":"Zea mays","gbif_key":5290052,"wikidata_qid":"Q11575"}, + {"scientific_name":"Unknown sp."} + ]}'''; + final seeds = parseSpeciesCatalog(json); + expect(seeds.first.gbifKey, 5290052); + expect(seeds.first.wikidataQid, 'Q11575'); + // Missing identifiers stay null, they are optional. + expect(seeds.last.gbifKey, isNull); + expect(seeds.last.wikidataQid, isNull); + }); + + test('seedBundled persists gbif and wikidata identifiers', () async { + await repo.seedBundled(const [ + SpeciesSeed( + scientificName: 'Zea mays', + gbifKey: 5290052, + wikidataQid: 'Q11575', + ), + ]); + final row = await db.select(db.species).getSingle(); + expect(row.gbifKey, 5290052); + expect(row.wikidataQid, 'Q11575'); + }); + + test( + 'seedBundled backfills identifiers onto rows seeded before them', + () async { + // A pre-existing bundled row that predates the identifier fields. + await db + .into(db.species) + .insert( + SpeciesCompanion.insert( + id: 'legacy', + createdAt: 0, + updatedAt: '0', + lastAuthor: 'bundle', + scientificName: 'Zea mays', + isBundled: const Value(true), + ), + ); + + await repo.seedBundled(const [ + SpeciesSeed( + scientificName: 'Zea mays', + gbifKey: 5290052, + wikidataQid: 'Q11575', + ), + ]); + + final rows = await db.select(db.species).get(); + expect(rows.single.id, 'legacy'); // same row, not a duplicate + expect(rows.single.gbifKey, 5290052); + expect(rows.single.wikidataQid, 'Q11575'); + }, + ); + test('seedBundled inserts species and is idempotent', () async { await repo.seedBundled(_seeds); await repo.seedBundled(_seeds); // second run must not duplicate diff --git a/apps/app_seeds/test/domain/species_reference_links_test.dart b/apps/app_seeds/test/domain/species_reference_links_test.dart new file mode 100644 index 0000000..f0bdc65 --- /dev/null +++ b/apps/app_seeds/test/domain/species_reference_links_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/species_reference_links.dart'; + +void main() { + test('all identifiers present yields gbif, wikipedia and wikispecies', () { + final links = speciesReferenceLinks( + scientificName: 'Solanum lycopersicum', + gbifKey: 2930137, + wikidataQid: 'Q23501', + languageCode: 'es', + ); + expect(links.map((l) => l.source), [ + ReferenceSource.gbif, + ReferenceSource.wikipedia, + ReferenceSource.wikispecies, + ]); + expect(links[0].url, 'https://www.gbif.org/species/2930137'); + expect( + links[1].url, + 'https://www.wikidata.org/wiki/Special:GoToLinkedPage/eswiki/Q23501', + ); + expect( + links[2].url, + 'https://species.wikimedia.org/wiki/Solanum_lycopersicum', + ); + }); + + test('wikipedia link is locale-aware', () { + String wiki(String lang) => speciesReferenceLinks( + scientificName: 'Zea mays', + wikidataQid: 'Q11575', + languageCode: lang, + ).firstWhere((l) => l.source == ReferenceSource.wikipedia).url; + + expect(wiki('en'), contains('/enwiki/Q11575')); + expect(wiki('ar'), contains('/arwiki/Q11575')); + }); + + test('locale region and case are normalised to the bare wiki subtag', () { + String wiki(String lang) => speciesReferenceLinks( + scientificName: 'Zea mays', + wikidataQid: 'Q11575', + languageCode: lang, + ).firstWhere((l) => l.source == ReferenceSource.wikipedia).url; + + expect(wiki('pt_BR'), contains('/ptwiki/')); + expect(wiki('zh-Hans'), contains('/zhwiki/')); + expect(wiki('EN'), contains('/enwiki/')); + expect(wiki(''), contains('/enwiki/')); // empty locale → English + }); + + test('a missing identifier simply drops that source', () { + final onlyGbif = speciesReferenceLinks( + scientificName: '', // no wikispecies without a name + gbifKey: 42, + ); + expect(onlyGbif.map((l) => l.source), [ReferenceSource.gbif]); + + final blankQid = speciesReferenceLinks( + scientificName: 'Daucus carota', + wikidataQid: ' ', + ); + expect( + blankQid.map((l) => l.source), + isNot(contains(ReferenceSource.wikipedia)), + ); + expect(blankQid.map((l) => l.source), [ReferenceSource.wikispecies]); + }); + + test('nothing identifiable yields no links', () { + expect(speciesReferenceLinks(scientificName: ' '), isEmpty); + }); + + test('scientific names with spaces and diacritics are URL-safe', () { + final link = speciesReferenceLinks( + scientificName: 'Allium × proliferum', + ).single; + expect(link.source, ReferenceSource.wikispecies); + expect( + link.url, + 'https://species.wikimedia.org/wiki/Allium_%C3%97_proliferum', + ); + }); +} diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index d1bd91b..0501b9c 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -373,6 +373,56 @@ void main() { await disposeTree(tester); }); + testWidgets( + 'a linked species with identifiers shows verified reference links', + (tester) async { + final repo = newTestRepository(db); + final species = newTestSpeciesRepository(db); + await species.seedBundled(const [ + SpeciesSeed( + scientificName: 'Solanum lycopersicum', + gbifKey: 2930137, + wikidataQid: 'Q23501', + ), + ]); + final match = (await species.search('Solanum')).single; + final id = await repo.addQuickVariety(label: 'Tomato'); + await repo.linkSpecies(id, match.id); + + await tester.pumpWidget( + wrapDetail(repository: repo, varietyId: id, species: species), + ); + await tester.pumpAndSettle(); + + // The "Learn more" section renders one chip per available source. + expect(find.text('Learn more'), findsOneWidget); + expect(find.byKey(const Key('ref.gbif')), findsOneWidget); + expect(find.byKey(const Key('ref.wikipedia')), findsOneWidget); + expect(find.byKey(const Key('ref.wikispecies')), findsOneWidget); + await disposeTree(tester); + }, + ); + + testWidgets('a variety with no species shows no reference section', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Mystery seed'); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Learn more'), findsNothing); + expect(find.byKey(const Key('ref.gbif')), findsNothing); + await disposeTree(tester); + }); + testWidgets('plant lots show the Plants chip and hide germination', ( tester, ) async {