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

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