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.
245 lines
7.4 KiB
Dart
245 lines
7.4 KiB
Dart
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|