tane/apps/app_seeds/lib/state/inventory_cubit.dart
vjrj 6809dc6143 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.
2026-07-09 21:23:46 +02:00

177 lines
5.5 KiB
Dart

import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
/// Inventory list state: all items from the DB plus the current search query
/// and active filters. [visibleItems] applies query ∧ category ∧ form; grouping
/// by category is done in the UI. Empty filter sets mean "show everything".
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.
final Set<String> categoryFilter;
/// Lot forms to keep; empty = all forms. An item matches if it holds at
/// 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
/// UI can offer one chip per category actually in use.
List<String> get categories {
final seen = <String>{};
final result = <String>[];
for (final item in items) {
final category = item.category;
if (category != null && seen.add(category)) result.add(category);
}
return result;
}
List<VarietyListItem> get visibleItems {
final q = query.trim().toLowerCase();
return items.where((i) {
if (q.isNotEmpty && !i.label.toLowerCase().contains(q)) return false;
if (categoryFilter.isNotEmpty &&
!categoryFilter.contains(i.category)) {
return false;
}
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,
);
}
@override
List<Object?> get props => [
items,
drafts,
query,
categoryFilter,
typeFilter,
organicOnly,
needsReproductionOnly,
loading,
];
}
/// Subscribes to the repository's reactive inventory stream. The list updates
/// automatically after a quick-add — no manual refresh.
class InventoryCubit extends Cubit<InventoryState> {
InventoryCubit(this._repo) : super(const 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));
/// Toggles a category in the filter (add if absent, remove if present).
void toggleCategory(String category) {
final next = Set<String>.of(state.categoryFilter);
if (!next.remove(category)) next.add(category);
emit(state.copyWith(categoryFilter: next));
}
/// Toggles a lot form in the filter (add if absent, remove if present).
void toggleType(LotType type) {
final next = Set<LotType>.of(state.typeFilter);
if (!next.remove(type)) next.add(type);
emit(state.copyWith(typeFilter: next));
}
/// 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();
}
}