feat(species): auto-classify variety species from its label

Infer the catalog species a free-text variety label names ("Maiz de la
abuela" -> Zea mays) and prefill the category from the species family.

- Pure, testable matcher (domain/species_autoclassify.dart): whole-word,
  accent/case-insensitive, Unicode-aware (any script), longest-name-wins,
  ambiguous names left unclassified, light plural fold.
- Quick-add and draft naming auto-link the species when the field is empty
  (non-destructive; an explicit category is kept).
- Edit sheet offers a one-tap suggestion from the typed name.

Tests: matcher unit, repository integration, SpeciesRepository.classifyLabel,
and an edit-sheet widget test.
This commit is contained in:
vjrj 2026-07-10 02:05:00 +02:00
parent 0e6ef13d00
commit b840b83c42
15 changed files with 510 additions and 3 deletions

View file

@ -605,6 +605,11 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
List<SpeciesMatch> _suggestions = const [];
String? _pickedSpeciesId;
/// A species inferred from the typed name, offered as a one-tap suggestion.
/// Only surfaced while the variety has no species and the grower has not
/// picked one, so it never nags over a deliberate choice.
SpeciesMatch? _suggested;
late bool _isOrganic = widget.detail.isOrganic;
late bool _needsReproduction = widget.detail.needsReproduction;
late int? _sowMonths = widget.detail.sowMonths;
@ -626,14 +631,26 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
Future<void> _onSpeciesQuery(String query) async {
final lang = Localizations.localeOf(context).languageCode;
final results = await widget.species.search(query, languageCode: lang);
// A manual species search takes over from the name-based suggestion.
if (mounted) setState(() => _suggestions = results);
}
/// As the name is typed, offer the species it names but only when the
/// variety has no species yet and the grower hasn't picked one, so an
/// existing link is never second-guessed.
Future<void> _onNameChanged(String name) async {
if (widget.detail.speciesId != null || _pickedSpeciesId != null) return;
final lang = Localizations.localeOf(context).languageCode;
final match = await widget.species.classifyLabel(name, languageCode: lang);
if (mounted) setState(() => _suggested = match);
}
void _pick(SpeciesMatch match) {
setState(() {
_pickedSpeciesId = match.id;
_speciesField.text = match.displayLabel;
_suggestions = const [];
_suggested = null;
});
}
@ -694,6 +711,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
onChanged: _onNameChanged,
),
const SizedBox(height: 12),
TextField(
@ -719,6 +737,19 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
: Text(match.family!),
onTap: () => _pick(match),
),
// One-tap suggestion inferred from the name (only when the
// species field is idle, so it never competes with a manual
// search).
if (_suggested case final suggested?
when _suggestions.isEmpty && _pickedSpeciesId == null)
ListTile(
dense: true,
key: const Key('editVariety.speciesSuggestion'),
leading: const Icon(Icons.auto_awesome_outlined),
title: Text(suggested.displayLabel),
subtitle: Text(t.editVariety.speciesSuggested),
onTap: () => _pick(suggested),
),
const SizedBox(height: 12),
TextField(
controller: _category,