Backend: - Translate all recognition prompts (receipt, products, dish) from Russian to English - Add lang parameter to Recognizer interface and pass locale.FromContext in handlers - DishResult type uses candidates array for multi-candidate responses Client: - Add meal tracking: diary provider, date selector, meal type model - DishResult parser: backward-compatible with legacy flat format and new candidates format - DishResultScreen: sticky bottom button, full-width portion/meal-type inputs, КБЖУ disclaimer moved under nutrition card, add date field to diary POST body - Recognition prompts now return dish/product names in user's preferred language - Onboarding, profile, home screen visual updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
245 lines
7.5 KiB
Dart
245 lines
7.5 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 '../../core/auth/auth_provider.dart';
|
||
import 'recognition_service.dart';
|
||
|
||
// Provider wired to the shared ApiClient.
|
||
final _recognitionServiceProvider = Provider<RecognitionService>((ref) {
|
||
return RecognitionService(ref.read(apiClientProvider));
|
||
});
|
||
|
||
/// Entry screen — lets the user choose how to add products.
|
||
/// If [GoRouterState.extra] is a non-null String, it is treated as a meal type ID
|
||
/// and the screen immediately opens the camera for dish recognition.
|
||
class ScanScreen extends ConsumerStatefulWidget {
|
||
const ScanScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<ScanScreen> createState() => _ScanScreenState();
|
||
}
|
||
|
||
class _ScanScreenState extends ConsumerState<ScanScreen> {
|
||
bool _autoStarted = false;
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
if (_autoStarted) return;
|
||
final mealType = GoRouterState.of(context).extra as String?;
|
||
if (mealType != null && mealType.isNotEmpty) {
|
||
_autoStarted = true;
|
||
// Defer to avoid calling context navigation during build.
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
_pickAndRecognize(context, _Mode.dish, mealType: mealType);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
@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: () => _pickAndRecognize(context, _Mode.dish),
|
||
),
|
||
const SizedBox(height: 16),
|
||
_ModeCard(
|
||
emoji: '✏️',
|
||
title: 'Добавить вручную',
|
||
subtitle: 'Ввести название, количество и срок',
|
||
onTap: () => context.push('/products/add'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _pickAndRecognize(
|
||
BuildContext context,
|
||
_Mode mode, {
|
||
String? mealType,
|
||
}) 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);
|
||
}
|
||
case _Mode.dish:
|
||
final dish = await service.recognizeDish(files.first);
|
||
if (context.mounted) {
|
||
Navigator.pop(context);
|
||
context.push('/scan/dish', extra: {'dish': dish, 'meal_type': mealType});
|
||
}
|
||
}
|
||
} 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, dish }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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('Распознаём...'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|