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
245
apps/app_seeds/lib/ui/draft_triage.dart
Normal file
245
apps/app_seeds/lib/ui/draft_triage.dart
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../data/variety_repository.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/ocr/label_text_extractor.dart';
|
||||
import 'photo_pick.dart';
|
||||
|
||||
/// Rapid "capture now, catalogue later": shoot/pick a burst of photos and turn
|
||||
/// each into a draft variety. Returns how many were captured.
|
||||
Future<int> captureDraftBurst(
|
||||
BuildContext context,
|
||||
VarietyRepository repository,
|
||||
) async {
|
||||
final photos = await pickPhotos(context);
|
||||
for (final bytes in photos) {
|
||||
await repository.addDraftVariety(bytes);
|
||||
}
|
||||
return photos.length;
|
||||
}
|
||||
|
||||
/// The "to catalogue" tray: lists photo-first drafts (live from the DB); tapping
|
||||
/// one prompts for a name and promotes it into the inventory.
|
||||
Future<void> showTriageSheet(
|
||||
BuildContext context,
|
||||
VarietyRepository repository,
|
||||
) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _TriageSheet(repository: repository),
|
||||
);
|
||||
}
|
||||
|
||||
class _TriageSheet extends StatelessWidget {
|
||||
const _TriageSheet({required this.repository});
|
||||
|
||||
final VarietyRepository repository;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return StreamBuilder<List<VarietyListItem>>(
|
||||
stream: repository.watchDrafts(),
|
||||
builder: (context, snapshot) {
|
||||
final drafts = snapshot.data ?? const <VarietyListItem>[];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
t.draft.triageTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (drafts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
t.inventory.empty,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: drafts.length,
|
||||
itemBuilder: (context, i) => _DraftTile(
|
||||
draft: drafts[i],
|
||||
repository: repository,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DraftTile extends StatelessWidget {
|
||||
const _DraftTile({required this.draft, required this.repository});
|
||||
|
||||
final VarietyListItem draft;
|
||||
final VarietyRepository repository;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final photo = draft.photo;
|
||||
return ListTile(
|
||||
key: Key('draft.tile.${draft.id}'),
|
||||
leading: photo == null
|
||||
? const Icon(Icons.image_outlined)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
photo,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
title: Text(t.draft.untitled),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: t.draft.discard,
|
||||
onPressed: () => repository.softDeleteVariety(draft.id),
|
||||
),
|
||||
onTap: () async {
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => NameDraftDialog(photo: photo),
|
||||
);
|
||||
if (name != null && name.trim().isNotEmpty) {
|
||||
await repository.nameDraft(draft.id, name.trim());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for a draft's name, showing its photo. Where an on-device OCR engine
|
||||
/// exists, a "Suggest name from photo" button pre-fills the field (always
|
||||
/// editable, never auto-confirmed).
|
||||
class NameDraftDialog extends StatefulWidget {
|
||||
const NameDraftDialog({required this.photo, this.extractor, super.key});
|
||||
|
||||
final Uint8List? photo;
|
||||
|
||||
/// Injectable for tests; otherwise resolved from the service locator (or a
|
||||
/// no-op when none is registered).
|
||||
final LabelTextExtractor? extractor;
|
||||
|
||||
@override
|
||||
State<NameDraftDialog> createState() => _NameDraftDialogState();
|
||||
}
|
||||
|
||||
class _NameDraftDialogState extends State<NameDraftDialog> {
|
||||
final _controller = TextEditingController();
|
||||
bool _suggesting = false;
|
||||
|
||||
LabelTextExtractor get _extractor =>
|
||||
widget.extractor ??
|
||||
(GetIt.instance.isRegistered<LabelTextExtractor>()
|
||||
? GetIt.instance<LabelTextExtractor>()
|
||||
: const NoOpLabelTextExtractor());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _suggest() async {
|
||||
final photo = widget.photo;
|
||||
if (photo == null) return;
|
||||
setState(() => _suggesting = true);
|
||||
final suggestion = await _extractor.suggestLabel(photo);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_suggesting = false;
|
||||
if (suggestion != null && suggestion.trim().isNotEmpty) {
|
||||
_controller.text = suggestion.trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final canSuggest = widget.photo != null && _extractor.isSupported;
|
||||
return AlertDialog(
|
||||
title: Text(t.draft.nameField),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.photo != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
widget.photo!,
|
||||
height: 140,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('draft.nameField'),
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.draft.nameHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (value) => Navigator.of(context).pop(value),
|
||||
),
|
||||
if (canSuggest) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
key: const Key('draft.suggestFromPhoto'),
|
||||
onPressed: _suggesting ? null : _suggest,
|
||||
icon: _suggesting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.auto_fix_high_outlined),
|
||||
label: Text(t.draft.suggestFromPhoto),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('draft.nameSave'),
|
||||
onPressed: () => Navigator.of(context).pop(_controller.text),
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue