feat(inventory): photo-first drafts + on-device OCR (digitization R2+R4)

Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":

- Photo-first drafts (capture now, catalogue later): burst-capture
  photos (camera or multi-gallery) into unnamed draft varieties, shown
  in a "to catalogue" tray, hidden from the main list until named.
  Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
  the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
  "Suggest name from photo" button in the naming dialog behind a
  LabelTextExtractor interface (Tesseract on Android/iOS, no-op
  elsewhere). Reads the largest print via hOCR bounding boxes, drops
  boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
  upscale) and sweeps rotations (0-315 deg) so tilted packets still
  read. Bundles tessdata_fast eng+spa; validated on-device against real
  packets. The photo is written to a temp file deleted immediately in a
  finally block (the plugin needs a path) - a bounded, documented
  exception to no-plaintext-at-rest.

This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.

Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
This commit is contained in:
vjrj 2026-07-09 21:23:46 +02:00
parent 12a2ee2d64
commit 6809dc6143
89 changed files with 17141 additions and 228 deletions

View file

@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart' show QuantityKind;
import '../../db/database.dart';
import '../../db/enums.dart';
import '../../domain/crop_calendar.dart';
import 'inventory_snapshot.dart';
/// Flattens an [InventorySnapshot] into a spreadsheet-friendly CSV one row
@ -20,6 +21,12 @@ class InventoryCsvCodec {
'variety_notes',
'vernacular_names',
'external_links',
'needs_reproduction',
'sow_months',
'transplant_months',
'flowering_months',
'fruiting_months',
'seed_harvest_months',
'lot_id',
'lot_type',
'harvest_year',
@ -30,7 +37,13 @@ class InventoryCsvCodec {
'presentation',
'storage_location',
'offer_status',
'origin_name',
'origin_place',
'abundance',
'preservation_format',
'latest_germination_rate',
'container_count',
'desiccant_state',
'created_at',
'updated_at',
];
@ -54,6 +67,7 @@ class InventoryCsvCodec {
.add(e.title == null ? e.url : '${e.title}|${e.url}');
}
final latestRateByLot = _latestGerminationRates(snapshot.germinationTests);
final latestCheckByLot = _latestConditionChecks(snapshot.conditionChecks);
final lines = <String>[_header.map(_escape).join(',')];
for (final v in snapshot.varieties) {
@ -66,13 +80,19 @@ class InventoryCsvCodec {
v.notes ?? '',
(namesByVariety[v.id] ?? const []).join('; '),
(linksByVariety[v.id] ?? const []).join('; '),
v.needsReproduction ? 'true' : '',
_monthsCell(v.sowMonths),
_monthsCell(v.transplantMonths),
_monthsCell(v.floweringMonths),
_monthsCell(v.fruitingMonths),
_monthsCell(v.seedHarvestMonths),
];
final lots = lotsByVariety[v.id] ?? const <Lot>[];
if (lots.isEmpty) {
lines.add(
[
...varietyCells,
...List.filled(11, ''),
...List.filled(17, ''),
_isoDate(v.createdAt),
_hlcMillisIso(v.updatedAt),
].map(_escape).join(','),
@ -80,6 +100,7 @@ class InventoryCsvCodec {
continue;
}
for (final l in lots) {
final check = latestCheckByLot[l.id];
lines.add(
[
...varietyCells,
@ -93,7 +114,13 @@ class InventoryCsvCodec {
l.presentation?.name ?? '',
l.storageLocation ?? '',
l.offerStatus.name,
l.originName ?? '',
l.originPlace ?? '',
l.abundance?.name ?? '',
l.preservationFormat?.name ?? '',
latestRateByLot[l.id]?.toStringAsFixed(2) ?? '',
check?.containerCount?.toString() ?? '',
check?.desiccantState?.name ?? '',
_isoDate(l.createdAt),
_hlcMillisIso(l.updatedAt),
].map(_escape).join(','),
@ -103,6 +130,25 @@ class InventoryCsvCodec {
return '${lines.join('\r\n')}\r\n';
}
/// Renders a crop-calendar month mask as a space-separated list of month
/// numbers (e.g. `3 4 9`), or empty when unset readable in a spreadsheet.
String _monthsCell(int? mask) => maskToMonths(mask).join(' ');
/// lotId most-recent condition check (by checkedOn), so the CSV can show a
/// lot's latest jar count and drying-agent state as flat columns.
Map<String, ConditionCheck> _latestConditionChecks(
List<ConditionCheck> checks,
) {
final latest = <String, ConditionCheck>{};
for (final c in checks) {
final current = latest[c.lotId];
if (current == null || (c.checkedOn ?? 0) > (current.checkedOn ?? 0)) {
latest[c.lotId] = c;
}
}
return latest;
}
/// lotId latest germination rate (0..1), from the most recent test that
/// has a computable rate.
Map<String, double> _latestGerminationRates(List<GerminationTest> tests) {
@ -200,6 +246,12 @@ class InventoryCsvCodec {
notes: cell(row, 'variety_notes'),
vernacularNames: _parseVernacular(cell(row, 'vernacular_names')),
links: _parseLinks(cell(row, 'external_links')),
needsReproduction: _parseBool(cell(row, 'needs_reproduction')),
sowMonths: _parseMonths(cell(row, 'sow_months')),
transplantMonths: _parseMonths(cell(row, 'transplant_months')),
floweringMonths: _parseMonths(cell(row, 'flowering_months')),
fruitingMonths: _parseMonths(cell(row, 'fruiting_months')),
seedHarvestMonths: _parseMonths(cell(row, 'seed_harvest_months')),
);
builders.add(builder);
if (groupId != null) byGroup[groupId] = builder;
@ -225,6 +277,10 @@ class InventoryCsvCodec {
final presentation = cell(row, 'presentation');
final storage = cell(row, 'storage_location');
final offer = cell(row, 'offer_status');
final originName = cell(row, 'origin_name');
final originPlace = cell(row, 'origin_place');
final abundance = cell(row, 'abundance');
final preservationFormat = cell(row, 'preservation_format');
final hasLot = [
typeCell,
@ -236,6 +292,10 @@ class InventoryCsvCodec {
presentation,
storage,
offer,
originName,
originPlace,
abundance,
preservationFormat,
].any((c) => c != null);
if (!hasLot) return null;
@ -249,9 +309,35 @@ class InventoryCsvCodec {
presentation: _enumOrNull(Presentation.values, presentation),
storageLocation: storage,
offerStatus: _enumOr(OfferStatus.values, offer, OfferStatus.private),
originName: originName,
originPlace: originPlace,
abundance: _enumOrNull(Abundance.values, abundance),
preservationFormat: _enumOrNull(
PreservationFormat.values,
preservationFormat,
),
);
}
/// Parses a boolean cell: `true`/`yes`/`1` (case-insensitive) true,
/// anything else (including null) false.
bool _parseBool(String? cell) {
final v = cell?.trim().toLowerCase();
return v == 'true' || v == 'yes' || v == '1';
}
/// Parses a crop-calendar months cell (month numbers separated by spaces,
/// commas or semicolons, e.g. `3 4 9`) into a 12-bit mask, or null when empty.
int? _parseMonths(String? cell) {
if (cell == null) return null;
final months = <int>[];
for (final part in cell.split(RegExp(r'[\s,;]+'))) {
final n = int.tryParse(part.trim());
if (n != null) months.add(n);
}
return monthsToMask(months);
}
/// `name (lang); name2` vernacular names; the trailing `(xx)` is the ISO
/// language, mirroring the export join in [encode].
List<CsvImportName> _parseVernacular(String? cell) {
@ -393,6 +479,12 @@ class CsvImportVariety {
this.vernacularNames = const [],
this.links = const [],
this.lots = const [],
this.needsReproduction = false,
this.sowMonths,
this.transplantMonths,
this.floweringMonths,
this.fruitingMonths,
this.seedHarvestMonths,
});
final String label;
@ -403,6 +495,12 @@ class CsvImportVariety {
final List<CsvImportName> vernacularNames;
final List<CsvImportLink> links;
final List<CsvImportLot> lots;
final bool needsReproduction;
final int? sowMonths;
final int? transplantMonths;
final int? floweringMonths;
final int? fruitingMonths;
final int? seedHarvestMonths;
}
/// One lot parsed from a CSV row.
@ -417,6 +515,10 @@ class CsvImportLot {
this.presentation,
this.storageLocation,
this.offerStatus = OfferStatus.private,
this.originName,
this.originPlace,
this.abundance,
this.preservationFormat,
});
final LotType type;
@ -428,6 +530,10 @@ class CsvImportLot {
final Presentation? presentation;
final String? storageLocation;
final OfferStatus offerStatus;
final String? originName;
final String? originPlace;
final Abundance? abundance;
final PreservationFormat? preservationFormat;
}
/// One vernacular name parsed from CSV.
@ -456,6 +562,12 @@ class _CsvVarietyBuilder {
this.notes,
this.vernacularNames = const [],
this.links = const [],
this.needsReproduction = false,
this.sowMonths,
this.transplantMonths,
this.floweringMonths,
this.fruitingMonths,
this.seedHarvestMonths,
});
final String label;
@ -465,6 +577,12 @@ class _CsvVarietyBuilder {
final String? notes;
final List<CsvImportName> vernacularNames;
final List<CsvImportLink> links;
final bool needsReproduction;
final int? sowMonths;
final int? transplantMonths;
final int? floweringMonths;
final int? fruitingMonths;
final int? seedHarvestMonths;
final List<CsvImportLot> lots = [];
CsvImportVariety build() => CsvImportVariety(
@ -475,6 +593,12 @@ class _CsvVarietyBuilder {
notes: notes,
vernacularNames: vernacularNames,
links: links,
needsReproduction: needsReproduction,
sowMonths: sowMonths,
transplantMonths: transplantMonths,
floweringMonths: floweringMonths,
fruitingMonths: fruitingMonths,
seedHarvestMonths: seedHarvestMonths,
lots: List.unmodifiable(lots),
);
}

View file

@ -42,6 +42,14 @@ class InventoryJsonCodec {
'cultivarName': v.cultivarName,
'category': v.category,
'notes': v.notes,
'isDraft': v.isDraft,
'isOrganic': v.isOrganic,
'needsReproduction': v.needsReproduction,
'sowMonths': v.sowMonths,
'transplantMonths': v.transplantMonths,
'floweringMonths': v.floweringMonths,
'fruitingMonths': v.fruitingMonths,
'seedHarvestMonths': v.seedHarvestMonths,
},
],
'lots': [
@ -65,6 +73,10 @@ class InventoryJsonCodec {
'storageLocation': l.storageLocation,
'offerStatus': l.offerStatus.name,
'seedbankId': l.seedbankId,
'originName': l.originName,
'originPlace': l.originPlace,
'abundance': l.abundance?.name,
'preservationFormat': l.preservationFormat?.name,
},
],
'vernacularNames': [
@ -116,6 +128,23 @@ class InventoryJsonCodec {
'notes': g.notes,
},
],
'conditionChecks': [
for (final c in snapshot.conditionChecks)
{
..._syncMeta(
id: c.id,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
lastAuthor: c.lastAuthor,
schemaRowVersion: c.schemaRowVersion,
),
'lotId': c.lotId,
'checkedOn': c.checkedOn,
'containerCount': c.containerCount,
'desiccantState': c.desiccantState?.name,
'notes': c.notes,
},
],
'movements': [
for (final m in snapshot.movements)
{
@ -214,6 +243,14 @@ class InventoryJsonCodec {
cultivarName: m['cultivarName'] as String?,
category: m['category'] as String?,
notes: m['notes'] as String?,
isDraft: m['isDraft'] == true,
isOrganic: m['isOrganic'] == true,
needsReproduction: m['needsReproduction'] == true,
sowMonths: m['sowMonths'] as int?,
transplantMonths: m['transplantMonths'] as int?,
floweringMonths: m['floweringMonths'] as int?,
fruitingMonths: m['fruitingMonths'] as int?,
seedHarvestMonths: m['seedHarvestMonths'] as int?,
);
});
@ -244,6 +281,13 @@ class InventoryJsonCodec {
OfferStatus.private,
),
seedbankId: m['seedbankId'] as String?,
originName: m['originName'] as String?,
originPlace: m['originPlace'] as String?,
abundance: _enumOrNull(Abundance.values, m['abundance']),
preservationFormat: _enumOrNull(
PreservationFormat.values,
m['preservationFormat'],
),
);
}),
vernacularNames: _rows(root, 'vernacularNames', (m) {
@ -291,6 +335,24 @@ class InventoryJsonCodec {
notes: m['notes'] as String?,
);
}),
conditionChecks: _rows(root, 'conditionChecks', (m) {
return ConditionCheck(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
checkedOn: m['checkedOn'] as int?,
containerCount: m['containerCount'] as int?,
desiccantState: _enumOrNull(
DesiccantState.values,
m['desiccantState'],
),
notes: m['notes'] as String?,
);
}),
movements: _rows(root, 'movements', (m) {
final type = _enumOrNull(MovementType.values, m['type']);
if (type == null) return null; // no safe default drop row

View file

@ -26,6 +26,7 @@ class InventorySnapshot {
this.vernacularNames = const [],
this.externalLinks = const [],
this.germinationTests = const [],
this.conditionChecks = const [],
this.movements = const [],
this.parties = const [],
this.attachments = const [],
@ -37,6 +38,7 @@ class InventorySnapshot {
final List<VarietyVernacularName> vernacularNames;
final List<ExternalLink> externalLinks;
final List<GerminationTest> germinationTests;
final List<ConditionCheck> conditionChecks;
final List<Movement> movements;
final List<Party> parties;
final List<Attachment> attachments;

View file

@ -19,6 +19,7 @@ List<SpeciesSeed> parseSpeciesCatalog(String jsonString) {
scientificName: e['scientific_name'] as String,
family: e['family'] as String?,
commonNames: common,
viabilityYears: (e['viability_years'] as num?)?.toInt(),
);
}).toList();
}

View file

@ -9,6 +9,7 @@ class SpeciesSeed {
required this.scientificName,
this.family,
this.commonNames = const {},
this.viabilityYears,
});
final String scientificName;
@ -16,6 +17,9 @@ class SpeciesSeed {
/// language code list of common names.
final Map<String, List<String>> commonNames;
/// Typical seed longevity in years (bundled reference data); null if unknown.
final int? viabilityYears;
}
/// A catalog match surfaced to the UI (with a best common name for the locale).
@ -47,7 +51,9 @@ class SpeciesRepository {
final String nodeId;
/// Idempotently inserts bundled species (keyed by scientific name). Safe to
/// call on every startup; existing entries are left untouched.
/// call on every startup. Existing rows keep their identity, but a bundled
/// row still missing [SpeciesSeed.viabilityYears] is backfilled so the
/// reference data reaches installs seeded before the field was bundled.
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
final stamp = Hlc.zero(nodeId).pack();
await _db.transaction(() async {
@ -56,7 +62,19 @@ class SpeciesRepository {
await (_db.select(_db.species)
..where((s) => s.scientificName.equals(seed.scientificName)))
.getSingleOrNull();
if (existing != null) continue;
if (existing != null) {
// Backfill bundled reference data added after the row was seeded.
if (existing.viabilityYears == null && seed.viabilityYears != null) {
await (_db.update(_db.species)
..where((s) => s.id.equals(existing.id)))
.write(
SpeciesCompanion(
viabilityYears: Value(seed.viabilityYears),
),
);
}
continue;
}
final speciesId = idGen.newId();
await _db
@ -70,6 +88,7 @@ class SpeciesRepository {
scientificName: seed.scientificName,
family: Value(seed.family),
isBundled: const Value(true),
viabilityYears: Value(seed.viabilityYears),
),
);

View file

@ -5,6 +5,7 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart';
@ -18,6 +19,10 @@ class VarietyListItem extends Equatable {
this.scientificName,
this.photo,
this.lotTypes = const {},
this.isDraft = false,
this.isOrganic = false,
this.needsReproduction = false,
this.viability = SeedViability.unknown,
});
final String id;
@ -34,6 +39,23 @@ class VarietyListItem extends Equatable {
/// 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;
/// 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;
@override
List<Object?> get props => [
id,
@ -42,6 +64,10 @@ class VarietyListItem extends Equatable {
scientificName,
photo,
lotTypes,
isDraft,
isOrganic,
needsReproduction,
viability,
];
}
@ -73,8 +99,35 @@ class GerminationEntry extends Equatable {
List<Object?> get props => [id, testedOn, sampleSize, germinatedCount, notes];
}
/// One held batch of a variety, for the detail view. [germinationTests] are
/// ordered most-recent first, so `germinationTests.first` is the latest.
/// 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,
@ -84,7 +137,12 @@ class VarietyLot extends Equatable {
this.quantity,
this.presentation,
this.storageLocation,
this.originName,
this.originPlace,
this.abundance,
this.preservationFormat,
this.germinationTests = const [],
this.conditionChecks = const [],
});
final String id;
@ -98,8 +156,23 @@ class VarietyLot extends Equatable {
/// 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;
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;
@ -113,7 +186,12 @@ class VarietyLot extends Equatable {
quantity,
presentation,
storageLocation,
originName,
originPlace,
abundance,
preservationFormat,
germinationTests,
conditionChecks,
];
}
@ -171,6 +249,14 @@ class VarietyDetail extends Equatable {
this.notes,
this.speciesId,
this.scientificName,
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 [],
@ -183,6 +269,35 @@ class VarietyDetail extends Equatable {
final String? notes;
final String? speciesId;
final String? scientificName;
/// 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;
@ -196,6 +311,14 @@ class VarietyDetail extends Equatable {
notes,
speciesId,
scientificName,
viabilityYears,
isOrganic,
needsReproduction,
sowMonths,
transplantMonths,
floweringMonths,
fruitingMonths,
seedHarvestMonths,
lots,
vernacularNames,
photos,
@ -245,18 +368,51 @@ class VarietyRepository {
Future<List<VarietyListItem>> _loadInventory() async {
final rows =
await (_db.select(_db.varieties)
..where((v) => v.isDeleted.equals(false))
// 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(
rows.map((v) => v.speciesId).whereType<String>().toSet(),
);
final sciNames = await _scientificNamesFor(speciesIds);
final speciesViability = await _speciesViabilityFor(speciesIds);
final lotTypes = await _lotTypesFor(varietyIds);
final seedYears = await _seedHarvestYearsFor(varietyIds);
final currentYear = DateTime.fromMillisecondsSinceEpoch(_now()).year;
return rows
.map(
(v) => VarietyListItem(
@ -266,11 +422,82 @@ class VarietyRepository {
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,
viability: _worstViability(
harvestYears: seedYears[v.id] ?? const [],
viabilityYears: v.speciesId == null
? null
: speciesViability[v.speciesId],
currentYear: currentYear,
),
),
)
.toList();
}
/// 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(
@ -394,6 +621,59 @@ class VarietyRepository {
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) = _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) = _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.
Future<void> nameDraft(String id, String label) async {
final (_, updated) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
label: Value(label),
isDraft: const Value(false),
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.
///
@ -419,6 +699,8 @@ class VarietyRepository {
)..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));
}
@ -431,11 +713,13 @@ class VarietyRepository {
if (v == null) return null;
String? scientificName;
int? viabilityYears;
if (v.speciesId != null) {
final species = await (_db.select(
_db.species,
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
scientificName = species?.scientificName;
viabilityYears = species?.viabilityYears;
}
final lots =
@ -446,6 +730,7 @@ class VarietyRepository {
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)
@ -465,6 +750,24 @@ class VarietyRepository {
),
);
}
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(
@ -503,7 +806,23 @@ class VarietyRepository {
notes: v.notes,
speciesId: v.speciesId,
scientificName: scientificName,
lots: lots.map((l) => _toLot(l, testsByLot[l.id] ?? const [])).toList(),
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(
@ -552,12 +871,21 @@ class VarietyRepository {
}
/// Updates a variety's scalar fields (LWW). Passing null clears [category]
/// and [notes]; a null [label] leaves the label unchanged.
/// 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) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
@ -565,6 +893,15 @@ class VarietyRepository {
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),
),
@ -580,6 +917,10 @@ class VarietyRepository {
Quantity? quantity,
Presentation? presentation,
String? storageLocation,
String? originName,
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
@ -600,13 +941,17 @@ class VarietyRepository {
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
originName: Value(originName),
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
),
);
return id;
}
/// Updates an existing lot's fields (LWW). The [quantity], [harvestYear] and
/// [harvestMonth] are set as given (null clears them).
/// 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,
@ -615,6 +960,10 @@ class VarietyRepository {
Quantity? quantity,
Presentation? presentation,
String? storageLocation,
String? originName,
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
@ -627,6 +976,10 @@ class VarietyRepository {
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
originName: Value(originName),
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
@ -829,6 +1182,35 @@ class VarietyRepository {
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) = _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;
}
/// 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.
@ -854,6 +1236,9 @@ class VarietyRepository {
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,
@ -928,6 +1313,14 @@ class VarietyRepository {
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,
@ -953,6 +1346,7 @@ class VarietyRepository {
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,
]);
@ -990,6 +1384,12 @@ class VarietyRepository {
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++;
@ -1014,6 +1414,10 @@ class VarietyRepository {
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),
),
);
}
@ -1153,7 +1557,11 @@ class VarietyRepository {
return ImportSummary(inserted: inserted, skipped: skipped);
}
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
VarietyLot _toLot(
Lot l,
List<GerminationEntry> germinationTests,
List<ConditionEntry> conditionChecks,
) {
final hasQuantity =
l.quantityKind != null ||
l.quantityPrecise != null ||
@ -1165,7 +1573,12 @@ class VarietyRepository {
harvestMonth: l.harvestMonth,
presentation: l.presentation,
storageLocation: l.storageLocation,
originName: l.originName,
originPlace: l.originPlace,
abundance: l.abundance,
preservationFormat: l.preservationFormat,
germinationTests: germinationTests,
conditionChecks: conditionChecks,
quantity: hasQuantity
? Quantity(
kind: _parseKind(l.quantityKind),