Merge branch 'main' into spike/block2-derisking

This commit is contained in:
vjrj 2026-07-10 03:06:30 +02:00
commit c3b904ce71
24 changed files with 69558 additions and 341 deletions

View file

@ -18,10 +18,10 @@ List<SpeciesSeed> parseSpeciesCatalog(String jsonString) {
return SpeciesSeed(
scientificName: e['scientific_name'] as String,
family: e['family'] as String?,
wikidataQid: e['wikidata_qid'] as String?,
gbifKey: (e['gbif_key'] as num?)?.toInt(),
commonNames: common,
viabilityYears: (e['viability_years'] as num?)?.toInt(),
gbifKey: (e['gbif_key'] as num?)?.toInt(),
wikidataQid: e['wikidata_qid'] as String?,
);
}).toList();
}

View file

@ -2,33 +2,35 @@ import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
import '../db/database.dart';
import '../domain/species_autoclassify.dart';
/// One entry from the bundled catalog, before it is stored.
class SpeciesSeed {
const SpeciesSeed({
required this.scientificName,
this.family,
this.wikidataQid,
this.gbifKey,
this.commonNames = const {},
this.viabilityYears,
this.gbifKey,
this.wikidataQid,
});
final String scientificName;
final String? family;
/// Wikidata entity id (e.g. `Q13223`), the CC0 source of this row; null when
/// the catalog was authored by hand. Kept for de-duplication and to feed the
/// derived Wikipedia (locale-aware) reference link.
final String? wikidataQid;
/// GBIF backbone taxon key; null if unknown. Feeds the derived reference link.
final int? gbifKey;
/// language code list of common names.
final Map<String, List<String>> commonNames;
/// Typical seed longevity in years (bundled reference data); null if unknown.
final int? viabilityYears;
/// GBIF backbone taxon key; null if unknown. Feeds the derived reference link.
final int? gbifKey;
/// Wikidata item id (e.g. `Q23501`); null if unknown. Feeds the derived
/// Wikipedia (locale-aware) reference link.
final String? wikidataQid;
}
/// A catalog match surfaced to the UI (with a best common name for the locale).
@ -60,94 +62,203 @@ class SpeciesRepository {
final String nodeId;
/// Idempotently inserts bundled species (keyed by scientific name). Safe to
/// call on every startup. Existing rows keep their identity, but a bundled
/// row still missing [SpeciesSeed.viabilityYears] is backfilled so the
/// reference data reaches installs seeded before the field was bundled.
/// call on every startup. Existing rows keep their identity, but bundled
/// reference fields still missing on a row ([SpeciesSeed.viabilityYears],
/// [SpeciesSeed.wikidataQid], [SpeciesSeed.gbifKey]) are backfilled so data
/// added to the catalog reaches installs seeded before the field was bundled.
///
/// Reads all existing species once and writes in a single batch: with a
/// catalog of ~1000 species a per-row SELECT on every startup would be a real
/// cost on the encrypted database.
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
final stamp = Hlc.zero(nodeId).pack();
await _db.transaction(() async {
final existing = {
for (final s in await _db.select(_db.species).get())
s.scientificName: s,
};
final newSpecies = <SpeciesCompanion>[];
final newNames = <SpeciesCommonNamesCompanion>[];
final backfills = <({String id, SpeciesCompanion patch})>[];
for (final seed in seeds) {
final existing =
await (_db.select(_db.species)
..where((s) => s.scientificName.equals(seed.scientificName)))
.getSingleOrNull();
if (existing != null) {
// Backfill bundled reference data added after the row was seeded, so
// it reaches installs seeded before these fields were bundled. Each
// field is only written when the row still lacks it.
final backfill = SpeciesCompanion(
viabilityYears:
existing.viabilityYears == null && seed.viabilityYears != null
? Value(seed.viabilityYears)
: const Value.absent(),
gbifKey: existing.gbifKey == null && seed.gbifKey != null
? Value(seed.gbifKey)
: const Value.absent(),
wikidataQid:
existing.wikidataQid == null && seed.wikidataQid != null
? Value(seed.wikidataQid)
: const Value.absent(),
);
if (backfill.viabilityYears.present ||
backfill.gbifKey.present ||
backfill.wikidataQid.present) {
await (_db.update(
_db.species,
)..where((s) => s.id.equals(existing.id))).write(backfill);
}
final row = existing[seed.scientificName];
if (row != null) {
final patch = _backfillPatch(row, seed);
if (patch != null) backfills.add((id: row.id, patch: patch));
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),
viabilityYears: Value(seed.viabilityYears),
gbifKey: Value(seed.gbifKey),
wikidataQid: Value(seed.wikidataQid),
),
);
newSpecies.add(
SpeciesCompanion.insert(
id: speciesId,
createdAt: 0,
updatedAt: stamp,
lastAuthor: nodeId,
scientificName: seed.scientificName,
family: Value(seed.family),
wikidataQid: Value(seed.wikidataQid),
gbifKey: Value(seed.gbifKey),
isBundled: const Value(true),
viabilityYears: Value(seed.viabilityYears),
),
);
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),
),
);
newNames.add(
SpeciesCommonNamesCompanion.insert(
id: idGen.newId(),
createdAt: 0,
updatedAt: stamp,
lastAuthor: nodeId,
speciesId: speciesId,
name: name,
language: Value(entry.key),
),
);
}
}
}
if (newSpecies.isEmpty && newNames.isEmpty && backfills.isEmpty) return;
await _db.batch((b) {
b.insertAll(_db.species, newSpecies);
b.insertAll(_db.speciesCommonNames, newNames);
for (final entry in backfills) {
b.update(
_db.species,
entry.patch,
where: (s) => s.id.equals(entry.id),
);
}
});
});
}
/// A partial update carrying only the bundled reference fields a stored row is
/// still missing, or null when the row is already complete.
SpeciesCompanion? _backfillPatch(Specy row, SpeciesSeed seed) {
var patch = const SpeciesCompanion();
var changed = false;
if (row.viabilityYears == null && seed.viabilityYears != null) {
patch = patch.copyWith(viabilityYears: Value(seed.viabilityYears));
changed = true;
}
if (row.wikidataQid == null && seed.wikidataQid != null) {
patch = patch.copyWith(wikidataQid: Value(seed.wikidataQid));
changed = true;
}
if (row.gbifKey == null && seed.gbifKey != null) {
patch = patch.copyWith(gbifKey: Value(seed.gbifKey));
changed = true;
}
return changed ? patch : null;
}
/// 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].
/// substring). Filtering runs in SQL (`LIKE`) so a large catalog is not pulled
/// into memory on every keystroke. Returns up to [limit] matches, each with a
/// best common name for [languageCode].
Future<List<SpeciesMatch>> search(
String query, {
String languageCode = 'en',
int limit = 8,
}) async {
final q = query.trim().toLowerCase();
final q = query.trim();
if (q.isEmpty) return const [];
// SQLite `LIKE` is case-insensitive for ASCII (matching the previous
// `toLowerCase().contains` behaviour); `%`/`_`/`\` in the query are escaped
// so they match literally rather than acting as wildcards.
final pattern = '%${_escapeLike(q)}%';
// A few extra candidates so the final scientific-name sort/limit is stable.
final scan = limit * 4;
final byScientific =
await (_db.select(_db.species)
..where(
(s) =>
s.isDeleted.equals(false) &
s.scientificName.like(pattern, escapeChar: r'\'),
)
..limit(scan))
.get();
final byCommon =
await (_db.select(_db.speciesCommonNames)
..where(
(n) =>
n.isDeleted.equals(false) &
n.name.like(pattern, escapeChar: r'\'),
)
..limit(scan))
.get();
final speciesById = {for (final s in byScientific) s.id: s};
final missing = byCommon
.map((n) => n.speciesId)
.where((id) => !speciesById.containsKey(id))
.toSet();
if (missing.isNotEmpty) {
final more =
await (_db.select(_db.species)..where(
(s) => s.isDeleted.equals(false) & s.id.isIn(missing.toList()),
))
.get();
for (final s in more) {
speciesById[s.id] = s;
}
}
if (speciesById.isEmpty) return const [];
// Resolve a localized label only for the matched species.
final names =
await (_db.select(_db.speciesCommonNames)..where(
(n) =>
n.isDeleted.equals(false) &
n.speciesId.isIn(speciesById.keys.toList()),
))
.get();
final namesBySpecies = <String, List<({String name, String? language})>>{};
for (final n in names) {
namesBySpecies.putIfAbsent(n.speciesId, () => []).add((
name: n.name,
language: n.language,
));
}
final matches = [
for (final s in speciesById.values)
SpeciesMatch(
id: s.id,
scientificName: s.scientificName,
family: s.family,
commonName: _bestName(namesBySpecies[s.id] ?? const [], languageCode),
),
];
matches.sort((a, b) => a.scientificName.compareTo(b.scientificName));
return matches.take(limit).toList();
}
/// Escapes SQL `LIKE` metacharacters so a user query matches literally.
String _escapeLike(String input) => input
.replaceAll(r'\', 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();
@ -155,32 +266,26 @@ class SpeciesRepository {
_db.speciesCommonNames,
)..where((n) => n.isDeleted.equals(false))).get();
final namesBySpecies = <String, List<({String name, String? language})>>{};
for (final c in commons) {
namesBySpecies.putIfAbsent(c.speciesId, () => []).add((
name: c.name,
language: c.language,
));
}
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 matches = <SpeciesMatch>[];
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();
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(

View file

@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import '../domain/species_autoclassify.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.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],
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
/// (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({
required String label,
String? category,
@ -665,8 +671,10 @@ class VarietyRepository {
Uint8List? photoBytes,
}) async {
final varietyId = idGen.newId();
final auto = await _autoClassifyFromLabel(label);
final resolvedCategory = _hasText(category) ? category : auto?.family;
await _db.transaction(() async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.varieties)
.insert(
@ -676,12 +684,13 @@ class VarietyRepository {
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
category: Value(category),
category: Value(resolvedCategory),
speciesId: Value(auto?.id),
),
);
if (quantity != null) {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.lots)
.insert(
@ -700,7 +709,7 @@ class VarietyRepository {
}
if (photoBytes != null) {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.attachments)
.insert(
@ -727,7 +736,7 @@ class VarietyRepository {
Future<String> addDraftVariety(Uint8List photoBytes) async {
final varietyId = idGen.newId();
await _db.transaction(() async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.varieties)
.insert(
@ -740,7 +749,7 @@ class VarietyRepository {
isDraft: const Value(true),
),
);
final (createdA, updatedA) = _stamp();
final (createdA, updatedA) = await _stamp();
await _db
.into(_db.attachments)
.insert(
@ -762,12 +771,26 @@ class VarietyRepository {
/// Names a draft and promotes it out of the "to catalogue" tray: sets its
/// [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 {
final (_, updated) = _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(
VarietiesCompanion(
label: Value(label),
isDraft: const Value(false),
speciesId: auto == null ? const Value.absent() : Value(auto.id),
category: prefillCategory ? Value(auto.family) : const Value.absent(),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
@ -949,10 +972,44 @@ 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
/// yet, prefill it from the species' botanical family (data-model §6).
Future<void> linkSpecies(String varietyId, String speciesId) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
final species = await (_db.select(
_db.species,
)..where((s) => s.id.equals(speciesId))).getSingleOrNull();
@ -993,7 +1050,7 @@ class VarietyRepository {
Value<int?> fruitingMonths = const Value.absent(),
Value<int?> seedHarvestMonths = const Value.absent(),
}) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
label: label == null ? const Value.absent() : Value(label),
@ -1029,7 +1086,7 @@ class VarietyRepository {
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.lots)
@ -1074,7 +1131,7 @@ class VarietyRepository {
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion(
type: Value(type),
@ -1155,7 +1212,7 @@ class VarietyRepository {
/// Soft-deletes a lot (tombstone); it leaves the variety's lot list.
Future<void> softDeleteLot(String lotId) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion(
isDeleted: const Value(true),
@ -1172,7 +1229,7 @@ class VarietyRepository {
String? language,
String? region,
}) async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.varietyVernacularNames)
@ -1193,7 +1250,7 @@ class VarietyRepository {
/// Soft-deletes a vernacular name (tombstone).
Future<void> removeVernacularName(String nameId) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(
_db.varietyVernacularNames,
)..where((n) => n.id.equals(nameId))).write(
@ -1207,7 +1264,7 @@ class VarietyRepository {
/// Adds a photo (encrypted BLOB) to a variety. Returns the new attachment id.
Future<String> addPhoto(String varietyId, Uint8List bytes) async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.attachments)
@ -1229,7 +1286,7 @@ class VarietyRepository {
/// Soft-deletes a photo (tombstone).
Future<void> removePhoto(String attachmentId) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(
_db.attachments,
)..where((a) => a.id.equals(attachmentId))).write(
@ -1257,7 +1314,7 @@ class VarietyRepository {
for (final a in siblings) {
if (a.sortOrder < minOrder) minOrder = a.sortOrder;
}
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(
_db.attachments,
)..where((a) => a.id.equals(attachmentId))).write(
@ -1275,7 +1332,7 @@ class VarietyRepository {
String url, {
String? title,
}) async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.externalLinks)
@ -1296,7 +1353,7 @@ class VarietyRepository {
/// Soft-deletes an external link (tombstone).
Future<void> removeExternalLink(String linkId) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(
_db.externalLinks,
)..where((e) => e.id.equals(linkId))).write(
@ -1311,7 +1368,7 @@ class VarietyRepository {
/// Soft-deletes a variety (tombstone); it disappears from the inventory but
/// the row survives for correct CRDT merges later.
Future<void> softDeleteVariety(String id) async {
final (_, updated) = _stamp();
final (_, updated) = await _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
isDeleted: const Value(true),
@ -1329,7 +1386,7 @@ class VarietyRepository {
int? germinatedCount,
String? notes,
}) async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.germinationTests)
@ -1358,7 +1415,7 @@ class VarietyRepository {
DesiccantState? desiccantState,
String? notes,
}) async {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.conditionChecks)
@ -1537,7 +1594,7 @@ class VarietyRepository {
for (final v in csv.varieties) {
final speciesId = await _resolveSpeciesByName(v.scientificName);
final varietyId = idGen.newId();
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.varieties)
.insert(
@ -1562,7 +1619,7 @@ class VarietyRepository {
inserted++;
for (final lot in v.lots) {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.lots)
.insert(
@ -1590,7 +1647,7 @@ class VarietyRepository {
}
for (final name in v.vernacularNames) {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.varietyVernacularNames)
.insert(
@ -1607,7 +1664,7 @@ class VarietyRepository {
}
for (final link in v.links) {
final (created, updated) = _stamp();
final (created, updated) = await _stamp();
await _db
.into(_db.externalLinks)
.insert(
@ -1767,9 +1824,65 @@ class VarietyRepository {
QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew;
/// Advances the local clock and returns `(createdAtMillis, packedHlc)`.
(int, String) _stamp() {
///
/// Before the first stamp of the session the clock is re-seeded from the
/// newest stamp already stored in the DB (see [_ensureClockSeeded]). The
/// in-memory clock starts fresh at [Hlc.zero] every app launch, so without
/// this a local edit could be stamped *older* than a row written in an
/// earlier session or imported from a peer whose wall clock ran ahead and
/// silently lose last-writer-wins when the backup is shared back.
Future<(int, String)> _stamp() async {
await _ensureClockSeeded();
final now = _now();
_clock = _clock.localEvent(now);
return (now, _clock.pack());
}
Future<void>? _clockSeeded;
/// Absorbs the newest stored stamp into the local clock exactly once per
/// repository instance, so it never runs behind data the DB already holds.
Future<void> _ensureClockSeeded() =>
_clockSeeded ??= _seedClockFromStoredRows();
Future<void> _seedClockFromStoredRows() async {
final maxStored = await _maxStoredHlc();
if (maxStored != null) {
_clock = _clock.receiveEvent(maxStored, _now());
}
}
/// The newest packed HLC `updatedAt` across every mutable table, or null when
/// the inventory is empty. Packed stamps are fixed-width and
/// lexicographically sortable, so SQL `MAX` orders them exactly like
/// [Hlc.compareTo].
Future<Hlc?> _maxStoredHlc() async {
final tables =
<
(
ResultSetImplementation<HasResultSet, dynamic>,
GeneratedColumn<String>,
)
>[
(_db.varieties, _db.varieties.updatedAt),
(_db.lots, _db.lots.updatedAt),
(_db.varietyVernacularNames, _db.varietyVernacularNames.updatedAt),
(_db.externalLinks, _db.externalLinks.updatedAt),
(_db.germinationTests, _db.germinationTests.updatedAt),
(_db.conditionChecks, _db.conditionChecks.updatedAt),
(_db.parties, _db.parties.updatedAt),
(_db.attachments, _db.attachments.updatedAt),
];
String? maxPacked;
for (final (table, column) in tables) {
final max = column.max();
final query = _db.selectOnly(table)..addColumns([max]);
final packed = (await query.getSingleOrNull())?.read(max);
if (packed != null &&
(maxPacked == null || packed.compareTo(maxPacked) > 0)) {
maxPacked = packed;
}
}
return maxPacked == null ? null : Hlc.parse(maxPacked);
}
}

View 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',
};

View file

@ -86,7 +86,8 @@
"licenseValue": "AGPL-3.0",
"website": "Website",
"openSourceLicenses": "Open-source licenses",
"openSourceLicensesSubtitle": "Third-party libraries and their licenses"
"openSourceLicensesSubtitle": "Third-party libraries and their licenses",
"copyright": "© {years} Comunes Association, under AGPLv3"
},
"intro": {
"skip": "Skip",
@ -190,6 +191,7 @@
"notes": "Notes",
"species": "Species (from catalog)",
"speciesHint": "Search a species…",
"speciesSuggested": "Suggested from the name",
"organic": "Organic",
"organicHint": "Grown organically (eco)"
},

View file

@ -86,7 +86,8 @@
"licenseValue": "AGPL-3.0",
"website": "Sitio web",
"openSourceLicenses": "Licencias de código abierto",
"openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias"
"openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias",
"copyright": "© {years} Asociación Comunes, bajo AGPLv3"
},
"intro": {
"skip": "Saltar",
@ -190,6 +191,7 @@
"notes": "Notas",
"species": "Especie (del catálogo)",
"speciesHint": "Buscar una especie…",
"speciesSuggested": "Sugerida por el nombre",
"organic": "Ecológica",
"organicHint": "Cultivada de forma ecológica (eco)"
},

View file

@ -86,7 +86,8 @@
"licenseValue": "AGPL-3.0",
"website": "Sítio web",
"openSourceLicenses": "Licenças de código aberto",
"openSourceLicensesSubtitle": "Bibliotecas de terceiros e as suas licenças"
"openSourceLicensesSubtitle": "Bibliotecas de terceiros e as suas licenças",
"copyright": "© {years} Associação Comunes, sob AGPLv3"
},
"intro": {
"skip": "Saltar",
@ -186,6 +187,7 @@
"notes": "Notas",
"species": "Espécie (do catálogo)",
"speciesHint": "Procura uma espécie…",
"speciesSuggested": "Sugerida pelo nome",
"organic": "Biológica",
"organicHint": "Cultivada em modo biológico"
},

View file

@ -0,0 +1,181 @@
/// Generated file. Do not edit.
///
/// Source: lib/i18n
/// To regenerate, run: `dart run slang`
///
/// Locales: 3
/// Strings: 875 (291 per locale)
///
/// Built on 2026-07-10 at 00:35 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
// dart format off
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:slang/generated.dart';
import 'package:slang_flutter/slang_flutter.dart';
export 'package:slang_flutter/slang_flutter.dart';
import 'strings_es.g.dart' as l_es;
import 'strings_pt.g.dart' as l_pt;
part 'strings_en.g.dart';
/// Supported locales.
///
/// Usage:
/// - LocaleSettings.setLocale(AppLocale.en) // set locale
/// - Locale locale = AppLocale.en.flutterLocale // get flutter locale from enum
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
en(languageCode: 'en'),
es(languageCode: 'es'),
pt(languageCode: 'pt');
const AppLocale({
required this.languageCode,
this.scriptCode, // ignore: unused_element, unused_element_parameter
this.countryCode, // ignore: unused_element, unused_element_parameter
});
@override final String languageCode;
@override final String? scriptCode;
@override final String? countryCode;
@override
Future<Translations> build({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) async {
return buildSync(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
@override
Translations buildSync({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) {
switch (this) {
case AppLocale.en:
return TranslationsEn(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.es:
return l_es.TranslationsEs(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.pt:
return l_pt.TranslationsPt(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
}
/// Gets current instance managed by [LocaleSettings].
Translations get translations => LocaleSettings.instance.getTranslations(this);
}
/// Method A: Simple
///
/// No rebuild after locale change.
/// Translation happens during initialization of the widget (call of t).
/// Configurable via 'translate_var'.
///
/// Usage:
/// String a = t.someKey.anotherKey;
/// String b = t['someKey.anotherKey']; // Only for edge cases!
Translations get t => LocaleSettings.instance.currentTranslations;
/// Method B: Advanced
///
/// All widgets using this method will trigger a rebuild when locale changes.
/// Use this if you have e.g. a settings page where the user can select the locale during runtime.
///
/// Step 1:
/// wrap your App with
/// TranslationProvider(
/// child: MyApp()
/// );
///
/// Step 2:
/// final t = Translations.of(context); // Get t variable.
/// String a = t.someKey.anotherKey; // Use t variable.
/// String b = t['someKey.anotherKey']; // Only for edge cases!
class TranslationProvider extends BaseTranslationProvider<AppLocale, Translations> {
TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance);
static InheritedLocaleData<AppLocale, Translations> of(BuildContext context) => InheritedLocaleData.of<AppLocale, Translations>(context);
}
/// Method B shorthand via [BuildContext] extension method.
/// Configurable via 'translate_var'.
///
/// Usage (e.g. in a widget's build method):
/// context.t.someKey.anotherKey
extension BuildContextTranslationsExtension on BuildContext {
Translations get t => TranslationProvider.of(this).translations;
}
/// Manages all translation instances and the current locale
class LocaleSettings extends BaseFlutterLocaleSettings<AppLocale, Translations> {
LocaleSettings._() : super(
utils: AppLocaleUtils.instance,
lazy: false,
);
static final instance = LocaleSettings._();
// static aliases (checkout base methods for documentation)
static AppLocale get currentLocale => instance.currentLocale;
static Stream<AppLocale> getLocaleStream() => instance.getLocaleStream();
static Future<AppLocale> setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> useDeviceLocale() => instance.useDeviceLocale();
static Future<void> setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
// synchronous versions
static AppLocale setLocaleSync(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocaleSync(locale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale setLocaleRawSync(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRawSync(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale useDeviceLocaleSync() => instance.useDeviceLocaleSync();
static void setPluralResolverSync({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolverSync(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
/// Provides utility functions without any side effects.
class AppLocaleUtils extends BaseAppLocaleUtils<AppLocale, Translations> {
AppLocaleUtils._() : super(
baseLocale: AppLocale.en,
locales: AppLocale.values,
);
static final instance = AppLocaleUtils._();
// static aliases (checkout base methods for documentation)
static AppLocale parse(String rawLocale) => instance.parse(rawLocale);
static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode);
static AppLocale findDeviceLocale() => instance.findDeviceLocale();
static List<Locale> get supportedLocales => instance.supportedLocales;
static List<String> get supportedLocalesRaw => instance.supportedLocalesRaw;
}

View file

@ -360,6 +360,9 @@ class Translations$about$en {
/// en: 'Third-party libraries and their licenses'
String get openSourceLicensesSubtitle => 'Third-party libraries and their licenses';
/// en: '© {years} Comunes Association, under AGPLv3'
String copyright({required Object years}) => '© ${years} Comunes Association, under AGPLv3';
}
// Path: intro
@ -630,6 +633,9 @@ class Translations$editVariety$en {
/// en: 'Search a species…'
String get speciesHint => 'Search a species…';
/// en: 'Suggested from the name'
String get speciesSuggested => 'Suggested from the name';
/// en: 'Organic'
String get organic => 'Organic';
@ -1567,6 +1573,7 @@ extension on Translations {
'about.website' => 'Website',
'about.openSourceLicenses' => 'Open-source licenses',
'about.openSourceLicensesSubtitle' => 'Third-party libraries and their licenses',
'about.copyright' => ({required Object years}) => '© ${years} Comunes Association, under AGPLv3',
'intro.skip' => 'Skip',
'intro.next' => 'Next',
'intro.start' => 'Get started',
@ -1642,6 +1649,7 @@ extension on Translations {
'editVariety.notes' => 'Notes',
'editVariety.species' => 'Species (from catalog)',
'editVariety.speciesHint' => 'Search a species…',
'editVariety.speciesSuggested' => 'Suggested from the name',
'editVariety.organic' => 'Organic',
'editVariety.organicHint' => 'Grown organically (eco)',
'addLot.title' => 'Add lot',

View file

@ -213,6 +213,7 @@ class _Translations$about$es extends Translations$about$en {
@override String get website => 'Sitio web';
@override String get openSourceLicenses => 'Licencias de código abierto';
@override String get openSourceLicensesSubtitle => 'Bibliotecas de terceros y sus licencias';
@override String copyright({required Object years}) => '© ${years} Asociación Comunes, bajo AGPLv3';
}
// Path: intro
@ -351,6 +352,7 @@ class _Translations$editVariety$es extends Translations$editVariety$en {
@override String get notes => 'Notas';
@override String get species => 'Especie (del catálogo)';
@override String get speciesHint => 'Buscar una especie…';
@override String get speciesSuggested => 'Sugerida por el nombre';
@override String get organic => 'Ecológica';
@override String get organicHint => 'Cultivada de forma ecológica (eco)';
}
@ -1005,6 +1007,7 @@ extension on TranslationsEs {
'about.website' => 'Sitio web',
'about.openSourceLicenses' => 'Licencias de código abierto',
'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceros y sus licencias',
'about.copyright' => ({required Object years}) => '© ${years} Asociación Comunes, bajo AGPLv3',
'intro.skip' => 'Saltar',
'intro.next' => 'Siguiente',
'intro.start' => 'Empezar',
@ -1080,6 +1083,7 @@ extension on TranslationsEs {
'editVariety.notes' => 'Notas',
'editVariety.species' => 'Especie (del catálogo)',
'editVariety.speciesHint' => 'Buscar una especie…',
'editVariety.speciesSuggested' => 'Sugerida por el nombre',
'editVariety.organic' => 'Ecológica',
'editVariety.organicHint' => 'Cultivada de forma ecológica (eco)',
'addLot.title' => 'Añadir lote',

View file

@ -213,6 +213,7 @@ class _Translations$about$pt extends Translations$about$en {
@override String get website => 'Sítio web';
@override String get openSourceLicenses => 'Licenças de código aberto';
@override String get openSourceLicensesSubtitle => 'Bibliotecas de terceiros e as suas licenças';
@override String copyright({required Object years}) => '© ${years} Associação Comunes, sob AGPLv3';
}
// Path: intro
@ -347,6 +348,7 @@ class _Translations$editVariety$pt extends Translations$editVariety$en {
@override String get notes => 'Notas';
@override String get species => 'Espécie (do catálogo)';
@override String get speciesHint => 'Procura uma espécie…';
@override String get speciesSuggested => 'Sugerida pelo nome';
@override String get organic => 'Biológica';
@override String get organicHint => 'Cultivada em modo biológico';
}
@ -1001,6 +1003,7 @@ extension on TranslationsPt {
'about.website' => 'Sítio web',
'about.openSourceLicenses' => 'Licenças de código aberto',
'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceiros e as suas licenças',
'about.copyright' => ({required Object years}) => '© ${years} Associação Comunes, sob AGPLv3',
'intro.skip' => 'Saltar',
'intro.next' => 'Seguinte',
'intro.start' => 'Começar',
@ -1072,6 +1075,7 @@ extension on TranslationsPt {
'editVariety.notes' => 'Notas',
'editVariety.species' => 'Espécie (do catálogo)',
'editVariety.speciesHint' => 'Procura uma espécie…',
'editVariety.speciesSuggested' => 'Sugerida pelo nome',
'editVariety.organic' => 'Biológica',
'editVariety.organicHint' => 'Cultivada em modo biológico',
'addLot.title' => 'Adicionar lote',

View file

@ -8,6 +8,19 @@ import 'theme.dart';
/// The app's public website. Shown as a tappable row in [AboutScreen].
const String _websiteUrl = 'https://tanemaki.app';
/// The year Tanemaki's copyright starts. Ğ1nkgo uses its own first year (2023);
/// Tanemaki's is 2026.
const int _copyrightStartYear = 2026;
/// A single year (`2026`) until the calendar rolls over, then a range
/// (`2026-2027`), mirroring Ğ1nkgo's legalese line.
String _copyrightYears() {
final int now = DateTime.now().year;
return now <= _copyrightStartYear
? '$_copyrightStartYear'
: '$_copyrightStartYear-$now';
}
/// An "about" screen à la Ğ1nkgo: brand header, the human explanation of what
/// Tanemaki is and where its name comes from, the app version, license, the
/// website, and the bundled open-source licenses page.
@ -62,12 +75,24 @@ class AboutScreen extends StatelessWidget {
onTap: () => showLicensePage(
context: context,
applicationName: t.app.title,
applicationLegalese: t.about.copyright(years: _copyrightYears()),
applicationIcon: Padding(
padding: const EdgeInsets.all(8),
child: Image.asset('assets/logo.png', width: 48),
),
),
),
const Divider(height: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
t.about.copyright(years: _copyrightYears()),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: seedOnSurface.withValues(alpha: 0.7),
),
),
),
],
),
);

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:drift/drift.dart' show Value;
@ -605,6 +606,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;
@ -614,8 +620,20 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
late int? _seedHarvestMonths = widget.detail.seedHarvestMonths;
late bool _calendarOpen = widget.detail.hasCropCalendar;
// Typing stays responsive by not touching the DB on every keystroke: each
// field waits until the grower pauses before running its (heavy) lookup, and
// a per-field query counter drops any result that a later keystroke has
// already superseded, so stale suggestions never flicker back in.
static const _debounce = Duration(milliseconds: 250);
Timer? _speciesDebounce;
Timer? _nameDebounce;
int _speciesQueryId = 0;
int _nameQueryId = 0;
@override
void dispose() {
_speciesDebounce?.cancel();
_nameDebounce?.cancel();
_name.dispose();
_category.dispose();
_notes.dispose();
@ -623,10 +641,38 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
super.dispose();
}
Future<void> _onSpeciesQuery(String query) async {
void _onSpeciesQuery(String query) {
_speciesDebounce?.cancel();
_speciesDebounce = Timer(_debounce, () => _runSpeciesQuery(query));
}
Future<void> _runSpeciesQuery(String query) async {
final queryId = ++_speciesQueryId;
final lang = Localizations.localeOf(context).languageCode;
final results = await widget.species.search(query, languageCode: lang);
if (mounted) setState(() => _suggestions = results);
// A manual species search takes over from the name-based suggestion.
if (mounted && queryId == _speciesQueryId) {
setState(() => _suggestions = results);
}
}
void _onNameChanged(String name) {
if (widget.detail.speciesId != null || _pickedSpeciesId != null) return;
_nameDebounce?.cancel();
_nameDebounce = Timer(_debounce, () => _runNameClassify(name));
}
/// 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> _runNameClassify(String name) async {
if (widget.detail.speciesId != null || _pickedSpeciesId != null) return;
final queryId = ++_nameQueryId;
final lang = Localizations.localeOf(context).languageCode;
final match = await widget.species.classifyLabel(name, languageCode: lang);
if (mounted && queryId == _nameQueryId) {
setState(() => _suggested = match);
}
}
void _pick(SpeciesMatch match) {
@ -634,6 +680,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
_pickedSpeciesId = match.id;
_speciesField.text = match.displayLabel;
_suggestions = const [];
_suggested = null;
});
}
@ -667,145 +714,172 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.editVariety.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('editVariety.name'),
controller: _name,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
// Fields scroll; the Cancel/Save row is pinned as a fixed footer so Save
// stays visible and tappable no matter how tall the sheet grows.
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.editVariety.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('editVariety.name'),
controller: _name,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
onChanged: _onNameChanged,
),
const SizedBox(height: 12),
TextField(
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
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),
),
// 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,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _notes,
minLines: 2,
maxLines: 5,
decoration: InputDecoration(
labelText: t.editVariety.notes,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 4),
SwitchListTile(
key: const Key('editVariety.organic'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.eco_outlined, color: seedGreen),
title: Text(t.editVariety.organic),
subtitle: Text(t.editVariety.organicHint),
value: _isOrganic,
onChanged: (v) => setState(() => _isOrganic = v),
),
SwitchListTile(
key: const Key('editVariety.needsReproduction'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew, color: seedGreen),
title: Text(t.needsReproduction.label),
subtitle: Text(t.needsReproduction.hint),
value: _needsReproduction,
onChanged: (v) => setState(() => _needsReproduction = v),
),
// Crop calendar: one collapsed reveal, not five dropdowns up front.
ExpansionTile(
key: const Key('editVariety.cropCalendar'),
initiallyExpanded: _calendarOpen,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.calendar_month_outlined),
title: Text(t.cropCalendar.title),
onExpansionChanged: (v) => _calendarOpen = v,
children: [
_MonthMultiSelect(
label: t.cropCalendar.sow,
mask: _sowMonths,
onChanged: (m) => setState(() => _sowMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.transplant,
mask: _transplantMonths,
onChanged: (m) => setState(() => _transplantMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.flowering,
mask: _floweringMonths,
onChanged: (m) => setState(() => _floweringMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.fruiting,
mask: _fruitingMonths,
onChanged: (m) => setState(() => _fruitingMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.seedHarvest,
mask: _seedHarvestMonths,
onChanged: (m) =>
setState(() => _seedHarvestMonths = m),
),
],
),
],
),
),
const SizedBox(height: 12),
TextField(
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
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 Spacer(),
FilledButton(
key: const Key('editVariety.save'),
onPressed: _save,
child: Text(t.common.save),
),
const SizedBox(height: 12),
TextField(
controller: _category,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _notes,
minLines: 2,
maxLines: 5,
decoration: InputDecoration(
labelText: t.editVariety.notes,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 4),
SwitchListTile(
key: const Key('editVariety.organic'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.eco_outlined, color: seedGreen),
title: Text(t.editVariety.organic),
subtitle: Text(t.editVariety.organicHint),
value: _isOrganic,
onChanged: (v) => setState(() => _isOrganic = v),
),
SwitchListTile(
key: const Key('editVariety.needsReproduction'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew, color: seedGreen),
title: Text(t.needsReproduction.label),
subtitle: Text(t.needsReproduction.hint),
value: _needsReproduction,
onChanged: (v) => setState(() => _needsReproduction = v),
),
// Crop calendar: one collapsed reveal, not five dropdowns up front.
ExpansionTile(
key: const Key('editVariety.cropCalendar'),
initiallyExpanded: _calendarOpen,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.calendar_month_outlined),
title: Text(t.cropCalendar.title),
onExpansionChanged: (v) => _calendarOpen = v,
children: [
_MonthMultiSelect(
label: t.cropCalendar.sow,
mask: _sowMonths,
onChanged: (m) => setState(() => _sowMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.transplant,
mask: _transplantMonths,
onChanged: (m) => setState(() => _transplantMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.flowering,
mask: _floweringMonths,
onChanged: (m) => setState(() => _floweringMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.fruiting,
mask: _fruitingMonths,
onChanged: (m) => setState(() => _fruitingMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.seedHarvest,
mask: _seedHarvestMonths,
onChanged: (m) => setState(() => _seedHarvestMonths = m),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('editVariety.save'),
onPressed: _save,
child: Text(t.common.save),
),
],
),
],
),
],
),
],
),
);
}