Files
food-ai/client/lib/features/scan/dish_result_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

524 lines
16 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/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/menu/menu_provider.dart';
import '../../features/home/home_provider.dart';
import '../../shared/models/meal_type.dart';
import 'recognition_service.dart';
/// Shows the recognition candidates and lets the user confirm a dish entry
/// before adding it to the diary.
class DishResultScreen extends ConsumerStatefulWidget {
const DishResultScreen({
super.key,
required this.dish,
this.preselectedMealType,
});
final DishResult dish;
final String? preselectedMealType;
@override
ConsumerState<DishResultScreen> createState() => _DishResultScreenState();
}
class _DishResultScreenState extends ConsumerState<DishResultScreen> {
late int _selectedIndex;
late int _portionGrams;
late String _mealType;
bool _saving = false;
final TextEditingController _portionController = TextEditingController();
@override
void initState() {
super.initState();
_selectedIndex = 0;
_portionGrams = widget.dish.candidates.isNotEmpty
? widget.dish.candidates.first.weightGrams
: 300;
_mealType = widget.preselectedMealType ??
kAllMealTypes.first.id;
_portionController.text = '$_portionGrams';
}
@override
void dispose() {
_portionController.dispose();
super.dispose();
}
DishCandidate get _selected => widget.dish.candidates[_selectedIndex];
/// Scales nutrition linearly to the current portion weight.
double _scale(double baseValue) {
final baseWeight = _selected.weightGrams;
if (baseWeight <= 0) return baseValue;
return baseValue * _portionGrams / baseWeight;
}
void _selectCandidate(int index) {
setState(() {
_selectedIndex = index;
_portionGrams = widget.dish.candidates[index].weightGrams;
_portionController.text = '$_portionGrams';
});
}
void _adjustPortion(int delta) {
final newValue = (_portionGrams + delta).clamp(10, 9999);
setState(() {
_portionGrams = newValue;
_portionController.text = '$newValue';
});
}
void _onPortionEdited(String value) {
final parsed = int.tryParse(value);
if (parsed != null && parsed >= 10) {
setState(() => _portionGrams = parsed.clamp(10, 9999));
}
}
Future<void> _addToDiary() async {
if (_saving) return;
setState(() => _saving = true);
final selectedDate = ref.read(selectedDateProvider);
final dateString = formatDateForDiary(selectedDate);
final scaledCalories = _scale(_selected.calories);
final scaledProtein = _scale(_selected.proteinG);
final scaledFat = _scale(_selected.fatG);
final scaledCarbs = _scale(_selected.carbsG);
try {
await ref.read(diaryProvider(dateString).notifier).add({
'date': dateString,
'meal_type': _mealType,
'name': _selected.dishName,
'calories': scaledCalories,
'protein_g': scaledProtein,
'fat_g': scaledFat,
'carbs_g': scaledCarbs,
'portion_g': _portionGrams,
'source': 'recognition',
});
if (mounted) context.go('/home');
} catch (addError) {
debugPrint('Add to diary error: $addError');
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось добавить. Попробуйте ещё раз.')),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasCandidates = widget.dish.candidates.isNotEmpty;
return Scaffold(
appBar: AppBar(title: const Text('Распознано блюдо')),
bottomNavigationBar: hasCandidates
? SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: FilledButton(
onPressed: _saving ? null : _addToDiary,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Добавить в журнал'),
),
),
)
: null,
body: hasCandidates
? ListView(
padding: const EdgeInsets.all(20),
children: [
_CandidatesSection(
candidates: widget.dish.candidates,
selectedIndex: _selectedIndex,
onSelect: _selectCandidate,
),
const SizedBox(height: 20),
_NutritionCard(
calories: _scale(_selected.calories),
proteinG: _scale(_selected.proteinG),
fatG: _scale(_selected.fatG),
carbsG: _scale(_selected.carbsG),
),
const SizedBox(height: 8),
Text(
'КБЖУ приблизительные — определены по фото.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
_PortionRow(
controller: _portionController,
onMinus: () => _adjustPortion(-10),
onPlus: () => _adjustPortion(10),
onChanged: _onPortionEdited,
),
const SizedBox(height: 20),
_MealTypeDropdown(
selected: _mealType,
onChanged: (value) {
if (value != null) setState(() => _mealType = value);
},
),
const SizedBox(height: 16),
],
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Блюдо не распознано',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => context.pop(),
child: const Text('Попробовать снова'),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Candidates selector
// ---------------------------------------------------------------------------
class _CandidatesSection extends StatelessWidget {
const _CandidatesSection({
required this.candidates,
required this.selectedIndex,
required this.onSelect,
});
final List<DishCandidate> candidates;
final int selectedIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Выберите блюдо', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
...candidates.asMap().entries.map((entry) {
final index = entry.key;
final candidate = entry.value;
final confPct = (candidate.confidence * 100).toInt();
final isSelected = index == selectedIndex;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onSelect(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
width: isSelected ? 2 : 1,
),
color: isSelected
? theme.colorScheme.primaryContainer
.withValues(alpha: 0.3)
: null,
),
child: Row(
children: [
Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
candidate.dishName,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
),
_ConfidenceBadge(confidence: confPct),
],
),
),
),
);
}),
],
);
}
}
class _ConfidenceBadge extends StatelessWidget {
const _ConfidenceBadge({required this.confidence});
final int confidence;
@override
Widget build(BuildContext context) {
final Color badgeColor;
if (confidence >= 80) {
badgeColor = Colors.green;
} else if (confidence >= 50) {
badgeColor = Colors.orange;
} else {
badgeColor = Colors.red;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$confidence%',
style: TextStyle(
color: badgeColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
);
}
}
// ---------------------------------------------------------------------------
// Nutrition card
// ---------------------------------------------------------------------------
class _NutritionCard extends StatelessWidget {
const _NutritionCard({
required this.calories,
required this.proteinG,
required this.fatG,
required this.carbsG,
});
final double calories;
final double proteinG;
final double fatG;
final double carbsG;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${calories.toInt()} ккал',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const Spacer(),
Tooltip(
message: 'Приблизительные значения на основе фото',
child: Text(
'',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_MacroChip(
label: 'Белки',
value: '${proteinG.toStringAsFixed(1)} г',
color: Colors.blue,
),
_MacroChip(
label: 'Жиры',
value: '${fatG.toStringAsFixed(1)} г',
color: Colors.orange,
),
_MacroChip(
label: 'Углеводы',
value: '${carbsG.toStringAsFixed(1)} г',
color: Colors.green,
),
],
),
],
),
),
);
}
}
class _MacroChip extends StatelessWidget {
const _MacroChip({
required this.label,
required this.value,
required this.color,
});
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: color,
),
),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
}
// ---------------------------------------------------------------------------
// Portion row
// ---------------------------------------------------------------------------
class _PortionRow extends StatelessWidget {
const _PortionRow({
required this.controller,
required this.onMinus,
required this.onPlus,
required this.onChanged,
});
final TextEditingController controller;
final VoidCallback onMinus;
final VoidCallback onPlus;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Порция', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
Row(
children: [
IconButton.outlined(
icon: const Icon(Icons.remove),
onPressed: onMinus,
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: onChanged,
decoration: const InputDecoration(
suffixText: 'г',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
IconButton.outlined(
icon: const Icon(Icons.add),
onPressed: onPlus,
),
],
),
],
);
}
}
// ---------------------------------------------------------------------------
// Meal type dropdown
// ---------------------------------------------------------------------------
class _MealTypeDropdown extends StatelessWidget {
const _MealTypeDropdown({
required this.selected,
required this.onChanged,
});
final String selected;
final ValueChanged<String?> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Приём пищи', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: selected,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
items: kAllMealTypes
.map((mealTypeOption) => DropdownMenuItem(
value: mealTypeOption.id,
child: Text(
'${mealTypeOption.emoji} ${mealTypeOption.label}'),
))
.toList(),
onChanged: onChanged,
),
],
);
}
}