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

@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
import 'seed_glyph.dart';
import 'theme.dart';
/// One onboarding page: a large emblem (a bundled seed glyph or a Material
/// symbol), a title and a short body. Illustrations use the app's own icon set
/// rather than photos no image licensing, and it keeps the bundle light.
class _Slide {
const _Slide({
this.glyph,
this.icon,
required this.title,
required this.body,
});
/// A `seedks` glyph char (see [SeedGlyphs]); mutually exclusive with [icon].
final String? glyph;
/// A Material symbol, used where no seed glyph fits (privacy, sprout).
final IconData? icon;
final String title;
final String body;
}
/// The first-run intro carousel (also reachable later from the drawer): a few
/// swipeable cards explaining what Tanemaki is, how privacy works, how sharing
/// works and the Plantare. [onDone] fires when the user skips or finishes, and
/// is where the caller marks the intro as seen and leaves the screen.
class IntroScreen extends StatefulWidget {
const IntroScreen({required this.onDone, super.key});
final VoidCallback onDone;
@override
State<IntroScreen> createState() => _IntroScreenState();
}
class _IntroScreenState extends State<IntroScreen> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<_Slide> _slides(Translations t) => [
_Slide(
glyph: SeedGlyphs.scattered,
title: t.intro.slides.welcome.title,
body: t.intro.slides.welcome.body,
),
_Slide(
glyph: SeedGlyphs.jars,
title: t.intro.slides.inventory.title,
body: t.intro.slides.inventory.body,
),
_Slide(
icon: Icons.lock_outline,
title: t.intro.slides.privacy.title,
body: t.intro.slides.privacy.body,
),
_Slide(
glyph: SeedGlyphs.share,
title: t.intro.slides.share.title,
body: t.intro.slides.share.body,
),
_Slide(
icon: Symbols.potted_plant,
title: t.intro.slides.plantare.title,
body: t.intro.slides.plantare.body,
),
];
void _next(int count) {
if (_page >= count - 1) {
widget.onDone();
return;
}
_controller.nextPage(
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final t = context.t;
final slides = _slides(t);
final isLast = _page == slides.length - 1;
return Scaffold(
backgroundColor: seedCanvas,
body: SafeArea(
child: Column(
children: [
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: widget.onDone,
child: Text(t.intro.skip),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
onPageChanged: (i) => setState(() => _page = i),
itemCount: slides.length,
itemBuilder: (context, i) => _SlideView(slide: slides[i]),
),
),
_Dots(count: slides.length, active: _page),
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => _next(slides.length),
child: Text(isLast ? t.intro.start : t.intro.next),
),
),
),
],
),
),
);
}
}
/// The centred emblem + title + body for a single [slide].
class _SlideView extends StatelessWidget {
const _SlideView({required this.slide});
final _Slide slide;
@override
Widget build(BuildContext context) {
// Centre when there's room, scroll when the viewport is short (small phones
// in landscape, split-screen) so the card never overflows.
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 160,
height: 160,
decoration: const BoxDecoration(
color: seedPrimaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: slide.glyph != null
? SeedGlyph(slide.glyph!, size: 76, color: seedGreen)
: Icon(slide.icon, size: 72, color: seedGreen),
),
const SizedBox(height: 36),
Text(
slide.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedTitle,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 14),
Text(
slide.body,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedOnSurfaceVariant,
fontSize: 16,
height: 1.45,
),
),
],
),
),
),
),
),
),
);
}
}
/// The page-position dots: the active one is a wider green pill.
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active});
final int count;
final int active;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: i == active ? 22 : 8,
height: 8,
decoration: BoxDecoration(
color: i == active ? seedGreen : seedOutline,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}