Merge branch 'main' into spike/block2-derisking
This commit is contained in:
commit
c3b904ce71
24 changed files with 69558 additions and 341 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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',
|
||||
};
|
||||
|
|
@ -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)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
181
apps/app_seeds/lib/i18n/strings.g.dart
Normal file
181
apps/app_seeds/lib/i18n/strings.g.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,37 @@ void main() {
|
|||
expect(variety.lastAuthor, 'node-other');
|
||||
});
|
||||
|
||||
test('import overwrites a lot edited elsewhere (LWW)', () async {
|
||||
final ids = await populate();
|
||||
|
||||
// Fork: a second device imports this inventory, edits the lot…
|
||||
final json = codec.encode(await source.exportInventory());
|
||||
final otherDb = newTestDatabase();
|
||||
addTearDown(otherDb.close);
|
||||
final other = newTestRepository(otherDb, nodeId: 'node-other');
|
||||
await other.importInventory(codec.decode(json));
|
||||
await other.updateLot(
|
||||
lotId: ids.lotId,
|
||||
type: LotType.seed,
|
||||
harvestYear: 2025,
|
||||
quantity: const Quantity(kind: QuantityKind.cob, count: 9),
|
||||
storageLocation: 'cellar',
|
||||
);
|
||||
|
||||
// …and its export comes back: the newer edit wins here.
|
||||
final back = codec.encode(await other.exportInventory());
|
||||
final summary = await source.importInventory(codec.decode(back));
|
||||
expect(summary.updated, greaterThan(0));
|
||||
|
||||
final lot = await (sourceDb.select(
|
||||
sourceDb.lots,
|
||||
)..where((l) => l.id.equals(ids.lotId))).getSingle();
|
||||
expect(lot.harvestYear, 2025);
|
||||
expect(lot.quantityPrecise, 9);
|
||||
expect(lot.storageLocation, 'cellar');
|
||||
expect(lot.lastAuthor, 'node-other');
|
||||
});
|
||||
|
||||
test('after an import the local clock never stamps behind it', () async {
|
||||
await populate();
|
||||
final json = codec.encode(await source.exportInventory());
|
||||
|
|
@ -236,6 +267,54 @@ void main() {
|
|||
expect(Hlc.parse(written.updatedAt).compareTo(maxImported), greaterThan(0));
|
||||
});
|
||||
|
||||
test('a restarted repository re-seeds its clock from stored rows so local '
|
||||
'edits still win LWW (survives a lagging wall clock)', () async {
|
||||
// A peer device whose wall clock runs far ahead writes the inventory.
|
||||
final peerDb = newTestDatabase();
|
||||
addTearDown(peerDb.close);
|
||||
final peer = VarietyRepository(
|
||||
peerDb,
|
||||
idGen: IdGen(),
|
||||
nodeId: 'peer',
|
||||
nowMillis: () => 9000000000000, // ~year 2255
|
||||
);
|
||||
final varietyId = await peer.addQuickVariety(label: 'Maize');
|
||||
final lotId = await peer.addLot(varietyId: varietyId, harvestYear: 2024);
|
||||
final json = codec.encode(await peer.exportInventory());
|
||||
|
||||
// Our device (wall clock in the real, much earlier present) imports it.
|
||||
final myDb = newTestDatabase();
|
||||
addTearDown(myDb.close);
|
||||
const myWall = 1000; // far behind the imported stamps
|
||||
final importing = VarietyRepository(
|
||||
myDb,
|
||||
idGen: IdGen(),
|
||||
nodeId: 'mine',
|
||||
nowMillis: () => myWall,
|
||||
);
|
||||
await importing.importInventory(codec.decode(json));
|
||||
|
||||
// App restart: a brand-new repository instance over the same DB, whose
|
||||
// in-memory clock starts fresh at Hlc.zero and whose wall clock lags.
|
||||
final restarted = VarietyRepository(
|
||||
myDb,
|
||||
idGen: IdGen(),
|
||||
nodeId: 'mine',
|
||||
nowMillis: () => myWall,
|
||||
);
|
||||
final before = await _lotStamp(myDb, lotId);
|
||||
await restarted.updateLot(
|
||||
lotId: lotId,
|
||||
type: LotType.seed,
|
||||
harvestYear: 2030,
|
||||
);
|
||||
final after = await _lotStamp(myDb, lotId);
|
||||
|
||||
// The edit must out-stamp the imported row, or it would silently lose
|
||||
// last-writer-wins when this device shares its backup back.
|
||||
expect(Hlc.parse(after).compareTo(Hlc.parse(before)), greaterThan(0));
|
||||
});
|
||||
|
||||
test('unmatched species leaves speciesId null instead of dangling', () async {
|
||||
await populate();
|
||||
final json = codec.encode(await source.exportInventory());
|
||||
|
|
@ -250,3 +329,11 @@ void main() {
|
|||
expect(variety.speciesId, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
/// The packed HLC `updatedAt` currently stored for [lotId].
|
||||
Future<String> _lotStamp(AppDatabase db, String lotId) async {
|
||||
final lot = await (db.select(
|
||||
db.lots,
|
||||
)..where((l) => l.id.equals(lotId))).getSingle();
|
||||
return lot.updatedAt;
|
||||
}
|
||||
|
|
|
|||
84
apps/app_seeds/test/data/species_catalog_asset_test.dart
Normal file
84
apps/app_seeds/test/data/species_catalog_asset_test.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/species_catalog.dart';
|
||||
|
||||
/// Guards the committed, generated catalog asset (produced by
|
||||
/// `tool/gen_species_catalog.dart`). Reads the file directly — no Flutter asset
|
||||
/// bundle needed — so it runs as a plain VM test.
|
||||
void main() {
|
||||
// `flutter test` runs with the package root as the working directory.
|
||||
final file = File('assets/catalog/species.json');
|
||||
final json = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||
final species = (json['species'] as List).cast<Map<String, dynamic>>();
|
||||
|
||||
test('is the current (version 3) generated catalog', () {
|
||||
expect(json['version'], 3);
|
||||
});
|
||||
|
||||
test('is a broad catalog, not the old starter set', () {
|
||||
// The expanded catalog holds ~1200 species (was 14 by hand). The floor
|
||||
// leaves headroom for Wikidata drift across regenerations.
|
||||
expect(species.length, greaterThan(900));
|
||||
});
|
||||
|
||||
test('every entry has a scientific name and at least one localized name', () {
|
||||
for (final e in species) {
|
||||
final sci = e['scientific_name'];
|
||||
expect(sci, isA<String>());
|
||||
expect((sci as String).trim(), isNotEmpty);
|
||||
|
||||
final common = e['common'] as Map<String, dynamic>;
|
||||
expect(common, isNotEmpty, reason: '$sci has no common names');
|
||||
for (final entry in common.entries) {
|
||||
final names = (entry.value as List).cast<String>();
|
||||
expect(names, isNotEmpty);
|
||||
expect(names.every((n) => n.trim().isNotEmpty), isTrue);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('identifiers and reference fields are well typed', () {
|
||||
for (final e in species) {
|
||||
expect(e['wikidata_qid'], matches(r'^Q\d+$'));
|
||||
if (e.containsKey('gbif_key')) expect(e['gbif_key'], isA<int>());
|
||||
if (e.containsKey('viability_years')) {
|
||||
expect(e['viability_years'], isA<int>());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('scientific names are unique', () {
|
||||
final names = species.map((e) => e['scientific_name'] as String).toList();
|
||||
expect(names.length, names.toSet().length);
|
||||
});
|
||||
|
||||
test('is genuinely multilingual (non-Latin scripts present)', () {
|
||||
int withLang(String code) =>
|
||||
species.where((e) => (e['common'] as Map).containsKey(code)).length;
|
||||
// International by design: Arabic (RTL) and CJK must be represented, not
|
||||
// an es/en-only catalog.
|
||||
expect(withLang('ar'), greaterThan(0), reason: 'no Arabic names');
|
||||
expect(withLang('zh'), greaterThan(0), reason: 'no Chinese names');
|
||||
expect(withLang('ja'), greaterThan(0), reason: 'no Japanese names');
|
||||
});
|
||||
|
||||
test('keeps the curated core crops with their authoritative names', () {
|
||||
final byName = {for (final e in species) e['scientific_name'] as String: e};
|
||||
final tomato = byName['Solanum lycopersicum'];
|
||||
final carrot = byName['Daucus carota'];
|
||||
expect(tomato, isNotNull);
|
||||
expect(carrot, isNotNull);
|
||||
// Curated names lead (not Wikidata's messier taxon labels) and viability
|
||||
// reference data survives the regeneration.
|
||||
expect((tomato!['common'] as Map)['es'], contains('Tomate'));
|
||||
expect((carrot!['common'] as Map)['es'], contains('Zanahoria'));
|
||||
expect(carrot['viability_years'], 3);
|
||||
});
|
||||
|
||||
test('parses through the app loader without error', () {
|
||||
final seeds = parseSpeciesCatalog(file.readAsStringSync());
|
||||
expect(seeds.length, species.length);
|
||||
});
|
||||
}
|
||||
|
|
@ -40,12 +40,14 @@ void main() {
|
|||
'parseSpeciesCatalog reads scientific name, family and common names',
|
||||
() {
|
||||
const json = '''
|
||||
{"version":1,"species":[
|
||||
{"scientific_name":"Zea mays","family":"Poaceae","common":{"en":["Maize"],"es":["Maíz"]}}
|
||||
{"version":3,"species":[
|
||||
{"scientific_name":"Zea mays","family":"Poaceae","wikidata_qid":"Q11575","gbif_key":2703077,"common":{"en":["Maize"],"es":["Maíz"]}}
|
||||
]}''';
|
||||
final seeds = parseSpeciesCatalog(json);
|
||||
expect(seeds.single.scientificName, 'Zea mays');
|
||||
expect(seeds.single.family, 'Poaceae');
|
||||
expect(seeds.single.wikidataQid, 'Q11575');
|
||||
expect(seeds.single.gbifKey, 2703077);
|
||||
expect(seeds.single.commonNames['es'], ['Maíz']);
|
||||
},
|
||||
);
|
||||
|
|
@ -140,4 +142,99 @@ void main() {
|
|||
await repo.seedBundled(_seeds);
|
||||
expect(await repo.search(' '), isEmpty);
|
||||
});
|
||||
|
||||
test('seedBundled stores wikidata id and gbif key', () async {
|
||||
await repo.seedBundled(const [
|
||||
SpeciesSeed(
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
family: 'Solanaceae',
|
||||
wikidataQid: 'Q13223',
|
||||
gbifKey: 2930137,
|
||||
commonNames: {
|
||||
'en': ['Tomato'],
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
final row = await db.select(db.species).getSingle();
|
||||
expect(row.wikidataQid, 'Q13223');
|
||||
expect(row.gbifKey, 2930137);
|
||||
});
|
||||
|
||||
test('seedBundled backfills reference fields added later', () async {
|
||||
// First seed lacks the enrichment fields (an older catalog).
|
||||
await repo.seedBundled(const [
|
||||
SpeciesSeed(
|
||||
scientificName: 'Allium cepa',
|
||||
commonNames: {
|
||||
'en': ['Onion'],
|
||||
},
|
||||
),
|
||||
]);
|
||||
// A later catalog carries them; identity is preserved, fields are filled.
|
||||
await repo.seedBundled(const [
|
||||
SpeciesSeed(
|
||||
scientificName: 'Allium cepa',
|
||||
wikidataQid: 'Q23485',
|
||||
gbifKey: 2857697,
|
||||
viabilityYears: 1,
|
||||
commonNames: {
|
||||
'en': ['Onion'],
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
final rows = await db.select(db.species).get();
|
||||
expect(rows.length, 1);
|
||||
expect(rows.single.wikidataQid, 'Q23485');
|
||||
expect(rows.single.gbifKey, 2857697);
|
||||
expect(rows.single.viabilityYears, 1);
|
||||
});
|
||||
|
||||
test('search matches a non-Latin common name', () async {
|
||||
await repo.seedBundled(const [
|
||||
SpeciesSeed(
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
commonNames: {
|
||||
'ar': ['طماطم'],
|
||||
'zh': ['番茄'],
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
final ar = await repo.search('طماطم', languageCode: 'ar');
|
||||
expect(ar.single.scientificName, 'Solanum lycopersicum');
|
||||
expect(ar.single.commonName, 'طماطم');
|
||||
|
||||
final zh = await repo.search('番茄', languageCode: 'zh');
|
||||
expect(zh.single.commonName, '番茄');
|
||||
});
|
||||
|
||||
test('search respects the limit', () async {
|
||||
final many = [
|
||||
for (var i = 0; i < 20; i++)
|
||||
SpeciesSeed(
|
||||
scientificName: 'Genus species$i',
|
||||
commonNames: const {
|
||||
'en': ['Plant'],
|
||||
},
|
||||
),
|
||||
];
|
||||
await repo.seedBundled(many);
|
||||
final results = await repo.search('Genus', limit: 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);
|
||||
});
|
||||
}
|
||||
159
apps/app_seeds/test/tool/gen_species_catalog_test.dart
Normal file
159
apps/app_seeds/test/tool/gen_species_catalog_test.dart
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../tool/gen_species_catalog.dart';
|
||||
|
||||
void main() {
|
||||
group('isVernacularName', () {
|
||||
test('accepts genuine common names', () {
|
||||
expect(isVernacularName('Tomate', 'solanum lycopersicum'), isTrue);
|
||||
expect(isVernacularName("Queen Anne's lace", 'daucus carota'), isTrue);
|
||||
expect(isVernacularName('番茄', 'solanum lycopersicum'), isTrue);
|
||||
});
|
||||
|
||||
test('rejects the scientific name itself', () {
|
||||
expect(
|
||||
isVernacularName('Solanum lycopersicum', 'solanum lycopersicum'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects scientific noise: commas, parentheses, ranks, initials', () {
|
||||
expect(isVernacularName('Remusatia formosana , Hayata', 'x'), isFalse);
|
||||
expect(isVernacularName('滇常山(原变种)', 'x'), isFalse);
|
||||
expect(isVernacularName('Brassica oleracea var. acephala', 'x'), isFalse);
|
||||
expect(isVernacularName('Z. mays', 'x'), isFalse);
|
||||
expect(isVernacularName('Allium cepa L.', 'x'), isFalse);
|
||||
expect(isVernacularName('Prunus × domestica', 'x'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildCatalog', () {
|
||||
test('emits version 3 and sorts by scientific name', () {
|
||||
final catalog = buildCatalog([
|
||||
SpeciesRecord(
|
||||
qid: 'Q2',
|
||||
scientificName: 'Zea mays',
|
||||
common: {
|
||||
'en': {'Maize'},
|
||||
},
|
||||
),
|
||||
SpeciesRecord(
|
||||
qid: 'Q1',
|
||||
scientificName: 'Allium cepa',
|
||||
common: {
|
||||
'en': {'Onion'},
|
||||
},
|
||||
),
|
||||
], const {});
|
||||
|
||||
expect(catalog['version'], 3);
|
||||
expect((catalog['species'] as List).map((e) => e['scientific_name']), [
|
||||
'Allium cepa',
|
||||
'Zea mays',
|
||||
]);
|
||||
});
|
||||
|
||||
test('dedups by scientific name, merging names and backfilling ids', () {
|
||||
final catalog = buildCatalog([
|
||||
SpeciesRecord(
|
||||
qid: 'Q13223',
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
family: 'Solanaceae',
|
||||
common: {
|
||||
'es': {'tomate'},
|
||||
},
|
||||
),
|
||||
SpeciesRecord(
|
||||
qid: 'Q-dupe',
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
gbifKey: 2930137,
|
||||
common: {
|
||||
'en': {'tomato'},
|
||||
'es': {'tomatera'},
|
||||
},
|
||||
),
|
||||
], const {});
|
||||
|
||||
final entry = (catalog['species'] as List).single as Map;
|
||||
expect(entry['wikidata_qid'], 'Q13223'); // first entity kept
|
||||
expect(entry['family'], 'Solanaceae');
|
||||
expect(entry['gbif_key'], 2930137); // backfilled from the duplicate
|
||||
expect(entry['common']['es'], containsAll(['tomate', 'tomatera']));
|
||||
expect(entry['common']['en'], ['tomato']);
|
||||
});
|
||||
|
||||
test('drops names that are not real vernaculars', () {
|
||||
final catalog = buildCatalog([
|
||||
SpeciesRecord(
|
||||
qid: 'Q1',
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
common: {
|
||||
'es': {'Solanum lycopersicum', 'Tomate'},
|
||||
},
|
||||
),
|
||||
], const {});
|
||||
expect((catalog['species'] as List).single['common']['es'], ['Tomate']);
|
||||
});
|
||||
|
||||
test('minLanguages is a relevance floor for Wikidata-only species', () {
|
||||
final records = [
|
||||
SpeciesRecord(
|
||||
qid: 'Q1',
|
||||
scientificName: 'Obscura ignota',
|
||||
common: {
|
||||
'zh': {'仅中文'},
|
||||
},
|
||||
),
|
||||
SpeciesRecord(
|
||||
qid: 'Q2',
|
||||
scientificName: 'Nota bene',
|
||||
common: {
|
||||
'en': {'well known'},
|
||||
'es': {'bien conocida'},
|
||||
},
|
||||
),
|
||||
];
|
||||
final kept =
|
||||
(buildCatalog(records, const {}, minLanguages: 2)['species'] as List)
|
||||
.map((e) => e['scientific_name'])
|
||||
.toList();
|
||||
expect(kept, ['Nota bene']); // the 1-language species is filtered out
|
||||
});
|
||||
|
||||
test('curated overrides win, come first, and bypass the floor', () {
|
||||
final catalog = buildCatalog(
|
||||
[
|
||||
SpeciesRecord(
|
||||
qid: 'Q081',
|
||||
scientificName: 'Daucus carota',
|
||||
family: 'Apiaceae',
|
||||
common: {
|
||||
// Wikidata's messy taxon labels for the wild carrot.
|
||||
'en': {'Devil’s Plague', 'wild carrot'},
|
||||
},
|
||||
),
|
||||
],
|
||||
const {
|
||||
'Daucus carota': CuratedOverride(
|
||||
family: 'Apiaceae',
|
||||
viabilityYears: 3,
|
||||
common: {
|
||||
'es': ['Zanahoria'],
|
||||
'en': ['Carrot'],
|
||||
},
|
||||
),
|
||||
},
|
||||
// Would be filtered as it has only one Wikidata language, but curated
|
||||
// species are always kept.
|
||||
minLanguages: 4,
|
||||
);
|
||||
|
||||
final entry = (catalog['species'] as List).single as Map;
|
||||
expect(entry['viability_years'], 3);
|
||||
expect(entry['common']['es'], ['Zanahoria']);
|
||||
// Curated name first, then the Wikidata vernaculars.
|
||||
expect(entry['common']['en'].first, 'Carrot');
|
||||
expect(entry['common']['en'], contains('wild carrot'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -76,6 +76,59 @@ void main() {
|
|||
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 {
|
||||
final repo = newTestRepository(db);
|
||||
final id = await repo.addQuickVariety(label: 'Bean');
|
||||
|
|
|
|||
56
apps/app_seeds/tool/README.md
Normal file
56
apps/app_seeds/tool/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Species catalog generator
|
||||
|
||||
Regenerates [`assets/catalog/species.json`](../assets/catalog/species.json) — the
|
||||
bundled, offline, multilingual catalog of edible / cultivated plant species the
|
||||
app seeds into the encrypted database at first run.
|
||||
|
||||
```sh
|
||||
cd apps/app_seeds
|
||||
dart run tool/gen_species_catalog.dart # writes species.json (version 3)
|
||||
dart run tool/gen_species_catalog.dart --dry # prints stats, writes nothing
|
||||
```
|
||||
|
||||
The generated JSON is **committed** to the repo — the app never queries the
|
||||
network for it (local-first, offline).
|
||||
|
||||
## Sources & licensing
|
||||
|
||||
- **Names** come only from **Wikidata** labels and aliases. Wikidata is **CC0**
|
||||
(public domain), so the bundled data carries **no attribution burden** and is
|
||||
clean for redistribution under the app's AGPL-3.0 license.
|
||||
- **`gbif_key`** links the [GBIF](https://www.gbif.org/) taxonomic backbone. It
|
||||
is used **only as an identifier** (a fact, not copyrightable text) — no GBIF
|
||||
vernacular text is bundled, so no CC-BY attribution is triggered.
|
||||
- **Curated core crops** (names, family and `viability_years`) live in
|
||||
[`curated_overrides.json`](curated_overrides.json), hand-maintained. For a
|
||||
listed species the curated common names **win** (placed first, never dropped)
|
||||
and its family/viability override Wikidata; Wikidata still adds more languages.
|
||||
A curated species is always kept even if it would miss the relevance floor.
|
||||
- **`viability_years`** (seed longevity) is **not** in Wikidata — it comes only
|
||||
from the curated overrides (public-domain agricultural-extension figures).
|
||||
Species without an entry simply get no age-based expiry warning.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Phase 1 — species list (SPARQL):** taxa with a binomial name that are a
|
||||
*crop* (`P279* Q235352`), the source/ingredient of a *food*
|
||||
(`P1672`/`P366`/reverse `P1582` → `P279* Q2095`), with scientific name
|
||||
(`P225`) and GBIF key (`P846`).
|
||||
2. **Phase 2 — names + family:** `rdfs:label` (primary) + `skos:altLabel`
|
||||
(aliases), batched over the species and filtered to the bundled languages;
|
||||
the family is walked up `P171*` to the family-rank ancestor.
|
||||
3. **Transform (`buildCatalog`, pure & unit-tested):** dedup by scientific name;
|
||||
drop non-vernacular noise (the binomial itself, author citations, `var.`/`×`
|
||||
ranks, abbreviated initials); merge the curated overrides (authoritative,
|
||||
first); require a real vernacular in **≥ 4 languages** as a relevance floor so
|
||||
the long tail of obscure single-label taxa is dropped; sort; serialize.
|
||||
|
||||
The current run yields **~1200 species** (`assets/catalog/species.json` ≈ 1.5 MB).
|
||||
|
||||
## Bundled languages
|
||||
|
||||
`es, en, fr, de, it, pt, ca, gl, eu, ar, zh, ja, ru` — a multilingual **seed**,
|
||||
not a ceiling (Latin + Arabic RTL + CJK + Cyrillic). Edit `kCatalogLanguages` in
|
||||
[`gen_species_catalog.dart`](gen_species_catalog.dart) to widen coverage, then
|
||||
regenerate. A species with no name in any bundled language is dropped (add its
|
||||
language rather than assuming es/en).
|
||||
19
apps/app_seeds/tool/curated_overrides.json
Normal file
19
apps/app_seeds/tool/curated_overrides.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"note": "Hand-curated, authoritative data for core crops, merged into the generated catalog by gen_species_catalog.dart. For a listed species the curated common names win (placed first, never dropped by the vernacular filter), and its family/viability_years override Wikidata; Wikidata still enriches with additional languages. A curated species is always kept even if it would miss the relevance floor. `viability_years` is public-domain agricultural-extension seed longevity (Wikidata has none); a conservative single value used to warn about aging lots. Extend this list to pin the names of species that matter to your community.",
|
||||
"species": {
|
||||
"Solanum lycopersicum": { "family": "Solanaceae", "viability_years": 5, "common": { "es": ["Tomate"], "en": ["Tomato"] } },
|
||||
"Solanum melongena": { "family": "Solanaceae", "viability_years": 5, "common": { "es": ["Berenjena"], "en": ["Aubergine", "Eggplant"] } },
|
||||
"Capsicum annuum": { "family": "Solanaceae", "viability_years": 3, "common": { "es": ["Pimiento", "Guindilla"], "en": ["Pepper", "Chilli"] } },
|
||||
"Phaseolus vulgaris": { "family": "Fabaceae", "viability_years": 3, "common": { "es": ["Judía", "Alubia", "Habichuela"], "en": ["Common bean"] } },
|
||||
"Vicia faba": { "family": "Fabaceae", "viability_years": 4, "common": { "es": ["Haba"], "en": ["Broad bean", "Fava bean"] } },
|
||||
"Pisum sativum": { "family": "Fabaceae", "viability_years": 3, "common": { "es": ["Guisante"], "en": ["Pea"] } },
|
||||
"Zea mays": { "family": "Poaceae", "viability_years": 2, "common": { "es": ["Maíz"], "en": ["Maize", "Corn"] } },
|
||||
"Cucurbita pepo": { "family": "Cucurbitaceae", "viability_years": 5, "common": { "es": ["Calabacín", "Calabaza"], "en": ["Courgette", "Zucchini", "Squash"] } },
|
||||
"Cucumis sativus": { "family": "Cucurbitaceae", "viability_years": 5, "common": { "es": ["Pepino"], "en": ["Cucumber"] } },
|
||||
"Lactuca sativa": { "family": "Asteraceae", "viability_years": 3, "common": { "es": ["Lechuga"], "en": ["Lettuce"] } },
|
||||
"Beta vulgaris": { "family": "Amaranthaceae", "viability_years": 4, "common": { "es": ["Acelga", "Remolacha"], "en": ["Chard", "Beetroot"] } },
|
||||
"Allium cepa": { "family": "Amaryllidaceae", "viability_years": 1, "common": { "es": ["Cebolla"], "en": ["Onion"] } },
|
||||
"Allium sativum": { "family": "Amaryllidaceae", "viability_years": 1, "common": { "es": ["Ajo"], "en": ["Garlic"] } },
|
||||
"Daucus carota": { "family": "Apiaceae", "viability_years": 3, "common": { "es": ["Zanahoria"], "en": ["Carrot"] } }
|
||||
}
|
||||
}
|
||||
368
apps/app_seeds/tool/gen_species_catalog.dart
Normal file
368
apps/app_seeds/tool/gen_species_catalog.dart
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
// Regenerates the bundled species catalog (`assets/catalog/species.json`) from
|
||||
// Wikidata (CC0). Dev-only tool — it is NOT shipped in the app; the app only
|
||||
// reads the committed JSON it produces (local-first, offline).
|
||||
//
|
||||
// dart run tool/gen_species_catalog.dart # writes species.json (v3)
|
||||
// dart run tool/gen_species_catalog.dart --dry # prints stats, writes nothing
|
||||
//
|
||||
// Names come only from Wikidata labels/aliases (CC0 → no attribution burden).
|
||||
// GBIF is used solely as an identifier (`gbif_key`, a fact, not copyrightable).
|
||||
// Seed-longevity (`viability_years`) and authoritative core-crop names are
|
||||
// merged from the hand-maintained `tool/curated_overrides.json`. See
|
||||
// `tool/README.md`.
|
||||
//
|
||||
// The pure transform (`buildCatalog`) is separated from the network fetch so it
|
||||
// is unit-testable without hitting the endpoint.
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// Locale set bundled at launch — a multilingual seed, not a ceiling. Covers
|
||||
/// Latin, Arabic (RTL), CJK (Chinese + Japanese) and Cyrillic scripts. Order
|
||||
/// here is the order common names are written in the JSON.
|
||||
const List<String> kCatalogLanguages = [
|
||||
'es',
|
||||
'en',
|
||||
'fr',
|
||||
'de',
|
||||
'it',
|
||||
'pt',
|
||||
'ca',
|
||||
'gl',
|
||||
'eu',
|
||||
'ar',
|
||||
'zh',
|
||||
'ja',
|
||||
'ru',
|
||||
];
|
||||
|
||||
const String _endpoint = 'https://query.wikidata.org/sparql';
|
||||
|
||||
/// Wikimedia asks bots to send a descriptive, contactable User-Agent.
|
||||
const String _userAgent =
|
||||
'TanemakiSpeciesCatalogBot/1.0 (https://tanemaki.app; vjrj@comunes.org)';
|
||||
|
||||
const String _catalogNote =
|
||||
'Edible / cultivated plant species, generated from Wikidata (CC0) by '
|
||||
'tool/gen_species_catalog.dart — see tool/README.md to regenerate. `common` '
|
||||
'is a locale-keyed map (Wikidata labels + aliases) the app resolves by the '
|
||||
"user's locale with graceful fallback, never assuming es/en only. "
|
||||
'`gbif_key` links the GBIF taxonomic backbone (identifier only). '
|
||||
'`viability_years` is public-domain agricultural-extension seed longevity '
|
||||
'merged from tool/curated_overrides.json (not in Wikidata); a conservative '
|
||||
'single value used to warn about aging lots.';
|
||||
|
||||
/// Hand-curated, authoritative data for a core crop (from
|
||||
/// `curated_overrides.json`). Its names win over Wikidata's; its family and
|
||||
/// viability override; a curated species is always kept.
|
||||
class CuratedOverride {
|
||||
const CuratedOverride({
|
||||
this.family,
|
||||
this.viabilityYears,
|
||||
this.common = const {},
|
||||
});
|
||||
|
||||
final String? family;
|
||||
final int? viabilityYears;
|
||||
final Map<String, List<String>> common;
|
||||
}
|
||||
|
||||
/// One aggregated species before it is serialized. Mutable during aggregation.
|
||||
class SpeciesRecord {
|
||||
SpeciesRecord({
|
||||
required this.qid,
|
||||
required this.scientificName,
|
||||
this.family,
|
||||
this.gbifKey,
|
||||
Map<String, Set<String>>? common,
|
||||
}) : common = common ?? {};
|
||||
|
||||
final String qid;
|
||||
final String scientificName;
|
||||
String? family;
|
||||
int? gbifKey;
|
||||
|
||||
/// language code → set of names (dedup, insertion-ordered).
|
||||
final Map<String, Set<String>> common;
|
||||
}
|
||||
|
||||
/// Whether [name] reads as a genuine vernacular rather than a scientific name,
|
||||
/// synonym or author citation. Wikidata aliases are full of the latter (commas,
|
||||
/// parentheses, hybrid `×`, `var.`/`subsp.` ranks, or a straight repeat of the
|
||||
/// binomial); those are noise in a common-name list.
|
||||
bool isVernacularName(String name, String scientificNameLower) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return false;
|
||||
if (n.toLowerCase() == scientificNameLower) return false;
|
||||
// Commas/parentheses (ASCII and CJK full-width) and the hybrid sign mark
|
||||
// author citations, synonym lists and hybrid formulas — never a vernacular.
|
||||
if (n.contains(RegExp(r'[,()(),×]'))) return false;
|
||||
if (n.contains(RegExp(r'\b(var|subsp|ssp|f|cv|nothosubsp)\.'))) return false;
|
||||
// A lone initial + period is an abbreviated genus or author citation
|
||||
// ("Z. mays", "Allium cepa L."), not a vernacular.
|
||||
if (n.contains(RegExp(r'\b[A-Z]\.'))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Pure transform: dedup by scientific name, keep only [languages], drop
|
||||
/// non-vernacular noise, require a name in at least [minLanguages] languages
|
||||
/// (a relevance floor — obscure taxa carry a vernacular in only one), merge the
|
||||
/// hand-curated [curated] overrides (authoritative names/family/viability that
|
||||
/// win over Wikidata and are always kept), and emit an ordered, JSON-ready
|
||||
/// catalog map (`{version, note, species: [...]}`). No network, no I/O.
|
||||
Map<String, dynamic> buildCatalog(
|
||||
Iterable<SpeciesRecord> records,
|
||||
Map<String, CuratedOverride> curated, {
|
||||
List<String> languages = kCatalogLanguages,
|
||||
int minLanguages = 1,
|
||||
}) {
|
||||
// Dedup by scientific name; merge names and backfill family/gbif across the
|
||||
// duplicate entities Wikidata sometimes has for one accepted binomial.
|
||||
final byName = <String, SpeciesRecord>{};
|
||||
for (final r in records) {
|
||||
final name = r.scientificName.trim();
|
||||
if (name.isEmpty) continue;
|
||||
final existing = byName[name];
|
||||
if (existing == null) {
|
||||
byName[name] = r;
|
||||
continue;
|
||||
}
|
||||
existing.family ??= r.family;
|
||||
existing.gbifKey ??= r.gbifKey;
|
||||
for (final entry in r.common.entries) {
|
||||
existing.common.putIfAbsent(entry.key, () => {}).addAll(entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
final species = <Map<String, dynamic>>[];
|
||||
for (final r in byName.values) {
|
||||
final sciLower = r.scientificName.trim().toLowerCase();
|
||||
final override = curated[r.scientificName];
|
||||
final common = <String, List<String>>{};
|
||||
for (final lang in languages) {
|
||||
final ordered = <String>[];
|
||||
final seen = <String>{};
|
||||
// Curated names first (authoritative, kept as-is), then Wikidata's
|
||||
// vernaculars, de-duplicated case-insensitively.
|
||||
for (final n in override?.common[lang] ?? const <String>[]) {
|
||||
if (n.trim().isNotEmpty && seen.add(n.toLowerCase())) ordered.add(n);
|
||||
}
|
||||
for (final n in r.common[lang] ?? const <String>{}) {
|
||||
final v = n.trim();
|
||||
if (!isVernacularName(v, sciLower)) continue;
|
||||
if (seen.add(v.toLowerCase())) ordered.add(v);
|
||||
}
|
||||
if (ordered.isNotEmpty) common[lang] = ordered;
|
||||
}
|
||||
// A relevance floor for Wikidata-only species: obscure taxa carry a real
|
||||
// vernacular in only one language (a lone CJK/Cyrillic label), well-known
|
||||
// crops in many. Curated species bypass the floor.
|
||||
if (override == null && common.length < minLanguages) continue;
|
||||
if (common.isEmpty) continue;
|
||||
|
||||
final family = override?.family ?? r.family;
|
||||
final viability = override?.viabilityYears;
|
||||
species.add({
|
||||
'scientific_name': r.scientificName,
|
||||
'family': ?family,
|
||||
'wikidata_qid': r.qid,
|
||||
if (r.gbifKey != null) 'gbif_key': r.gbifKey,
|
||||
'common': common,
|
||||
'viability_years': ?viability,
|
||||
});
|
||||
}
|
||||
|
||||
species.sort(
|
||||
(a, b) => (a['scientific_name'] as String).compareTo(
|
||||
b['scientific_name'] as String,
|
||||
),
|
||||
);
|
||||
|
||||
return {'version': 3, 'note': _catalogNote, 'species': species};
|
||||
}
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
final dryRun = args.contains('--dry');
|
||||
final toolDir = File(Platform.script.toFilePath()).parent;
|
||||
final overridesFile = File('${toolDir.path}/curated_overrides.json');
|
||||
final outFile = File('${toolDir.path}/../assets/catalog/species.json');
|
||||
|
||||
final curated = _readCuratedOverrides(overridesFile);
|
||||
stdout.writeln('Loaded ${curated.length} curated overrides.');
|
||||
|
||||
final client = HttpClient()..userAgent = _userAgent;
|
||||
try {
|
||||
stdout.writeln('Querying Wikidata for edible/cultivated species…');
|
||||
final skeleton = await _fetchSpeciesSkeleton(client);
|
||||
stdout.writeln(
|
||||
'Found ${skeleton.length} candidate species. Fetching '
|
||||
'names in ${kCatalogLanguages.length} languages…',
|
||||
);
|
||||
await _fetchLabels(client, skeleton);
|
||||
|
||||
final catalog = buildCatalog(skeleton.values, curated, minLanguages: 4);
|
||||
final count = (catalog['species'] as List).length;
|
||||
stdout.writeln('Built catalog: $count species with bundled-locale names.');
|
||||
|
||||
if (dryRun) {
|
||||
stdout.writeln('--dry: not writing ${outFile.path}');
|
||||
return;
|
||||
}
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
await outFile.writeAsString('${encoder.convert(catalog)}\n');
|
||||
stdout.writeln('Wrote ${outFile.path}');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, CuratedOverride> _readCuratedOverrides(File file) {
|
||||
if (!file.existsSync()) return {};
|
||||
final data = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||
final species = (data['species'] as Map<String, dynamic>);
|
||||
return species.map((name, raw) {
|
||||
final o = raw as Map<String, dynamic>;
|
||||
final common = (o['common'] as Map<String, dynamic>? ?? const {}).map(
|
||||
(lang, names) => MapEntry(lang, (names as List).cast<String>()),
|
||||
);
|
||||
return MapEntry(
|
||||
name,
|
||||
CuratedOverride(
|
||||
family: o['family'] as String?,
|
||||
viabilityYears: (o['viability_years'] as num?)?.toInt(),
|
||||
common: common,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Phase 1: the list of species QIDs with scientific name, family and GBIF key.
|
||||
Future<Map<String, SpeciesRecord>> _fetchSpeciesSkeleton(
|
||||
HttpClient client,
|
||||
) async {
|
||||
// Taxa with a binomial (or lower) scientific name — CONTAINS(" ") keeps
|
||||
// species and botanical varieties (relevant for seed savers: e.g. the many
|
||||
// Brassica oleracea var. cultivars) while dropping genus-level rows — that are
|
||||
// edible/cultivated, reached three ways: a crop, the source of a food, or the
|
||||
// source of a food ingredient. Taxon rank is intentionally not required: many
|
||||
// cultivated taxa in Wikidata leave P105 unset.
|
||||
const query = r'''
|
||||
SELECT ?item ?sci ?gbif WHERE {
|
||||
?item wdt:P225 ?sci .
|
||||
FILTER(CONTAINS(?sci, " "))
|
||||
{
|
||||
?item wdt:P279* wd:Q235352 . # a crop
|
||||
} UNION {
|
||||
?item wdt:P1672 ?use . # this taxon is source of …
|
||||
?use wdt:P279* wd:Q2095 . # … a food
|
||||
} UNION {
|
||||
?item wdt:P1672 ?use .
|
||||
?use wdt:P279* wd:Q25403900 . # … a food ingredient
|
||||
} UNION {
|
||||
?food wdt:P1582 ?item . # … the natural source of a food
|
||||
?food wdt:P279* wd:Q2095 .
|
||||
} UNION {
|
||||
?item wdt:P366 ?use . # this taxon has use …
|
||||
?use wdt:P279* wd:Q2095 . # … food
|
||||
}
|
||||
OPTIONAL { ?item wdt:P846 ?gbif . }
|
||||
}''';
|
||||
final rows = await _runSparql(client, query);
|
||||
final out = <String, SpeciesRecord>{};
|
||||
for (final row in rows) {
|
||||
final qid = _qid(row['item']?['value'] as String?);
|
||||
final sci = row['sci']?['value'] as String?;
|
||||
if (qid == null || sci == null) continue;
|
||||
final rec = out.putIfAbsent(
|
||||
qid,
|
||||
() => SpeciesRecord(qid: qid, scientificName: sci),
|
||||
);
|
||||
final gbif = row['gbif']?['value'] as String?;
|
||||
if (gbif != null) rec.gbifKey ??= int.tryParse(gbif);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Phase 2: labels + aliases (and the family, walked up `P171*`) in the bundled
|
||||
/// languages, batched over QIDs to keep each query under the endpoint timeout.
|
||||
Future<void> _fetchLabels(
|
||||
HttpClient client,
|
||||
Map<String, SpeciesRecord> skeleton,
|
||||
) async {
|
||||
final langFilter = kCatalogLanguages.map((l) => '"$l"').join(', ');
|
||||
final qids = skeleton.keys.toList();
|
||||
const batchSize = 100;
|
||||
for (var i = 0; i < qids.length; i += batchSize) {
|
||||
final batch = qids.skip(i).take(batchSize);
|
||||
final values = batch.map((q) => 'wd:$q').join(' ');
|
||||
// `kind` orders the primary label ("a", rdfs:label — usually the real
|
||||
// vernacular) ahead of aliases ("b", skos:altLabel — often synonyms).
|
||||
final query =
|
||||
'''
|
||||
SELECT ?item ?label ?kind ?family WHERE {
|
||||
VALUES ?item { $values }
|
||||
OPTIONAL {
|
||||
?item wdt:P171* ?fam . # walk up to the family-rank ancestor
|
||||
?fam wdt:P105 wd:Q35409 ;
|
||||
wdt:P225 ?family .
|
||||
}
|
||||
{ ?item rdfs:label ?label . BIND("a" AS ?kind) }
|
||||
UNION
|
||||
{ ?item skos:altLabel ?label . BIND("b" AS ?kind) }
|
||||
FILTER(LANG(?label) IN ($langFilter))
|
||||
}''';
|
||||
final rows = await _runSparql(client, query);
|
||||
// Primary labels first so they land first in each language's ordered set.
|
||||
rows.sort(
|
||||
(a, b) => (a['kind']?['value'] as String? ?? 'b').compareTo(
|
||||
b['kind']?['value'] as String? ?? 'b',
|
||||
),
|
||||
);
|
||||
for (final row in rows) {
|
||||
final qid = _qid(row['item']?['value'] as String?);
|
||||
final label = row['label'];
|
||||
if (qid == null || label == null) continue;
|
||||
final rec = skeleton[qid];
|
||||
if (rec == null) continue;
|
||||
rec.family ??= row['family']?['value'] as String?;
|
||||
final lang = label['xml:lang'] as String?;
|
||||
final value = label['value'] as String?;
|
||||
if (lang == null || value == null) continue;
|
||||
rec.common.putIfAbsent(lang, () => {}).add(value);
|
||||
}
|
||||
stdout.writeln(
|
||||
' labels: ${(i + batchSize).clamp(0, qids.length)}'
|
||||
'/${qids.length}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a SPARQL SELECT and returns the `results.bindings` list.
|
||||
Future<List<Map<String, dynamic>>> _runSparql(
|
||||
HttpClient client,
|
||||
String query,
|
||||
) async {
|
||||
final uri = Uri.parse(
|
||||
_endpoint,
|
||||
).replace(queryParameters: {'query': query, 'format': 'json'});
|
||||
final request = await client.getUrl(uri);
|
||||
request.headers.set(
|
||||
HttpHeaders.acceptHeader,
|
||||
'application/sparql-results+json',
|
||||
);
|
||||
final response = await request.close();
|
||||
if (response.statusCode != 200) {
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
throw HttpException('SPARQL ${response.statusCode}: $body');
|
||||
}
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final decoded = jsonDecode(body) as Map<String, dynamic>;
|
||||
final bindings = (decoded['results']?['bindings'] as List?) ?? const [];
|
||||
return bindings.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Extracts the `Qxxxx` id from a Wikidata entity URI.
|
||||
String? _qid(String? uri) {
|
||||
if (uri == null) return null;
|
||||
final i = uri.lastIndexOf('/');
|
||||
return i == -1 ? uri : uri.substring(i + 1);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue