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 get props => [id, label, category]; } /// One germination test on a lot; [rate] is derived (0..1), null when it can't /// be computed. class GerminationEntry extends Equatable { const GerminationEntry({ required this.id, this.testedOn, this.sampleSize, this.germinatedCount, this.notes, }); final String id; final int? testedOn; // ms since epoch final int? sampleSize; final int? germinatedCount; final String? notes; double? get rate { final sample = sampleSize; final germinated = germinatedCount; if (sample == null || sample <= 0 || germinated == null) return null; return germinated / sample; } @override List get props => [id, testedOn, sampleSize, germinatedCount, notes]; } /// One held batch of a variety, for the detail view. [germinationTests] are /// ordered most-recent first, so `germinationTests.first` is the latest. class VarietyLot extends Equatable { const VarietyLot({ required this.id, this.harvestYear, this.quantity, this.storageLocation, this.germinationTests = const [], }); final String id; final int? harvestYear; final Quantity? quantity; final String? storageLocation; final List germinationTests; /// The most recent germination rate (0..1), or null if there are no tests. double? get latestGerminationRate => germinationTests.isEmpty ? null : germinationTests.first.rate; @override List get props => [ id, harvestYear, quantity, storageLocation, germinationTests, ]; } /// 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 lots; final List vernacularNames; final Uint8List? photo; @override List 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> 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 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 watchVariety(String id) { final triggers = StreamGroup.merge([ (_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((_) {}), // Coarse: any germination-test change re-emits (catalog of tests is tiny). _db.select(_db.germinationTests).watch().map((_) {}), ]); return triggers.asyncMap((_) => _loadVariety(id)); } Future _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 lotIds = lots.map((l) => l.id).toList(); final testsByLot = >{}; if (lotIds.isNotEmpty) { final tests = await (_db.select(_db.germinationTests) ..where((g) => g.lotId.isIn(lotIds) & g.isDeleted.equals(false)) ..orderBy([(g) => OrderingTerm.desc(g.testedOn)])) .get(); for (final t in tests) { testsByLot .putIfAbsent(t.lotId, () => []) .add( GerminationEntry( id: t.id, testedOn: t.testedOn, sampleSize: t.sampleSize, germinatedCount: t.germinatedCount, notes: t.notes, ), ); } } 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((l) => _toLot(l, testsByLot[l.id] ?? const [])).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 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 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 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 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), ), ); } /// Records a germination test for a lot. Returns the new test id. Future addGerminationTest({ required String lotId, int? testedOn, int? sampleSize, int? germinatedCount, String? notes, }) async { final (created, updated) = _stamp(); final id = idGen.newId(); await _db .into(_db.germinationTests) .insert( GerminationTestsCompanion.insert( id: id, lotId: lotId, createdAt: created, updatedAt: updated, lastAuthor: nodeId, testedOn: Value(testedOn), sampleSize: Value(sampleSize), germinatedCount: Value(germinatedCount), notes: Value(notes), ), ); return id; } VarietyLot _toLot(Lot l, List germinationTests) { final hasQuantity = l.quantityKind != null || l.quantityPrecise != null || l.quantityLabel != null; return VarietyLot( id: l.id, harvestYear: l.harvestYear, storageLocation: l.storageLocation, germinationTests: germinationTests, 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()); } }