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

@ -7,10 +7,12 @@ import 'package:go_router/go_router.dart';
import 'data/species_repository.dart';
import 'data/variety_repository.dart';
import 'i18n/strings.g.dart';
import 'services/onboarding_store.dart';
import 'state/inventory_cubit.dart';
import 'state/variety_detail_cubit.dart';
import 'ui/about_screen.dart';
import 'ui/home_screen.dart';
import 'ui/intro_screen.dart';
import 'ui/inventory_list_screen.dart';
import 'ui/settings_screen.dart';
import 'ui/theme.dart';
@ -18,18 +20,40 @@ import 'ui/variety_detail_screen.dart';
/// Root widget. Provides the repositories to the tree and wires go_router:
/// `/` is the home menu, `/inventory` the list, `/variety/:id` the item detail.
/// When [showIntro] is set (first launch), the app opens on `/intro`.
class TaneApp extends StatelessWidget {
TaneApp({required this.repository, required this.species, super.key})
: _router = _buildRouter(repository);
TaneApp({
required this.repository,
required this.species,
required this.onboarding,
this.showIntro = false,
super.key,
}) : _router = _buildRouter(repository, onboarding, showIntro);
final VarietyRepository repository;
final SpeciesRepository species;
final OnboardingStore onboarding;
final bool showIntro;
final GoRouter _router;
static GoRouter _buildRouter(VarietyRepository repository) {
static GoRouter _buildRouter(
VarietyRepository repository,
OnboardingStore onboarding,
bool showIntro,
) {
return GoRouter(
initialLocation: showIntro ? '/intro' : '/',
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/intro',
builder: (context, state) => IntroScreen(
onDone: () async {
await onboarding.markIntroSeen();
if (context.mounted) context.go('/');
},
),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),