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 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 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.lots = const [], this.vernacularNames = const [], this.photo, }); final String id; final String label; final String? category; final String? notes; final List lots; final List vernacularNames; final Uint8List? photo; @override List get props => [ id, label, category, notes, 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((_) {}), ]); 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; 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, lots: lots.map(_toLot).toList(), vernacularNames: names.map((n) => n.name).toList(), photo: photos.isEmpty ? null : photos.first.bytes, ); } /// 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), ), ); } 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()); } }