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 {}, this.viabilityYears, }); final String scientificName; final String? family; /// language code → list of common names. final Map> commonNames; /// Typical seed longevity in years (bundled reference data); null if unknown. final int? viabilityYears; } /// 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 rows keep their identity, but a bundled /// row still missing [SpeciesSeed.viabilityYears] is backfilled — so the /// reference data reaches installs seeded before the field was bundled. Future seedBundled(List 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) { // Backfill bundled reference data added after the row was seeded. if (existing.viabilityYears == null && seed.viabilityYears != null) { await (_db.update(_db.species) ..where((s) => s.id.equals(existing.id))) .write( SpeciesCompanion( viabilityYears: Value(seed.viabilityYears), ), ); } 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), viabilityYears: Value(seed.viabilityYears), ), ); 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> 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 = >{}; for (final c in commons) { namesBySpecies.putIfAbsent(c.speciesId, () => []).add(( name: c.name, language: c.language, )); } final matches = []; 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; } }