Introduce 6-step onboarding screen (Goal → Gender → DOB → Height+Weight → Activity → Calories) with per-step accent colors, hero illustration area (concentric circles + icon), and white card content panel. Backend user entity and service updated to support onboarding fields (goal, activity, height, weight, DOB, dailyCalories). Router guards unauthenticated and onboarding-incomplete users. Profile service and screen updated to expose language and onboarding preferences. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1216 lines
36 KiB
Dart
1216 lines
36 KiB
Dart
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 '../../core/theme/app_colors.dart';
|
||
import '../profile/profile_provider.dart';
|
||
import '../profile/profile_service.dart';
|
||
|
||
const int _totalSteps = 6;
|
||
|
||
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(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.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;
|
||
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: // Calories
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
void _nextStep() {
|
||
if (!_canAdvance()) return;
|
||
|
||
// Entering calorie review — pre-calculate based on collected data
|
||
if (_currentStep == 4) {
|
||
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,
|
||
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: _CalorieStepContent(
|
||
calories: _dailyCalories,
|
||
accentColor: _stepAccentColors[5],
|
||
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: 'Тренировки 3–5 раз в неделю',
|
||
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: 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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|