tane/apps/app_seeds/lib/ui/draft_triage.dart

243 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),
),
],
);
}
}