feat(block1): bundled species catalog + autocomplete link
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.
This commit is contained in:
parent
7ff4e38a15
commit
4e8b8293e0
22 changed files with 700 additions and 47 deletions
30
apps/app_seeds/lib/data/species_catalog.dart
Normal file
30
apps/app_seeds/lib/data/species_catalog.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import 'species_repository.dart';
|
||||
|
||||
const _catalogAsset = 'assets/catalog/species.json';
|
||||
|
||||
/// Parses the bundled catalog JSON into [SpeciesSeed]s. Kept separate from asset
|
||||
/// loading so it is trivially unit-testable without a Flutter binding.
|
||||
List<SpeciesSeed> parseSpeciesCatalog(String jsonString) {
|
||||
final data = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
final entries = (data['species'] as List).cast<Map<String, dynamic>>();
|
||||
return entries.map((e) {
|
||||
final common = (e['common'] as Map<String, dynamic>? ?? const {}).map(
|
||||
(lang, names) => MapEntry(lang, (names as List).cast<String>()),
|
||||
);
|
||||
return SpeciesSeed(
|
||||
scientificName: e['scientific_name'] as String,
|
||||
family: e['family'] as String?,
|
||||
commonNames: common,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Loads and parses the bundled species catalog asset.
|
||||
Future<List<SpeciesSeed>> loadBundledSpecies() async {
|
||||
final jsonString = await rootBundle.loadString(_catalogAsset);
|
||||
return parseSpeciesCatalog(jsonString);
|
||||
}
|
||||
151
apps/app_seeds/lib/data/species_repository.dart
Normal file
151
apps/app_seeds/lib/data/species_repository.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,8 @@ class VarietyDetail extends Equatable {
|
|||
required this.label,
|
||||
this.category,
|
||||
this.notes,
|
||||
this.speciesId,
|
||||
this.scientificName,
|
||||
this.lots = const [],
|
||||
this.vernacularNames = const [],
|
||||
this.photo,
|
||||
|
|
@ -52,6 +54,8 @@ class VarietyDetail extends Equatable {
|
|||
final String label;
|
||||
final String? category;
|
||||
final String? notes;
|
||||
final String? speciesId;
|
||||
final String? scientificName;
|
||||
final List<VarietyLot> lots;
|
||||
final List<String> vernacularNames;
|
||||
final Uint8List? photo;
|
||||
|
|
@ -62,6 +66,8 @@ class VarietyDetail extends Equatable {
|
|||
label,
|
||||
category,
|
||||
notes,
|
||||
speciesId,
|
||||
scientificName,
|
||||
lots,
|
||||
vernacularNames,
|
||||
photo,
|
||||
|
|
@ -202,6 +208,14 @@ class VarietyRepository {
|
|||
.getSingleOrNull();
|
||||
if (v == null) return null;
|
||||
|
||||
String? scientificName;
|
||||
if (v.speciesId != null) {
|
||||
final species = await (_db.select(
|
||||
_db.species,
|
||||
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
|
||||
scientificName = species?.scientificName;
|
||||
}
|
||||
|
||||
final lots =
|
||||
await (_db.select(_db.lots)
|
||||
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
|
||||
|
|
@ -227,12 +241,41 @@ class VarietyRepository {
|
|||
label: v.label,
|
||||
category: v.category,
|
||||
notes: v.notes,
|
||||
speciesId: v.speciesId,
|
||||
scientificName: scientificName,
|
||||
lots: lots.map(_toLot).toList(),
|
||||
vernacularNames: names.map((n) => n.name).toList(),
|
||||
photo: photos.isEmpty ? null : photos.first.bytes,
|
||||
);
|
||||
}
|
||||
|
||||
/// Links [varietyId] to a catalog [speciesId]. If the variety has no category
|
||||
/// yet, prefill it from the species' botanical family (data-model §6).
|
||||
Future<void> linkSpecies(String varietyId, String speciesId) async {
|
||||
final (_, updated) = _stamp();
|
||||
final species = await (_db.select(
|
||||
_db.species,
|
||||
)..where((s) => s.id.equals(speciesId))).getSingleOrNull();
|
||||
final variety = await (_db.select(
|
||||
_db.varieties,
|
||||
)..where((v) => v.id.equals(varietyId))).getSingleOrNull();
|
||||
|
||||
final categoryIsEmpty =
|
||||
variety?.category == null || variety!.category!.trim().isEmpty;
|
||||
final prefill = categoryIsEmpty ? species?.family : null;
|
||||
|
||||
await (_db.update(
|
||||
_db.varieties,
|
||||
)..where((v) => v.id.equals(varietyId))).write(
|
||||
VarietiesCompanion(
|
||||
speciesId: Value(speciesId),
|
||||
category: prefill == null ? const Value.absent() : Value(prefill),
|
||||
updatedAt: Value(updated),
|
||||
lastAuthor: Value(nodeId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a variety's scalar fields (LWW). Passing null clears [category]
|
||||
/// and [notes]; a null [label] leaves the label unchanged.
|
||||
Future<void> updateVariety({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue