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:
parent
12a2ee2d64
commit
6809dc6143
89 changed files with 17141 additions and 228 deletions
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue