tane/apps/app_seeds/lib/data/variety_repository.dart
vjrj 4bb94db3e0 feat(seed-saving): per-crop 'how to save this seed' guidance
A bundled, curated knowledge layer — the differentiator of a serious
seed app. For a variety's crop it shows how the plant reproduces and what
it takes to keep the variety true: life cycle, pollination (self/cross +
insect/wind), isolation distance, how many plants to save from, dry/wet
processing, a difficulty pill, and a short tip — all in human words.

- assets/catalog/seed_saving.json: curated public-domain data (Seed to
  Seed / Seed Savers), keyed by botanical FAMILY (defaults) with
  per-species/genus OVERRIDES that win — so e.g. Vicia faba is correctly
  insect-cross-pollinated despite the selfing Fabaceae default, and maize
  is wind-pollinated with a large population. Notes are locale-keyed
  (es/en), extensible to any language — a starter set, not a ceiling.
- domain/seed_saving.dart (pure): enums + SeedSavingGuide (merge, note
  fallback) + SeedSavingData.guideFor (species → genus → family).
- data/seed_saving_catalog.dart: loads the asset once into an in-memory
  singleton (no DB table, no migration); injector loads it at startup.
- VarietyDetail gains  (from the linked species — the reliable
  key, vs the editable category); detail screen shows a _SeedSavingView
  when a guide resolves.
- i18n seedSaving block (labels + enum values + params) in en/es/pt/ast.
- Tests: domain lookup/merge, the real asset's key corrections, and the
  detail section shows/hides. 314 suite tests green; analyze clean.
2026-07-11 07:18:39 +02:00

2443 lines
81 KiB
Dart

import 'package:async/async.dart';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import '../domain/species_autoclassify.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart';
/// A lightweight row for the inventory list (only what the list renders).
class VarietyListItem extends Equatable {
const VarietyListItem({
required this.id,
required this.label,
this.category,
this.scientificName,
this.photo,
this.lotTypes = const {},
this.isDraft = false,
this.isOrganic = false,
this.needsReproduction = false,
this.isShared = false,
this.viability = SeedViability.unknown,
this.sowMonths,
});
final String id;
final String label;
final String? category;
/// Scientific name of the linked species, shown as the tile subtitle.
final String? scientificName;
/// First photo (encrypted BLOB) for the avatar, or null → show an initial.
final Uint8List? photo;
/// Distinct lot forms this variety currently holds (seed, plant, tree…),
/// used to filter the list by form. Empty when it has no lots yet.
final Set<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,
});
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;
@override
List<Object?> get props =>
[lotId, varietyId, summary, offerStatus, category, harvestYear, isOrganic];
}
/// 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,
];
}
/// 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.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;
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,
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,
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
_clock = Hlc.zero(nodeId);
final AppDatabase _db;
final IdGen idGen;
final String nodeId;
final int Function() _now;
Hlc _clock;
/// Emits the non-deleted inventory, ordered by category then label, each with
/// its first photo for the avatar.
///
/// Re-emits on any change to varieties, their photos (attachments) or the
/// species catalog, so avatars and scientific names refresh reactively — a
/// photo added on the detail screen shows up in the list right away.
Stream<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((_) {}),
]);
return triggers.asyncMap(
(_) async => (items: await _loadInventory(), drafts: await _loadDrafts()),
);
}
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 BLOB for each of [varietyIds] (one query).
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 bytes = row.bytes;
if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes);
}
return byVariety;
}
/// The cover photo (lowest `sortOrder`) for a single variety, or null when it
/// has none. Used by the publish step to host an offer's image; reuses the
/// same "first photo" rule as the inventory avatar.
Future<Uint8List?> coverPhotoFor(String varietyId) async =>
(await _firstPhotosFor([varietyId]))[varietyId];
/// 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),
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),
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,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.lots)
.insert(
LotsCompanion.insert(
id: id,
varietyId: varietyId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
type: Value(type),
harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
originName: Value(originName),
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
),
);
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,
}) async {
final (_, updated) = await _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion(
type: Value(type),
harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
originName: Value(originName),
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
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,
),
];
}
/// 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),
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;
}
// --- 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,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db.into(_db.plantares).insert(
PlantaresCompanion.insert(
id: id,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
direction: direction,
varietyId: Value(varietyId),
counterparty: Value(counterparty),
owedDescription: Value(owedDescription),
madeOn: created,
dueBy: Value(dueBy),
note: Value(note),
),
);
return id;
}
/// All live commitments, newest first (for the Plantares screen).
Stream<List<Plantare>> watchPlantares() => (_db.select(_db.plantares)
..where((p) => p.isDeleted.equals(false))
..orderBy([(p) => OrderingTerm.desc(p.madeOn)]))
.watch();
/// The commitments recorded against one variety.
Stream<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),
),
);
}
/// 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(),
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(),
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.parties,
idColumn: _db.parties.id,
rows: snapshot.parties,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.attachments,
idColumn: _db.attachments.id,
rows: snapshot.attachments,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.plantares,
idColumn: _db.plantares.id,
rows: snapshot.plantares,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.sales,
idColumn: _db.sales.id,
rows: snapshot.sales,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMovements(snapshot.movements, reconciler);
});
final maxIncoming = reconciler.maxHlc([
for (final v in snapshot.varieties) v.updatedAt,
for (final l in snapshot.lots) l.updatedAt,
for (final n in snapshot.vernacularNames) n.updatedAt,
for (final e in snapshot.externalLinks) e.updatedAt,
for (final g in snapshot.germinationTests) g.updatedAt,
for (final c in snapshot.conditionChecks) c.updatedAt,
for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt,
for (final pl in snapshot.plantares) pl.updatedAt,
for (final s in snapshot.sales) s.updatedAt,
]);
if (maxIncoming != null) {
_clock = _clock.receiveEvent(maxIncoming, _now());
}
return summary;
}
/// Additive import of a spreadsheet [csv]: every parsed variety is inserted
/// fresh (new UUIDv7 + local HLC), so it never overwrites existing rows and
/// re-importing the same file adds it again. The canonical, id-reconciling
/// import is JSON ([importInventory]); this is the low-friction path for a
/// list a collective already keeps in a spreadsheet.
///
/// Species are re-linked by scientific name against the local catalog
/// (unmatched → null). Returns the number of varieties added.
Future<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,
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);
}
}