- Add ShelfBarcodeScanScreen: scans barcode via mobile_scanner, looks up
product via GET /products/barcode/{barcode} (Open Food Facts fallback),
returns CatalogProduct to caller; loading overlay while looking up;
"Add manually" fallback in AppBar for unknown products
- Extract AddToShelfSheet to add_to_shelf_sheet.dart (was private in
product_search_screen.dart) so both search and scan screens can reuse it
- Add barcode icon button to ProductSearchScreen AppBar → opens scanner
- Add "Scan barcode" card (📷) to ScanScreen alongside receipt and photo modes
- Rename ScanScreen title: addFromReceiptOrPhoto → scanScreenTitle
("Сканировать" / "Scan & Recognize") to reflect all three modes
- Add 2 L10n keys (scanScreenTitle, barcodeScanSubtitle) across all 12 locales
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
6.7 KiB
Dart
233 lines
6.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../../shared/models/product.dart';
|
|
import '../products/add_to_shelf_sheet.dart';
|
|
import '../products/shelf_barcode_scan_screen.dart';
|
|
import 'recognition_service.dart';
|
|
|
|
/// Entry screen — lets the user choose how to add products.
|
|
class ScanScreen extends ConsumerStatefulWidget {
|
|
const ScanScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<ScanScreen> createState() => _ScanScreenState();
|
|
}
|
|
|
|
class _ScanScreenState extends ConsumerState<ScanScreen> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(l10n.scanScreenTitle)),
|
|
body: ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n.chooseMethod,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 32),
|
|
_ModeCard(
|
|
emoji: '🧾',
|
|
title: l10n.photoReceipt,
|
|
subtitle: l10n.photoReceiptSubtitle,
|
|
onTap: () => _pickAndRecognize(context, _Mode.receipt),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_ModeCard(
|
|
emoji: '🥦',
|
|
title: l10n.photoProducts,
|
|
subtitle: l10n.photoProductsSubtitle,
|
|
onTap: () => _pickAndRecognize(context, _Mode.products),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_ModeCard(
|
|
emoji: '📷',
|
|
title: l10n.scanBarcode,
|
|
subtitle: l10n.barcodeScanSubtitle,
|
|
onTap: _openBarcode,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openBarcode() {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final addedText = AppLocalizations.of(context)!.productAddedToShelf;
|
|
|
|
Navigator.push<CatalogProduct>(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const ShelfBarcodeScanScreen()),
|
|
).then((catalogProduct) {
|
|
if (catalogProduct != null && mounted) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => AddToShelfSheet(
|
|
catalogProduct: catalogProduct,
|
|
onAdded: () => messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text('${catalogProduct.displayName} — $addedText'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _pickAndRecognize(
|
|
BuildContext context,
|
|
_Mode mode,
|
|
) async {
|
|
final picker = ImagePicker();
|
|
|
|
List<XFile> files = [];
|
|
|
|
if (mode == _Mode.products) {
|
|
// Allow up to 3 images.
|
|
final picked = await picker.pickMultiImage(
|
|
imageQuality: 70,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
);
|
|
if (picked.isEmpty) return;
|
|
files = picked.take(3).toList();
|
|
} else {
|
|
final source = await _chooseSource(context);
|
|
if (source == null) return;
|
|
final picked = await picker.pickImage(
|
|
source: source,
|
|
imageQuality: 70,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
);
|
|
if (picked == null) return;
|
|
files = [picked];
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
final service = ref.read(recognitionServiceProvider);
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (dialogContext) => _LoadingDialog(label: l10n.scanSubmitting),
|
|
);
|
|
|
|
try {
|
|
ProductJobCreated jobCreated;
|
|
switch (mode) {
|
|
case _Mode.receipt:
|
|
jobCreated = await service.submitReceiptRecognition(files.first);
|
|
case _Mode.products:
|
|
jobCreated = await service.submitProductsRecognition(files);
|
|
}
|
|
if (context.mounted) {
|
|
Navigator.pop(context); // close loading dialog
|
|
context.push('/scan/product-job-watch', extra: jobCreated);
|
|
}
|
|
} catch (recognitionError) {
|
|
debugPrint('Recognition submit error: $recognitionError');
|
|
if (context.mounted) {
|
|
Navigator.pop(context); // close loading
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.recognitionFailed)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<ImageSource?> _chooseSource(BuildContext context) async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return showModalBottomSheet<ImageSource>(
|
|
context: context,
|
|
builder: (_) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.camera_alt),
|
|
title: Text(l10n.camera),
|
|
onTap: () => Navigator.pop(context, ImageSource.camera),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_library),
|
|
title: Text(l10n.gallery),
|
|
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mode enum
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum _Mode { receipt, products }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Widgets
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _ModeCard extends StatelessWidget {
|
|
const _ModeCard({
|
|
required this.emoji,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String emoji;
|
|
final String title;
|
|
final String subtitle;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Card(
|
|
child: ListTile(
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
leading: Text(emoji, style: const TextStyle(fontSize: 32)),
|
|
title: Text(title, style: theme.textTheme.titleMedium),
|
|
subtitle: Text(subtitle, style: theme.textTheme.bodySmall),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: onTap,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LoadingDialog extends StatelessWidget {
|
|
const _LoadingDialog({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 16),
|
|
Text(label),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|