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
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue