Remove "Определить блюдо" from ScanScreen and the /scan/dish route. The + button on each meal card now triggers dish recognition inline — picks image, shows loading dialog, then presents DishResultSheet as a modal bottom sheet. After adding to diary the sheet closes and the user stays on home. Also fix Navigator.pop crash: showDialog uses the root navigator by default, so capture Navigator.of(context, rootNavigator: true) before the async gap and use it to close the loading dialog. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
6.0 KiB
Dart
205 lines
6.0 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 '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) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Добавить продукты')),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(20),
|
||
children: [
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Выберите способ',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 32),
|
||
_ModeCard(
|
||
emoji: '🧾',
|
||
title: 'Сфотографировать чек',
|
||
subtitle: 'Распознаем все продукты из чека',
|
||
onTap: () => _pickAndRecognize(context, _Mode.receipt),
|
||
),
|
||
const SizedBox(height: 16),
|
||
_ModeCard(
|
||
emoji: '🥦',
|
||
title: 'Сфотографировать продукты',
|
||
subtitle: 'Холодильник, стол, полка — до 3 фото',
|
||
onTap: () => _pickAndRecognize(context, _Mode.products),
|
||
),
|
||
const SizedBox(height: 16),
|
||
_ModeCard(
|
||
emoji: '✏️',
|
||
title: 'Добавить вручную',
|
||
subtitle: 'Ввести название, количество и срок',
|
||
onTap: () => context.push('/products/add'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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);
|
||
|
||
// Show loading overlay while the AI processes.
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => const _LoadingDialog(),
|
||
);
|
||
|
||
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(
|
||
const SnackBar(
|
||
content: Text('Не удалось распознать. Попробуйте ещё раз.'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<ImageSource?> _chooseSource(BuildContext context) async {
|
||
return showModalBottomSheet<ImageSource>(
|
||
context: context,
|
||
builder: (_) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.camera_alt),
|
||
title: const Text('Камера'),
|
||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_library),
|
||
title: const Text('Галерея'),
|
||
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();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return const AlertDialog(
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CircularProgressIndicator(),
|
||
SizedBox(height: 16),
|
||
Text('Распознаём...'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|