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:
parent
7ff4e38a15
commit
4e8b8293e0
22 changed files with 700 additions and 47 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue