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
|
|
@ -12,13 +12,21 @@ import '../db/enums.dart';
|
|||
class InventoryState extends Equatable {
|
||||
const InventoryState({
|
||||
this.items = const [],
|
||||
this.drafts = const [],
|
||||
this.query = '',
|
||||
this.categoryFilter = const {},
|
||||
this.typeFilter = const {},
|
||||
this.organicOnly = false,
|
||||
this.needsReproductionOnly = false,
|
||||
this.loading = true,
|
||||
});
|
||||
|
||||
final List<VarietyListItem> items;
|
||||
|
||||
/// Photo-first captures still awaiting a name — the "to catalogue" tray,
|
||||
/// shown above the list and never mixed into [items].
|
||||
final List<VarietyListItem> drafts;
|
||||
|
||||
final String query;
|
||||
|
||||
/// Categories to keep; empty = all categories.
|
||||
|
|
@ -28,6 +36,12 @@ class InventoryState extends Equatable {
|
|||
/// least one lot of a selected form.
|
||||
final Set<LotType> typeFilter;
|
||||
|
||||
/// When true, keep only varieties flagged organic ("eco").
|
||||
final bool organicOnly;
|
||||
|
||||
/// When true, keep only varieties flagged "needs reproducing this season".
|
||||
final bool needsReproductionOnly;
|
||||
|
||||
final bool loading;
|
||||
|
||||
/// Categories present across all items, in display order (deduped), so the
|
||||
|
|
@ -53,22 +67,39 @@ class InventoryState extends Equatable {
|
|||
if (typeFilter.isNotEmpty && i.lotTypes.intersection(typeFilter).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (organicOnly && !i.isOrganic) return false;
|
||||
if (needsReproductionOnly && !i.needsReproduction) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Whether any item is flagged organic, so the UI only offers the eco filter
|
||||
/// chip when it would actually match something.
|
||||
bool get hasOrganic => items.any((i) => i.isOrganic);
|
||||
|
||||
/// Whether any item is flagged "needs reproducing", so the UI only offers
|
||||
/// that filter chip when it would match something.
|
||||
bool get hasNeedsReproduction => items.any((i) => i.needsReproduction);
|
||||
|
||||
InventoryState copyWith({
|
||||
List<VarietyListItem>? items,
|
||||
List<VarietyListItem>? drafts,
|
||||
String? query,
|
||||
Set<String>? categoryFilter,
|
||||
Set<LotType>? typeFilter,
|
||||
bool? organicOnly,
|
||||
bool? needsReproductionOnly,
|
||||
bool? loading,
|
||||
}) {
|
||||
return InventoryState(
|
||||
items: items ?? this.items,
|
||||
drafts: drafts ?? this.drafts,
|
||||
query: query ?? this.query,
|
||||
categoryFilter: categoryFilter ?? this.categoryFilter,
|
||||
typeFilter: typeFilter ?? this.typeFilter,
|
||||
organicOnly: organicOnly ?? this.organicOnly,
|
||||
needsReproductionOnly:
|
||||
needsReproductionOnly ?? this.needsReproductionOnly,
|
||||
loading: loading ?? this.loading,
|
||||
);
|
||||
}
|
||||
|
|
@ -76,9 +107,12 @@ class InventoryState extends Equatable {
|
|||
@override
|
||||
List<Object?> get props => [
|
||||
items,
|
||||
drafts,
|
||||
query,
|
||||
categoryFilter,
|
||||
typeFilter,
|
||||
organicOnly,
|
||||
needsReproductionOnly,
|
||||
loading,
|
||||
];
|
||||
}
|
||||
|
|
@ -90,10 +124,14 @@ class InventoryCubit extends Cubit<InventoryState> {
|
|||
_sub = _repo.watchInventory().listen(
|
||||
(items) => emit(state.copyWith(items: items, loading: false)),
|
||||
);
|
||||
_draftsSub = _repo.watchDrafts().listen(
|
||||
(drafts) => emit(state.copyWith(drafts: drafts)),
|
||||
);
|
||||
}
|
||||
|
||||
final VarietyRepository _repo;
|
||||
late final StreamSubscription<List<VarietyListItem>> _sub;
|
||||
late final StreamSubscription<List<VarietyListItem>> _draftsSub;
|
||||
|
||||
void search(String query) => emit(state.copyWith(query: query));
|
||||
|
||||
|
|
@ -111,13 +149,29 @@ class InventoryCubit extends Cubit<InventoryState> {
|
|||
emit(state.copyWith(typeFilter: next));
|
||||
}
|
||||
|
||||
/// Clears both filters (search is left untouched).
|
||||
void clearFilters() =>
|
||||
emit(state.copyWith(categoryFilter: const {}, typeFilter: const {}));
|
||||
/// Toggles the "organic only" filter.
|
||||
void toggleOrganicOnly() =>
|
||||
emit(state.copyWith(organicOnly: !state.organicOnly));
|
||||
|
||||
/// Toggles the "needs reproducing only" filter.
|
||||
void toggleNeedsReproductionOnly() => emit(
|
||||
state.copyWith(needsReproductionOnly: !state.needsReproductionOnly),
|
||||
);
|
||||
|
||||
/// Clears all filters (search is left untouched).
|
||||
void clearFilters() => emit(
|
||||
state.copyWith(
|
||||
categoryFilter: const {},
|
||||
typeFilter: const {},
|
||||
organicOnly: false,
|
||||
needsReproductionOnly: false,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _sub.cancel();
|
||||
await _draftsSub.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
|
|
@ -44,13 +45,30 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
final String varietyId;
|
||||
late final StreamSubscription<VarietyDetail?> _sub;
|
||||
|
||||
Future<void> updateFields({String? label, String? category, String? notes}) =>
|
||||
_repo.updateVariety(
|
||||
id: varietyId,
|
||||
label: label,
|
||||
category: category,
|
||||
notes: notes,
|
||||
);
|
||||
Future<void> updateFields({
|
||||
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(),
|
||||
}) => _repo.updateVariety(
|
||||
id: varietyId,
|
||||
label: label,
|
||||
category: category,
|
||||
notes: notes,
|
||||
isOrganic: isOrganic,
|
||||
needsReproduction: needsReproduction,
|
||||
sowMonths: sowMonths,
|
||||
transplantMonths: transplantMonths,
|
||||
floweringMonths: floweringMonths,
|
||||
fruitingMonths: fruitingMonths,
|
||||
seedHarvestMonths: seedHarvestMonths,
|
||||
);
|
||||
|
||||
Future<void> addLot({
|
||||
LotType type = LotType.seed,
|
||||
|
|
@ -58,6 +76,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
int? month,
|
||||
Quantity? quantity,
|
||||
Presentation? presentation,
|
||||
String? originName,
|
||||
String? originPlace,
|
||||
Abundance? abundance,
|
||||
PreservationFormat? preservationFormat,
|
||||
}) => _repo.addLot(
|
||||
varietyId: varietyId,
|
||||
type: type,
|
||||
|
|
@ -65,6 +87,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
harvestMonth: month,
|
||||
quantity: quantity,
|
||||
presentation: presentation,
|
||||
originName: originName,
|
||||
originPlace: originPlace,
|
||||
abundance: abundance,
|
||||
preservationFormat: preservationFormat,
|
||||
);
|
||||
|
||||
Future<void> updateLot({
|
||||
|
|
@ -74,6 +100,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
int? month,
|
||||
Quantity? quantity,
|
||||
Presentation? presentation,
|
||||
String? originName,
|
||||
String? originPlace,
|
||||
Abundance? abundance,
|
||||
PreservationFormat? preservationFormat,
|
||||
}) => _repo.updateLot(
|
||||
lotId: lotId,
|
||||
type: type,
|
||||
|
|
@ -81,6 +111,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
harvestMonth: month,
|
||||
quantity: quantity,
|
||||
presentation: presentation,
|
||||
originName: originName,
|
||||
originPlace: originPlace,
|
||||
abundance: abundance,
|
||||
preservationFormat: preservationFormat,
|
||||
);
|
||||
|
||||
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);
|
||||
|
|
@ -120,6 +154,18 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
germinatedCount: germinatedCount,
|
||||
);
|
||||
|
||||
Future<void> addConditionCheck({
|
||||
required String lotId,
|
||||
int? checkedOn,
|
||||
int? containerCount,
|
||||
DesiccantState? desiccantState,
|
||||
}) => _repo.addConditionCheck(
|
||||
lotId: lotId,
|
||||
checkedOn: checkedOn,
|
||||
containerCount: containerCount,
|
||||
desiccantState: desiccantState,
|
||||
);
|
||||
|
||||
Future<void> deleteVariety() async {
|
||||
await _repo.softDeleteVariety(varietyId);
|
||||
emit(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue