feat(species): derived verified reference links (GBIF/Wikipedia/Wikispecies)
Add a read-only "Learn more" section on the variety detail that surfaces verified external references for the linked species, built deterministically from bundled identifiers — no titles bundled, no network, offline-first: - GBIF taxon page from the backbone key - Wikipedia via Wikidata Special:GoToLinkedPage (locale-aware, RTL-safe, falls back to the Wikidata item when a language lacks the article) - Wikispecies from the scientific name Enrich the bundled catalog (species.json v3) with gbif_key + wikidata_qid for all 14 species, cross-checked (each QID carries GBIF ID P846 = the key). Schema already had the columns; seedBundled now persists and backfills them. The grower's own manual links stay but are demoted (progressive disclosure): the section only shows when links exist and "Add link" is a low-emphasis button, since hand-adding a link is the rarer power move. Pure-Dart link builder is unit-tested; repo persistence/backfill and the detail-screen section are covered too.
This commit is contained in:
parent
ba87bf2719
commit
fde0ea6eab
14 changed files with 484 additions and 46 deletions
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ List<SpeciesSeed> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
90
apps/app_seeds/lib/domain/species_reference_links.dart
Normal file
90
apps/app_seeds/lib/domain/species_reference_links.dart
Normal file
|
|
@ -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<ReferenceLink> speciesReferenceLinks({
|
||||
required String scientificName,
|
||||
int? gbifKey,
|
||||
String? wikidataQid,
|
||||
String languageCode = 'en',
|
||||
}) {
|
||||
final links = <ReferenceLink>[];
|
||||
|
||||
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(' ', '_'));
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,12 +117,33 @@ class _DetailView extends StatelessWidget {
|
|||
_SectionTitle(t.cropCalendar.title),
|
||||
_CropCalendarView(detail: detail),
|
||||
],
|
||||
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,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final link in detail.links)
|
||||
InputChip(
|
||||
|
|
@ -132,13 +154,17 @@ class _DetailView extends StatelessWidget {
|
|||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
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<void> _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<ReferenceLink> _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<void> _openUrl(String url) async {
|
||||
final normalized = url.contains('://') ? url : 'https://$url';
|
||||
final uri = Uri.tryParse(normalized);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
84
apps/app_seeds/test/domain/species_reference_links_test.dart
Normal file
84
apps/app_seeds/test/domain/species_reference_links_test.dart
Normal file
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue