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

@ -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),