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'; 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'; /// 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, this.lotTypes = const {}, this.isDraft = false, this.isOrganic = false, this.needsReproduction = false, this.isShared = false, this.viability = SeedViability.unknown, this.sowMonths, }); 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; /// Distinct lot forms this variety currently holds (seed, plant, tree…), /// used to filter the list by form. Empty when it has no lots yet. final Set lotTypes; /// True for a photo-first capture still awaiting a name (in the "to /// catalogue" tray, hidden from the main inventory list). final bool isDraft; /// Grower-declared organic ("eco") provenance — badged and filterable. final bool isOrganic; /// Grower-flagged "regrow this season" stewardship intent — badged and /// filterable, alongside the automatic viability warning. final bool needsReproduction; /// True when at least one lot is marked to give away, swap or sell — the /// "what I share" view filters on this. final bool isShared; /// The most-urgent viability status across this variety's seed lots /// (expired ranks above expiringSoon), so the list can flag what to sow or /// reproduce before it lapses. [SeedViability.unknown] when nothing is aging /// or there is no reference figure. final SeedViability viability; /// The grower's recorded sow-months bitmask (see `domain/crop_calendar.dart`), /// or null when unrecorded — drives the "to sow this month" inventory filter. final int? sowMonths; @override List get props => [ id, label, category, scientificName, photo, lotTypes, isDraft, isOrganic, needsReproduction, isShared, viability, sowMonths, ]; } /// A variety's whole recorded crop calendar, for the aggregate "what's due this /// month" view. Each field is a 12-bit month mask (see `crop_calendar.dart`); /// only varieties with at least one recorded phase are emitted. class CalendarEntry extends Equatable { const CalendarEntry({ required this.id, required this.label, this.category, this.photo, this.sowMonths, this.transplantMonths, this.floweringMonths, this.fruitingMonths, this.seedHarvestMonths, }); final String id; final String label; final String? category; final Uint8List? photo; final int? sowMonths; final int? transplantMonths; final int? floweringMonths; final int? fruitingMonths; final int? seedHarvestMonths; @override List get props => [ id, label, category, photo, sowMonths, transplantMonths, floweringMonths, fruitingMonths, seedHarvestMonths, ]; } /// One shared lot for the printable catalog: the variety it belongs to plus /// the batch facts a fair-goer needs (what, from when, how much, on what /// terms). Built by [VarietyRepository.sharedCatalog]. class SharedCatalogEntry extends Equatable { const SharedCatalogEntry({ required this.varietyLabel, required this.offerStatus, this.scientificName, this.category, this.type = LotType.seed, this.harvestYear, this.quantity, this.abundance, }); final String varietyLabel; final String? scientificName; final String? category; final LotType type; final int? harvestYear; final Quantity? quantity; final Abundance? abundance; /// Never [OfferStatus.private] — private lots don't enter the catalog. final OfferStatus offerStatus; @override List get props => [ varietyLabel, scientificName, category, type, harvestYear, quantity, abundance, offerStatus, ]; } /// One printable seed label for a selection of the inventory: a variety batch /// (lot) with the facts a physical label carries — the species common name in /// the keeper's locale, the variety label, the scientific name, the harvest /// year and where it came from. A variety with no lots yields a single /// name-only entry. Built by [VarietyRepository.labelRows]; unlike /// [SharedCatalogEntry] it is not restricted to shared lots — you label what /// you hold, private or not. class SeedLabelEntry extends Equatable { const SeedLabelEntry({ required this.varietyLabel, this.commonName, this.scientificName, this.category, this.harvestYear, this.quantity, this.originName, this.originPlace, this.suggestedCopies = 1, }); final String varietyLabel; /// The species' common name in the keeper's locale ("Acelga"), if known. final String? commonName; final String? scientificName; final String? category; final int? harvestYear; final Quantity? quantity; final String? originName; final String? originPlace; /// How many copies of this label to suggest — the latest condition check's /// container count (e.g. 3 jars → 3 labels), or 1 when unknown. Always ≥ 1; /// the print sheet prefills it and lets the keeper edit. final int suggestedCopies; @override List get props => [ varietyLabel, commonName, scientificName, category, harvestYear, quantity, originName, originPlace, suggestedCopies, ]; } /// A lot the user has marked for sharing, carrying the lot [lotId] (so the /// published offer has a stable, updatable id) plus the summary a network offer /// needs. Distinct from [SharedCatalogEntry], which is for the printable local /// catalog and has no id. class ShareableLot extends Equatable { const ShareableLot({ required this.lotId, required this.varietyId, required this.summary, required this.offerStatus, this.category, this.harvestYear, this.isOrganic = false, this.priceAmount, this.priceCurrency, }); final String lotId; /// The lot's variety, so the publish step can fetch its cover photo to host /// as the offer image. Not published itself — never leaves the device. final String varietyId; /// What to publish: the variety label (never the full inventory). final String summary; /// Never [OfferStatus.private] — private lots don't leave the device. final OfferStatus offerStatus; final String? category; final int? harvestYear; /// The variety's grower-declared organic ("eco") flag, published so peers can /// filter the market for it. final bool isOrganic; /// Asking price, only populated for [OfferStatus.sell] lots. Null amount on /// a sale means "price to be agreed" — the offer publishes without a price. final double? priceAmount; final String? priceCurrency; @override List get props => [ lotId, varietyId, summary, offerStatus, category, harvestYear, isOrganic, priceAmount, priceCurrency, ]; } /// 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 storage-condition check on a lot: how many containers held it and the /// state of the drying agent at a point in time. class ConditionEntry extends Equatable { const ConditionEntry({ required this.id, this.checkedOn, this.containerCount, this.desiccantState, this.notes, }); final String id; final int? checkedOn; // ms since epoch final int? containerCount; final DesiccantState? desiccantState; final String? notes; @override List get props => [ id, checkedOn, containerCount, desiccantState, notes, ]; } /// One held batch of a variety, for the detail view. [germinationTests] and /// [conditionChecks] are ordered most-recent first, so `.first` is the latest. class VarietyLot extends Equatable { const VarietyLot({ required this.id, this.type = LotType.seed, this.harvestYear, this.harvestMonth, this.quantity, this.presentation, this.storageLocation, this.originName, this.originPlace, this.abundance, this.preservationFormat, this.offerStatus = OfferStatus.private, this.priceAmount, this.priceCurrency, this.germinationTests = const [], this.conditionChecks = 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; /// How living material is packaged (pot, tray, bare-root…); null for seeds. final Presentation? presentation; final String? storageLocation; /// Provenance of this batch: who grew/gave it ([originName]) and where it /// comes from ([originPlace], with region/province). Optional free text. final String? originName; final String? originPlace; /// Optional coarse "how much I have" fusing amount with shareability. final Abundance? abundance; /// How the (seed) lot is physically conserved; distinct from storage location. final PreservationFormat? preservationFormat; /// Whether (and how) this batch is offered to others: kept private, to give /// away, to swap, or for sale. Local-only for now — publishing an offer to /// the network is Block 2. final OfferStatus offerStatus; /// Asking price, meaningful when [offerStatus] is `sell`. Null amount means /// "price to be agreed". final double? priceAmount; final String? priceCurrency; final List germinationTests; /// Storage-condition checks, most-recent first (`conditionChecks.first` = last). final List conditionChecks; /// 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, presentation, storageLocation, originName, originPlace, abundance, preservationFormat, offerStatus, priceAmount, priceCurrency, germinationTests, conditionChecks, ]; } /// 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.family, this.gbifKey, this.wikidataQid, this.viabilityYears, this.isOrganic = false, this.needsReproduction = false, this.sowMonths, this.transplantMonths, this.floweringMonths, this.fruitingMonths, this.seedHarvestMonths, 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; /// Botanical family of the linked species (Latin), the reliable key for /// seed-saving guidance; null when no species is linked. (The editable /// [category] is prefilled from this but may be changed by the grower.) final String? family; /// Bundled identifiers of the linked species, used to derive verified /// "learn more" reference links (GBIF/Wikipedia/Wikispecies); null when no /// species is linked or the catalog entry lacks the id. final int? gbifKey; final String? wikidataQid; /// Grower-declared organic ("eco") provenance. final bool isOrganic; /// Grower-flagged "regrow this season" stewardship intent. final bool needsReproduction; /// Advisory crop-calendar months, packed as 12-bit masks (a phase can span /// several months); null where unrecorded. See [Varieties.sowMonths] et al. /// and `domain/crop_calendar.dart` for packing. final int? sowMonths; final int? transplantMonths; final int? floweringMonths; final int? fruitingMonths; final int? seedHarvestMonths; /// True when any crop-calendar phase is recorded — so the UI shows the /// calendar section only when there is something in it. bool get hasCropCalendar => sowMonths != null || transplantMonths != null || floweringMonths != null || fruitingMonths != null || seedHarvestMonths != null; /// Typical seed longevity (years) of the linked species, from the bundled /// catalog; null when no species is linked or the figure is unknown. Drives /// the per-lot viability warning together with each lot's harvest year. final int? viabilityYears; final List lots; final List vernacularNames; final List photos; final List links; @override List get props => [ id, label, category, notes, speciesId, scientificName, family, gbifKey, wikidataQid, viabilityYears, isOrganic, needsReproduction, sowMonths, transplantMonths, floweringMonths, fruitingMonths, seedHarvestMonths, 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((_) {}), // Lots drive the form filter, so re-emit when they change too. _db.select(_db.lots).watch().map((_) {}), ]); return triggers.asyncMap((_) => _loadInventory()); } /// The whole inventory screen in one reactive subscription: the catalogued /// list and the draft tray, from a single [StreamGroup]. The cubit MUST use /// this rather than subscribing to [watchInventory] and [watchDrafts] /// separately — two overlapping StreamGroups (both watching `attachments`) /// re-emit in a tight loop that starves the event loop and hangs widget /// tests' `pumpAndSettle`. Stream<({List items, List drafts})> watchInventoryView() { 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((_) {}), _db.select(_db.lots).watch().map((_) {}), ]); return triggers.asyncMap( (_) async => (items: await _loadInventory(), drafts: await _loadDrafts()), ); } Future> _loadInventory() async { final rows = await (_db.select(_db.varieties) // Drafts live in the "to catalogue" tray, not the main list. ..where( (v) => v.isDeleted.equals(false) & v.isDraft.equals(false), ) ..orderBy([ (v) => OrderingTerm(expression: v.category), (v) => OrderingTerm(expression: v.label), ])) .get(); return _toListItems(rows); } /// Reactively watches the photo-first drafts awaiting a name, newest first — /// the "to catalogue" tray. Re-emits when a draft is added, named or its /// photo changes. Stream> watchDrafts() { final triggers = StreamGroup.merge([ (_db.select( _db.varieties, )..where((v) => v.isDraft.equals(true))).watch().map((_) {}), _db.select(_db.attachments).watch().map((_) {}), ]); return triggers.asyncMap((_) => _loadDrafts()); } Future> _loadDrafts() async { final rows = await (_db.select(_db.varieties) ..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(true)) // Packed HLC sorts monotonically, so newest capture is first even // when two are stamped within the same wall-clock millisecond. ..orderBy([(v) => OrderingTerm.desc(v.updatedAt)])) .get(); return _toListItems(rows); } Future> _toListItems(List rows) async { final varietyIds = rows.map((v) => v.id).toList(); final speciesIds = rows.map((v) => v.speciesId).whereType().toSet(); final photos = await _firstPhotosFor(varietyIds); final sciNames = await _scientificNamesFor(speciesIds); final speciesViability = await _speciesViabilityFor(speciesIds); final lotTypes = await _lotTypesFor(varietyIds); final shared = await _sharedVarietyIdsFor(varietyIds); final seedYears = await _seedHarvestYearsFor(varietyIds); final currentYear = DateTime.fromMillisecondsSinceEpoch(_now()).year; 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], lotTypes: lotTypes[v.id] ?? const {}, isDraft: v.isDraft, isOrganic: v.isOrganic, needsReproduction: v.needsReproduction, isShared: shared.contains(v.id), viability: _worstViability( harvestYears: seedYears[v.id] ?? const [], viabilityYears: v.speciesId == null ? null : speciesViability[v.speciesId], currentYear: currentYear, ), sowMonths: v.sowMonths, ), ) .toList(); } /// Emits every non-draft variety that has any recorded crop-calendar phase, /// for the aggregate "what's due this month" screen. Re-emits on variety or /// photo changes. Stream> watchCalendar() { final triggers = StreamGroup.merge([ (_db.select( _db.varieties, )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}), _db.select(_db.attachments).watch().map((_) {}), ]); return triggers.asyncMap((_) => _loadCalendar()); } Future> _loadCalendar() async { final rows = await (_db.select(_db.varieties) ..where( (v) => v.isDeleted.equals(false) & v.isDraft.equals(false) & (v.sowMonths.isNotNull() | v.transplantMonths.isNotNull() | v.floweringMonths.isNotNull() | v.fruitingMonths.isNotNull() | v.seedHarvestMonths.isNotNull()), ) ..orderBy([(v) => OrderingTerm(expression: v.label)])) .get(); final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); return [ for (final v in rows) CalendarEntry( id: v.id, label: v.label, category: v.category, photo: photos[v.id], sowMonths: v.sowMonths, transplantMonths: v.transplantMonths, floweringMonths: v.floweringMonths, fruitingMonths: v.fruitingMonths, seedHarvestMonths: v.seedHarvestMonths, ), ]; } /// Maps each of [speciesIds] to its bundled viability figure (years); species /// without a figure are simply absent from the map. Future> _speciesViabilityFor(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) if (s.viabilityYears != null) s.id: s.viabilityYears!, }; } /// Harvest years of each variety's non-deleted seed lots (living lots don't /// age like dry seed). Lots without a year are omitted — they can't be judged. Future>> _seedHarvestYearsFor( List varietyIds, ) async { if (varietyIds.isEmpty) return const {}; final rows = await (_db.select(_db.lots)..where( (l) => l.varietyId.isIn(varietyIds) & l.isDeleted.equals(false) & l.type.equalsValue(LotType.seed), )) .get(); final byVariety = >{}; for (final row in rows) { final year = row.harvestYear; if (year != null) (byVariety[row.varietyId] ??= []).add(year); } return byVariety; } /// The most-urgent viability across a variety's seed lots (expired outranks /// expiring-soon), so the list surfaces the worst case at a glance. SeedViability _worstViability({ required List harvestYears, required int? viabilityYears, required int currentYear, }) { var worst = SeedViability.unknown; for (final year in harvestYears) { final status = seedViability( harvestYear: year, viabilityYears: viabilityYears, currentYear: currentYear, ); if (_viabilityRank(status) > _viabilityRank(worst)) worst = status; } return worst; } int _viabilityRank(SeedViability status) => switch (status) { SeedViability.unknown => 0, SeedViability.fresh => 1, SeedViability.expiringSoon => 2, SeedViability.expired => 3, }; /// Maps each variety to the distinct lot forms it currently holds (one /// query). Varieties without lots are simply absent from the map. Future>> _lotTypesFor( List varietyIds, ) async { if (varietyIds.isEmpty) return const {}; final rows = await (_db.select(_db.lots)..where( (l) => l.varietyId.isIn(varietyIds) & l.isDeleted.equals(false), )) .get(); final byVariety = >{}; for (final row in rows) { (byVariety[row.varietyId] ??= {}).add(row.type); } return byVariety; } /// The subset of [varietyIds] holding at least one lot offered to others /// (to give away, swap or sell) — one query. Future> _sharedVarietyIdsFor(List varietyIds) async { if (varietyIds.isEmpty) return const {}; final rows = await (_db.select(_db.lots)..where( (l) => l.varietyId.isIn(varietyIds) & l.isDeleted.equals(false) & l.offerStatus.equalsValue(OfferStatus.private).not(), )) .get(); return rows.map((l) => l.varietyId).toSet(); } /// 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), ) ..orderBy([ (a) => OrderingTerm(expression: a.sortOrder), (a) => OrderingTerm(expression: a.createdAt), ])) .get(); final byVariety = {}; for (final row in rows) { final bytes = row.bytes; if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes); } return byVariety; } /// The cover photo (lowest `sortOrder`) for a single variety, or null when it /// has none. Used by the publish step to host an offer's image; reuses the /// same "first photo" rule as the inventory avatar. Future coverPhotoFor(String varietyId) async => (await _firstPhotosFor([varietyId]))[varietyId]; /// 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. /// /// 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 addQuickVariety({ required String label, String? category, LotType lotType = LotType.seed, Quantity? quantity, 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) = await _stamp(); await _db .into(_db.varieties) .insert( VarietiesCompanion.insert( id: varietyId, label: label, createdAt: created, updatedAt: updated, lastAuthor: nodeId, category: Value(resolvedCategory), speciesId: Value(auto?.id), ), ); if (quantity != null) { final (created, updated) = await _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) = await _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; } /// Captures a photo-first draft: a Variety with no name yet ([label] empty), /// flagged [isDraft], plus its photo. It lands in the "to catalogue" tray /// ([watchDrafts]) until the user names it with [nameDraft]. Returns the id. Future addDraftVariety(Uint8List photoBytes) async { final varietyId = idGen.newId(); await _db.transaction(() async { final (created, updated) = await _stamp(); await _db .into(_db.varieties) .insert( VarietiesCompanion.insert( id: varietyId, label: '', createdAt: created, updatedAt: updated, lastAuthor: nodeId, isDraft: const Value(true), ), ); final (createdA, updatedA) = await _stamp(); await _db .into(_db.attachments) .insert( AttachmentsCompanion.insert( id: idGen.newId(), createdAt: createdA, updatedAt: updatedA, lastAuthor: nodeId, parentType: ParentType.variety, parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(photoBytes), mimeType: const Value('image/jpeg'), ), ); }); return varietyId; } /// 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 nameDraft(String id, String label) async { 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), ), ); } /// 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((_) {}), // Same for condition checks (jars + drying-agent state). _db.select(_db.conditionChecks).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; String? family; int? viabilityYears; int? gbifKey; String? wikidataQid; if (v.speciesId != null) { final species = await (_db.select( _db.species, )..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull(); scientificName = species?.scientificName; family = species?.family; viabilityYears = species?.viabilityYears; gbifKey = species?.gbifKey; wikidataQid = species?.wikidataQid; } 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 = >{}; final checksByLot = >{}; 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 checks = await (_db.select(_db.conditionChecks) ..where((c) => c.lotId.isIn(lotIds) & c.isDeleted.equals(false)) ..orderBy([(c) => OrderingTerm.desc(c.checkedOn)])) .get(); for (final c in checks) { checksByLot .putIfAbsent(c.lotId, () => []) .add( ConditionEntry( id: c.id, checkedOn: c.checkedOn, containerCount: c.containerCount, desiccantState: c.desiccantState, notes: c.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.sortOrder), (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, family: family, gbifKey: gbifKey, wikidataQid: wikidataQid, viabilityYears: viabilityYears, isOrganic: v.isOrganic, needsReproduction: v.needsReproduction, sowMonths: v.sowMonths, transplantMonths: v.transplantMonths, floweringMonths: v.floweringMonths, fruitingMonths: v.fruitingMonths, seedHarvestMonths: v.seedHarvestMonths, lots: lots .map( (l) => _toLot( l, testsByLot[l.id] ?? const [], checksByLot[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(), ); } /// 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> _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 linkSpecies(String varietyId, String speciesId) async { final (_, updated) = await _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. Boolean flags and /// the crop-calendar months are only touched when a wrapping [Value] is /// passed, so callers can update one field without disturbing the rest. Future updateVariety({ required String id, String? label, String? category, String? notes, bool? isOrganic, bool? needsReproduction, Value sowMonths = const Value.absent(), Value transplantMonths = const Value.absent(), Value floweringMonths = const Value.absent(), Value fruitingMonths = const Value.absent(), Value seedHarvestMonths = const Value.absent(), }) async { 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), category: Value(category), notes: Value(notes), isOrganic: isOrganic == null ? const Value.absent() : Value(isOrganic), needsReproduction: needsReproduction == null ? const Value.absent() : Value(needsReproduction), sowMonths: sowMonths, transplantMonths: transplantMonths, floweringMonths: floweringMonths, fruitingMonths: fruitingMonths, seedHarvestMonths: seedHarvestMonths, 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, Presentation? presentation, String? storageLocation, String? originName, String? originPlace, Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, double? priceAmount, String? priceCurrency, }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); final forSale = offerStatus == OfferStatus.sell; 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), presentation: Value(presentation), storageLocation: Value(storageLocation), originName: Value(originName), originPlace: Value(originPlace), abundance: Value(abundance), preservationFormat: Value(preservationFormat), offerStatus: Value(offerStatus), priceAmount: Value(forSale ? priceAmount : null), priceCurrency: Value(forSale ? priceCurrency : null), ), ); return id; } /// Updates an existing lot's fields (LWW). Every field is set as given (null /// clears it), matching the "save the whole lot form" call from the UI. Future updateLot({ required String lotId, required LotType type, int? harvestYear, int? harvestMonth, Quantity? quantity, Presentation? presentation, String? storageLocation, String? originName, String? originPlace, Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, double? priceAmount, String? priceCurrency, }) async { final (_, updated) = await _stamp(); // Price only makes sense on a lot offered for sale; clearing the sale // status clears the price with it. final forSale = offerStatus == OfferStatus.sell; 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), presentation: Value(presentation), storageLocation: Value(storageLocation), originName: Value(originName), originPlace: Value(originPlace), abundance: Value(abundance), preservationFormat: Value(preservationFormat), offerStatus: Value(offerStatus), priceAmount: Value(forSale ? priceAmount : null), priceCurrency: Value(forSale ? priceCurrency : null), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// Every lot offered to others (to give away, swap or sell), joined with its /// variety and ordered by label then harvest year — the content of the /// printable "what I share" catalog. Future> sharedCatalog() async { final lots = await (_db.select(_db.lots)..where( (l) => l.isDeleted.equals(false) & l.offerStatus.equalsValue(OfferStatus.private).not(), )) .get(); if (lots.isEmpty) return const []; final varietyIds = lots.map((l) => l.varietyId).toSet(); final varieties = await (_db.select(_db.varieties)..where( (v) => v.id.isIn(varietyIds) & v.isDeleted.equals(false) & v.isDraft.equals(false), )) .get(); final byId = {for (final v in varieties) v.id: v}; final sciNames = await _scientificNamesFor( varieties.map((v) => v.speciesId).whereType().toSet(), ); final entries = [ for (final l in lots) if (byId[l.varietyId] case final v?) SharedCatalogEntry( varietyLabel: v.label, scientificName: v.speciesId == null ? null : sciNames[v.speciesId], category: v.category, type: l.type, harvestYear: l.harvestYear, quantity: l.quantityKind != null || l.quantityPrecise != null || l.quantityLabel != null ? Quantity( kind: _parseKind(l.quantityKind), count: l.quantityPrecise, label: l.quantityLabel, ) : null, abundance: l.abundance, offerStatus: l.offerStatus, ), ]; entries.sort( (a, b) => a.varietyLabel.toLowerCase().compareTo(b.varietyLabel.toLowerCase()), ); return entries; } /// Printable label rows for a selection of [varietyIds]: one entry per /// non-deleted lot (year/origin/quantity are per batch), or a single /// name-only entry for a selected variety that holds no lots. [languageCode] /// picks the species common name shown on top. Ordered by category, label /// then harvest year. Draft and deleted varieties are excluded. Future> labelRows( Set varietyIds, { required String languageCode, }) async { if (varietyIds.isEmpty) return const []; final varieties = await (_db.select(_db.varieties)..where( (v) => v.id.isIn(varietyIds) & v.isDeleted.equals(false) & v.isDraft.equals(false), )) .get(); if (varieties.isEmpty) return const []; final speciesIds = varieties .map((v) => v.speciesId) .whereType() .toSet(); final sciNames = await _scientificNamesFor(speciesIds); final commonNames = await _commonNamesFor(speciesIds, languageCode); final lots = await (_db.select(_db.lots)..where( (l) => l.varietyId.isIn(varieties.map((v) => v.id).toList()) & l.isDeleted.equals(false), )) .get(); final lotsByVariety = >{}; for (final l in lots) { (lotsByVariety[l.varietyId] ??= []).add(l); } // Suggest one label per stored container: the latest condition check's // container count (3 jars → 3 labels). `.first` is the most recent because // we order by check date descending. final containersByLot = {}; if (lots.isNotEmpty) { final checks = await (_db.select(_db.conditionChecks) ..where( (c) => c.lotId.isIn(lots.map((l) => l.id).toList()) & c.isDeleted.equals(false), ) ..orderBy([(c) => OrderingTerm.desc(c.checkedOn)])) .get(); for (final c in checks) { final count = c.containerCount; if (count != null && count > 0) { containersByLot.putIfAbsent(c.lotId, () => count); } } } SeedLabelEntry entryFor(Variety v, Lot? l) { final hasQuantity = l != null && (l.quantityKind != null || l.quantityPrecise != null || l.quantityLabel != null); return SeedLabelEntry( varietyLabel: v.label, commonName: v.speciesId == null ? null : commonNames[v.speciesId], scientificName: v.speciesId == null ? null : sciNames[v.speciesId], category: v.category, harvestYear: l?.harvestYear, quantity: hasQuantity ? Quantity( kind: _parseKind(l.quantityKind), count: l.quantityPrecise, label: l.quantityLabel, ) : null, originName: l?.originName, originPlace: l?.originPlace, suggestedCopies: l == null ? 1 : (containersByLot[l.id] ?? 1), ); } final entries = []; for (final v in varieties) { final vLots = lotsByVariety[v.id]; if (vLots == null || vLots.isEmpty) { entries.add(entryFor(v, null)); } else { for (final l in vLots) { entries.add(entryFor(v, l)); } } } entries.sort((a, b) { final byCategory = (a.category ?? '').toLowerCase().compareTo( (b.category ?? '').toLowerCase(), ); if (byCategory != 0) return byCategory; final byLabel = a.varietyLabel.toLowerCase().compareTo( b.varietyLabel.toLowerCase(), ); if (byLabel != 0) return byLabel; return (a.harvestYear ?? 0).compareTo(b.harvestYear ?? 0); }); return entries; } /// Maps each of [speciesIds] to its best common name for [languageCode] (one /// query), preferring a name in the locale, else any. Species without a /// common name are absent from the map. Future> _commonNamesFor( Set speciesIds, String languageCode, ) async { if (speciesIds.isEmpty) return const {}; final rows = await (_db.select(_db.speciesCommonNames)..where( (n) => n.speciesId.isIn(speciesIds) & n.isDeleted.equals(false), )) .get(); final bySpecies = >{}; for (final n in rows) { (bySpecies[n.speciesId] ??= []).add((name: n.name, language: n.language)); } final result = {}; for (final entry in bySpecies.entries) { final inLocale = entry.value.where((n) => n.language == languageCode); result[entry.key] = (inLocale.isEmpty ? entry.value.first : inLocale.first).name; } return result; } /// Lots the user marked to share (not private), each with its id and the /// variety label — the input for publishing offers to the network (Block 2). Future> shareableLots() async { final lots = await (_db.select(_db.lots)..where( (l) => l.isDeleted.equals(false) & l.offerStatus.equalsValue(OfferStatus.private).not(), )) .get(); if (lots.isEmpty) return const []; final varietyIds = lots.map((l) => l.varietyId).toSet(); final varieties = await (_db.select(_db.varieties)..where( (v) => v.id.isIn(varietyIds) & v.isDeleted.equals(false) & v.isDraft.equals(false), )) .get(); final byId = {for (final v in varieties) v.id: v}; return [ for (final l in lots) if (byId[l.varietyId] case final v?) ShareableLot( lotId: l.id, varietyId: v.id, summary: v.label, offerStatus: l.offerStatus, category: v.category, harvestYear: l.harvestYear, isOrganic: v.isOrganic, priceAmount: l.offerStatus == OfferStatus.sell ? l.priceAmount : null, priceCurrency: l.offerStatus == OfferStatus.sell ? l.priceCurrency : null, ), ]; } /// Soft-deletes a lot (tombstone); it leaves the variety's lot list. Future softDeleteLot(String lotId) async { final (_, updated) = await _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) = await _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) = await _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) = await _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) = await _stamp(); await (_db.update( _db.attachments, )..where((a) => a.id.equals(attachmentId))).write( AttachmentsCompanion( isDeleted: const Value(true), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// Makes [attachmentId] the cover (preferred) attachment of [varietyId] by /// giving it a [sortOrder] below every current sibling. The cover is what the /// inventory list shows as the avatar and the detail gallery shows first. Future setPreferredPhoto(String varietyId, String attachmentId) async { final siblings = await (_db.select(_db.attachments)..where( (a) => a.parentId.equals(varietyId) & a.parentType.equalsValue(ParentType.variety) & a.isDeleted.equals(false), )) .get(); var minOrder = 0; for (final a in siblings) { if (a.sortOrder < minOrder) minOrder = a.sortOrder; } final (_, updated) = await _stamp(); await (_db.update( _db.attachments, )..where((a) => a.id.equals(attachmentId))).write( AttachmentsCompanion( sortOrder: Value(minOrder - 1), 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) = await _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) = await _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) = await _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) = await _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; } /// Records a storage-condition check for a lot (container count + drying-agent /// state). Returns the new check id. Future addConditionCheck({ required String lotId, int? checkedOn, int? containerCount, DesiccantState? desiccantState, String? notes, }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.conditionChecks) .insert( ConditionChecksCompanion.insert( id: id, lotId: lotId, createdAt: created, updatedAt: updated, lastAuthor: nodeId, checkedOn: Value(checkedOn), containerCount: Value(containerCount), desiccantState: Value(desiccantState), notes: Value(notes), ), ); return id; } // --- Plantares: reproduction commitments (data-model §2.7) ---------------- /// Records a Plantare — a promise to reproduce seed and return some (or a /// promise made TO you). Returns the new commitment id. Future createPlantare({ required PlantareDirection direction, String? varietyId, String? counterparty, String? owedDescription, int? dueBy, String? note, }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.plantares) .insert( PlantaresCompanion.insert( id: id, createdAt: created, updatedAt: updated, lastAuthor: nodeId, direction: direction, varietyId: Value(varietyId), counterparty: Value(counterparty), owedDescription: Value(owedDescription), madeOn: created, dueBy: Value(dueBy), note: Value(note), ), ); return id; } /// All live commitments, newest first (for the Plantares screen). Stream> watchPlantares() => (_db.select(_db.plantares) ..where((p) => p.isDeleted.equals(false)) ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) .watch(); /// The commitments recorded against one variety. Stream> watchPlantaresForVariety(String varietyId) => (_db.select(_db.plantares) ..where( (p) => p.varietyId.equals(varietyId) & p.isDeleted.equals(false), ) ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) .watch(); /// Moves a commitment to [status]; stamps the settle time for returned/ /// forgiven, clears it when reopened. Future setPlantareStatus(String id, PlantareStatus status) async { final (now, updated) = await _stamp(); await (_db.update(_db.plantares)..where((p) => p.id.equals(id))).write( PlantaresCompanion( status: Value(status), settledOn: Value(status == PlantareStatus.open ? null : now), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } /// Soft-deletes a commitment (tombstone, so it syncs as a removal). Future deletePlantare(String id) async { final (_, updated) = await _stamp(); await (_db.update(_db.plantares)..where((p) => p.id.equals(id))).write( PlantaresCompanion( isDeleted: const Value(true), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } // --- Sales: recorded seed sales (separate from gift/Plantare) -------------- /// Records a seed sale (or purchase) — price in ANY currency. Returns its id. Future createSale({ required SaleDirection direction, String? varietyId, String? counterparty, double? amount, String? currency, String? note, }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); await _db .into(_db.sales) .insert( SalesCompanion.insert( id: id, createdAt: created, updatedAt: updated, lastAuthor: nodeId, direction: direction, varietyId: Value(varietyId), counterparty: Value(counterparty), amount: Value(amount), currency: Value(currency), soldOn: created, note: Value(note), ), ); return id; } /// All live sales, newest first (for the Sales screen). Stream> watchSales() => (_db.select(_db.sales) ..where((s) => s.isDeleted.equals(false)) ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) .watch(); /// The sales recorded against one variety. Stream> watchSalesForVariety(String varietyId) => (_db.select(_db.sales) ..where( (s) => s.varietyId.equals(varietyId) & s.isDeleted.equals(false), ) ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) .watch(); /// Soft-deletes a sale (tombstone, so it syncs as a removal). Future deleteSale(String id) async { final (_, updated) = await _stamp(); await (_db.update(_db.sales)..where((s) => s.id.equals(id))).write( SalesCompanion( isDeleted: const Value(true), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } // --- Hand-over: the one moment seeds change hands -------------------------- /// Records a hand-over in a single transaction (data-model §2.8: a closed /// deal produces a `Movement` and, optionally, a `Plantare`): the Movement /// (given/received) plus — when they came with it — the Sale (money changed /// hands, informational only, never processed in-app) and the Plantare (a /// return promise), all sharing the same free-text [counterparty]. /// /// With nothing optional this is a plain gift: just the Movement. /// /// [gaveAll] is the one-tap "all of it" (the natural path for a whole plant /// or shrub): the movement carries the lot's current quantity, the lot's /// precise quantity drops to 0 and it returns to [OfferStatus.private] — /// withdrawing it from the market on the next publish cycle. Otherwise /// [quantity] says how much of the batch moved (optional). Future<({String movementId, String? saleId, String? plantareId})> recordHandover({ required String lotId, required HandoverDirection direction, bool gaveAll = false, Quantity? quantity, String? counterparty, bool withPayment = false, double? paymentAmount, String? paymentCurrency, bool withPromise = false, String? promiseOwedDescription, int? promiseDueBy, String? note, }) async { final gave = direction == HandoverDirection.iGave; return _db.transaction(() async { final lot = await (_db.select( _db.lots, )..where((l) => l.id.equals(lotId))).getSingle(); // The promise first, so the movement can carry its id — the link the // model reserved for exactly this (data-model §2.4: "a `given` may // carry a Plantare"). String? plantareId; if (withPromise) { plantareId = await createPlantare( direction: gave ? PlantareDirection.owedToMe : PlantareDirection.iReturn, varietyId: lot.varietyId, counterparty: counterparty, owedDescription: promiseOwedDescription, dueBy: promiseDueBy, ); } final movedAll = gaveAll && gave; final lotHasQuantity = lot.quantityKind != null || lot.quantityPrecise != null || lot.quantityLabel != null; final movedQuantity = movedAll ? (lotHasQuantity ? Quantity( kind: _parseKind(lot.quantityKind), count: lot.quantityPrecise, label: lot.quantityLabel, ) : null) : quantity; final noteParts = [ if (counterparty != null && counterparty.trim().isNotEmpty) counterparty.trim(), if (note != null && note.trim().isNotEmpty) note.trim(), ]; final (created, _) = await _stamp(); final movementId = idGen.newId(); await _db .into(_db.movements) .insert( MovementsCompanion.insert( id: movementId, createdAt: created, lastAuthor: nodeId, lotId: lotId, type: gave ? MovementType.given : MovementType.received, occurredOn: Value(created), quantityKind: Value(movedQuantity?.kind.name), quantityPrecise: Value(movedQuantity?.count?.toDouble()), quantityLabel: Value(movedQuantity?.label), plantareId: Value(plantareId), notes: Value(noteParts.isEmpty ? null : noteParts.join(' — ')), ), ); if (movedAll) { // The batch is gone: an empty jar (0, same unit) with nothing left to // declare or offer. Price goes with the sale status. final (_, updated) = await _stamp(); await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( LotsCompanion( quantityPrecise: const Value(0), abundance: const Value(null), offerStatus: const Value(OfferStatus.private), priceAmount: const Value(null), priceCurrency: const Value(null), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } String? saleId; if (withPayment) { saleId = await createSale( direction: gave ? SaleDirection.iSold : SaleDirection.iBought, varietyId: lot.varietyId, counterparty: counterparty, amount: paymentAmount, currency: paymentCurrency, note: note, ); } return (movementId: movementId, saleId: saleId, plantareId: plantareId); }); } /// Snapshots the live inventory (tombstones excluded) for the interchange /// export — data-model §7. Includes photo bytes; the JSON codec embeds them /// as base64 and the CSV codec ignores them. Future exportInventory() async { final varieties = await (_db.select( _db.varieties, )..where((v) => v.isDeleted.equals(false))).get(); final speciesNamesById = await _scientificNamesFor( varieties.map((v) => v.speciesId).whereType().toSet(), ); return InventorySnapshot( varieties: varieties, speciesNamesById: speciesNamesById, lots: await (_db.select( _db.lots, )..where((l) => l.isDeleted.equals(false))).get(), vernacularNames: await (_db.select( _db.varietyVernacularNames, )..where((n) => n.isDeleted.equals(false))).get(), externalLinks: await (_db.select( _db.externalLinks, )..where((e) => e.isDeleted.equals(false))).get(), germinationTests: await (_db.select( _db.germinationTests, )..where((g) => g.isDeleted.equals(false))).get(), conditionChecks: await (_db.select( _db.conditionChecks, )..where((c) => c.isDeleted.equals(false))).get(), movements: await _db.select(_db.movements).get(), parties: await (_db.select( _db.parties, )..where((p) => p.isDeleted.equals(false))).get(), attachments: await (_db.select( _db.attachments, )..where((a) => a.isDeleted.equals(false))).get(), plantares: await (_db.select( _db.plantares, )..where((p) => p.isDeleted.equals(false))).get(), sales: await (_db.select( _db.sales, )..where((s) => s.isDeleted.equals(false))).get(), ); } /// Snapshots the inventory for device-to-device SYNC — unlike [exportInventory] /// this INCLUDES tombstones (so deletions replicate) and EXCLUDES attachment /// bytes (photos are large and stay device-local for now; syncing media is a /// separate concern). Everything else — the CRDT rows with their sync metadata /// — replicates and merges LWW-by-HLC on the other device. Future exportForSync() async { final varieties = await _db.select(_db.varieties).get(); // incl. tombstones return InventorySnapshot( varieties: varieties, speciesNamesById: await _scientificNamesFor( varieties.map((v) => v.speciesId).whereType().toSet(), ), lots: await _db.select(_db.lots).get(), vernacularNames: await _db.select(_db.varietyVernacularNames).get(), externalLinks: await _db.select(_db.externalLinks).get(), germinationTests: await _db.select(_db.germinationTests).get(), conditionChecks: await _db.select(_db.conditionChecks).get(), movements: await _db.select(_db.movements).get(), parties: await _db.select(_db.parties).get(), plantares: await _db.select(_db.plantares).get(), sales: await _db.select(_db.sales).get(), // attachments intentionally omitted — photos don't ride the sync wire. ); } /// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates: /// unknown id → insert preserving the original sync metadata; known mutable /// id → last-writer-wins on the packed HLC `updatedAt`; movements are /// append-only (insert-if-unknown). Runs in one transaction; afterwards the /// local clock absorbs the newest imported stamp so it never runs behind. /// /// Species ids are per-install, so incoming `speciesId`s are re-resolved /// against the local catalog by scientific name (unmatched → null). Future importInventory(InventorySnapshot snapshot) async { const reconciler = ImportReconciler(); final localSpeciesIdByIncoming = await _resolveSpecies( snapshot.speciesNamesById, ); final varieties = [ for (final v in snapshot.varieties) v.speciesId == null ? v : v.copyWith( speciesId: Value(localSpeciesIdByIncoming[v.speciesId]), ), ]; var summary = const ImportSummary(); await _db.transaction(() async { summary += await _importMutableRows( table: _db.varieties, idColumn: _db.varieties.id, rows: varieties, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.lots, idColumn: _db.lots.id, rows: snapshot.lots, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.varietyVernacularNames, idColumn: _db.varietyVernacularNames.id, rows: snapshot.vernacularNames, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.externalLinks, idColumn: _db.externalLinks.id, rows: snapshot.externalLinks, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.germinationTests, idColumn: _db.germinationTests.id, rows: snapshot.germinationTests, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.conditionChecks, idColumn: _db.conditionChecks.id, rows: snapshot.conditionChecks, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.parties, idColumn: _db.parties.id, rows: snapshot.parties, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.attachments, idColumn: _db.attachments.id, rows: snapshot.attachments, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.plantares, idColumn: _db.plantares.id, rows: snapshot.plantares, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMutableRows( table: _db.sales, idColumn: _db.sales.id, rows: snapshot.sales, idOf: (r) => r.id, updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); summary += await _importMovements(snapshot.movements, reconciler); }); final maxIncoming = reconciler.maxHlc([ for (final v in snapshot.varieties) v.updatedAt, for (final l in snapshot.lots) l.updatedAt, for (final n in snapshot.vernacularNames) n.updatedAt, for (final e in snapshot.externalLinks) e.updatedAt, for (final g in snapshot.germinationTests) g.updatedAt, for (final c in snapshot.conditionChecks) c.updatedAt, for (final p in snapshot.parties) p.updatedAt, for (final a in snapshot.attachments) a.updatedAt, for (final pl in snapshot.plantares) pl.updatedAt, for (final s in snapshot.sales) s.updatedAt, ]); if (maxIncoming != null) { _clock = _clock.receiveEvent(maxIncoming, _now()); } return summary; } /// Additive import of a spreadsheet [csv]: every parsed variety is inserted /// fresh (new UUIDv7 + local HLC), so it never overwrites existing rows and /// re-importing the same file adds it again. The canonical, id-reconciling /// import is JSON ([importInventory]); this is the low-friction path for a /// list a collective already keeps in a spreadsheet. /// /// Species are re-linked by scientific name against the local catalog /// (unmatched → null). Returns the number of varieties added. Future importCsv(CsvImport csv) async { var inserted = 0; await _db.transaction(() async { for (final v in csv.varieties) { final speciesId = await _resolveSpeciesByName(v.scientificName); final varietyId = idGen.newId(); final (created, updated) = await _stamp(); await _db .into(_db.varieties) .insert( VarietiesCompanion.insert( id: varietyId, label: v.label, createdAt: created, updatedAt: updated, lastAuthor: nodeId, category: Value(v.category), cultivarName: Value(v.cultivarName), notes: Value(v.notes), speciesId: Value(speciesId), needsReproduction: Value(v.needsReproduction), sowMonths: Value(v.sowMonths), transplantMonths: Value(v.transplantMonths), floweringMonths: Value(v.floweringMonths), fruitingMonths: Value(v.fruitingMonths), seedHarvestMonths: Value(v.seedHarvestMonths), ), ); inserted++; for (final lot in v.lots) { final (created, updated) = await _stamp(); await _db .into(_db.lots) .insert( LotsCompanion.insert( id: idGen.newId(), varietyId: varietyId, createdAt: created, updatedAt: updated, lastAuthor: nodeId, type: Value(lot.type), harvestYear: Value(lot.harvestYear), harvestMonth: Value(lot.harvestMonth), quantityKind: Value(lot.quantityKind), quantityPrecise: Value(lot.quantityPrecise), quantityLabel: Value(lot.quantityLabel), presentation: Value(lot.presentation), storageLocation: Value(lot.storageLocation), offerStatus: Value(lot.offerStatus), originName: Value(lot.originName), originPlace: Value(lot.originPlace), abundance: Value(lot.abundance), preservationFormat: Value(lot.preservationFormat), ), ); } for (final name in v.vernacularNames) { final (created, updated) = await _stamp(); await _db .into(_db.varietyVernacularNames) .insert( VarietyVernacularNamesCompanion.insert( id: idGen.newId(), varietyId: varietyId, name: name.name, createdAt: created, updatedAt: updated, lastAuthor: nodeId, language: Value(name.language), ), ); } for (final link in v.links) { final (created, updated) = await _stamp(); await _db .into(_db.externalLinks) .insert( ExternalLinksCompanion.insert( id: idGen.newId(), createdAt: created, updatedAt: updated, lastAuthor: nodeId, parentType: ParentType.variety, parentId: varietyId, url: link.url, title: Value(link.title), ), ); } } }); return ImportSummary(inserted: inserted); } /// Resolves a single scientific name to a local catalog species id, or null /// when blank or unmatched. Future _resolveSpeciesByName(String? scientificName) async { final name = scientificName?.trim(); if (name == null || name.isEmpty) return null; final local = await (_db.select(_db.species)..where( (s) => s.scientificName.equals(name) & s.isDeleted.equals(false), )) .getSingleOrNull(); return local?.id; } /// Maps incoming species ids to local catalog ids by scientific name. Future> _resolveSpecies( Map speciesNamesById, ) async { final resolved = {}; for (final entry in speciesNamesById.entries) { final local = await (_db.select(_db.species)..where( (s) => s.scientificName.equals(entry.value) & s.isDeleted.equals(false), )) .getSingleOrNull(); resolved[entry.key] = local?.id; } return resolved; } /// LWW import of one mutable table. Existing rows are looked up including /// tombstones, so a locally-deleted row only resurrects when the incoming /// version is strictly newer — plain last-writer-wins. Future _importMutableRows({ required TableInfo table, required GeneratedColumn idColumn, required List rows, required String Function(R) idOf, required String Function(R) updatedAtOf, required ImportReconciler reconciler, }) async { if (rows.isEmpty) return const ImportSummary(); final byId = {for (final r in rows) idOf(r): r}; final existing = await (_db.select( table, )..where((_) => idColumn.isIn(byId.keys))).get(); final existingUpdatedAt = { for (final r in existing) idOf(r): updatedAtOf(r), }; var inserted = 0, updated = 0, skipped = 0; for (final row in byId.values) { final action = reconciler.reconcileMutable( existingUpdatedAt: existingUpdatedAt[idOf(row)], incomingUpdatedAt: updatedAtOf(row), ); switch (action) { case ReconcileAction.insert: await _db.into(table).insert(row as Insertable); inserted++; case ReconcileAction.update: await _db.update(table).replace(row as Insertable); updated++; case ReconcileAction.skip: skipped++; } } return ImportSummary( inserted: inserted, updated: updated, skipped: skipped, ); } /// Movements are append-only: insert unknown ids, never touch known ones. Future _importMovements( List movements, ImportReconciler reconciler, ) async { if (movements.isEmpty) return const ImportSummary(); final ids = movements.map((m) => m.id).toList(); final existing = await (_db.select( _db.movements, )..where((m) => m.id.isIn(ids))).get(); final existingIds = existing.map((m) => m.id).toSet(); var inserted = 0, skipped = 0; for (final movement in movements) { final action = reconciler.reconcileAppendOnly( exists: existingIds.contains(movement.id), ); if (action == ReconcileAction.insert) { await _db.into(_db.movements).insert(movement); inserted++; } else { skipped++; } } return ImportSummary(inserted: inserted, skipped: skipped); } VarietyLot _toLot( Lot l, List germinationTests, List conditionChecks, ) { 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, presentation: l.presentation, storageLocation: l.storageLocation, originName: l.originName, originPlace: l.originPlace, abundance: l.abundance, preservationFormat: l.preservationFormat, offerStatus: l.offerStatus, priceAmount: l.priceAmount, priceCurrency: l.priceCurrency, germinationTests: germinationTests, conditionChecks: conditionChecks, 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)`. /// /// 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? _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 _ensureClockSeeded() => _clockSeeded ??= _seedClockFromStoredRows(); Future _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 _maxStoredHlc() async { final tables = < ( ResultSetImplementation, GeneratedColumn, ) >[ (_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); } }