Add a small curated catalog of Iberian horticultural species and let a variety be linked to it from the edit sheet. - assets/catalog/species.json: 14 species with botanical family and ES/EN common names (wikidata_qid/gbif_key deferred to the varilla enrichment). - SpeciesRepository: idempotent seedBundled (is_bundled rows, keyed by scientific name) + search by scientific/common name with a locale-best label. Seeded on startup from DI. - VarietyRepository.linkSpecies: sets species_id and prefills category from the species' family when empty (never overwrites an existing category). VarietyDetail now carries the scientific name. - Edit sheet gains a live species-search field; the detail view shows the scientific name (italic). i18n strings added (ES/EN). Tests: catalog parse, idempotent seeding, search by scientific/common name, linkSpecies prefill semantics, and a widget test for the autocomplete → link → scientific-name-shown flow. Full suite: 32 passing, 0 skipped.
151 lines
4.7 KiB
Dart
151 lines
4.7 KiB
Dart
import 'package:commons_core/commons_core.dart';
|
|
import 'package:drift/drift.dart';
|
|
|
|
import '../db/database.dart';
|
|
|
|
/// One entry from the bundled catalog, before it is stored.
|
|
class SpeciesSeed {
|
|
const SpeciesSeed({
|
|
required this.scientificName,
|
|
this.family,
|
|
this.commonNames = const {},
|
|
});
|
|
|
|
final String scientificName;
|
|
final String? family;
|
|
|
|
/// language code → list of common names.
|
|
final Map<String, List<String>> commonNames;
|
|
}
|
|
|
|
/// A catalog match surfaced to the UI (with a best common name for the locale).
|
|
class SpeciesMatch {
|
|
const SpeciesMatch({
|
|
required this.id,
|
|
required this.scientificName,
|
|
this.family,
|
|
this.commonName,
|
|
});
|
|
|
|
final String id;
|
|
final String scientificName;
|
|
final String? family;
|
|
final String? commonName;
|
|
|
|
/// Label shown in the autocomplete: common name (scientific) when both exist.
|
|
String get displayLabel =>
|
|
commonName == null ? scientificName : '$commonName ($scientificName)';
|
|
}
|
|
|
|
/// Reads and seeds the bundled species catalog. Bundled rows are marked
|
|
/// `is_bundled = true` and are not synced (data-model §2.2).
|
|
class SpeciesRepository {
|
|
SpeciesRepository(this._db, {required this.idGen, this.nodeId = 'bundle'});
|
|
|
|
final AppDatabase _db;
|
|
final IdGen idGen;
|
|
final String nodeId;
|
|
|
|
/// Idempotently inserts bundled species (keyed by scientific name). Safe to
|
|
/// call on every startup; existing entries are left untouched.
|
|
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
|
|
final stamp = Hlc.zero(nodeId).pack();
|
|
await _db.transaction(() async {
|
|
for (final seed in seeds) {
|
|
final existing =
|
|
await (_db.select(_db.species)
|
|
..where((s) => s.scientificName.equals(seed.scientificName)))
|
|
.getSingleOrNull();
|
|
if (existing != null) continue;
|
|
|
|
final speciesId = idGen.newId();
|
|
await _db
|
|
.into(_db.species)
|
|
.insert(
|
|
SpeciesCompanion.insert(
|
|
id: speciesId,
|
|
createdAt: 0,
|
|
updatedAt: stamp,
|
|
lastAuthor: nodeId,
|
|
scientificName: seed.scientificName,
|
|
family: Value(seed.family),
|
|
isBundled: const Value(true),
|
|
),
|
|
);
|
|
|
|
for (final entry in seed.commonNames.entries) {
|
|
for (final name in entry.value) {
|
|
await _db
|
|
.into(_db.speciesCommonNames)
|
|
.insert(
|
|
SpeciesCommonNamesCompanion.insert(
|
|
id: idGen.newId(),
|
|
createdAt: 0,
|
|
updatedAt: stamp,
|
|
lastAuthor: nodeId,
|
|
speciesId: speciesId,
|
|
name: name,
|
|
language: Value(entry.key),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Searches species by scientific name or a common name (case-insensitive
|
|
/// substring). The catalog is small, so it is filtered in Dart. Returns up to
|
|
/// [limit] matches, each with a best common name for [languageCode].
|
|
Future<List<SpeciesMatch>> search(
|
|
String query, {
|
|
String languageCode = 'en',
|
|
int limit = 8,
|
|
}) async {
|
|
final q = query.trim().toLowerCase();
|
|
if (q.isEmpty) return const [];
|
|
|
|
final species = await (_db.select(
|
|
_db.species,
|
|
)..where((s) => s.isDeleted.equals(false))).get();
|
|
final commons = await (_db.select(
|
|
_db.speciesCommonNames,
|
|
)..where((n) => n.isDeleted.equals(false))).get();
|
|
|
|
final namesBySpecies = <String, List<({String name, String? language})>>{};
|
|
for (final c in commons) {
|
|
namesBySpecies.putIfAbsent(c.speciesId, () => []).add((
|
|
name: c.name,
|
|
language: c.language,
|
|
));
|
|
}
|
|
|
|
final matches = <SpeciesMatch>[];
|
|
for (final s in species) {
|
|
final names = namesBySpecies[s.id] ?? const [];
|
|
final matchesScientific = s.scientificName.toLowerCase().contains(q);
|
|
final matchesCommon = names.any((n) => n.name.toLowerCase().contains(q));
|
|
if (matchesScientific || matchesCommon) {
|
|
matches.add(
|
|
SpeciesMatch(
|
|
id: s.id,
|
|
scientificName: s.scientificName,
|
|
family: s.family,
|
|
commonName: _bestName(names, languageCode),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
matches.sort((a, b) => a.scientificName.compareTo(b.scientificName));
|
|
return matches.take(limit).toList();
|
|
}
|
|
|
|
String? _bestName(
|
|
List<({String name, String? language})> names,
|
|
String languageCode,
|
|
) {
|
|
if (names.isEmpty) return null;
|
|
final inLocale = names.where((n) => n.language == languageCode);
|
|
return (inLocale.isNotEmpty ? inLocale.first : names.first).name;
|
|
}
|
|
}
|