Files
food-ai/client/lib/features/scan/scan_screen.dart
dbastrikin 87ef2097fc feat: meal tracking, dish recognition UX improvements, English AI prompts
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>
2026-03-17 14:29:36 +02:00

245 lines
7.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('Распознаём...'),
],
),
);
}
}