tane/apps/app_seeds/lib/data/species_repository.dart

168 lines
5.4 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 {},
this.viabilityYears,
});
final String scientificName;
final String? family;
/// language code → list of common names.
final Map<String, List<String>> 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<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) {
// 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<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;
}
}