diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index beb193b..6aa6fa6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,9 +19,10 @@ cache: - .pub-cache/ before_script: - # SQLCipher runtime lib so the "no plaintext at rest" security test actually - # runs (it skips where the library is absent). - - apt-get update -qq && apt-get install -y -qq libsqlcipher0 + # SQLCipher so the "no plaintext at rest" security test actually runs (it + # skips where the library is absent). The -dev package ships the unversioned + # libsqlcipher.so symlink, which package:sqlite3 loads reliably. + - apt-get update -qq && apt-get install -y -qq libsqlcipher-dev - flutter --version - flutter pub get diff --git a/apps/app_seeds/assets/catalog/species.json b/apps/app_seeds/assets/catalog/species.json new file mode 100644 index 0000000..2463e65 --- /dev/null +++ b/apps/app_seeds/assets/catalog/species.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "note": "Curated starter set of Iberian horticultural species. Family and common names are stable; wikidata_qid/gbif_key are intentionally omitted here and enriched later from the varilla (Wikidata CC0 + GBIF) work.", + "species": [ + { + "scientific_name": "Solanum lycopersicum", + "family": "Solanaceae", + "common": { "es": ["Tomate"], "en": ["Tomato"] } + }, + { + "scientific_name": "Solanum melongena", + "family": "Solanaceae", + "common": { "es": ["Berenjena"], "en": ["Aubergine", "Eggplant"] } + }, + { + "scientific_name": "Capsicum annuum", + "family": "Solanaceae", + "common": { "es": ["Pimiento", "Guindilla"], "en": ["Pepper", "Chilli"] } + }, + { + "scientific_name": "Phaseolus vulgaris", + "family": "Fabaceae", + "common": { "es": ["Judía", "Alubia", "Habichuela"], "en": ["Common bean"] } + }, + { + "scientific_name": "Vicia faba", + "family": "Fabaceae", + "common": { "es": ["Haba"], "en": ["Broad bean", "Fava bean"] } + }, + { + "scientific_name": "Pisum sativum", + "family": "Fabaceae", + "common": { "es": ["Guisante"], "en": ["Pea"] } + }, + { + "scientific_name": "Zea mays", + "family": "Poaceae", + "common": { "es": ["Maíz"], "en": ["Maize", "Corn"] } + }, + { + "scientific_name": "Cucurbita pepo", + "family": "Cucurbitaceae", + "common": { "es": ["Calabacín", "Calabaza"], "en": ["Courgette", "Zucchini", "Squash"] } + }, + { + "scientific_name": "Cucumis sativus", + "family": "Cucurbitaceae", + "common": { "es": ["Pepino"], "en": ["Cucumber"] } + }, + { + "scientific_name": "Lactuca sativa", + "family": "Asteraceae", + "common": { "es": ["Lechuga"], "en": ["Lettuce"] } + }, + { + "scientific_name": "Beta vulgaris", + "family": "Amaranthaceae", + "common": { "es": ["Acelga", "Remolacha"], "en": ["Chard", "Beetroot"] } + }, + { + "scientific_name": "Allium cepa", + "family": "Amaryllidaceae", + "common": { "es": ["Cebolla"], "en": ["Onion"] } + }, + { + "scientific_name": "Allium sativum", + "family": "Amaryllidaceae", + "common": { "es": ["Ajo"], "en": ["Garlic"] } + }, + { + "scientific_name": "Daucus carota", + "family": "Apiaceae", + "common": { "es": ["Zanahoria"], "en": ["Carrot"] } + } + ] +} diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index a260645..2d415ee 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -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, diff --git a/apps/app_seeds/lib/data/species_catalog.dart b/apps/app_seeds/lib/data/species_catalog.dart new file mode 100644 index 0000000..1969c21 --- /dev/null +++ b/apps/app_seeds/lib/data/species_catalog.dart @@ -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 parseSpeciesCatalog(String jsonString) { + final data = jsonDecode(jsonString) as Map; + final entries = (data['species'] as List).cast>(); + return entries.map((e) { + final common = (e['common'] as Map? ?? const {}).map( + (lang, names) => MapEntry(lang, (names as List).cast()), + ); + 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> loadBundledSpecies() async { + final jsonString = await rootBundle.loadString(_catalogAsset); + return parseSpeciesCatalog(jsonString); +} diff --git a/apps/app_seeds/lib/data/species_repository.dart b/apps/app_seeds/lib/data/species_repository.dart new file mode 100644 index 0000000..af485e8 --- /dev/null +++ b/apps/app_seeds/lib/data/species_repository.dart @@ -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> 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 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) 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> 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; + } +} diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index f2ecd0e..a340cd4 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 lots; final List 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 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 updateVariety({ diff --git a/apps/app_seeds/lib/db/encrypted_executor.dart b/apps/app_seeds/lib/db/encrypted_executor.dart index b882973..8c1f60d 100644 --- a/apps/app_seeds/lib/db/encrypted_executor.dart +++ b/apps/app_seeds/lib/db/encrypted_executor.dart @@ -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]. diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index a03ef8c..fa07af6 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -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 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(keyStore) ..registerSingleton(database) + ..registerSingleton(speciesRepository) ..registerSingleton( VarietyRepository(database, idGen: IdGen(), nodeId: nodeId), ); diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index be99d1b..4420ac4 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 6240ba1..21617d2 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index bc3caac..356c471 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index e2354fe..8ca6f36 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 9a4bb70..f98a224 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -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', diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index b2db26c..7c2ae84 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -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 main() async { LocaleSettings.useDeviceLocaleSync(); await configureDependencies(); runApp( - TranslationProvider(child: TaneApp(repository: getIt())), + TranslationProvider( + child: TaneApp( + repository: getIt(), + species: getIt(), + ), + ), ); } diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index 0419cec..f8695ea 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -53,6 +53,9 @@ class VarietyDetailCubit extends Cubit { Future addLot({int? year, Quantity? quantity}) => _repo.addLot(varietyId: varietyId, harvestYear: year, quantity: quantity); + Future linkSpecies(String speciesId) => + _repo.linkSpecies(varietyId, speciesId); + Future deleteVariety() async { await _repo.softDeleteVariety(varietyId); emit( diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index e10ce65..dbf22d8 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -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(), + ), ), 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 _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( 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 _suggestions = const []; + String? _pickedSpeciesId; + + @override + void dispose() { + _name.dispose(); + _category.dispose(); + _notes.dispose(); + _speciesField.dispose(); + super.dispose(); + } + + Future _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 _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 _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 _showEditSheet( ), const SizedBox(height: 12), TextField( - controller: notesController, + controller: _notes, minLines: 2, maxLines: 5, decoration: InputDecoration( @@ -233,29 +344,21 @@ Future _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 _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) { diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 7824e38..5c3f3af 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -63,3 +63,5 @@ dev_dependencies: flutter: uses-material-design: true # Seed strings for slang live under lib/i18n (not a Flutter asset; compiled). + assets: + - assets/catalog/species.json diff --git a/apps/app_seeds/test/data/species_repository_test.dart b/apps/app_seeds/test/data/species_repository_test.dart new file mode 100644 index 0000000..6bbb705 --- /dev/null +++ b/apps/app_seeds/test/data/species_repository_test.dart @@ -0,0 +1,83 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/species_catalog.dart'; +import 'package:tane/data/species_repository.dart'; +import 'package:tane/db/database.dart'; + +import '../support/test_support.dart'; + +const _seeds = [ + SpeciesSeed( + scientificName: 'Solanum lycopersicum', + family: 'Solanaceae', + commonNames: { + 'en': ['Tomato'], + 'es': ['Tomate'], + }, + ), + SpeciesSeed( + scientificName: 'Phaseolus vulgaris', + family: 'Fabaceae', + commonNames: { + 'en': ['Common bean'], + 'es': ['Judía', 'Alubia'], + }, + ), +]; + +void main() { + late AppDatabase db; + late SpeciesRepository repo; + + setUp(() { + db = newTestDatabase(); + repo = SpeciesRepository(db, idGen: IdGen()); + }); + tearDown(() => db.close()); + + test( + 'parseSpeciesCatalog reads scientific name, family and common names', + () { + const json = ''' + {"version":1,"species":[ + {"scientific_name":"Zea mays","family":"Poaceae","common":{"en":["Maize"],"es":["Maíz"]}} + ]}'''; + final seeds = parseSpeciesCatalog(json); + expect(seeds.single.scientificName, 'Zea mays'); + expect(seeds.single.family, 'Poaceae'); + expect(seeds.single.commonNames['es'], ['Maíz']); + }, + ); + + test('seedBundled inserts species and is idempotent', () async { + await repo.seedBundled(_seeds); + await repo.seedBundled(_seeds); // second run must not duplicate + + final species = await db.select(db.species).get(); + expect(species.length, 2); + expect(species.every((s) => s.isBundled), isTrue); + }); + + test('search matches by scientific name', () async { + await repo.seedBundled(_seeds); + final results = await repo.search('phaseolus'); + expect(results.single.scientificName, 'Phaseolus vulgaris'); + }); + + test('search matches by common name and returns a localized label', () async { + await repo.seedBundled(_seeds); + + final es = await repo.search('alubia', languageCode: 'es'); + expect(es.single.scientificName, 'Phaseolus vulgaris'); + expect(es.single.commonName, anyOf('Judía', 'Alubia')); + + final en = await repo.search('tom', languageCode: 'en'); + expect(en.single.commonName, 'Tomato'); + expect(en.single.displayLabel, 'Tomato (Solanum lycopersicum)'); + }); + + test('empty query returns nothing', () async { + await repo.seedBundled(_seeds); + expect(await repo.search(' '), isEmpty); + }); +} diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart index 16d3167..bb7dfc8 100644 --- a/apps/app_seeds/test/data/variety_detail_test.dart +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -1,11 +1,20 @@ import 'package:async/async.dart'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/species_repository.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; import '../support/test_support.dart'; +const _tomatoSeed = SpeciesSeed( + scientificName: 'Solanum lycopersicum', + family: 'Solanaceae', + commonNames: { + 'en': ['Tomato'], + }, +); + void main() { late AppDatabase db; late VarietyRepository repo; @@ -60,4 +69,32 @@ void main() { expect(await repo.watchInventory().first, isEmpty); }, ); + + test( + 'linkSpecies exposes the scientific name and prefills empty category', + () async { + final species = newTestSpeciesRepository(db); + await species.seedBundled(const [_tomatoSeed]); + final match = (await species.search('tomato')).single; + + final id = await repo.addQuickVariety(label: "Grandma's tomato"); + await repo.linkSpecies(id, match.id); + + final detail = await repo.watchVariety(id).first; + expect(detail!.scientificName, 'Solanum lycopersicum'); + expect(detail.category, 'Solanaceae'); // prefilled from family + }, + ); + + test('linkSpecies does not overwrite an existing category', () async { + final species = newTestSpeciesRepository(db); + await species.seedBundled(const [_tomatoSeed]); + final match = (await species.search('tomato')).single; + + final id = await repo.addQuickVariety(label: 'T', category: 'My tomatoes'); + await repo.linkSpecies(id, match.id); + + final detail = await repo.watchVariety(id).first; + expect(detail!.category, 'My tomatoes'); + }); } diff --git a/apps/app_seeds/test/support/test_support.dart b/apps/app_seeds/test/support/test_support.dart index c889c5a..5506779 100644 --- a/apps/app_seeds/test/support/test_support.dart +++ b/apps/app_seeds/test/support/test_support.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/species_repository.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; @@ -29,6 +30,9 @@ VarietyRepository newTestRepository( String nodeId = 'test-node', }) => VarietyRepository(db, idGen: IdGen(), nodeId: nodeId); +SpeciesRepository newTestSpeciesRepository(AppDatabase db) => + SpeciesRepository(db, idGen: IdGen()); + /// Wraps [child] with the providers a screen expects (repository, inventory /// cubit) plus i18n and Material localizations, pinned to [locale]. Widget wrapScreen({ @@ -61,12 +65,16 @@ Widget wrapScreen({ Widget wrapDetail({ required VarietyRepository repository, required String varietyId, + SpeciesRepository? species, AppLocale locale = AppLocale.en, }) { LocaleSettings.setLocaleSync(locale); return TranslationProvider( - child: RepositoryProvider.value( - value: repository, + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: repository), + if (species != null) RepositoryProvider.value(value: species), + ], child: BlocProvider( create: (_) => VarietyDetailCubit(repository, varietyId), child: MaterialApp( diff --git a/apps/app_seeds/test/ui/quick_add_flow_test.dart b/apps/app_seeds/test/ui/quick_add_flow_test.dart index 3fbd422..d0db00a 100644 --- a/apps/app_seeds/test/ui/quick_add_flow_test.dart +++ b/apps/app_seeds/test/ui/quick_add_flow_test.dart @@ -15,7 +15,12 @@ void main() { final repo = newTestRepository(db); await tester.pumpWidget( - TranslationProvider(child: TaneApp(repository: repo)), + TranslationProvider( + child: TaneApp( + repository: repo, + species: newTestSpeciesRepository(db), + ), + ), ); await tester.pumpAndSettle(); diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index 13dae99..ddf3d4e 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -1,6 +1,7 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/species_repository.dart'; import 'package:tane/db/database.dart'; import '../support/test_support.dart'; @@ -22,7 +23,13 @@ void main() { quantity: const Quantity(kind: QuantityKind.cob), ); - await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); await tester.pumpAndSettle(); expect(find.text('Maize'), findsOneWidget); // app bar title @@ -35,7 +42,13 @@ void main() { final repo = newTestRepository(db); final id = await repo.addQuickVariety(label: 'Old name'); - await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('detail.edit'))); @@ -56,7 +69,13 @@ void main() { final repo = newTestRepository(db); final id = await repo.addQuickVariety(label: 'Bean'); - await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); await tester.pumpAndSettle(); expect(find.text('No lots yet.'), findsOneWidget); @@ -77,7 +96,13 @@ void main() { final repo = newTestRepository(db); final id = await repo.addQuickVariety(label: 'Doomed'); - await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('detail.delete'))); @@ -89,4 +114,47 @@ void main() { expect(find.text('This seed is no longer here.'), findsOneWidget); await disposeTree(tester); }); + + testWidgets( + 'linking a species from the edit sheet shows its scientific name', + (tester) async { + final repo = newTestRepository(db); + final species = newTestSpeciesRepository(db); + await species.seedBundled(const [ + SpeciesSeed( + scientificName: 'Solanum lycopersicum', + family: 'Solanaceae', + commonNames: { + 'en': ['Tomato'], + }, + ), + ]); + final id = await repo.addQuickVariety(label: "Grandma's tomato"); + + await tester.pumpWidget( + wrapDetail(repository: repo, varietyId: id, species: species), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.edit'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('editVariety.species')), + 'toma', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Tomato (Solanum lycopersicum)')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('editVariety.save'))); + await tester.pumpAndSettle(); + + // Detail now shows the scientific name and the family-prefilled category. + expect(find.text('Solanum lycopersicum'), findsOneWidget); + expect(find.text('Solanaceae'), findsOneWidget); + await disposeTree(tester); + }, + ); }