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;