Files
food-ai/client/lib/features/scan/scan_screen.dart
dbastrikin b2bdcbae6f feat: barcode scanning for shelf add + scan screen barcode option
- 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>
2026-03-26 15:21:56 +02:00

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