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>
This commit is contained in:
@@ -1,134 +1,396 @@
|
||||
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 nutritional breakdown of a recognized dish.
|
||||
class DishResultScreen extends StatelessWidget {
|
||||
const DishResultScreen({super.key, required this.dish});
|
||||
/// 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 confPct = (dish.confidence * 100).toInt();
|
||||
final hasCandidates = widget.dish.candidates.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Распознано блюдо')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
// Dish name + confidence
|
||||
Text(
|
||||
dish.dishName,
|
||||
style: theme.textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Уверенность: $confPct%',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
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('Добавить в журнал'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Nutrition card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'≈ ${dish.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_MacroChip(
|
||||
label: 'Белки',
|
||||
value: '${dish.proteinG.toStringAsFixed(1)} г',
|
||||
color: Colors.blue,
|
||||
),
|
||||
_MacroChip(
|
||||
label: 'Жиры',
|
||||
value: '${dish.fatG.toStringAsFixed(1)} г',
|
||||
color: Colors.orange,
|
||||
),
|
||||
_MacroChip(
|
||||
label: 'Углеводы',
|
||||
value: '${dish.carbsG.toStringAsFixed(1)} г',
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
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),
|
||||
Text(
|
||||
'Вес порции: ~${dish.weightGrams} г',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Попробовать снова'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Similar dishes
|
||||
if (dish.similarDishes.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text('Похожие блюда', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: dish.similarDishes
|
||||
.map((name) => Chip(label: Text(name)))
|
||||
.toList(),
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'КБЖУ приблизительные — определены по фото.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -157,9 +419,103 @@ class _MacroChip extends StatelessWidget {
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user