tane/apps/app_seeds/lib/app.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

124 lines
4 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
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';
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,
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,
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(),
),
GoRoute(
path: '/about',
builder: (context, state) => const AboutScreen(),
),
GoRoute(
path: '/inventory',
builder: (context, state) => BlocProvider(
create: (_) => InventoryCubit(repository),
child: const InventoryListScreen(),
),
),
GoRoute(
path: '/variety/:id',
builder: (context, state) => BlocProvider(
create: (_) =>
VarietyDetailCubit(repository, state.pathParameters['id']!),
child: const VarietyDetailScreen(),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return MultiRepositoryProvider(
providers: [
RepositoryProvider.value(value: repository),
RepositoryProvider.value(value: species),
],
child: MaterialApp.router(
onGenerateTitle: (context) => context.t.app.title,
debugShowCheckedModeBanner: false,
theme: buildTaneTheme(),
// Let scrollables & PageViews be dragged with a mouse/trackpad on
// desktop (Flutter disables that by default), so the photo carousel and
// lists swipe there too.
scrollBehavior: const _DragScrollBehavior(),
locale: TranslationProvider.of(context).flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
routerConfig: _router,
),
);
}
}
/// Enables mouse/trackpad drag scrolling (off by default on desktop) so the
/// photo carousel and lists can be swiped with a pointer.
class _DragScrollBehavior extends MaterialScrollBehavior {
const _DragScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
};
}