Scale the local inventory to 10k+ varieties: - Render the list with ListView.builder over a flattened header/item model instead of building every tile upfront. - Store a small regenerable JPEG thumbnail per photo (schema v14) and use it for the 48px list avatar; full bytes stay for offer image hosting. Existing photos are backfilled lazily at startup. Thumbnail is local-only (excluded from CRDT sync and backups by the JSON codec). - Add indexes on varieties(is_deleted,is_draft), attachments(parent_type, parent_id,kind), lots(variety_id) via @TableIndex. - Debounce watchInventoryView (~250ms) so a burst of table writes triggers one reload, not seven. - cacheWidth/cacheHeight on the list avatar decode. - Scale test raised 3k -> 10k; migration test v13 -> v14.
3129 lines
105 KiB
Dart
3129 lines
105 KiB
Dart
import 'dart:async';
|
|
|
|
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 commitment plus the label of the variety it's linked to (if any), for the
|
|
/// Plantares list — lets a tile name the seed and link back to its detail.
|
|
typedef PlantareView = ({Plantare plantare, String? varietyLabel});
|
|
|
|
/// 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<LotType> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> get props => [
|
|
id,
|
|
checkedOn,
|
|
containerCount,
|
|
desiccantState,
|
|
notes,
|
|
];
|
|
}
|
|
|
|
/// What kind of event a [LotHistoryEntry] narrates.
|
|
enum LotHistoryKind {
|
|
created,
|
|
movement,
|
|
germinationTest,
|
|
conditionCheck,
|
|
gardenOutcome,
|
|
}
|
|
|
|
/// One line of a lot's composite history — the batch's own story, merged from
|
|
/// what is already recorded (the lot's creation with its origin, movements,
|
|
/// germination tests and condition checks). Read-only; built by
|
|
/// [VarietyRepository.lotHistory].
|
|
class LotHistoryEntry extends Equatable {
|
|
const LotHistoryEntry({
|
|
required this.kind,
|
|
this.when,
|
|
this.movementType,
|
|
this.notes,
|
|
this.quantity,
|
|
this.counterpartyName,
|
|
this.originName,
|
|
this.originPlace,
|
|
this.germinationRate,
|
|
this.containerCount,
|
|
this.desiccantState,
|
|
this.rating,
|
|
this.year,
|
|
this.hasParentLink = false,
|
|
});
|
|
|
|
final LotHistoryKind kind;
|
|
|
|
/// When it happened (ms since epoch): the event's own date when recorded,
|
|
/// falling back to the row's `createdAt`. Null only when neither exists.
|
|
final int? when;
|
|
|
|
/// Set for [LotHistoryKind.movement] entries.
|
|
final MovementType? movementType;
|
|
final String? notes;
|
|
final Quantity? quantity;
|
|
|
|
/// Display name of the movement's counterparty, when one was recorded.
|
|
final String? counterpartyName;
|
|
|
|
/// Provenance carried by the creation entry (who grew/gave it, where from).
|
|
final String? originName;
|
|
final String? originPlace;
|
|
|
|
/// Set for [LotHistoryKind.germinationTest] entries (0..1), when computable.
|
|
final double? germinationRate;
|
|
|
|
/// Set for [LotHistoryKind.conditionCheck] entries.
|
|
final int? containerCount;
|
|
final DesiccantState? desiccantState;
|
|
|
|
/// Set for [LotHistoryKind.gardenOutcome] entries: how that season went.
|
|
final GardenOutcomeRating? rating;
|
|
final int? year;
|
|
|
|
/// True when the movement links back to a source batch (provenance DAG).
|
|
final bool hasParentLink;
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
kind,
|
|
when,
|
|
movementType,
|
|
notes,
|
|
quantity,
|
|
counterpartyName,
|
|
originName,
|
|
originPlace,
|
|
germinationRate,
|
|
containerCount,
|
|
desiccantState,
|
|
rating,
|
|
year,
|
|
hasParentLink,
|
|
];
|
|
}
|
|
|
|
/// 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<GerminationEntry> germinationTests;
|
|
|
|
/// Storage-condition checks, most-recent first (`conditionChecks.first` = last).
|
|
final List<ConditionEntry> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<VarietyLot> lots;
|
|
final List<VernacularName> vernacularNames;
|
|
final List<VarietyPhoto> photos;
|
|
final List<ExternalLinkItem> links;
|
|
|
|
@override
|
|
List<Object?> 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,
|
|
Uint8List? Function(Uint8List)? thumbnailBuilder,
|
|
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
|
|
_thumbnailBuilder = thumbnailBuilder,
|
|
_clock = Hlc.zero(nodeId);
|
|
|
|
final AppDatabase _db;
|
|
final IdGen idGen;
|
|
final String nodeId;
|
|
final int Function() _now;
|
|
|
|
/// Builds the small list-avatar thumbnail from a full photo. Injected (from
|
|
/// `services/offer_thumbnail.dart` in production) so the data layer stays free
|
|
/// of the image codec and unit tests run without decoding. Null → no
|
|
/// thumbnail is stored and the list falls back to the full photo.
|
|
final Uint8List? Function(Uint8List)? _thumbnailBuilder;
|
|
|
|
/// The thumbnail for [photoBytes], or null when no builder is wired or the
|
|
/// bytes aren't decodable. Wrapped in a Value for direct use in a companion.
|
|
Value<Uint8List?> _thumbValue(Uint8List photoBytes) =>
|
|
Value(_thumbnailBuilder?.call(photoBytes));
|
|
|
|
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<List<VarietyListItem>> watchInventory() {
|
|
final triggers = StreamGroup.merge<void>([
|
|
(_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<VarietyListItem> items, List<VarietyListItem> drafts})>
|
|
watchInventoryView() {
|
|
final triggers = StreamGroup.merge<void>([
|
|
(_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((_) {}),
|
|
]);
|
|
// Coalesce bursts: a single quick-add or handover touches several of these
|
|
// tables at once, and each would otherwise re-run the full (7-query) load.
|
|
// Debouncing collapses the burst into one reload — decisive with a large
|
|
// inventory.
|
|
return _debounce(triggers, const Duration(milliseconds: 250)).asyncMap(
|
|
(_) async => (items: await _loadInventory(), drafts: await _loadDrafts()),
|
|
);
|
|
}
|
|
|
|
/// One-shot list of catalogued (named, non-draft) seeds for pickers — id +
|
|
/// label only, no photo/species joins. A Future (not a stream) so a transient
|
|
/// sheet doesn't hold a live subscription (which would hang widget tests).
|
|
Future<List<({String id, String label})>> varietyLabels() async {
|
|
final rows = await (_db.select(_db.varieties)
|
|
..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(false))
|
|
..orderBy([(v) => OrderingTerm(expression: v.label)]))
|
|
.get();
|
|
return [
|
|
for (final v in rows)
|
|
if (v.label.trim().isNotEmpty) (id: v.id, label: v.label),
|
|
];
|
|
}
|
|
|
|
Future<List<VarietyListItem>> _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<List<VarietyListItem>> watchDrafts() {
|
|
final triggers = StreamGroup.merge<void>([
|
|
(_db.select(
|
|
_db.varieties,
|
|
)..where((v) => v.isDraft.equals(true))).watch().map((_) {}),
|
|
_db.select(_db.attachments).watch().map((_) {}),
|
|
]);
|
|
return triggers.asyncMap((_) => _loadDrafts());
|
|
}
|
|
|
|
Future<List<VarietyListItem>> _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<List<VarietyListItem>> _toListItems(List<Variety> rows) async {
|
|
final varietyIds = rows.map((v) => v.id).toList();
|
|
final speciesIds = rows.map((v) => v.speciesId).whereType<String>().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<List<CalendarEntry>> watchCalendar() {
|
|
final triggers = StreamGroup.merge<void>([
|
|
(_db.select(
|
|
_db.varieties,
|
|
)..where((v) => v.isDeleted.equals(false))).watch().map((_) {}),
|
|
_db.select(_db.attachments).watch().map((_) {}),
|
|
]);
|
|
return triggers.asyncMap((_) => _loadCalendar());
|
|
}
|
|
|
|
Future<List<CalendarEntry>> _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<Map<String, int>> _speciesViabilityFor(Set<String> 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<Map<String, List<int>>> _seedHarvestYearsFor(
|
|
List<String> 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 = <String, List<int>>{};
|
|
for (final row in rows) {
|
|
final year = row.harvestYear;
|
|
if (year != null) (byVariety[row.varietyId] ??= <int>[]).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<int> 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<Map<String, Set<LotType>>> _lotTypesFor(
|
|
List<String> 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 = <String, Set<LotType>>{};
|
|
for (final row in rows) {
|
|
(byVariety[row.varietyId] ??= <LotType>{}).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<Set<String>> _sharedVarietyIdsFor(List<String> 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's small [thumbnail] for each of [varietyIds] (one
|
|
/// query) — for the inventory-list avatar. Falls back to the full-resolution
|
|
/// [bytes] only when a thumbnail hasn't been generated yet (older rows,
|
|
/// pending lazy backfill), so the list stays correct meanwhile.
|
|
Future<Map<String, Uint8List>> _firstPhotosFor(
|
|
List<String> 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 = <String, Uint8List>{};
|
|
for (final row in rows) {
|
|
final image = row.thumbnail ?? row.bytes;
|
|
if (image != null) byVariety.putIfAbsent(row.parentId, () => image);
|
|
}
|
|
return byVariety;
|
|
}
|
|
|
|
/// Generates the missing list thumbnails for photos that predate the
|
|
/// thumbnail column (or arrived via sync/backup restore, which never carry
|
|
/// one). Processes in small batches so decoding doesn't block the UI; safe to
|
|
/// call at startup as fire-and-forget. No-op when no thumbnail builder is
|
|
/// wired. Returns how many thumbnails were written.
|
|
Future<int> backfillThumbnails({int batchSize = 20}) async {
|
|
final build = _thumbnailBuilder;
|
|
if (build == null) return 0;
|
|
var written = 0;
|
|
while (true) {
|
|
final batch =
|
|
await (_db.select(_db.attachments)
|
|
..where(
|
|
(a) =>
|
|
a.kind.equalsValue(AttachmentKind.photo) &
|
|
a.isDeleted.equals(false) &
|
|
a.thumbnail.isNull() &
|
|
a.bytes.isNotNull(),
|
|
)
|
|
..limit(batchSize))
|
|
.get();
|
|
if (batch.isEmpty) break;
|
|
for (final row in batch) {
|
|
final thumb = build(row.bytes!);
|
|
// No decodable image → store the full bytes as the "thumbnail" so this
|
|
// row isn't re-scanned forever. It's rare (corrupt photo) and still
|
|
// bounded by the avatar's cacheWidth at render time.
|
|
await (_db.update(_db.attachments)..where((a) => a.id.equals(row.id)))
|
|
.write(AttachmentsCompanion(thumbnail: Value(thumb ?? row.bytes)));
|
|
written++;
|
|
}
|
|
if (batch.length < batchSize) break;
|
|
}
|
|
return written;
|
|
}
|
|
|
|
/// 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, so it returns
|
|
/// the FULL-resolution [bytes] (not the list thumbnail).
|
|
Future<Uint8List?> coverPhotoFor(String varietyId) async {
|
|
final rows =
|
|
await (_db.select(_db.attachments)
|
|
..where(
|
|
(a) =>
|
|
a.parentId.equals(varietyId) &
|
|
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),
|
|
])
|
|
..limit(1))
|
|
.get();
|
|
return rows.isEmpty ? null : rows.first.bytes;
|
|
}
|
|
|
|
/// Maps each of [speciesIds] to its scientific name (one query).
|
|
Future<Map<String, String>> _scientificNamesFor(
|
|
Set<String> 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<String> 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),
|
|
thumbnail: _thumbValue(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<String> 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),
|
|
thumbnail: _thumbValue(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<void> 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<VarietyDetail?> watchVariety(String id) {
|
|
final triggers = StreamGroup.merge<void>([
|
|
(_db.select(
|
|
_db.varieties,
|
|
)..where((v) => v.id.equals(id))).watch().map((_) {}),
|
|
(_db.select(
|
|
_db.lots,
|
|
)..where((l) => l.varietyId.equals(id))).watch().map((_) {}),
|
|
(_db.select(
|
|
_db.varietyVernacularNames,
|
|
)..where((n) => n.varietyId.equals(id))).watch().map((_) {}),
|
|
(_db.select(
|
|
_db.attachments,
|
|
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
|
|
(_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<VarietyDetail?> _loadVariety(String id) async {
|
|
final v =
|
|
await (_db.select(_db.varieties)
|
|
..where((t) => t.id.equals(id) & t.isDeleted.equals(false)))
|
|
.getSingleOrNull();
|
|
if (v == null) return null;
|
|
|
|
String? scientificName;
|
|
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 = <String, List<GerminationEntry>>{};
|
|
final checksByLot = <String, List<ConditionEntry>>{};
|
|
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<List<SpeciesNameEntry>> _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<void> 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<void> updateVariety({
|
|
required String id,
|
|
String? label,
|
|
String? category,
|
|
String? notes,
|
|
bool? isOrganic,
|
|
bool? needsReproduction,
|
|
Value<int?> sowMonths = const Value.absent(),
|
|
Value<int?> transplantMonths = const Value.absent(),
|
|
Value<int?> floweringMonths = const Value.absent(),
|
|
Value<int?> fruitingMonths = const Value.absent(),
|
|
Value<int?> 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<String> 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<void> 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<List<SharedCatalogEntry>> 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<String>().toSet(),
|
|
);
|
|
|
|
final entries = <SharedCatalogEntry>[
|
|
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<List<SeedLabelEntry>> labelRows(
|
|
Set<String> 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<String>()
|
|
.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 = <String, List<Lot>>{};
|
|
for (final l in lots) {
|
|
(lotsByVariety[l.varietyId] ??= <Lot>[]).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 = <String, int>{};
|
|
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 = <SeedLabelEntry>[];
|
|
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<Map<String, String>> _commonNamesFor(
|
|
Set<String> 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 = <String, List<({String name, String? language})>>{};
|
|
for (final n in rows) {
|
|
(bySpecies[n.speciesId] ??= []).add((name: n.name, language: n.language));
|
|
}
|
|
final result = <String, String>{};
|
|
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<List<ShareableLot>> 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<void> 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<String> 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<void> 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<String> 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),
|
|
thumbnail: _thumbValue(bytes),
|
|
mimeType: const Value('image/jpeg'),
|
|
),
|
|
);
|
|
return id;
|
|
}
|
|
|
|
/// Soft-deletes a photo (tombstone).
|
|
Future<void> 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<void> 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<String> 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<void> 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<void> 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<String> 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<String> 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;
|
|
}
|
|
|
|
/// Records a one-tap cultivation event on a lot — the grower sowed from it or
|
|
/// harvested into it. Only [MovementType.sown] and [MovementType.harvested]
|
|
/// are cultivation events; exchanges go through [recordHandover]. Returns the
|
|
/// new movement id. [occurredOn] defaults to now.
|
|
Future<String> recordCultivation({
|
|
required String lotId,
|
|
required MovementType type,
|
|
int? occurredOn,
|
|
String? notes,
|
|
}) async {
|
|
if (type != MovementType.sown && type != MovementType.harvested) {
|
|
throw ArgumentError.value(
|
|
type,
|
|
'type',
|
|
'only sown/harvested are cultivation events',
|
|
);
|
|
}
|
|
final (created, _) = await _stamp();
|
|
final id = idGen.newId();
|
|
await _db
|
|
.into(_db.movements)
|
|
.insert(
|
|
MovementsCompanion.insert(
|
|
id: id,
|
|
createdAt: created,
|
|
lastAuthor: nodeId,
|
|
lotId: lotId,
|
|
type: type,
|
|
occurredOn: Value(occurredOn ?? created),
|
|
notes: Value(_hasText(notes) ? notes!.trim() : null),
|
|
),
|
|
);
|
|
return id;
|
|
}
|
|
|
|
/// The id of the non-deleted variety whose label matches [label]
|
|
/// (case-insensitive, trimmed), or null. This is how a scanned envelope
|
|
/// label finds its record: the label IS the identity a keeper printed.
|
|
Future<String?> findVarietyIdByLabel(String label) async {
|
|
final row =
|
|
await (_db.select(_db.varieties)
|
|
..where(
|
|
(v) =>
|
|
v.isDeleted.equals(false) &
|
|
v.label.lower().equals(label.trim().toLowerCase()),
|
|
)
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
return row?.id;
|
|
}
|
|
|
|
/// Records how a season went for a lot — the optional, skippable answer to
|
|
/// "how did it do?" asked when a harvest is noted. Returns the new row id.
|
|
Future<String> addGardenOutcome({
|
|
required String lotId,
|
|
int? year,
|
|
GardenOutcomeRating? rating,
|
|
String? notes,
|
|
}) async {
|
|
final (created, updated) = await _stamp();
|
|
final id = idGen.newId();
|
|
await _db
|
|
.into(_db.gardenOutcomes)
|
|
.insert(
|
|
GardenOutcomesCompanion.insert(
|
|
id: id,
|
|
lotId: lotId,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
year: Value(year),
|
|
rating: Value(rating),
|
|
notes: Value(_hasText(notes) ? notes!.trim() : null),
|
|
),
|
|
);
|
|
return id;
|
|
}
|
|
|
|
/// The lot's composite history, most-recent first: every movement,
|
|
/// germination test and condition check already recorded, closed by the
|
|
/// lot's own creation entry (carrying its origin, so "received from…" shows
|
|
/// even when no movement was ever logged). One-shot Future — a transient
|
|
/// sheet must never hold a live subscription.
|
|
Future<List<LotHistoryEntry>> lotHistory(String lotId) async {
|
|
final lot = await (_db.select(
|
|
_db.lots,
|
|
)..where((l) => l.id.equals(lotId))).getSingleOrNull();
|
|
if (lot == null) return const [];
|
|
|
|
final movements = await (_db.select(
|
|
_db.movements,
|
|
)..where((m) => m.lotId.equals(lotId))).get();
|
|
final tests = await (_db.select(
|
|
_db.germinationTests,
|
|
)..where((g) => g.lotId.equals(lotId) & g.isDeleted.equals(false))).get();
|
|
final checks = await (_db.select(
|
|
_db.conditionChecks,
|
|
)..where((c) => c.lotId.equals(lotId) & c.isDeleted.equals(false))).get();
|
|
final outcomes = await (_db.select(
|
|
_db.gardenOutcomes,
|
|
)..where((o) => o.lotId.equals(lotId) & o.isDeleted.equals(false))).get();
|
|
|
|
// Resolve counterparty display names in one go (usually zero or one).
|
|
final partyIds = movements
|
|
.map((m) => m.counterpartyId)
|
|
.whereType<String>()
|
|
.toSet();
|
|
final partyNames = <String, String>{};
|
|
if (partyIds.isNotEmpty) {
|
|
final parties = await (_db.select(
|
|
_db.parties,
|
|
)..where((p) => p.id.isIn(partyIds))).get();
|
|
for (final p in parties) {
|
|
partyNames[p.id] = p.displayName;
|
|
}
|
|
}
|
|
|
|
final entries = <LotHistoryEntry>[
|
|
for (final m in movements)
|
|
LotHistoryEntry(
|
|
kind: LotHistoryKind.movement,
|
|
when: m.occurredOn ?? m.createdAt,
|
|
movementType: m.type,
|
|
notes: m.notes,
|
|
quantity: m.quantityKind == null && m.quantityLabel == null
|
|
? null
|
|
: Quantity(
|
|
kind: _parseKind(m.quantityKind),
|
|
count: m.quantityPrecise,
|
|
label: m.quantityLabel,
|
|
),
|
|
counterpartyName: m.counterpartyId == null
|
|
? null
|
|
: partyNames[m.counterpartyId],
|
|
hasParentLink: m.parentMovementId != null,
|
|
),
|
|
for (final g in tests)
|
|
LotHistoryEntry(
|
|
kind: LotHistoryKind.germinationTest,
|
|
when: g.testedOn ?? g.createdAt,
|
|
notes: g.notes,
|
|
germinationRate: GerminationEntry(
|
|
id: g.id,
|
|
sampleSize: g.sampleSize,
|
|
germinatedCount: g.germinatedCount,
|
|
).rate,
|
|
),
|
|
for (final c in checks)
|
|
LotHistoryEntry(
|
|
kind: LotHistoryKind.conditionCheck,
|
|
when: c.checkedOn ?? c.createdAt,
|
|
notes: c.notes,
|
|
containerCount: c.containerCount,
|
|
desiccantState: c.desiccantState,
|
|
),
|
|
for (final o in outcomes)
|
|
LotHistoryEntry(
|
|
kind: LotHistoryKind.gardenOutcome,
|
|
when: o.createdAt,
|
|
notes: o.notes,
|
|
rating: o.rating,
|
|
year: o.year,
|
|
),
|
|
]..sort((a, b) => (b.when ?? 0).compareTo(a.when ?? 0));
|
|
|
|
return [
|
|
...entries,
|
|
// Creation closes the story (oldest), whatever its stamp: it reads as
|
|
// "this batch entered your collection", origin attached.
|
|
LotHistoryEntry(
|
|
kind: LotHistoryKind.created,
|
|
when: lot.createdAt,
|
|
originName: lot.originName,
|
|
originPlace: lot.originPlace,
|
|
),
|
|
];
|
|
}
|
|
|
|
// --- 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<String> createPlantare({
|
|
required PlantareDirection direction,
|
|
String? varietyId,
|
|
String? counterparty,
|
|
String? owedDescription,
|
|
int? dueBy,
|
|
String? note,
|
|
// Bilateral signed form (plantare-bilateral.md) — all optional so a plain
|
|
// local note stays a v1 row with these null.
|
|
String? id,
|
|
int? madeOn,
|
|
String? pledgeId,
|
|
String? debtorKey,
|
|
String? creditorKey,
|
|
String? debtorSignature,
|
|
String? creditorSignature,
|
|
String? movementId,
|
|
PlantareRemoteState? remoteState,
|
|
PlantareReturnKind returnKind = PlantareReturnKind.similar,
|
|
double? workHours,
|
|
}) async {
|
|
final (created, updated) = await _stamp();
|
|
final rowId = id ?? idGen.newId();
|
|
await _db
|
|
.into(_db.plantares)
|
|
.insert(
|
|
PlantaresCompanion.insert(
|
|
id: rowId,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
direction: direction,
|
|
varietyId: Value(varietyId),
|
|
counterparty: Value(counterparty),
|
|
owedDescription: Value(owedDescription),
|
|
madeOn: madeOn ?? created,
|
|
dueBy: Value(dueBy),
|
|
note: Value(note),
|
|
pledgeId: Value(pledgeId),
|
|
debtorKey: Value(debtorKey),
|
|
creditorKey: Value(creditorKey),
|
|
debtorSignature: Value(debtorSignature),
|
|
creditorSignature: Value(creditorSignature),
|
|
movementId: Value(movementId),
|
|
remoteState: Value(remoteState),
|
|
returnKind: Value(returnKind),
|
|
workHours: Value(workHours),
|
|
),
|
|
);
|
|
return rowId;
|
|
}
|
|
|
|
/// The local row holding the bilateral pledge [pledgeId], or null. Both parties
|
|
/// key their own row by the shared pledge id so accepts/declines reconcile.
|
|
Future<Plantare?> plantareByPledgeId(String pledgeId) =>
|
|
(_db.select(_db.plantares)
|
|
..where((p) => p.pledgeId.equals(pledgeId))
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
|
|
/// Applies a counterparty's stub(s) and the new handshake [remoteState] to the
|
|
/// row for [pledgeId] (e.g. on receiving an `accept`). No-op if unknown.
|
|
Future<void> applyPlantareSignatures({
|
|
required String pledgeId,
|
|
String? debtorSignature,
|
|
String? creditorSignature,
|
|
PlantareRemoteState? remoteState,
|
|
String? movementId,
|
|
}) async {
|
|
final (_, updated) = await _stamp();
|
|
await (_db.update(_db.plantares)
|
|
..where((p) => p.pledgeId.equals(pledgeId)))
|
|
.write(
|
|
PlantaresCompanion(
|
|
debtorSignature:
|
|
debtorSignature == null ? const Value.absent() : Value(debtorSignature),
|
|
creditorSignature: creditorSignature == null
|
|
? const Value.absent()
|
|
: Value(creditorSignature),
|
|
movementId:
|
|
movementId == null ? const Value.absent() : Value(movementId),
|
|
remoteState:
|
|
remoteState == null ? const Value.absent() : Value(remoteState),
|
|
updatedAt: Value(updated),
|
|
lastAuthor: Value(nodeId),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Moves the row for [pledgeId] to a new handshake [state] (e.g. `declined`).
|
|
Future<void> setPlantareRemoteState(
|
|
String pledgeId,
|
|
PlantareRemoteState state,
|
|
) async {
|
|
final (_, updated) = await _stamp();
|
|
await (_db.update(_db.plantares)
|
|
..where((p) => p.pledgeId.equals(pledgeId)))
|
|
.write(
|
|
PlantaresCompanion(
|
|
remoteState: Value(state),
|
|
updatedAt: Value(updated),
|
|
lastAuthor: Value(nodeId),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// All live commitments, newest first (for the Plantares screen).
|
|
Stream<List<Plantare>> watchPlantares() =>
|
|
(_db.select(_db.plantares)
|
|
..where((p) => p.isDeleted.equals(false))
|
|
..orderBy([(p) => OrderingTerm.desc(p.madeOn)]))
|
|
.watch();
|
|
|
|
/// Live commitments joined with the label of the variety each is linked to
|
|
/// (null when unlinked or the variety is gone), newest first — so the list
|
|
/// can name the seed and offer a tap-through to its detail.
|
|
Stream<List<PlantareView>> watchPlantareViews() {
|
|
final query = _db.select(_db.plantares).join([
|
|
leftOuterJoin(
|
|
_db.varieties,
|
|
_db.varieties.id.equalsExp(_db.plantares.varietyId),
|
|
),
|
|
])
|
|
..where(_db.plantares.isDeleted.equals(false))
|
|
..orderBy([OrderingTerm.desc(_db.plantares.madeOn)]);
|
|
return query.watch().map((rows) => [
|
|
for (final row in rows)
|
|
(
|
|
plantare: row.readTable(_db.plantares),
|
|
varietyLabel: switch (row.readTableOrNull(_db.varieties)) {
|
|
final v? when !v.isDeleted => v.label,
|
|
_ => null,
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
|
|
/// The commitments recorded against one variety.
|
|
Stream<List<Plantare>> 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<void> 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<void> 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<String> 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<List<Sale>> 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<List<Sale>> 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<void> 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<InventorySnapshot> 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<String>().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(),
|
|
gardenOutcomes: await (_db.select(
|
|
_db.gardenOutcomes,
|
|
)..where((o) => o.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<InventorySnapshot> 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<String>().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(),
|
|
gardenOutcomes: await _db.select(_db.gardenOutcomes).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<ImportSummary> 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.gardenOutcomes,
|
|
idColumn: _db.gardenOutcomes.id,
|
|
rows: snapshot.gardenOutcomes,
|
|
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 o in snapshot.gardenOutcomes) o.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<ImportSummary> 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<String?> _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<Map<String, String?>> _resolveSpecies(
|
|
Map<String, String> speciesNamesById,
|
|
) async {
|
|
final resolved = <String, String?>{};
|
|
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<ImportSummary> _importMutableRows<T extends Table, R>({
|
|
required TableInfo<T, R> table,
|
|
required GeneratedColumn<String> idColumn,
|
|
required List<R> 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<R>);
|
|
inserted++;
|
|
case ReconcileAction.update:
|
|
await _db.update(table).replace(row as Insertable<R>);
|
|
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<ImportSummary> _importMovements(
|
|
List<Movement> 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<GerminationEntry> germinationTests,
|
|
List<ConditionEntry> 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<void>? _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<void> _ensureClockSeeded() =>
|
|
_clockSeeded ??= _seedClockFromStoredRows();
|
|
|
|
Future<void> _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<Hlc?> _maxStoredHlc() async {
|
|
final tables =
|
|
<
|
|
(
|
|
ResultSetImplementation<HasResultSet, dynamic>,
|
|
GeneratedColumn<String>,
|
|
)
|
|
>[
|
|
(_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);
|
|
}
|
|
}
|
|
|
|
/// Emits the latest event from [source] only once [duration] has elapsed with
|
|
/// no newer event — collapsing a burst of rapid change-triggers into a single
|
|
/// downstream reload. Trailing-edge; single-subscription (matches the merged
|
|
/// Drift trigger stream it wraps).
|
|
Stream<T> _debounce<T>(Stream<T> source, Duration duration) {
|
|
late StreamController<T> controller;
|
|
StreamSubscription<T>? sub;
|
|
Timer? timer;
|
|
T? pending;
|
|
var hasPending = false;
|
|
|
|
void flush() {
|
|
if (hasPending) {
|
|
hasPending = false;
|
|
controller.add(pending as T);
|
|
}
|
|
}
|
|
|
|
controller = StreamController<T>(
|
|
onListen: () {
|
|
sub = source.listen(
|
|
(value) {
|
|
pending = value;
|
|
hasPending = true;
|
|
timer?.cancel();
|
|
timer = Timer(duration, flush);
|
|
},
|
|
onError: controller.addError,
|
|
onDone: () {
|
|
timer?.cancel();
|
|
flush();
|
|
controller.close();
|
|
},
|
|
);
|
|
},
|
|
onCancel: () async {
|
|
timer?.cancel();
|
|
await sub?.cancel();
|
|
},
|
|
);
|
|
return controller.stream;
|
|
}
|