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

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