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
228
apps/app_seeds/lib/ui/intro_screen.dart
Normal file
228
apps/app_seeds/lib/ui/intro_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue