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:
dbastrikin
2026-03-17 14:29:36 +02:00
parent 2a95bcd53c
commit 87ef2097fc
16 changed files with 1269 additions and 350 deletions

View File

@@ -4,10 +4,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_colors.dart';
import '../../shared/models/meal_type.dart';
import '../profile/profile_provider.dart';
import '../profile/profile_service.dart';
const int _totalSteps = 6;
const int _totalSteps = 7;
const List<Color> _stepAccentColors = [
Color(0xFF5856D6), // Goal — iOS purple
@@ -15,6 +16,7 @@ const List<Color> _stepAccentColors = [
Color(0xFFFF9500), // DOB — iOS orange
Color(0xFF34C759), // Height + Weight — iOS green
Color(0xFFFF2D55), // Activity — iOS pink
Color(0xFF30B0C7), // Meal Types — teal
Color(0xFFFF6B00), // Calories — deep orange
];
@@ -24,6 +26,7 @@ const List<IconData> _stepIcons = [
Icons.cake_outlined,
Icons.monitor_weight_outlined,
Icons.directions_run,
Icons.restaurant_menu,
Icons.local_fire_department,
];
@@ -45,6 +48,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _heightController = TextEditingController();
final _weightController = TextEditingController();
String? _activity;
List<String> _mealTypes = List<String>.from(kDefaultMealTypeIds);
int _dailyCalories = 2000;
bool _saving = false;
@@ -116,7 +120,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
weightValue <= 300;
case 4: // Activity
return _activity != null;
case 5: // Calories
case 5: // Meal Types — at least one must be selected
return _mealTypes.isNotEmpty;
case 6: // Calories
return true;
default:
return false;
@@ -127,7 +133,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
if (!_canAdvance()) return;
// Entering calorie review — pre-calculate based on collected data
if (_currentStep == 4) {
if (_currentStep == 5) {
setState(() => _dailyCalories = _calculateCalories());
}
@@ -166,6 +172,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
gender: _gender,
goal: _goal,
activity: _activity,
mealTypes: _mealTypes,
dailyCalories: _dailyCalories,
);
@@ -257,9 +264,19 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
_StepPage(
accentColor: _stepAccentColors[5],
icon: _stepIcons[5],
child: _MealTypesStepContent(
selected: _mealTypes,
accentColor: _stepAccentColors[5],
onChanged: (updated) =>
setState(() => _mealTypes = updated),
),
),
_StepPage(
accentColor: _stepAccentColors[6],
icon: _stepIcons[6],
child: _CalorieStepContent(
calories: _dailyCalories,
accentColor: _stepAccentColors[5],
accentColor: _stepAccentColors[6],
onChanged: (value) =>
setState(() => _dailyCalories = value),
),
@@ -1108,7 +1125,83 @@ class _ActivityCard extends StatelessWidget {
}
}
// ── Step 6: Calorie review ─────────────────────────────────────
// ── Step 6: Meal types ─────────────────────────────────────────
class _MealTypesStepContent extends StatelessWidget {
final List<String> selected;
final Color accentColor;
final ValueChanged<List<String>> onChanged;
const _MealTypesStepContent({
required this.selected,
required this.accentColor,
required this.onChanged,
});
void _toggle(String mealTypeId) {
final updated = List<String>.from(selected);
if (updated.contains(mealTypeId)) {
// Keep at least one meal type selected.
if (updated.length > 1) updated.remove(mealTypeId);
} else {
updated.add(mealTypeId);
}
onChanged(updated);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ваши приёмы пищи', style: theme.textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
'Выберите, какие приёмы пищи вы хотите отслеживать',
style: theme.textTheme.bodyMedium
?.copyWith(color: AppColors.textSecondary),
),
const SizedBox(height: 20),
...kAllMealTypes.map((mealTypeOption) {
final isSelected = selected.contains(mealTypeOption.id);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _SelectableCard(
selected: isSelected,
accentColor: accentColor,
onTap: () => _toggle(mealTypeOption.id),
child: Row(
children: [
Text(mealTypeOption.emoji,
style: const TextStyle(fontSize: 24)),
const SizedBox(width: 12),
Expanded(
child: Text(
mealTypeOption.label,
style: theme.textTheme.bodyLarge?.copyWith(
color: isSelected
? accentColor
: AppColors.textPrimary,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (isSelected)
Icon(Icons.check_circle, color: accentColor),
],
),
),
);
}),
],
);
}
}
// ── Step 7: Calorie review ─────────────────────────────────────
class _CalorieStepContent extends StatelessWidget {
final int calories;