tane/apps/app_seeds/lib/ui/draft_triage.dart
vjrj 071be44851
Some checks are pending
ci / analyze (push) Waiting to run
ci / test-commons-core (push) Waiting to run
ci / test-app-seeds (push) Waiting to run
site / deploy (push) Successful in 1m8s
perf(android): R8, bitmap downscaling, edge-to-edge; recover device compat; publish to production
Device compatibility (regression fix):
- The CAMERA permission (from zxing_barcode_scanner / image_picker) implicitly
  required android.hardware.camera, excluding camera-less devices — Play showed
  Automotive -96%, Chromebook -86%, TV -25%. Declare camera & location
  uses-feature required="false" to keep those devices supported.
- Detect a real camera at runtime (PackageManager.FEATURE_CAMERA_ANY via the
  existing MethodChannel), cache it at bootstrap, and hide the QR scan button and
  the camera photo-source option when absent, so no broken actions are offered.

Play "app optimization" recommendations:
- Enable R8 (isMinifyEnabled + isShrinkResources) with keep rules for the
  OCR (tesseract4android), SQLCipher and notifications JNI/reflection code.
- Bitmap downscaling: cacheWidth/cacheHeight (and ResizeImage for avatars) on
  list thumbnails and avatars so photos decode to on-screen size, not full res.
- Edge-to-edge: opt in with transparent system bars in main().

Release flow:
- Tagged builds now publish to the production track at 100% (was internal);
  add a manual deploy_internal lane as a QA safety net.
- Document country/region availability as a Play Console setting (not in-repo).
2026-07-20 22:53:48 +02:00

249 lines
7.7 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,
cacheWidth:
(48 * MediaQuery.devicePixelRatioOf(context)).round(),
cacheHeight:
(48 * MediaQuery.devicePixelRatioOf(context)).round(),
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,
cacheHeight:
(140 * MediaQuery.devicePixelRatioOf(context)).round(),
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),
),
],
);
}
}