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:
vjrj 2026-07-09 22:44:12 +02:00
parent ba87bf2719
commit fde0ea6eab
14 changed files with 484 additions and 46 deletions

View file

@ -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();
}

View file

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

View file

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

View 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(' ', '_'));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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