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:
parent
0e6ef13d00
commit
b840b83c42
15 changed files with 510 additions and 3 deletions
|
|
@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
import '../db/database.dart';
|
import '../db/database.dart';
|
||||||
|
import '../domain/species_autoclassify.dart';
|
||||||
|
|
||||||
/// One entry from the bundled catalog, before it is stored.
|
/// One entry from the bundled catalog, before it is stored.
|
||||||
class SpeciesSeed {
|
class SpeciesSeed {
|
||||||
|
|
@ -249,6 +250,44 @@ class SpeciesRepository {
|
||||||
.replaceAll('%', r'\%')
|
.replaceAll('%', r'\%')
|
||||||
.replaceAll('_', r'\_');
|
.replaceAll('_', r'\_');
|
||||||
|
|
||||||
|
/// Auto-classification for the UI: infers the single species a free-text
|
||||||
|
/// variety [label] names (see [matchSpeciesInLabel]) and returns it as a
|
||||||
|
/// [SpeciesMatch] with a best common name for [languageCode]. Returns null
|
||||||
|
/// when the label names no known species or the match is ambiguous — so the
|
||||||
|
/// caller can offer it as a one-tap suggestion, never a silent guess.
|
||||||
|
Future<SpeciesMatch?> classifyLabel(
|
||||||
|
String label, {
|
||||||
|
String languageCode = 'en',
|
||||||
|
}) async {
|
||||||
|
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 names = <SpeciesNameEntry>[
|
||||||
|
for (final s in species)
|
||||||
|
SpeciesNameEntry(speciesId: s.id, name: s.scientificName),
|
||||||
|
for (final c in commons)
|
||||||
|
SpeciesNameEntry(speciesId: c.speciesId, name: c.name),
|
||||||
|
];
|
||||||
|
final id = matchSpeciesInLabel(label, names);
|
||||||
|
if (id == null) return null;
|
||||||
|
|
||||||
|
final match = species.firstWhere((s) => s.id == id);
|
||||||
|
final matchNames = [
|
||||||
|
for (final c in commons)
|
||||||
|
if (c.speciesId == id) (name: c.name, language: c.language),
|
||||||
|
];
|
||||||
|
return SpeciesMatch(
|
||||||
|
id: match.id,
|
||||||
|
scientificName: match.scientificName,
|
||||||
|
family: match.family,
|
||||||
|
commonName: _bestName(matchNames, languageCode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String? _bestName(
|
String? _bestName(
|
||||||
List<({String name, String? language})> names,
|
List<({String name, String? language})> names,
|
||||||
String languageCode,
|
String languageCode,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
|
||||||
import '../db/database.dart';
|
import '../db/database.dart';
|
||||||
import '../db/enums.dart';
|
import '../db/enums.dart';
|
||||||
import '../domain/seed_viability.dart';
|
import '../domain/seed_viability.dart';
|
||||||
|
import '../domain/species_autoclassify.dart';
|
||||||
import 'export_import/import_reconciler.dart';
|
import 'export_import/import_reconciler.dart';
|
||||||
import 'export_import/inventory_csv_codec.dart';
|
import 'export_import/inventory_csv_codec.dart';
|
||||||
import 'export_import/inventory_snapshot.dart';
|
import 'export_import/inventory_snapshot.dart';
|
||||||
|
|
@ -657,6 +658,11 @@ class VarietyRepository {
|
||||||
/// The 20-second quick-add: a [label] (required) plus an optional [category],
|
/// The 20-second quick-add: a [label] (required) plus an optional [category],
|
||||||
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
|
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
|
||||||
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
|
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
|
||||||
|
///
|
||||||
|
/// If the label names a bundled species ("Maíz de la abuela" → *Zea mays*),
|
||||||
|
/// the variety is linked to it automatically and its category prefilled from
|
||||||
|
/// the species' family — the grower never has to open the species picker for
|
||||||
|
/// the common case. An explicit [category] is always kept as given.
|
||||||
Future<String> addQuickVariety({
|
Future<String> addQuickVariety({
|
||||||
required String label,
|
required String label,
|
||||||
String? category,
|
String? category,
|
||||||
|
|
@ -665,6 +671,8 @@ class VarietyRepository {
|
||||||
Uint8List? photoBytes,
|
Uint8List? photoBytes,
|
||||||
}) async {
|
}) async {
|
||||||
final varietyId = idGen.newId();
|
final varietyId = idGen.newId();
|
||||||
|
final auto = await _autoClassifyFromLabel(label);
|
||||||
|
final resolvedCategory = _hasText(category) ? category : auto?.family;
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
final (created, updated) = await _stamp();
|
final (created, updated) = await _stamp();
|
||||||
await _db
|
await _db
|
||||||
|
|
@ -676,7 +684,8 @@ class VarietyRepository {
|
||||||
createdAt: created,
|
createdAt: created,
|
||||||
updatedAt: updated,
|
updatedAt: updated,
|
||||||
lastAuthor: nodeId,
|
lastAuthor: nodeId,
|
||||||
category: Value(category),
|
category: Value(resolvedCategory),
|
||||||
|
speciesId: Value(auto?.id),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -762,12 +771,26 @@ class VarietyRepository {
|
||||||
|
|
||||||
/// Names a draft and promotes it out of the "to catalogue" tray: sets its
|
/// Names a draft and promotes it out of the "to catalogue" tray: sets its
|
||||||
/// [label] and clears [isDraft] in one LWW write.
|
/// [label] and clears [isDraft] in one LWW write.
|
||||||
|
///
|
||||||
|
/// Like [addQuickVariety], if the new [label] names a bundled species the
|
||||||
|
/// draft is auto-linked to it and its category prefilled from the family —
|
||||||
|
/// but only when the draft has neither yet, so it never overrides a species
|
||||||
|
/// or category the grower already set on the draft.
|
||||||
Future<void> nameDraft(String id, String label) async {
|
Future<void> nameDraft(String id, String label) async {
|
||||||
final (_, updated) = await _stamp();
|
final (_, updated) = await _stamp();
|
||||||
|
final draft = await (_db.select(
|
||||||
|
_db.varieties,
|
||||||
|
)..where((v) => v.id.equals(id))).getSingleOrNull();
|
||||||
|
final auto = draft?.speciesId == null
|
||||||
|
? await _autoClassifyFromLabel(label)
|
||||||
|
: null;
|
||||||
|
final prefillCategory = auto != null && !_hasText(draft?.category);
|
||||||
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
|
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
|
||||||
VarietiesCompanion(
|
VarietiesCompanion(
|
||||||
label: Value(label),
|
label: Value(label),
|
||||||
isDraft: const Value(false),
|
isDraft: const Value(false),
|
||||||
|
speciesId: auto == null ? const Value.absent() : Value(auto.id),
|
||||||
|
category: prefillCategory ? Value(auto.family) : const Value.absent(),
|
||||||
updatedAt: Value(updated),
|
updatedAt: Value(updated),
|
||||||
lastAuthor: Value(nodeId),
|
lastAuthor: Value(nodeId),
|
||||||
),
|
),
|
||||||
|
|
@ -949,6 +972,40 @@ class VarietyRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Infers a catalog species from a free-text [label] (see
|
||||||
|
/// [matchSpeciesInLabel]). Returns the matched species' id and family, or null
|
||||||
|
/// when the label names no known species or the match is ambiguous.
|
||||||
|
Future<({String id, String? family})?> _autoClassifyFromLabel(
|
||||||
|
String label,
|
||||||
|
) async {
|
||||||
|
final speciesId = matchSpeciesInLabel(label, await _speciesNamesForMatching());
|
||||||
|
if (speciesId == null) return null;
|
||||||
|
final species = await (_db.select(
|
||||||
|
_db.species,
|
||||||
|
)..where((s) => s.id.equals(speciesId))).getSingleOrNull();
|
||||||
|
return species == null ? null : (id: species.id, family: species.family);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every catalog name that can identify a species — its scientific name and
|
||||||
|
/// all non-deleted common names, across every bundled locale — flattened for
|
||||||
|
/// the language-agnostic matcher. The catalog is small, so this is cheap.
|
||||||
|
Future<List<SpeciesNameEntry>> _speciesNamesForMatching() async {
|
||||||
|
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();
|
||||||
|
return [
|
||||||
|
for (final s in species)
|
||||||
|
SpeciesNameEntry(speciesId: s.id, name: s.scientificName),
|
||||||
|
for (final c in commons)
|
||||||
|
SpeciesNameEntry(speciesId: c.speciesId, name: c.name),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasText(String? value) => value != null && value.trim().isNotEmpty;
|
||||||
|
|
||||||
/// Links [varietyId] to a catalog [speciesId]. If the variety has no category
|
/// Links [varietyId] to a catalog [speciesId]. If the variety has no category
|
||||||
/// yet, prefill it from the species' botanical family (data-model §6).
|
/// yet, prefill it from the species' botanical family (data-model §6).
|
||||||
Future<void> linkSpecies(String varietyId, String speciesId) async {
|
Future<void> linkSpecies(String varietyId, String speciesId) async {
|
||||||
|
|
|
||||||
128
apps/app_seeds/lib/domain/species_autoclassify.dart
Normal file
128
apps/app_seeds/lib/domain/species_autoclassify.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/// Auto-classification: infer the species a free-text variety label refers to,
|
||||||
|
/// so typing "Maíz de la abuela" can link *Zea mays* (and prefill the category)
|
||||||
|
/// without the grower opening the species picker.
|
||||||
|
///
|
||||||
|
/// Pure domain logic (no DB, no Flutter) so it is trivially unit-testable. The
|
||||||
|
/// repository feeds it the bundled catalog names; matching is done here.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// One catalog name that can identify a species: a common (vernacular) name or
|
||||||
|
/// the scientific name, paired with the species it belongs to. The matcher is
|
||||||
|
/// language-agnostic — it never assumes es/en — so the repository passes names
|
||||||
|
/// from every bundled locale at once.
|
||||||
|
class SpeciesNameEntry {
|
||||||
|
const SpeciesNameEntry({required this.speciesId, required this.name});
|
||||||
|
|
||||||
|
final String speciesId;
|
||||||
|
final String name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects the species a free-text variety [label] refers to by matching a
|
||||||
|
/// bundled [names] entry as a whole word inside the label — accent- and
|
||||||
|
/// case-insensitively. "Maíz de la abuela" → *Zea mays*; "Tomate cherry" →
|
||||||
|
/// *Solanum lycopersicum*.
|
||||||
|
///
|
||||||
|
/// Returns the matched species id, or null when nothing matches or the best
|
||||||
|
/// match is ambiguous. The rules keep it from mislabelling:
|
||||||
|
/// * **Whole-word (token) match only**, so "pea" never fires on "peach" nor
|
||||||
|
/// "haba" on "habanero" — a common name must appear as its own word(s).
|
||||||
|
/// * **Longest name wins** (most specific): "broad bean" beats "bean".
|
||||||
|
/// * **Ambiguous → null**: if the winning name maps to more than one species
|
||||||
|
/// (e.g. "calabaza" spanning several squashes), it is left for the grower
|
||||||
|
/// to disambiguate rather than guessed.
|
||||||
|
///
|
||||||
|
/// A light trailing-`s` fold lets a plural label word ("Tomates", "Habas")
|
||||||
|
/// still match a singular catalog name. It is an additive nudge for the many
|
||||||
|
/// languages that pluralise with `-s`, never an override of an exact match, and
|
||||||
|
/// it cannot introduce a match that a different word did not already carry.
|
||||||
|
String? matchSpeciesInLabel(String label, List<SpeciesNameEntry> names) {
|
||||||
|
final haystack = _tokenize(label);
|
||||||
|
if (haystack.isEmpty) return null;
|
||||||
|
|
||||||
|
({int tokens, int chars})? best;
|
||||||
|
final winners = <String>{};
|
||||||
|
for (final entry in names) {
|
||||||
|
final needle = _tokenize(entry.name);
|
||||||
|
if (!_containsSequence(haystack, needle)) continue;
|
||||||
|
final chars = needle.fold<int>(0, (sum, t) => sum + t.length);
|
||||||
|
final score = (tokens: needle.length, chars: chars);
|
||||||
|
if (best == null || _isBetter(score, best!)) {
|
||||||
|
best = score;
|
||||||
|
winners
|
||||||
|
..clear()
|
||||||
|
..add(entry.speciesId);
|
||||||
|
} else if (score.tokens == best!.tokens && score.chars == best!.chars) {
|
||||||
|
winners.add(entry.speciesId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single species behind the best-scoring name(s) → confident; otherwise the
|
||||||
|
// name is shared across species, so leave it unclassified.
|
||||||
|
return winners.length == 1 ? winners.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isBetter(({int tokens, int chars}) a, ({int tokens, int chars}) b) =>
|
||||||
|
a.tokens > b.tokens || (a.tokens == b.tokens && a.chars > b.chars);
|
||||||
|
|
||||||
|
/// Lowercases, splits on any non-letter/non-digit boundary (Unicode-aware, so
|
||||||
|
/// non-Latin scripts survive), and folds Latin diacritics on each token.
|
||||||
|
List<String> _tokenize(String s) => s
|
||||||
|
.toLowerCase()
|
||||||
|
.split(_wordBoundary)
|
||||||
|
.where((t) => t.isNotEmpty)
|
||||||
|
.map(_foldDiacritics)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final RegExp _wordBoundary = RegExp(r'[^\p{L}\p{N}]+', unicode: true);
|
||||||
|
|
||||||
|
/// True when [needle] appears as a contiguous run of words inside [haystack].
|
||||||
|
bool _containsSequence(List<String> haystack, List<String> needle) {
|
||||||
|
if (needle.isEmpty || needle.length > haystack.length) return false;
|
||||||
|
for (var i = 0; i + needle.length <= haystack.length; i++) {
|
||||||
|
var matched = true;
|
||||||
|
for (var j = 0; j < needle.length; j++) {
|
||||||
|
if (!_wordsEqual(haystack[i + j], needle[j])) {
|
||||||
|
matched = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _wordsEqual(String a, String b) => a == b || _singular(a) == _singular(b);
|
||||||
|
|
||||||
|
/// Drops a single trailing `s` from words longer than three letters — a
|
||||||
|
/// deliberately crude plural fold (see [matchSpeciesInLabel]).
|
||||||
|
String _singular(String t) =>
|
||||||
|
(t.length > 3 && t.endsWith('s')) ? t.substring(0, t.length - 1) : t;
|
||||||
|
|
||||||
|
/// Folds common Latin diacritics to their base letter so "Maiz" matches "Maíz".
|
||||||
|
/// Only Latin marks are folded; other scripts pass through untouched, keeping
|
||||||
|
/// the matcher usable for any language.
|
||||||
|
String _foldDiacritics(String token) {
|
||||||
|
if (token.codeUnits.every((c) => c < 0x80)) return token; // fast path: ASCII
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final ch in token.split('')) {
|
||||||
|
buffer.write(_diacriticFolds[ch] ?? ch);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Map<String, String> _diacriticFolds = {
|
||||||
|
'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a',
|
||||||
|
'ă': 'a', 'ą': 'a',
|
||||||
|
'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e',
|
||||||
|
'ě': 'e',
|
||||||
|
'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i',
|
||||||
|
'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o',
|
||||||
|
'ő': 'o',
|
||||||
|
'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u',
|
||||||
|
'ñ': 'n', 'ń': 'n', 'ň': 'n',
|
||||||
|
'ç': 'c', 'ć': 'c', 'č': 'c',
|
||||||
|
'ş': 's', 'š': 's', 'ș': 's', 'ś': 's',
|
||||||
|
'ž': 'z', 'ź': 'z', 'ż': 'z',
|
||||||
|
'ý': 'y', 'ÿ': 'y',
|
||||||
|
'ğ': 'g', 'ł': 'l', 'ð': 'd', 'þ': 'th', 'ß': 'ss', 'œ': 'oe', 'æ': 'ae',
|
||||||
|
};
|
||||||
|
|
@ -190,6 +190,7 @@
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"species": "Species (from catalog)",
|
"species": "Species (from catalog)",
|
||||||
"speciesHint": "Search a species…",
|
"speciesHint": "Search a species…",
|
||||||
|
"speciesSuggested": "Suggested from the name",
|
||||||
"organic": "Organic",
|
"organic": "Organic",
|
||||||
"organicHint": "Grown organically (eco)"
|
"organicHint": "Grown organically (eco)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,7 @@
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"species": "Especie (del catálogo)",
|
"species": "Especie (del catálogo)",
|
||||||
"speciesHint": "Buscar una especie…",
|
"speciesHint": "Buscar una especie…",
|
||||||
|
"speciesSuggested": "Sugerida por el nombre",
|
||||||
"organic": "Ecológica",
|
"organic": "Ecológica",
|
||||||
"organicHint": "Cultivada de forma ecológica (eco)"
|
"organicHint": "Cultivada de forma ecológica (eco)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,7 @@
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"species": "Espécie (do catálogo)",
|
"species": "Espécie (do catálogo)",
|
||||||
"speciesHint": "Procura uma espécie…",
|
"speciesHint": "Procura uma espécie…",
|
||||||
|
"speciesSuggested": "Sugerida pelo nome",
|
||||||
"organic": "Biológica",
|
"organic": "Biológica",
|
||||||
"organicHint": "Cultivada em modo biológico"
|
"organicHint": "Cultivada em modo biológico"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 3
|
/// Locales: 3
|
||||||
/// Strings: 869 (289 per locale)
|
/// Strings: 872 (290 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-10 at 00:08 UTC
|
/// Built on 2026-07-10 at 00:13 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,9 @@ class Translations$editVariety$en {
|
||||||
/// en: 'Search a species…'
|
/// en: 'Search a species…'
|
||||||
String get speciesHint => 'Search a species…';
|
String get speciesHint => 'Search a species…';
|
||||||
|
|
||||||
|
/// en: 'Suggested from the name'
|
||||||
|
String get speciesSuggested => 'Suggested from the name';
|
||||||
|
|
||||||
/// en: 'Organic'
|
/// en: 'Organic'
|
||||||
String get organic => 'Organic';
|
String get organic => 'Organic';
|
||||||
|
|
||||||
|
|
@ -1642,6 +1645,7 @@ extension on Translations {
|
||||||
'editVariety.notes' => 'Notes',
|
'editVariety.notes' => 'Notes',
|
||||||
'editVariety.species' => 'Species (from catalog)',
|
'editVariety.species' => 'Species (from catalog)',
|
||||||
'editVariety.speciesHint' => 'Search a species…',
|
'editVariety.speciesHint' => 'Search a species…',
|
||||||
|
'editVariety.speciesSuggested' => 'Suggested from the name',
|
||||||
'editVariety.organic' => 'Organic',
|
'editVariety.organic' => 'Organic',
|
||||||
'editVariety.organicHint' => 'Grown organically (eco)',
|
'editVariety.organicHint' => 'Grown organically (eco)',
|
||||||
'addLot.title' => 'Add lot',
|
'addLot.title' => 'Add lot',
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,7 @@ class _Translations$editVariety$es extends Translations$editVariety$en {
|
||||||
@override String get notes => 'Notas';
|
@override String get notes => 'Notas';
|
||||||
@override String get species => 'Especie (del catálogo)';
|
@override String get species => 'Especie (del catálogo)';
|
||||||
@override String get speciesHint => 'Buscar una especie…';
|
@override String get speciesHint => 'Buscar una especie…';
|
||||||
|
@override String get speciesSuggested => 'Sugerida por el nombre';
|
||||||
@override String get organic => 'Ecológica';
|
@override String get organic => 'Ecológica';
|
||||||
@override String get organicHint => 'Cultivada de forma ecológica (eco)';
|
@override String get organicHint => 'Cultivada de forma ecológica (eco)';
|
||||||
}
|
}
|
||||||
|
|
@ -1080,6 +1081,7 @@ extension on TranslationsEs {
|
||||||
'editVariety.notes' => 'Notas',
|
'editVariety.notes' => 'Notas',
|
||||||
'editVariety.species' => 'Especie (del catálogo)',
|
'editVariety.species' => 'Especie (del catálogo)',
|
||||||
'editVariety.speciesHint' => 'Buscar una especie…',
|
'editVariety.speciesHint' => 'Buscar una especie…',
|
||||||
|
'editVariety.speciesSuggested' => 'Sugerida por el nombre',
|
||||||
'editVariety.organic' => 'Ecológica',
|
'editVariety.organic' => 'Ecológica',
|
||||||
'editVariety.organicHint' => 'Cultivada de forma ecológica (eco)',
|
'editVariety.organicHint' => 'Cultivada de forma ecológica (eco)',
|
||||||
'addLot.title' => 'Añadir lote',
|
'addLot.title' => 'Añadir lote',
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,7 @@ class _Translations$editVariety$pt extends Translations$editVariety$en {
|
||||||
@override String get notes => 'Notas';
|
@override String get notes => 'Notas';
|
||||||
@override String get species => 'Espécie (do catálogo)';
|
@override String get species => 'Espécie (do catálogo)';
|
||||||
@override String get speciesHint => 'Procura uma espécie…';
|
@override String get speciesHint => 'Procura uma espécie…';
|
||||||
|
@override String get speciesSuggested => 'Sugerida pelo nome';
|
||||||
@override String get organic => 'Biológica';
|
@override String get organic => 'Biológica';
|
||||||
@override String get organicHint => 'Cultivada em modo biológico';
|
@override String get organicHint => 'Cultivada em modo biológico';
|
||||||
}
|
}
|
||||||
|
|
@ -1072,6 +1073,7 @@ extension on TranslationsPt {
|
||||||
'editVariety.notes' => 'Notas',
|
'editVariety.notes' => 'Notas',
|
||||||
'editVariety.species' => 'Espécie (do catálogo)',
|
'editVariety.species' => 'Espécie (do catálogo)',
|
||||||
'editVariety.speciesHint' => 'Procura uma espécie…',
|
'editVariety.speciesHint' => 'Procura uma espécie…',
|
||||||
|
'editVariety.speciesSuggested' => 'Sugerida pelo nome',
|
||||||
'editVariety.organic' => 'Biológica',
|
'editVariety.organic' => 'Biológica',
|
||||||
'editVariety.organicHint' => 'Cultivada em modo biológico',
|
'editVariety.organicHint' => 'Cultivada em modo biológico',
|
||||||
'addLot.title' => 'Adicionar lote',
|
'addLot.title' => 'Adicionar lote',
|
||||||
|
|
|
||||||
|
|
@ -605,6 +605,11 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
||||||
|
|
||||||
List<SpeciesMatch> _suggestions = const [];
|
List<SpeciesMatch> _suggestions = const [];
|
||||||
String? _pickedSpeciesId;
|
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 _isOrganic = widget.detail.isOrganic;
|
||||||
late bool _needsReproduction = widget.detail.needsReproduction;
|
late bool _needsReproduction = widget.detail.needsReproduction;
|
||||||
late int? _sowMonths = widget.detail.sowMonths;
|
late int? _sowMonths = widget.detail.sowMonths;
|
||||||
|
|
@ -626,14 +631,26 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
||||||
Future<void> _onSpeciesQuery(String query) async {
|
Future<void> _onSpeciesQuery(String query) async {
|
||||||
final lang = Localizations.localeOf(context).languageCode;
|
final lang = Localizations.localeOf(context).languageCode;
|
||||||
final results = await widget.species.search(query, languageCode: lang);
|
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);
|
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) {
|
void _pick(SpeciesMatch match) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_pickedSpeciesId = match.id;
|
_pickedSpeciesId = match.id;
|
||||||
_speciesField.text = match.displayLabel;
|
_speciesField.text = match.displayLabel;
|
||||||
_suggestions = const [];
|
_suggestions = const [];
|
||||||
|
_suggested = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -694,6 +711,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
onChanged: _onNameChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
|
|
@ -719,6 +737,19 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
||||||
: Text(match.family!),
|
: Text(match.family!),
|
||||||
onTap: () => _pick(match),
|
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),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _category,
|
controller: _category,
|
||||||
|
|
|
||||||
|
|
@ -224,4 +224,17 @@ void main() {
|
||||||
final results = await repo.search('Genus', limit: 5);
|
final results = await repo.search('Genus', limit: 5);
|
||||||
expect(results.length, 5);
|
expect(results.length, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('classifyLabel infers the species named in a free-text label', () async {
|
||||||
|
await repo.seedBundled(_seeds);
|
||||||
|
|
||||||
|
final match = await repo.classifyLabel('Tomate cherry', languageCode: 'es');
|
||||||
|
expect(match, isNotNull);
|
||||||
|
expect(match!.scientificName, 'Solanum lycopersicum');
|
||||||
|
expect(match.family, 'Solanaceae');
|
||||||
|
expect(match.commonName, 'Tomate'); // best name for the locale
|
||||||
|
|
||||||
|
// No species named → no suggestion.
|
||||||
|
expect(await repo.classifyLabel('Girasol gigante'), isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
apps/app_seeds/test/data/variety_autoclassify_test.dart
Normal file
107
apps/app_seeds/test/data/variety_autoclassify_test.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// A starter catalog, including a name ("Calabaza") shared across two species
|
||||||
|
/// so the ambiguous case can be exercised end-to-end.
|
||||||
|
const _seeds = [
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Zea mays',
|
||||||
|
family: 'Poaceae',
|
||||||
|
commonNames: {
|
||||||
|
'es': ['Maíz'],
|
||||||
|
'en': ['Maize', 'Corn'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Solanum lycopersicum',
|
||||||
|
family: 'Solanaceae',
|
||||||
|
commonNames: {
|
||||||
|
'es': ['Tomate'],
|
||||||
|
'en': ['Tomato'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Cucurbita pepo',
|
||||||
|
family: 'Cucurbitaceae',
|
||||||
|
commonNames: {
|
||||||
|
'es': ['Calabaza', 'Calabacín'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Cucurbita maxima',
|
||||||
|
family: 'Cucurbitaceae',
|
||||||
|
commonNames: {
|
||||||
|
'es': ['Calabaza'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late VarietyRepository repo;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = newTestDatabase();
|
||||||
|
repo = newTestRepository(db);
|
||||||
|
await SpeciesRepository(db, idGen: IdGen()).seedBundled(_seeds);
|
||||||
|
});
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
Future<Variety> only() async => db.select(db.varieties).getSingle();
|
||||||
|
|
||||||
|
test('addQuickVariety auto-links the species named in the label', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Maíz de la abuela');
|
||||||
|
final v = await only();
|
||||||
|
final species = await (db.select(
|
||||||
|
db.species,
|
||||||
|
)..where((s) => s.id.equals(v.speciesId!))).getSingle();
|
||||||
|
expect(species.scientificName, 'Zea mays');
|
||||||
|
// Category is prefilled from the species family.
|
||||||
|
expect(v.category, 'Poaceae');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-classification is accent-insensitive', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Maiz dulce'); // typed without the accent
|
||||||
|
final v = await only();
|
||||||
|
expect(v.speciesId, isNotNull);
|
||||||
|
expect(v.category, 'Poaceae');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicit category is kept, species still links', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Tomate cherry', category: 'Mi huerto');
|
||||||
|
final v = await only();
|
||||||
|
expect(v.speciesId, isNotNull);
|
||||||
|
expect(v.category, 'Mi huerto'); // not overwritten by the family
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown label leaves the variety unclassified', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Girasol gigante');
|
||||||
|
final v = await only();
|
||||||
|
expect(v.speciesId, isNull);
|
||||||
|
expect(v.category, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ambiguous name is left for the grower to disambiguate', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Calabaza de invierno');
|
||||||
|
final v = await only();
|
||||||
|
expect(v.speciesId, isNull);
|
||||||
|
expect(v.category, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nameDraft auto-classifies when the draft has no species yet', () async {
|
||||||
|
final id = await repo.addDraftVariety(Uint8List.fromList([1, 2, 3]));
|
||||||
|
await repo.nameDraft(id, 'Tomate rosa');
|
||||||
|
final v = await only();
|
||||||
|
expect(v.isDraft, isFalse);
|
||||||
|
expect(v.label, 'Tomate rosa');
|
||||||
|
expect(v.speciesId, isNotNull);
|
||||||
|
expect(v.category, 'Solanaceae');
|
||||||
|
});
|
||||||
|
}
|
||||||
68
apps/app_seeds/test/domain/species_autoclassify_test.dart
Normal file
68
apps/app_seeds/test/domain/species_autoclassify_test.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/domain/species_autoclassify.dart';
|
||||||
|
|
||||||
|
/// A small catalog spanning the tricky cases: accents, multi-word names, a
|
||||||
|
/// name shared across two species, and substrings that must NOT fire.
|
||||||
|
const _names = [
|
||||||
|
SpeciesNameEntry(speciesId: 'zea', name: 'Maíz'),
|
||||||
|
SpeciesNameEntry(speciesId: 'zea', name: 'Maize'),
|
||||||
|
SpeciesNameEntry(speciesId: 'zea', name: 'Zea mays'),
|
||||||
|
SpeciesNameEntry(speciesId: 'tomato', name: 'Tomate'),
|
||||||
|
SpeciesNameEntry(speciesId: 'tomato', name: 'Tomato'),
|
||||||
|
SpeciesNameEntry(speciesId: 'bean', name: 'Common bean'),
|
||||||
|
SpeciesNameEntry(speciesId: 'faba', name: 'Broad bean'),
|
||||||
|
SpeciesNameEntry(speciesId: 'faba', name: 'Haba'),
|
||||||
|
SpeciesNameEntry(speciesId: 'pea', name: 'Pea'),
|
||||||
|
// "Calabaza" is deliberately shared → ambiguous, must stay unclassified.
|
||||||
|
SpeciesNameEntry(speciesId: 'pepo', name: 'Calabaza'),
|
||||||
|
SpeciesNameEntry(speciesId: 'maxima', name: 'Calabaza'),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
String? match(String label) => matchSpeciesInLabel(label, _names);
|
||||||
|
|
||||||
|
test('matches a common name embedded in a free-text label', () {
|
||||||
|
expect(match('Maíz de la abuela'), 'zea');
|
||||||
|
expect(match('Tomate cherry'), 'tomato');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is accent- and case-insensitive', () {
|
||||||
|
expect(match('MAIZ dulce'), 'zea'); // no accent, upper-case
|
||||||
|
expect(match('tomate rosa'), 'tomato');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches the scientific name too', () {
|
||||||
|
expect(match('Zea mays landrace'), 'zea');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches multi-word names as a contiguous phrase', () {
|
||||||
|
expect(match('Broad bean Aquadulce'), 'faba');
|
||||||
|
expect(match('Common bean from grandma'), 'bean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers the longest (most specific) name', () {
|
||||||
|
// "bean" alone is not in the catalog, but the two-word names should win
|
||||||
|
// over any single-word coincidence.
|
||||||
|
expect(match('Broad bean'), 'faba');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('folds a plural label word to a singular catalog name', () {
|
||||||
|
expect(match('Tomates de rama'), 'tomato');
|
||||||
|
expect(match('Habas de mi huerto'), 'faba');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not fire on a substring that is not a whole word', () {
|
||||||
|
expect(match('Habanero picante'), isNull); // contains "haba" as substring
|
||||||
|
expect(match('Peach tree'), isNull); // contains "pea" as substring
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when a name is shared across species (ambiguous)', () {
|
||||||
|
expect(match('Calabaza de invierno'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when nothing matches', () {
|
||||||
|
expect(match('Girasol gigante'), isNull);
|
||||||
|
expect(match(' '), isNull);
|
||||||
|
expect(match(''), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,59 @@ void main() {
|
||||||
await disposeTree(tester);
|
await disposeTree(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('typing a name suggests the species and links it on tap', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final species = newTestSpeciesRepository(db);
|
||||||
|
await species.seedBundled(const [
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Zea mays',
|
||||||
|
family: 'Poaceae',
|
||||||
|
commonNames: {
|
||||||
|
'en': ['Maize'],
|
||||||
|
'es': ['Maíz'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
// A label that names no species yet, so nothing is auto-linked on create.
|
||||||
|
final id = await repo.addQuickVariety(label: 'Grandma seed');
|
||||||
|
|
||||||
|
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.name')),
|
||||||
|
'Maize criollo',
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The suggestion surfaces from the typed name.
|
||||||
|
final suggestion = find.byKey(const Key('editVariety.speciesSuggestion'));
|
||||||
|
expect(suggestion, findsOneWidget);
|
||||||
|
expect(find.text('Maize (Zea mays)'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(suggestion);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Picking fills the species field with the catalog label.
|
||||||
|
expect(find.text('Maize (Zea mays)'), findsOneWidget);
|
||||||
|
|
||||||
|
final save = find.byKey(const Key('editVariety.save'));
|
||||||
|
await tester.ensureVisible(save);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(save, warnIfMissed: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The linked species now shows on the detail header (driven by the stream).
|
||||||
|
expect(find.text('Zea mays'), findsOneWidget);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('adding a lot shows it in the list', (tester) async {
|
testWidgets('adding a lot shows it in the list', (tester) async {
|
||||||
final repo = newTestRepository(db);
|
final repo = newTestRepository(db);
|
||||||
final id = await repo.addQuickVariety(label: 'Bean');
|
final id = await repo.addQuickVariety(label: 'Bean');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue