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:
vjrj 2026-07-07 21:21:59 +02:00
parent 7ff4e38a15
commit 4e8b8293e0
22 changed files with 700 additions and 47 deletions

View file

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'data/species_repository.dart';
import 'data/variety_repository.dart';
import 'i18n/strings.g.dart';
import 'state/inventory_cubit.dart';
@ -14,10 +15,11 @@ import 'ui/variety_detail_screen.dart';
/// go_router. The list is `/`; `/variety/:id` is a placeholder detail route
/// (the full item screen is a follow-on story).
class TaneApp extends StatelessWidget {
TaneApp({required this.repository, super.key})
TaneApp({required this.repository, required this.species, super.key})
: _router = _buildRouter(repository);
final VarietyRepository repository;
final SpeciesRepository species;
final GoRouter _router;
static GoRouter _buildRouter(VarietyRepository repository) {
@ -44,8 +46,11 @@ class TaneApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RepositoryProvider.value(
value: repository,
return MultiRepositoryProvider(
providers: [
RepositoryProvider.value(value: repository),
RepositoryProvider.value(value: species),
],
child: MaterialApp.router(
onGenerateTitle: (context) => context.t.app.title,
debugShowCheckedModeBanner: false,

View 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);
}

View 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;
}
}

View file

@ -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({

View file

@ -19,13 +19,22 @@ void useSqlCipher() {
}
DynamicLibrary _openLinuxCipher() {
// The dev package ships `libsqlcipher.so`; the runtime package only the
// versioned `libsqlcipher.so.0`. Accept either.
try {
return DynamicLibrary.open('libsqlcipher.so');
} on ArgumentError {
return DynamicLibrary.open('libsqlcipher.so.0');
// The dev package ships the `libsqlcipher.so` symlink; runtime packages ship
// only a versioned name (`.so.0`, `.so.1`, ). Try them in turn.
const candidates = [
'libsqlcipher.so',
'libsqlcipher.so.1',
'libsqlcipher.so.0',
];
Object? lastError;
for (final name in candidates) {
try {
return DynamicLibrary.open(name);
} on ArgumentError catch (e) {
lastError = e;
}
}
throw StateError('Could not load SQLCipher (tried $candidates): $lastError');
}
/// Opens [file] as an encrypted database using the raw 256-bit [keyHex].

View file

@ -5,6 +5,8 @@ import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../data/species_catalog.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/database.dart';
import '../db/encrypted_executor.dart';
@ -31,9 +33,14 @@ Future<void> configureDependencies() async {
// slice of the root seed. It becomes the user's public key in the social layer.
final nodeId = rootSeedHex.substring(0, 16);
// Seed the bundled species catalog (idempotent) before the UI opens.
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
await speciesRepository.seedBundled(await loadBundledSpecies());
getIt
..registerSingleton<SecureKeyStore>(keyStore)
..registerSingleton<AppDatabase>(database)
..registerSingleton<SpeciesRepository>(speciesRepository)
..registerSingleton<VarietyRepository>(
VarietyRepository(database, idGen: IdGen(), nodeId: nodeId),
);

View file

@ -39,7 +39,9 @@
"title": "Edit seed",
"name": "Name",
"category": "Category",
"notes": "Notes"
"notes": "Notes",
"species": "Species (from catalog)",
"speciesHint": "Search a species…"
},
"addLot": {
"title": "Add lot",

View file

@ -39,7 +39,9 @@
"title": "Editar semilla",
"name": "Nombre",
"category": "Categoría",
"notes": "Notas"
"notes": "Notas",
"species": "Especie (del catálogo)",
"speciesHint": "Buscar una especie…"
},
"addLot": {
"title": "Añadir lote",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 102 (51 per locale)
/// Strings: 106 (53 per locale)
///
/// Built on 2026-07-07 at 13:25 UTC
/// Built on 2026-07-07 at 19:21 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -192,6 +192,12 @@ class Translations$editVariety$en {
/// en: 'Notes'
String get notes => 'Notes';
/// en: 'Species (from catalog)'
String get species => 'Species (from catalog)';
/// en: 'Search a species…'
String get speciesHint => 'Search a species…';
}
// Path: addLot
@ -313,6 +319,8 @@ extension on Translations {
'editVariety.name' => 'Name',
'editVariety.category' => 'Category',
'editVariety.notes' => 'Notes',
'editVariety.species' => 'Species (from catalog)',
'editVariety.speciesHint' => 'Search a species…',
'addLot.title' => 'Add lot',
'addLot.year' => 'Harvest year',
'addLot.quantity' => 'Quantity',

View file

@ -131,6 +131,8 @@ class _Translations$editVariety$es extends Translations$editVariety$en {
@override String get name => 'Nombre';
@override String get category => 'Categoría';
@override String get notes => 'Notas';
@override String get species => 'Especie (del catálogo)';
@override String get speciesHint => 'Buscar una especie…';
}
// Path: addLot
@ -210,6 +212,8 @@ extension on TranslationsEs {
'editVariety.name' => 'Nombre',
'editVariety.category' => 'Categoría',
'editVariety.notes' => 'Notas',
'editVariety.species' => 'Especie (del catálogo)',
'editVariety.speciesHint' => 'Buscar una especie…',
'addLot.title' => 'Añadir lote',
'addLot.year' => 'Año de cosecha',
'addLot.quantity' => 'Cantidad',

View file

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'app.dart';
import 'data/species_repository.dart';
import 'data/variety_repository.dart';
import 'di/injector.dart';
import 'i18n/strings.g.dart';
@ -10,6 +11,11 @@ Future<void> main() async {
LocaleSettings.useDeviceLocaleSync();
await configureDependencies();
runApp(
TranslationProvider(child: TaneApp(repository: getIt<VarietyRepository>())),
TranslationProvider(
child: TaneApp(
repository: getIt<VarietyRepository>(),
species: getIt<SpeciesRepository>(),
),
),
);
}

View file

@ -53,6 +53,9 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
Future<void> addLot({int? year, Quantity? quantity}) =>
_repo.addLot(varietyId: varietyId, harvestYear: year, quantity: quantity);
Future<void> linkSpecies(String speciesId) =>
_repo.linkSpecies(varietyId, speciesId);
Future<void> deleteVariety() async {
await _repo.softDeleteVariety(varietyId);
emit(

View file

@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
@ -53,7 +54,12 @@ class _DetailView extends StatelessWidget {
key: const Key('detail.edit'),
icon: const Icon(Icons.edit_outlined),
tooltip: t.common.edit,
onPressed: () => _showEditSheet(context, cubit, detail),
onPressed: () => _showEditSheet(
context,
cubit,
detail,
context.read<SpeciesRepository>(),
),
),
IconButton(
key: const Key('detail.delete'),
@ -66,6 +72,16 @@ class _DetailView extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (detail.scientificName != null) ...[
Text(
detail.scientificName!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
],
if (detail.photo != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
@ -179,20 +195,95 @@ Future<void> _showEditSheet(
BuildContext context,
VarietyDetailCubit cubit,
VarietyDetail detail,
SpeciesRepository species,
) {
final t = context.t;
final nameController = TextEditingController(text: detail.label);
final categoryController = TextEditingController(text: detail.category ?? '');
final notesController = TextEditingController(text: detail.notes ?? '');
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => Padding(
builder: (_) =>
_EditVarietySheet(cubit: cubit, detail: detail, species: species),
);
}
/// The edit sheet, stateful so it can search the species catalog live and link
/// the picked species on save.
class _EditVarietySheet extends StatefulWidget {
const _EditVarietySheet({
required this.cubit,
required this.detail,
required this.species,
});
final VarietyDetailCubit cubit;
final VarietyDetail detail;
final SpeciesRepository species;
@override
State<_EditVarietySheet> createState() => _EditVarietySheetState();
}
class _EditVarietySheetState extends State<_EditVarietySheet> {
late final TextEditingController _name = TextEditingController(
text: widget.detail.label,
);
late final TextEditingController _category = TextEditingController(
text: widget.detail.category ?? '',
);
late final TextEditingController _notes = TextEditingController(
text: widget.detail.notes ?? '',
);
late final TextEditingController _speciesField = TextEditingController(
text: widget.detail.scientificName ?? '',
);
List<SpeciesMatch> _suggestions = const [];
String? _pickedSpeciesId;
@override
void dispose() {
_name.dispose();
_category.dispose();
_notes.dispose();
_speciesField.dispose();
super.dispose();
}
Future<void> _onSpeciesQuery(String query) async {
final lang = Localizations.localeOf(context).languageCode;
final results = await widget.species.search(query, languageCode: lang);
if (mounted) setState(() => _suggestions = results);
}
void _pick(SpeciesMatch match) {
setState(() {
_pickedSpeciesId = match.id;
_speciesField.text = match.displayLabel;
_suggestions = const [];
});
}
void _save() {
final name = _name.text.trim();
widget.cubit.updateFields(
label: name.isEmpty ? null : name,
category: _nullIfBlank(_category.text),
notes: _nullIfBlank(_notes.text),
);
if (_pickedSpeciesId != null) {
widget.cubit.linkSpecies(_pickedSpeciesId!);
}
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final t = context.t;
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -200,12 +291,12 @@ Future<void> _showEditSheet(
children: [
Text(
t.editVariety.title,
style: Theme.of(sheetContext).textTheme.titleLarge,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('editVariety.name'),
controller: nameController,
controller: _name,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(),
@ -213,7 +304,27 @@ Future<void> _showEditSheet(
),
const SizedBox(height: 12),
TextField(
controller: categoryController,
key: const Key('editVariety.species'),
controller: _speciesField,
decoration: InputDecoration(
labelText: t.editVariety.species,
hintText: t.editVariety.speciesHint,
prefixIcon: const Icon(Icons.eco_outlined),
border: const OutlineInputBorder(),
),
onChanged: _onSpeciesQuery,
),
for (final match in _suggestions)
ListTile(
dense: true,
key: Key('species.option.${match.id}'),
title: Text(match.displayLabel),
subtitle: match.family == null ? null : Text(match.family!),
onTap: () => _pick(match),
),
const SizedBox(height: 12),
TextField(
controller: _category,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(),
@ -221,7 +332,7 @@ Future<void> _showEditSheet(
),
const SizedBox(height: 12),
TextField(
controller: notesController,
controller: _notes,
minLines: 2,
maxLines: 5,
decoration: InputDecoration(
@ -233,29 +344,21 @@ Future<void> _showEditSheet(
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('editVariety.save'),
onPressed: () {
final name = nameController.text.trim();
cubit.updateFields(
label: name.isEmpty ? null : name,
category: _nullIfBlank(categoryController.text),
notes: _nullIfBlank(notesController.text),
);
Navigator.of(sheetContext).pop();
},
onPressed: _save,
child: Text(t.common.save),
),
],
),
],
),
),
);
);
}
}
Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {