Replace all hardcoded Russian strings in ScanScreen and _LoadingDialog with AppLocalizations keys; add addFromReceiptOrPhoto, chooseMethod, photoReceipt, photoReceiptSubtitle, photoProducts, photoProductsSubtitle, recognizing keys to all 12 ARB files and regenerate AppLocalizations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
5.7 KiB
Dart
202 lines
5.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 '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.addFromReceiptOrPhoto)),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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)!;
|
|
|
|
// Show loading overlay while the AI processes.
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (dialogContext) => _LoadingDialog(label: l10n.recognizing),
|
|
);
|
|
|
|
try {
|
|
switch (mode) {
|
|
case _Mode.receipt:
|
|
final result = await service.recognizeReceipt(files.first);
|
|
if (context.mounted) {
|
|
Navigator.pop(context); // close loading
|
|
context.push('/scan/confirm', extra: result.items);
|
|
}
|
|
case _Mode.products:
|
|
final items = await service.recognizeProducts(files);
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
context.push('/scan/confirm', extra: items);
|
|
}
|
|
}
|
|
} catch (recognitionError) {
|
|
debugPrint('Recognition 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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|