diff --git a/backend/internal/domain/user/entity.go b/backend/internal/domain/user/entity.go index 4f54124..4147dc5 100644 --- a/backend/internal/domain/user/entity.go +++ b/backend/internal/domain/user/entity.go @@ -33,7 +33,7 @@ type UpdateProfileRequest struct { Activity *string `json:"activity"` Goal *string `json:"goal"` Preferences *json.RawMessage `json:"preferences"` - DailyCalories *int `json:"-"` // internal, set by service + DailyCalories *int `json:"daily_calories,omitempty"` } // HasBodyParams returns true if any body parameter is being updated diff --git a/backend/internal/domain/user/service.go b/backend/internal/domain/user/service.go index b81580f..0c4b049 100644 --- a/backend/internal/domain/user/service.go +++ b/backend/internal/domain/user/service.go @@ -65,7 +65,12 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdatePr goal = req.Goal } - calories := CalculateDailyCalories(height, weight, age, gender, activity, goal) + var calories *int + if req.DailyCalories != nil { + calories = req.DailyCalories + } else { + calories = CalculateDailyCalories(height, weight, age, gender, activity, goal) + } var calReq *UpdateProfileRequest if calories != nil { diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 5de2657..e105b68 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -6,6 +6,9 @@ import '../auth/auth_provider.dart'; import '../../features/auth/login_screen.dart'; import '../../features/auth/register_screen.dart'; import '../../features/home/home_screen.dart'; +import '../../features/onboarding/onboarding_screen.dart'; +import '../../features/profile/profile_provider.dart'; +import '../../shared/models/user.dart'; import '../../features/products/products_screen.dart'; import '../../features/products/add_product_screen.dart'; import '../../features/scan/scan_screen.dart'; @@ -22,10 +25,20 @@ import '../../features/products/product_provider.dart'; import '../../shared/models/recipe.dart'; import '../../shared/models/saved_recipe.dart'; -// Notifies GoRouter when auth state changes without recreating the router. +// Notifies GoRouter when auth state or profile state changes. class _RouterNotifier extends ChangeNotifier { _RouterNotifier(Ref ref) { - ref.listen(authProvider, (_, __) => notifyListeners()); + ref.listen(authProvider, (previous, next) { + // Reload profile whenever auth transitions to authenticated. + // This handles the case where profileProvider did an initial load before + // tokens were available (401 → AsyncError) and needs a fresh load after login. + if (next.status == AuthStatus.authenticated && + previous?.status != AuthStatus.authenticated) { + ref.read(profileProvider.notifier).load(); + } + notifyListeners(); + }); + ref.listen>(profileProvider, (_, __) => notifyListeners()); } } @@ -41,11 +54,29 @@ final routerProvider = Provider((ref) { final authState = ref.read(authProvider); final isLoggedIn = authState.status == AuthStatus.authenticated; final isAuthRoute = state.matchedLocation.startsWith('/auth'); + final isOnboarding = state.matchedLocation.startsWith('/onboarding'); // Show splash until the stored-token check completes. if (authState.status == AuthStatus.unknown) return '/loading'; if (!isLoggedIn && !isAuthRoute) return '/auth/login'; - if (isLoggedIn && isAuthRoute) return '/home'; + + if (isLoggedIn) { + // Reading profileProvider triggers its lazy initialization (load() in constructor). + final profileState = ref.read(profileProvider); + // Keep showing splash while profile loads. + if (profileState.isLoading) { + return state.matchedLocation == '/loading' ? null : '/loading'; + } + final profileUser = profileState.valueOrNull; + // If profile failed to load, don't block navigation. + if (profileUser == null) return isAuthRoute ? '/home' : null; + + final needsOnboarding = !profileUser.hasCompletedOnboarding; + if (isAuthRoute) return needsOnboarding ? '/onboarding' : '/home'; + if (needsOnboarding && !isOnboarding) return '/onboarding'; + if (!needsOnboarding && isOnboarding) return '/home'; + } + return null; }, routes: [ @@ -62,6 +93,11 @@ final routerProvider = Provider((ref) { path: '/auth/register', builder: (_, __) => const RegisterScreen(), ), + // Onboarding — full-screen, no bottom nav, shown to new users. + GoRoute( + path: '/onboarding', + builder: (_, __) => const OnboardingScreen(), + ), // Full-screen recipe detail — shown without the bottom navigation bar. GoRoute( path: '/recipe-detail', diff --git a/client/lib/features/onboarding/onboarding_screen.dart b/client/lib/features/onboarding/onboarding_screen.dart new file mode 100644 index 0000000..8cdeefa --- /dev/null +++ b/client/lib/features/onboarding/onboarding_screen.dart @@ -0,0 +1,1215 @@ +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 _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 _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 createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends ConsumerState { + 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 _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(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 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 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 onChanged; + + const _DobStepContent({ + required this.selectedDob, + required this.accentColor, + required this.onChanged, + }); + + Future _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 inputFormatters; + final ValueChanged 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 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 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, + ), + ), + ); + } +} diff --git a/client/lib/features/profile/profile_screen.dart b/client/lib/features/profile/profile_screen.dart index 9a3dc58..9c5813c 100644 --- a/client/lib/features/profile/profile_screen.dart +++ b/client/lib/features/profile/profile_screen.dart @@ -51,12 +51,12 @@ class ProfileScreen extends ConsumerWidget { // ── Profile body ────────────────────────────────────────────── -class _ProfileBody extends StatelessWidget { +class _ProfileBody extends ConsumerWidget { final User user; const _ProfileBody({required this.user}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ diff --git a/client/lib/features/profile/profile_service.dart b/client/lib/features/profile/profile_service.dart index 6514148..340f217 100644 --- a/client/lib/features/profile/profile_service.dart +++ b/client/lib/features/profile/profile_service.dart @@ -17,6 +17,7 @@ class UpdateProfileRequest { final String? activity; final String? goal; final String? language; + final int? dailyCalories; const UpdateProfileRequest({ this.name, @@ -27,6 +28,7 @@ class UpdateProfileRequest { this.activity, this.goal, this.language, + this.dailyCalories, }); Map toJson() { @@ -39,6 +41,7 @@ class UpdateProfileRequest { if (activity != null) map['activity'] = activity; if (goal != null) map['goal'] = goal; if (language != null) map['preferences'] = {'language': language}; + if (dailyCalories != null) map['daily_calories'] = dailyCalories; return map; } } diff --git a/client/lib/shared/models/user.dart b/client/lib/shared/models/user.dart index 47a3583..a49a83b 100644 --- a/client/lib/shared/models/user.dart +++ b/client/lib/shared/models/user.dart @@ -57,5 +57,6 @@ class User { } bool get hasCompletedOnboarding => - heightCm != null && weightKg != null && dateOfBirth != null && gender != null; + heightCm != null && weightKg != null && dateOfBirth != null && + gender != null && goal != null && activity != null; } diff --git a/client/pubspec.lock b/client/pubspec.lock index df7fdde..79c5cdf 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -668,26 +668,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -993,10 +993,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: