Files
food-ai/client/lib/features/onboarding/onboarding_screen.dart
dbastrikin 54b10d51e2 feat: Flutter client localisation (12 languages)
Add flutter_localizations + intl, 12 ARB files (en/ru/es/de/fr/it/pt/zh/ja/ko/ar/hi),
replace all hardcoded Russian UI strings with AppLocalizations, detect system locale
on first launch, localise bottom nav bar labels, document rule in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:22:52 +02:00

1311 lines
39 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:food_ai/l10n/app_localizations.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 = 7;
const List<Color> _stepAccentColors = [
Color(0xFF5856D6), // Goal — iOS purple
Color(0xFF007AFF), // Gender — iOS blue
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
];
const List<IconData> _stepIcons = [
Icons.emoji_events_outlined,
Icons.person_outline,
Icons.cake_outlined,
Icons.monitor_weight_outlined,
Icons.directions_run,
Icons.restaurant_menu,
Icons.local_fire_department,
];
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _pageController = PageController();
int _currentStep = 0;
// Collected data — new order: goal first
String? _goal;
String? _gender;
DateTime? _selectedDob;
final _heightController = TextEditingController();
final _weightController = TextEditingController();
String? _activity;
List<String> _mealTypes = List<String>.from(kDefaultMealTypeIds);
int _dailyCalories = 2000;
bool _saving = false;
@override
void dispose() {
_pageController.dispose();
_heightController.dispose();
_weightController.dispose();
super.dispose();
}
// Mifflin-St Jeor formula — mirrors backend calories.go
int _calculateCalories() {
final heightValue = int.tryParse(_heightController.text);
final weightValue = double.tryParse(_weightController.text);
if (heightValue == null ||
weightValue == null ||
_selectedDob == null ||
_gender == null ||
_activity == null ||
_goal == null) {
return 2000;
}
final now = DateTime.now();
int age = now.year - _selectedDob!.year;
if (now.month < _selectedDob!.month ||
(now.month == _selectedDob!.month && now.day < _selectedDob!.day)) {
age--;
}
final double bmr = _gender == 'male'
? 10 * weightValue + 6.25 * heightValue - 5 * age + 5
: 10 * weightValue + 6.25 * heightValue - 5 * age - 161;
const activityMultipliers = {
'low': 1.375,
'moderate': 1.55,
'high': 1.725,
};
const goalAdjustments = {
'lose': -500.0,
'maintain': 0.0,
'gain': 300.0,
};
final tdee = bmr * (activityMultipliers[_activity] ?? 1.55);
final adjusted = tdee + (goalAdjustments[_goal] ?? 0);
return adjusted.round().clamp(1000, 5000);
}
bool _canAdvance() {
switch (_currentStep) {
case 0: // Goal
return _goal != null;
case 1: // Gender
return _gender != null;
case 2: // DOB
return _selectedDob != null;
case 3: // Height + Weight
final heightValue = int.tryParse(_heightController.text);
final weightValue = double.tryParse(_weightController.text);
return heightValue != null &&
heightValue >= 100 &&
heightValue <= 250 &&
weightValue != null &&
weightValue >= 30 &&
weightValue <= 300;
case 4: // Activity
return _activity != null;
case 5: // Meal Types — at least one must be selected
return _mealTypes.isNotEmpty;
case 6: // Calories
return true;
default:
return false;
}
}
void _nextStep() {
if (!_canAdvance()) return;
// Entering calorie review — pre-calculate based on collected data
if (_currentStep == 5) {
setState(() => _dailyCalories = _calculateCalories());
}
if (_currentStep < _totalSteps - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
_finish();
}
}
void _prevStep() {
if (_currentStep > 0) {
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Future<void> _finish() async {
setState(() => _saving = true);
final dob = _selectedDob!;
final dobString =
'${dob.year.toString().padLeft(4, '0')}-'
'${dob.month.toString().padLeft(2, '0')}-'
'${dob.day.toString().padLeft(2, '0')}';
final profileRequest = UpdateProfileRequest(
heightCm: int.tryParse(_heightController.text),
weightKg: double.tryParse(_weightController.text),
dateOfBirth: dobString,
gender: _gender,
goal: _goal,
activity: _activity,
mealTypes: _mealTypes,
dailyCalories: _dailyCalories,
);
final success =
await ref.read(profileProvider.notifier).update(profileRequest);
if (!mounted) return;
setState(() => _saving = false);
if (success) {
context.go('/home');
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось сохранить. Попробуйте ещё раз.')),
);
}
}
@override
Widget build(BuildContext context) {
final accentColor = _stepAccentColors[_currentStep];
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
children: [
_ProgressHeader(
currentStep: _currentStep,
accentColor: accentColor,
onBack: _currentStep > 0 ? _prevStep : null,
),
Expanded(
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (index) =>
setState(() => _currentStep = index),
children: [
_StepPage(
accentColor: _stepAccentColors[0],
icon: _stepIcons[0],
child: _GoalStepContent(
selected: _goal,
accentColor: _stepAccentColors[0],
onChanged: (value) => setState(() => _goal = value),
),
),
_StepPage(
accentColor: _stepAccentColors[1],
icon: _stepIcons[1],
child: _GenderStepContent(
selected: _gender,
accentColor: _stepAccentColors[1],
onChanged: (value) =>
setState(() => _gender = value),
),
),
_StepPage(
accentColor: _stepAccentColors[2],
icon: _stepIcons[2],
child: _DobStepContent(
selectedDob: _selectedDob,
accentColor: _stepAccentColors[2],
onChanged: (value) =>
setState(() => _selectedDob = value),
),
),
_StepPage(
accentColor: _stepAccentColors[3],
icon: _stepIcons[3],
child: _HeightWeightStepContent(
heightController: _heightController,
weightController: _weightController,
accentColor: _stepAccentColors[3],
onChanged: () => setState(() {}),
),
),
_StepPage(
accentColor: _stepAccentColors[4],
icon: _stepIcons[4],
child: _ActivityStepContent(
selected: _activity,
accentColor: _stepAccentColors[4],
onChanged: (value) =>
setState(() => _activity = value),
),
),
_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[6],
onChanged: (value) =>
setState(() => _dailyCalories = value),
),
),
],
),
),
_ContinueButton(
isLastStep: _currentStep == _totalSteps - 1,
enabled: _canAdvance() && !_saving,
saving: _saving,
accentColor: accentColor,
onPressed: _nextStep,
),
],
),
),
);
}
}
// ── Progress header ────────────────────────────────────────────
class _ProgressHeader extends StatelessWidget {
final int currentStep;
final Color accentColor;
final VoidCallback? onBack;
const _ProgressHeader({
required this.currentStep,
required this.accentColor,
this.onBack,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(
children: [
SizedBox(
width: 48,
child: onBack != null
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
)
: const SizedBox.shrink(),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (currentStep + 1) / _totalSteps,
minHeight: 6,
backgroundColor: AppColors.separator,
valueColor: AlwaysStoppedAnimation<Color>(accentColor),
),
),
),
const SizedBox(width: 48),
],
),
);
}
}
// ── Continue button ────────────────────────────────────────────
class _ContinueButton extends StatelessWidget {
final bool isLastStep;
final bool enabled;
final bool saving;
final Color accentColor;
final VoidCallback onPressed;
const _ContinueButton({
required this.isLastStep,
required this.enabled,
required this.saving,
required this.accentColor,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: accentColor),
onPressed: enabled ? onPressed : null,
child: saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(isLastStep ? 'Готово' : 'Продолжить'),
),
),
);
}
}
// ── Step page: hero + content ──────────────────────────────────
class _StepPage extends StatelessWidget {
final Color accentColor;
final IconData icon;
final Widget child;
const _StepPage({
required this.accentColor,
required this.icon,
required this.child,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
flex: 2,
child: _StepHero(accentColor: accentColor, icon: icon),
),
Expanded(
flex: 3,
child: _StepContent(child: child),
),
],
);
}
}
// ── Step hero — colored illustration area ──────────────────────
class _StepHero extends StatelessWidget {
final Color accentColor;
final IconData icon;
const _StepHero({required this.accentColor, required this.icon});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: accentColor.withValues(alpha: 0.12),
child: Center(
child: SizedBox(
width: 180,
height: 180,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: accentColor.withValues(alpha: 0.12),
),
),
Container(
width: 130,
height: 130,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: accentColor.withValues(alpha: 0.18),
),
),
Container(
width: 90,
height: 90,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: accentColor.withValues(alpha: 0.25),
),
),
Icon(icon, size: 48, color: accentColor),
],
),
),
),
);
}
}
// ── Step content — white card with rounded top corners ─────────
class _StepContent extends StatelessWidget {
final Widget child;
const _StepContent({required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
padding: const EdgeInsets.fromLTRB(24, 28, 24, 16),
child: SingleChildScrollView(child: child),
);
}
}
// ── Shared selectable card ──────────────────────────────────────
class _SelectableCard extends StatelessWidget {
final bool selected;
final Color accentColor;
final VoidCallback onTap;
final Widget child;
final EdgeInsets? padding;
const _SelectableCard({
required this.selected,
required this.accentColor,
required this.onTap,
required this.child,
this.padding,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: padding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected ? accentColor : AppColors.separator,
width: selected ? 2 : 1,
),
color: selected
? accentColor.withValues(alpha: 0.08)
: Colors.transparent,
),
child: child,
),
);
}
}
// ── Step 1: Goal ───────────────────────────────────────────────
class _GoalStepContent extends StatelessWidget {
final String? selected;
final Color accentColor;
final ValueChanged<String?> onChanged;
const _GoalStepContent({
required this.selected,
required this.accentColor,
required this.onChanged,
});
@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),
_GoalCard(
icon: Icons.trending_down,
label: 'Похудеть',
selected: selected == 'lose',
accentColor: accentColor,
onTap: () => onChanged('lose'),
),
const SizedBox(height: 12),
_GoalCard(
icon: Icons.balance,
label: 'Поддержать вес',
selected: selected == 'maintain',
accentColor: accentColor,
onTap: () => onChanged('maintain'),
),
const SizedBox(height: 12),
_GoalCard(
icon: Icons.trending_up,
label: 'Набрать массу',
selected: selected == 'gain',
accentColor: accentColor,
onTap: () => onChanged('gain'),
),
],
);
}
}
class _GoalCard extends StatelessWidget {
final IconData icon;
final String label;
final bool selected;
final Color accentColor;
final VoidCallback onTap;
const _GoalCard({
required this.icon,
required this.label,
required this.selected,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return _SelectableCard(
selected: selected,
accentColor: accentColor,
onTap: onTap,
child: Row(
children: [
Icon(
icon,
size: 28,
color: selected ? accentColor : AppColors.textSecondary,
),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
color: selected ? accentColor : AppColors.textPrimary,
fontWeight:
selected ? FontWeight.w600 : FontWeight.normal,
),
),
),
Icon(
Icons.arrow_forward_ios,
size: 16,
color: selected ? accentColor : AppColors.textSecondary,
),
],
),
);
}
}
// ── Step 2: Gender ─────────────────────────────────────────────
class _GenderStepContent extends StatelessWidget {
final String? selected;
final Color accentColor;
final ValueChanged<String?> onChanged;
const _GenderStepContent({
required this.selected,
required this.accentColor,
required this.onChanged,
});
@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(
'Учитывается при расчёте BMR',
style: theme.textTheme.bodyMedium
?.copyWith(color: AppColors.textSecondary),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _GenderCard(
label: 'Мужской',
icon: Icons.person_outline,
selected: selected == 'male',
accentColor: accentColor,
onTap: () => onChanged('male'),
),
),
const SizedBox(width: 12),
Expanded(
child: _GenderCard(
label: 'Женский',
icon: Icons.person_2_outlined,
selected: selected == 'female',
accentColor: accentColor,
onTap: () => onChanged('female'),
),
),
],
),
],
);
}
}
class _GenderCard extends StatelessWidget {
final String label;
final IconData icon;
final bool selected;
final Color accentColor;
final VoidCallback onTap;
const _GenderCard({
required this.label,
required this.icon,
required this.selected,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return _SelectableCard(
selected: selected,
accentColor: accentColor,
onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 36,
color: selected ? accentColor : AppColors.textSecondary,
),
const SizedBox(height: 10),
Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
color: selected ? accentColor : AppColors.textPrimary,
fontWeight:
selected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
);
}
}
// ── Step 3: Date of birth ──────────────────────────────────────
class _DobStepContent extends StatelessWidget {
final DateTime? selectedDob;
final Color accentColor;
final ValueChanged<DateTime?> onChanged;
const _DobStepContent({
required this.selectedDob,
required this.accentColor,
required this.onChanged,
});
Future<void> _pickDate(BuildContext context) async {
final now = DateTime.now();
final initialDate =
selectedDob ?? DateTime(now.year - 25, now.month, now.day);
final pickedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(now.year - 120),
lastDate: DateTime(now.year - 10, now.month, now.day),
);
if (pickedDate != null) onChanged(pickedDate);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final displayText = selectedDob != null
? '${selectedDob!.day.toString().padLeft(2, '0')}.'
'${selectedDob!.month.toString().padLeft(2, '0')}.'
'${selectedDob!.year}'
: 'Выбрать дату';
final isSelected = selectedDob != null;
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),
InkWell(
onTap: () => _pickDate(context),
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 22),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
isSelected ? accentColor : AppColors.separator,
width: isSelected ? 2 : 1,
),
color: isSelected
? accentColor.withValues(alpha: 0.08)
: Colors.transparent,
),
child: Row(
children: [
Icon(
Icons.calendar_today_outlined,
color: isSelected
? accentColor
: AppColors.textSecondary,
),
const SizedBox(width: 16),
Text(
displayText,
style: theme.textTheme.bodyLarge?.copyWith(
color: isSelected
? accentColor
: AppColors.textSecondary,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
),
],
);
}
}
// ── Step 4: Height + Weight ────────────────────────────────────
class _HeightWeightStepContent extends StatelessWidget {
final TextEditingController heightController;
final TextEditingController weightController;
final Color accentColor;
final VoidCallback onChanged;
const _HeightWeightStepContent({
required this.heightController,
required this.weightController,
required this.accentColor,
required this.onChanged,
});
@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),
Row(
children: [
Expanded(
child: _MeasurementTile(
controller: heightController,
unit: 'см',
accentColor: accentColor,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 16),
Expanded(
child: _MeasurementTile(
controller: weightController,
unit: 'кг',
accentColor: accentColor,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[0-9.]')),
],
onChanged: (_) => onChanged(),
),
),
],
),
],
);
}
}
class _MeasurementTile extends StatefulWidget {
final TextEditingController controller;
final String unit;
final Color accentColor;
final TextInputType keyboardType;
final List<TextInputFormatter> inputFormatters;
final ValueChanged<String> onChanged;
const _MeasurementTile({
required this.controller,
required this.unit,
required this.accentColor,
required this.keyboardType,
required this.inputFormatters,
required this.onChanged,
});
@override
State<_MeasurementTile> createState() => _MeasurementTileState();
}
class _MeasurementTileState extends State<_MeasurementTile> {
late final FocusNode _focusNode;
bool _hasFocus = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(
() => setState(() => _hasFocus = _focusNode.hasFocus));
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasValue = widget.controller.text.isNotEmpty;
final isActive = _hasFocus || hasValue;
return InkWell(
onTap: () => _focusNode.requestFocus(),
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isActive
? widget.accentColor
: AppColors.separator,
width: isActive ? 2 : 1,
),
color: isActive
? widget.accentColor.withValues(alpha: 0.06)
: Colors.transparent,
),
child: Column(
children: [
TextField(
controller: widget.controller,
focusNode: _focusNode,
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
textAlign: TextAlign.center,
style: theme.textTheme.headlineLarge?.copyWith(
color: isActive
? widget.accentColor
: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
hintText: '',
hintStyle: theme.textTheme.headlineLarge?.copyWith(
color: AppColors.separator,
fontWeight: FontWeight.w300,
),
),
onChanged: widget.onChanged,
),
const SizedBox(height: 4),
Text(
widget.unit,
style: theme.textTheme.bodyMedium?.copyWith(
color: isActive
? widget.accentColor
: AppColors.textSecondary,
),
),
],
),
),
);
}
}
// ── Step 5: Activity ───────────────────────────────────────────
class _ActivityStepContent extends StatelessWidget {
final String? selected;
final Color accentColor;
final ValueChanged<String?> onChanged;
const _ActivityStepContent({
required this.selected,
required this.accentColor,
required this.onChanged,
});
@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),
_ActivityCard(
icon: Icons.directions_walk,
label: 'Низкая',
description: 'Сидячая работа, мало движения',
selected: selected == 'low',
accentColor: accentColor,
onTap: () => onChanged('low'),
),
const SizedBox(height: 12),
_ActivityCard(
icon: Icons.directions_run,
label: 'Средняя',
description: 'Тренировки 35 раз в неделю',
selected: selected == 'moderate',
accentColor: accentColor,
onTap: () => onChanged('moderate'),
),
const SizedBox(height: 12),
_ActivityCard(
icon: Icons.fitness_center,
label: 'Высокая',
description: 'Интенсивные тренировки каждый день',
selected: selected == 'high',
accentColor: accentColor,
onTap: () => onChanged('high'),
),
],
);
}
}
class _ActivityCard extends StatelessWidget {
final IconData icon;
final String label;
final String description;
final bool selected;
final Color accentColor;
final VoidCallback onTap;
const _ActivityCard({
required this.icon,
required this.label,
required this.description,
required this.selected,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return _SelectableCard(
selected: selected,
accentColor: accentColor,
onTap: onTap,
child: Row(
children: [
Icon(
icon,
size: 28,
color: selected ? accentColor : AppColors.textSecondary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
color:
selected ? accentColor : AppColors.textPrimary,
fontWeight:
selected ? FontWeight.w600 : FontWeight.normal,
),
),
const SizedBox(height: 2),
Text(
description,
style: theme.textTheme.bodySmall
?.copyWith(color: AppColors.textSecondary),
),
],
),
),
if (selected)
Icon(Icons.check_circle, color: accentColor),
],
),
);
}
}
// ── 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 l10n = AppLocalizations.of(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(
mealTypeLabel(mealTypeOption.id, l10n),
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;
final Color accentColor;
final ValueChanged<int> onChanged;
const _CalorieStepContent({
required this.calories,
required this.accentColor,
required this.onChanged,
});
@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: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_AdjustButton(
icon: Icons.remove,
accentColor: accentColor,
onPressed:
calories > 1000 ? () => onChanged(calories - 50) : null,
),
const SizedBox(width: 24),
Column(
children: [
Text(
'$calories',
style: theme.textTheme.displaySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
),
Text(
'ккал / день',
style: theme.textTheme.bodyMedium
?.copyWith(color: AppColors.textSecondary),
),
],
),
const SizedBox(width: 24),
_AdjustButton(
icon: Icons.add,
accentColor: accentColor,
onPressed:
calories < 5000 ? () => onChanged(calories + 50) : null,
),
],
),
const SizedBox(height: 20),
Center(
child: Text(
'Шаг: 50 ккал',
style: theme.textTheme.bodySmall
?.copyWith(color: AppColors.textSecondary),
),
),
],
);
}
}
class _AdjustButton extends StatelessWidget {
final IconData icon;
final Color accentColor;
final VoidCallback? onPressed;
const _AdjustButton({
required this.icon,
required this.accentColor,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return IconButton.outlined(
onPressed: onPressed,
icon: Icon(
icon,
color: onPressed != null ? accentColor : AppColors.separator,
),
iconSize: 28,
padding: const EdgeInsets.all(12),
style: IconButton.styleFrom(
side: BorderSide(
color:
onPressed != null ? accentColor : AppColors.separator,
),
),
);
}
}