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, this.scientificName, this.photo, }); final String id; final String label; final String? category; /// Scientific name of the linked species, shown as the tile subtitle. final String? scientificName; /// First photo (encrypted BLOB) for the avatar, or null → show an initial. final Uint8List? photo; @override List get props => [id, label, category, scientificName, photo]; } /// 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.type = LotType.seed, this.harvestYear, this.harvestMonth, this.quantity, this.storageLocation, this.germinationTests = const [], }); final String id; final LotType type; final int? harvestYear; /// Optional harvest month (1..12). Only meaningful when [harvestYear] is set. final int? harvestMonth; 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, type, harvestYear, harvestMonth, quantity, storageLocation, germinationTests, ]; } /// One vernacular (common) name of a variety. class VernacularName extends Equatable { const VernacularName({ required this.id, required this.name, this.language, this.region, }); final String id; final String name; final String? language; final String? region; @override List get props => [id, name, language, region]; } /// One stored photo of a variety (encrypted BLOB in the DB). class VarietyPhoto extends Equatable { const VarietyPhoto({required this.id, required this.bytes}); final String id; final Uint8List bytes; @override List get props => [id, bytes]; } /// An external URL attached to a variety (Wikipedia, a forum thread…). class ExternalLinkItem extends Equatable { const ExternalLinkItem({required this.id, required this.url, this.title}); final String id; final String url; final String? title; /// What to show: the title if present, otherwise the URL. String get display => (title != null && title!.trim().isNotEmpty) ? title! : url; @override List get props => [id, url, title]; } /// The full detail of one variety (identity + its lots, names and photos). 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.photos = const [], this.links = const [], }); 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 List photos; final List links; @override List get props => [ id, label, category, notes, speciesId, scientificName, lots, vernacularNames, photos, links, ]; } /// 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, each with /// its first photo for the avatar. /// /// Re-emits on any change to varieties, their photos (attachments) or the /// species catalog, so avatars and scientific names refresh reactively — a /// photo added on the detail screen shows up in the list right away. Stream> watchInventory() { final triggers = StreamGroup.merge([ (_db.select( _db.varieties, )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}), _db.select(_db.attachments).watch().map((_) {}), _db.select(_db.species).watch().map((_) {}), ]); return triggers.asyncMap((_) => _loadInventory()); } Future> _loadInventory() async { final rows = await (_db.select(_db.varieties) ..where((v) => v.isDeleted.equals(false)) ..orderBy([ (v) => OrderingTerm(expression: v.category), (v) => OrderingTerm(expression: v.label), ])) .get(); final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); final sciNames = await _scientificNamesFor( rows.map((v) => v.speciesId).whereType().toSet(), ); return rows .map( (v) => VarietyListItem( id: v.id, label: v.label, category: v.category, scientificName: v.speciesId == null ? null : sciNames[v.speciesId], photo: photos[v.id], ), ) .toList(); } /// Loads the first photo BLOB for each of [varietyIds] (one query). Future> _firstPhotosFor( List varietyIds, ) async { if (varietyIds.isEmpty) return const {}; final rows = await (_db.select(_db.attachments)..where( (a) => a.parentId.isIn(varietyIds) & a.parentType.equalsValue(ParentType.variety) & a.kind.equalsValue(AttachmentKind.photo) & a.isDeleted.equals(false), )) .get(); final byVariety = {}; for (final row in rows) { final bytes = row.bytes; if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes); } return byVariety; } /// Maps each of [speciesIds] to its scientific name (one query). Future> _scientificNamesFor( Set speciesIds, ) async { if (speciesIds.isEmpty) return const {}; final rows = await (_db.select( _db.species, )..where((s) => s.id.isIn(speciesIds))).get(); return {for (final s in rows) s.id: s.scientificName}; } /// 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, LotType lotType = LotType.seed, 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, type: Value(lotType), quantityKind: Value(quantity.kind.name), quantityPrecise: Value(quantity.count?.toDouble()), 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((_) {}), (_db.select( _db.externalLinks, )..where((e) => e.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), ) ..orderBy([(a) => OrderingTerm(expression: a.createdAt)])) .get(); final links = await (_db.select(_db.externalLinks) ..where( (e) => e.parentId.equals(id) & e.parentType.equalsValue(ParentType.variety) & e.isDeleted.equals(false), ) ..orderBy([(e) => OrderingTerm(expression: e.createdAt)])) .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) => VernacularName( id: n.id, name: n.name, language: n.language, region: n.region, ), ) .toList(), photos: [ for (final p in photos) if (p.bytes != null) VarietyPhoto(id: p.id, bytes: p.bytes!), ], links: links .map((e) => ExternalLinkItem(id: e.id, url: e.url, title: e.title)) .toList(), ); } /// 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, LotType type = LotType.seed, int? harvestYear, int? harvestMonth, 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, type: Value(type), harvestYear: Value(harvestYear), harvestMonth: Value(harvestMonth), quantityKind: Value(quantity?.kind.name), quantityPrecise: Value(quantity?.count?.toDouble()), quantityLabel: Value(quantity?.label), storageLocation: Value(storageLocation), ), ); return id; } /// Updates an existing lot's fields (LWW). The [quantity], [harvestYear] and /// [harvestMonth] are set as given (null clears them). Future updateLot({ required String lotId, required LotType type, int? harvestYear, int? harvestMonth, Quantity? quantity, String? storageLocation, }) async { final (_, updated) = _stamp(); await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( LotsCompanion( type: Value(type), harvestYear: Value(harvestYear), harvestMonth: Value(harvestMonth), quantityKind: Value(quantity?.kind.name), quantityPrecise: Value(quantity?.count?.toDouble()), quantityLabel: Value(quantity?.label), storageLocation: Value(storageLocation), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// Soft-deletes a lot (tombstone); it leaves the variety's lot list. Future softDeleteLot(String lotId) async { final (_, updated) = _stamp(); await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( LotsCompanion( isDeleted: const Value(true), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// Adds a vernacular (common) name to a variety. Returns the new row id. Future addVernacularName( String varietyId, String name, { String? language, String? region, }) async { final (created, updated) = _stamp(); final id = idGen.newId(); await _db .into(_db.varietyVernacularNames) .insert( VarietyVernacularNamesCompanion.insert( id: id, varietyId: varietyId, name: name, createdAt: created, updatedAt: updated, lastAuthor: nodeId, language: Value(language), region: Value(region), ), ); return id; } /// Soft-deletes a vernacular name (tombstone). Future removeVernacularName(String nameId) async { final (_, updated) = _stamp(); await (_db.update( _db.varietyVernacularNames, )..where((n) => n.id.equals(nameId))).write( VarietyVernacularNamesCompanion( isDeleted: const Value(true), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// Adds a photo (encrypted BLOB) to a variety. Returns the new attachment id. Future addPhoto(String varietyId, Uint8List bytes) async { final (created, updated) = _stamp(); final id = idGen.newId(); await _db .into(_db.attachments) .insert( AttachmentsCompanion.insert( id: id, createdAt: created, updatedAt: updated, lastAuthor: nodeId, parentType: ParentType.variety, parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(bytes), mimeType: const Value('image/jpeg'), ), ); return id; } /// Soft-deletes a photo (tombstone). Future removePhoto(String attachmentId) async { final (_, updated) = _stamp(); await (_db.update( _db.attachments, )..where((a) => a.id.equals(attachmentId))).write( AttachmentsCompanion( isDeleted: const Value(true), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// Adds an external link (URL, optional title) to a variety. Returns its id. Future addExternalLink( String varietyId, String url, { String? title, }) async { final (created, updated) = _stamp(); final id = idGen.newId(); await _db .into(_db.externalLinks) .insert( ExternalLinksCompanion.insert( id: id, createdAt: created, updatedAt: updated, lastAuthor: nodeId, parentType: ParentType.variety, parentId: varietyId, url: url, title: Value(title), ), ); return id; } /// Soft-deletes an external link (tombstone). Future removeExternalLink(String linkId) async { final (_, updated) = _stamp(); await (_db.update( _db.externalLinks, )..where((e) => e.id.equals(linkId))).write( ExternalLinksCompanion( isDeleted: const Value(true), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// 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, type: l.type, harvestYear: l.harvestYear, harvestMonth: l.harvestMonth, storageLocation: l.storageLocation, germinationTests: germinationTests, quantity: hasQuantity ? Quantity( kind: _parseKind(l.quantityKind), count: 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()); } }