Add a small curated catalog of Iberian horticultural species and let a variety be linked to it from the edit sheet. - assets/catalog/species.json: 14 species with botanical family and ES/EN common names (wikidata_qid/gbif_key deferred to the varilla enrichment). - SpeciesRepository: idempotent seedBundled (is_bundled rows, keyed by scientific name) + search by scientific/common name with a locale-best label. Seeded on startup from DI. - VarietyRepository.linkSpecies: sets species_id and prefills category from the species' family when empty (never overwrites an existing category). VarietyDetail now carries the scientific name. - Edit sheet gains a live species-search field; the detail view shows the scientific name (italic). i18n strings added (ES/EN). Tests: catalog parse, idempotent seeding, search by scientific/common name, linkSpecies prefill semantics, and a widget test for the autocomplete → link → scientific-name-shown flow. Full suite: 32 passing, 0 skipped.
368 lines
11 KiB
Dart
368 lines
11 KiB
Dart
import 'package:async/async.dart';
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:drift/drift.dart';
|
|
import 'package:equatable/equatable.dart';
|
|
|
|
import '../db/database.dart';
|
|
import '../db/enums.dart';
|
|
|
|
/// A lightweight row for the inventory list (only what the list renders).
|
|
class VarietyListItem extends Equatable {
|
|
const VarietyListItem({required this.id, required this.label, this.category});
|
|
|
|
final String id;
|
|
final String label;
|
|
final String? category;
|
|
|
|
@override
|
|
List<Object?> get props => [id, label, category];
|
|
}
|
|
|
|
/// One held batch of a variety, for the detail view.
|
|
class VarietyLot extends Equatable {
|
|
const VarietyLot({
|
|
required this.id,
|
|
this.harvestYear,
|
|
this.quantity,
|
|
this.storageLocation,
|
|
});
|
|
|
|
final String id;
|
|
final int? harvestYear;
|
|
final Quantity? quantity;
|
|
final String? storageLocation;
|
|
|
|
@override
|
|
List<Object?> get props => [id, harvestYear, quantity, storageLocation];
|
|
}
|
|
|
|
/// The full detail of one variety (identity + its lots, names and first photo).
|
|
class VarietyDetail extends Equatable {
|
|
const VarietyDetail({
|
|
required this.id,
|
|
required this.label,
|
|
this.category,
|
|
this.notes,
|
|
this.speciesId,
|
|
this.scientificName,
|
|
this.lots = const [],
|
|
this.vernacularNames = const [],
|
|
this.photo,
|
|
});
|
|
|
|
final String id;
|
|
final String label;
|
|
final String? category;
|
|
final String? notes;
|
|
final String? speciesId;
|
|
final String? scientificName;
|
|
final List<VarietyLot> lots;
|
|
final List<String> vernacularNames;
|
|
final Uint8List? photo;
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
id,
|
|
label,
|
|
category,
|
|
notes,
|
|
speciesId,
|
|
scientificName,
|
|
lots,
|
|
vernacularNames,
|
|
photo,
|
|
];
|
|
}
|
|
|
|
/// Reads and writes the inventory. The encrypted Drift DB is the single source
|
|
/// of truth; [watchInventory] exposes a reactive stream the UI subscribes to.
|
|
///
|
|
/// Every write stamps the row with the local [Hlc] and [nodeId] so the CRDT
|
|
/// metadata is correct from day one, even though sync does not exist yet.
|
|
class VarietyRepository {
|
|
VarietyRepository(
|
|
this._db, {
|
|
required this.idGen,
|
|
required this.nodeId,
|
|
int Function()? nowMillis,
|
|
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
|
|
_clock = Hlc.zero(nodeId);
|
|
|
|
final AppDatabase _db;
|
|
final IdGen idGen;
|
|
final String nodeId;
|
|
final int Function() _now;
|
|
Hlc _clock;
|
|
|
|
/// Emits the non-deleted inventory, ordered by category then label.
|
|
Stream<List<VarietyListItem>> watchInventory() {
|
|
final query = _db.select(_db.varieties)
|
|
..where((v) => v.isDeleted.equals(false))
|
|
..orderBy([
|
|
(v) => OrderingTerm(expression: v.category),
|
|
(v) => OrderingTerm(expression: v.label),
|
|
]);
|
|
return query.watch().map(
|
|
(rows) => rows
|
|
.map(
|
|
(v) =>
|
|
VarietyListItem(id: v.id, label: v.label, category: v.category),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
/// 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.
|
|
Future<String> addQuickVariety({
|
|
required String label,
|
|
String? category,
|
|
Quantity? quantity,
|
|
Uint8List? photoBytes,
|
|
}) async {
|
|
final varietyId = idGen.newId();
|
|
await _db.transaction(() async {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.varieties)
|
|
.insert(
|
|
VarietiesCompanion.insert(
|
|
id: varietyId,
|
|
label: label,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
category: Value(category),
|
|
),
|
|
);
|
|
|
|
if (quantity != null) {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.lots)
|
|
.insert(
|
|
LotsCompanion.insert(
|
|
id: idGen.newId(),
|
|
varietyId: varietyId,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
quantityKind: Value(quantity.kind.name),
|
|
quantityPrecise: Value(quantity.precise),
|
|
quantityLabel: Value(quantity.label),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (photoBytes != null) {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.attachments)
|
|
.insert(
|
|
AttachmentsCompanion.insert(
|
|
id: idGen.newId(),
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
parentType: ParentType.variety,
|
|
parentId: varietyId,
|
|
kind: AttachmentKind.photo,
|
|
bytes: Value(photoBytes),
|
|
mimeType: const Value('image/jpeg'),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
return varietyId;
|
|
}
|
|
|
|
/// Reactively watches one variety with its lots, vernacular names and first
|
|
/// photo. Emits `null` if the variety does not exist or is soft-deleted.
|
|
///
|
|
/// Re-emits whenever the variety or any related row changes: a merged trigger
|
|
/// stream over the four tables drives a full reload, so adding a lot (a change
|
|
/// to a *different* table) still refreshes the view.
|
|
Stream<VarietyDetail?> watchVariety(String id) {
|
|
final triggers = StreamGroup.merge<void>([
|
|
(_db.select(
|
|
_db.varieties,
|
|
)..where((v) => v.id.equals(id))).watch().map((_) {}),
|
|
(_db.select(
|
|
_db.lots,
|
|
)..where((l) => l.varietyId.equals(id))).watch().map((_) {}),
|
|
(_db.select(
|
|
_db.varietyVernacularNames,
|
|
)..where((n) => n.varietyId.equals(id))).watch().map((_) {}),
|
|
(_db.select(
|
|
_db.attachments,
|
|
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
|
|
]);
|
|
return triggers.asyncMap((_) => _loadVariety(id));
|
|
}
|
|
|
|
Future<VarietyDetail?> _loadVariety(String id) async {
|
|
final v =
|
|
await (_db.select(_db.varieties)
|
|
..where((t) => t.id.equals(id) & t.isDeleted.equals(false)))
|
|
.getSingleOrNull();
|
|
if (v == null) return null;
|
|
|
|
String? scientificName;
|
|
if (v.speciesId != null) {
|
|
final species = await (_db.select(
|
|
_db.species,
|
|
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
|
|
scientificName = species?.scientificName;
|
|
}
|
|
|
|
final lots =
|
|
await (_db.select(_db.lots)
|
|
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
|
|
..orderBy([(l) => OrderingTerm.desc(l.harvestYear)]))
|
|
.get();
|
|
final names = await (_db.select(
|
|
_db.varietyVernacularNames,
|
|
)..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get();
|
|
final photos =
|
|
await (_db.select(_db.attachments)
|
|
..where(
|
|
(a) =>
|
|
a.parentId.equals(id) &
|
|
a.parentType.equalsValue(ParentType.variety) &
|
|
a.kind.equalsValue(AttachmentKind.photo) &
|
|
a.isDeleted.equals(false),
|
|
)
|
|
..limit(1))
|
|
.get();
|
|
|
|
return VarietyDetail(
|
|
id: v.id,
|
|
label: v.label,
|
|
category: v.category,
|
|
notes: v.notes,
|
|
speciesId: v.speciesId,
|
|
scientificName: scientificName,
|
|
lots: lots.map(_toLot).toList(),
|
|
vernacularNames: names.map((n) => n.name).toList(),
|
|
photo: photos.isEmpty ? null : photos.first.bytes,
|
|
);
|
|
}
|
|
|
|
/// 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 species = await (_db.select(
|
|
_db.species,
|
|
)..where((s) => s.id.equals(speciesId))).getSingleOrNull();
|
|
final variety = await (_db.select(
|
|
_db.varieties,
|
|
)..where((v) => v.id.equals(varietyId))).getSingleOrNull();
|
|
|
|
final categoryIsEmpty =
|
|
variety?.category == null || variety!.category!.trim().isEmpty;
|
|
final prefill = categoryIsEmpty ? species?.family : null;
|
|
|
|
await (_db.update(
|
|
_db.varieties,
|
|
)..where((v) => v.id.equals(varietyId))).write(
|
|
VarietiesCompanion(
|
|
speciesId: Value(speciesId),
|
|
category: prefill == null ? const Value.absent() : Value(prefill),
|
|
updatedAt: Value(updated),
|
|
lastAuthor: Value(nodeId),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Updates a variety's scalar fields (LWW). Passing null clears [category]
|
|
/// and [notes]; a null [label] leaves the label unchanged.
|
|
Future<void> updateVariety({
|
|
required String id,
|
|
String? label,
|
|
String? category,
|
|
String? notes,
|
|
}) async {
|
|
final (_, updated) = _stamp();
|
|
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
|
|
VarietiesCompanion(
|
|
label: label == null ? const Value.absent() : Value(label),
|
|
category: Value(category),
|
|
notes: Value(notes),
|
|
updatedAt: Value(updated),
|
|
lastAuthor: Value(nodeId),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Adds a lot (a held batch) to a variety. Returns the new lot id.
|
|
Future<String> addLot({
|
|
required String varietyId,
|
|
int? harvestYear,
|
|
Quantity? quantity,
|
|
String? storageLocation,
|
|
}) async {
|
|
final (created, updated) = _stamp();
|
|
final id = idGen.newId();
|
|
await _db
|
|
.into(_db.lots)
|
|
.insert(
|
|
LotsCompanion.insert(
|
|
id: id,
|
|
varietyId: varietyId,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
harvestYear: Value(harvestYear),
|
|
quantityKind: Value(quantity?.kind.name),
|
|
quantityPrecise: Value(quantity?.precise),
|
|
quantityLabel: Value(quantity?.label),
|
|
storageLocation: Value(storageLocation),
|
|
),
|
|
);
|
|
return id;
|
|
}
|
|
|
|
/// 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();
|
|
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
|
|
VarietiesCompanion(
|
|
isDeleted: const Value(true),
|
|
updatedAt: Value(updated),
|
|
lastAuthor: Value(nodeId),
|
|
),
|
|
);
|
|
}
|
|
|
|
VarietyLot _toLot(Lot l) {
|
|
final hasQuantity =
|
|
l.quantityKind != null ||
|
|
l.quantityPrecise != null ||
|
|
l.quantityLabel != null;
|
|
return VarietyLot(
|
|
id: l.id,
|
|
harvestYear: l.harvestYear,
|
|
storageLocation: l.storageLocation,
|
|
quantity: hasQuantity
|
|
? Quantity(
|
|
kind: _parseKind(l.quantityKind),
|
|
precise: l.quantityPrecise,
|
|
label: l.quantityLabel,
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
QuantityKind _parseKind(String? name) =>
|
|
QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew;
|
|
|
|
/// Advances the local clock and returns `(createdAtMillis, packedHlc)`.
|
|
(int, String) _stamp() {
|
|
final now = _now();
|
|
_clock = _clock.localEvent(now);
|
|
return (now, _clock.pack());
|
|
}
|
|
}
|