diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index 509abdb..acde6fd 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../shared/models/home_summary.dart'; +import '../profile/profile_provider.dart'; import 'home_provider.dart'; class HomeScreen extends ConsumerWidget { @@ -12,6 +13,7 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(homeProvider); + final userName = ref.watch(profileProvider).valueOrNull?.name; return Scaffold( body: state.when( @@ -26,7 +28,7 @@ class HomeScreen extends ConsumerWidget { onRefresh: () => ref.read(homeProvider.notifier).load(), child: CustomScrollView( slivers: [ - _AppBar(summary: summary), + _AppBar(summary: summary, userName: userName), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), sliver: SliverList( @@ -62,15 +64,22 @@ class HomeScreen extends ConsumerWidget { class _AppBar extends StatelessWidget { final HomeSummary summary; - const _AppBar({required this.summary}); + final String? userName; + const _AppBar({required this.summary, this.userName}); - String get _greeting { + String get _greetingBase { final hour = DateTime.now().hour; if (hour < 12) return 'Доброе утро'; if (hour < 18) return 'Добрый день'; return 'Добрый вечер'; } + String get _greeting { + final name = userName; + if (name != null && name.isNotEmpty) return '$_greetingBase, $name!'; + return _greetingBase; + } + String get _dateLabel { final now = DateTime.now(); const months = [ diff --git a/client/lib/features/profile/profile_provider.dart b/client/lib/features/profile/profile_provider.dart new file mode 100644 index 0000000..e9164fd --- /dev/null +++ b/client/lib/features/profile/profile_provider.dart @@ -0,0 +1,32 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/models/user.dart'; +import 'profile_service.dart'; + +class ProfileNotifier extends StateNotifier> { + final ProfileService _service; + + ProfileNotifier(this._service) : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _service.getProfile()); + } + + Future update(UpdateProfileRequest req) async { + try { + final updated = await _service.updateProfile(req); + state = AsyncValue.data(updated); + return true; + } catch (_) { + return false; + } + } +} + +final profileProvider = + StateNotifierProvider>( + (ref) => ProfileNotifier(ref.read(profileServiceProvider)), +); diff --git a/client/lib/features/profile/profile_screen.dart b/client/lib/features/profile/profile_screen.dart index b9570b6..238348c 100644 --- a/client/lib/features/profile/profile_screen.dart +++ b/client/lib/features/profile/profile_screen.dart @@ -1,13 +1,581 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class ProfileScreen extends StatelessWidget { +import '../../core/auth/auth_provider.dart'; +import '../../core/theme/app_colors.dart'; +import '../../shared/models/user.dart'; +import 'profile_provider.dart'; +import 'profile_service.dart'; + +class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(profileProvider); + return Scaffold( - appBar: AppBar(title: const Text('Профиль')), - body: const Center(child: Text('Раздел в разработке')), + appBar: AppBar( + title: const Text('Профиль'), + actions: [ + if (state.hasValue) + TextButton( + onPressed: () => _openEdit(context, state.value!), + child: const Text('Изменить'), + ), + ], + ), + body: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => Center( + child: FilledButton( + onPressed: () => ref.read(profileProvider.notifier).load(), + child: const Text('Повторить'), + ), + ), + data: (user) => _ProfileBody(user: user), + ), + ); + } + + void _openEdit(BuildContext context, User user) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => EditProfileSheet(user: user), + ); + } +} + +// ── Profile body ────────────────────────────────────────────── + +class _ProfileBody extends StatelessWidget { + final User user; + const _ProfileBody({required this.user}); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + _ProfileHeader(user: user), + const SizedBox(height: 24), + + // Body params + _SectionLabel('ПАРАМЕТРЫ ТЕЛА'), + const SizedBox(height: 6), + _InfoCard(children: [ + _InfoRow('Рост', user.heightCm != null ? '${user.heightCm} см' : null), + const Divider(height: 1, indent: 16), + _InfoRow('Вес', + user.weightKg != null ? '${_fmt(user.weightKg!)} кг' : null), + const Divider(height: 1, indent: 16), + _InfoRow('Возраст', user.age != null ? '${user.age} лет' : null), + const Divider(height: 1, indent: 16), + _InfoRow('Пол', _genderLabel(user.gender)), + ]), + if (user.heightCm == null && user.weightKg == null) ...[ + const SizedBox(height: 8), + _HintBanner('Укажите параметры тела для расчёта нормы калорий'), + ], + const SizedBox(height: 16), + + // Goal & activity + _SectionLabel('ЦЕЛЬ И АКТИВНОСТЬ'), + const SizedBox(height: 6), + _InfoCard(children: [ + _InfoRow('Цель', _goalLabel(user.goal)), + const Divider(height: 1, indent: 16), + _InfoRow('Активность', _activityLabel(user.activity)), + ]), + const SizedBox(height: 16), + + // Calories + _SectionLabel('ПИТАНИЕ'), + const SizedBox(height: 6), + _InfoCard(children: [ + _InfoRow( + 'Норма калорий', + user.dailyCalories != null + ? '${user.dailyCalories} ккал/день' + : null, + ), + ]), + if (user.dailyCalories != null) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Рассчитано по формуле Миффлина-Сан Жеора', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + ], + const SizedBox(height: 32), + + _LogoutButton(), + ], + ); + } + + static String _fmt(double w) => + w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1); + + static String? _genderLabel(String? g) => switch (g) { + 'male' => 'Мужской', + 'female' => 'Женский', + _ => null, + }; + + static String? _goalLabel(String? g) => switch (g) { + 'lose' => 'Похудение', + 'maintain' => 'Поддержание', + 'gain' => 'Набор массы', + _ => null, + }; + + static String? _activityLabel(String? a) => switch (a) { + 'low' => 'Низкая', + 'moderate' => 'Средняя', + 'high' => 'Высокая', + _ => null, + }; +} + +// ── Header ──────────────────────────────────────────────────── + +class _ProfileHeader extends StatelessWidget { + final User user; + const _ProfileHeader({required this.user}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final initials = user.name.isNotEmpty ? user.name[0].toUpperCase() : '?'; + + return Column( + children: [ + const SizedBox(height: 8), + CircleAvatar( + radius: 40, + backgroundColor: AppColors.primary.withValues(alpha: 0.15), + child: Text( + initials, + style: theme.textTheme.headlineMedium?.copyWith( + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 12), + Text(user.name, style: theme.textTheme.titleMedium), + const SizedBox(height: 2), + Text( + user.email, + style: theme.textTheme.bodySmall + ?.copyWith(color: AppColors.textSecondary), + ), + ], + ); + } +} + +// ── Shared widgets ──────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + text, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppColors.textSecondary, + letterSpacing: 0.5, + ), + ), + ); +} + +class _InfoCard extends StatelessWidget { + final List children; + const _InfoCard({required this.children}); + + @override + Widget build(BuildContext context) => + Card(child: Column(children: children)); +} + +class _InfoRow extends StatelessWidget { + final String label; + final String? value; + const _InfoRow(this.label, this.value); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Text(label, style: theme.textTheme.bodyMedium), + const Spacer(), + Text( + value ?? 'Не задано', + style: theme.textTheme.bodyMedium?.copyWith( + color: value != null + ? AppColors.textPrimary + : AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} + +class _HintBanner extends StatelessWidget { + final String text; + const _HintBanner(this.text); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, size: 16, color: AppColors.warning), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: AppColors.warning), + ), + ), + ], + ), + ); + } +} + +class _LogoutButton extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: const BorderSide(color: AppColors.error), + ), + onPressed: () => _confirmLogout(context, ref), + child: const Text('Выйти из аккаунта'), + ); + } + + Future _confirmLogout(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Выйти из аккаунта?'), + content: const Text('Вы будете перенаправлены на экран входа.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Отмена'), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: AppColors.error), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Выйти'), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(authProvider.notifier).signOut(); + } + } +} + +// ── Edit sheet ──────────────────────────────────────────────── + +class EditProfileSheet extends ConsumerStatefulWidget { + final User user; + const EditProfileSheet({super.key, required this.user}); + + @override + ConsumerState createState() => _EditProfileSheetState(); +} + +class _EditProfileSheetState extends ConsumerState { + final _formKey = GlobalKey(); + late final TextEditingController _nameCtrl; + late final TextEditingController _heightCtrl; + late final TextEditingController _weightCtrl; + late final TextEditingController _ageCtrl; + String? _gender; + String? _goal; + String? _activity; + bool _saving = false; + + @override + void initState() { + super.initState(); + final u = widget.user; + _nameCtrl = TextEditingController(text: u.name); + _heightCtrl = TextEditingController(text: u.heightCm?.toString() ?? ''); + _weightCtrl = TextEditingController( + text: u.weightKg != null ? _fmt(u.weightKg!) : ''); + _ageCtrl = TextEditingController(text: u.age?.toString() ?? ''); + _gender = u.gender; + _goal = u.goal; + _activity = u.activity; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _heightCtrl.dispose(); + _weightCtrl.dispose(); + _ageCtrl.dispose(); + super.dispose(); + } + + String _fmt(double w) => + w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1); + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + + final req = UpdateProfileRequest( + name: _nameCtrl.text.trim(), + heightCm: int.tryParse(_heightCtrl.text), + weightKg: double.tryParse(_weightCtrl.text), + age: int.tryParse(_ageCtrl.text), + gender: _gender, + goal: _goal, + activity: _activity, + ); + + final ok = await ref.read(profileProvider.notifier).update(req); + + if (!mounted) return; + setState(() => _saving = false); + + if (ok) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Профиль обновлён')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось сохранить')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: DraggableScrollableSheet( + expand: false, + initialChildSize: 0.85, + maxChildSize: 0.95, + builder: (_, controller) => Column( + children: [ + const SizedBox(height: 12), + // Drag handle + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.separator, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Text('Редактировать профиль', + style: theme.textTheme.titleMedium), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: Form( + key: _formKey, + child: ListView( + controller: controller, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + // Name + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Имя'), + textCapitalization: TextCapitalization.words, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Введите имя' : null, + ), + const SizedBox(height: 12), + + // Height + Weight + Row( + children: [ + Expanded( + child: TextFormField( + controller: _heightCtrl, + decoration: + const InputDecoration(labelText: 'Рост (см)'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + validator: (v) { + if (v == null || v.isEmpty) return null; + final n = int.tryParse(v); + if (n == null || n < 100 || n > 250) { + return '100–250'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _weightCtrl, + decoration: + const InputDecoration(labelText: 'Вес (кг)'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'[0-9.]')) + ], + validator: (v) { + if (v == null || v.isEmpty) return null; + final n = double.tryParse(v); + if (n == null || n < 30 || n > 300) { + return '30–300'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Age + TextFormField( + controller: _ageCtrl, + decoration: + const InputDecoration(labelText: 'Возраст (лет)'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + validator: (v) { + if (v == null || v.isEmpty) return null; + final n = int.tryParse(v); + if (n == null || n < 10 || n > 120) return '10–120'; + return null; + }, + ), + const SizedBox(height: 20), + + // Gender + Text('Пол', style: theme.textTheme.labelMedium), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'male', label: Text('Мужской')), + ButtonSegment(value: 'female', label: Text('Женский')), + ], + selected: _gender != null ? {_gender!} : const {}, + emptySelectionAllowed: true, + onSelectionChanged: (s) => + setState(() => _gender = s.isEmpty ? null : s.first), + ), + const SizedBox(height: 20), + + // Goal + Text('Цель', style: theme.textTheme.labelMedium), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'lose', label: Text('Похудение')), + ButtonSegment( + value: 'maintain', label: Text('Поддержание')), + ButtonSegment(value: 'gain', label: Text('Набор')), + ], + selected: _goal != null ? {_goal!} : const {}, + emptySelectionAllowed: true, + onSelectionChanged: (s) => + setState(() => _goal = s.isEmpty ? null : s.first), + ), + const SizedBox(height: 20), + + // Activity + Text('Активность', style: theme.textTheme.labelMedium), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'low', label: Text('Низкая')), + ButtonSegment( + value: 'moderate', label: Text('Средняя')), + ButtonSegment(value: 'high', label: Text('Высокая')), + ], + selected: _activity != null ? {_activity!} : const {}, + emptySelectionAllowed: true, + onSelectionChanged: (s) => setState( + () => _activity = s.isEmpty ? null : s.first), + ), + const SizedBox(height: 32), + + // Save + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Сохранить'), + ), + ], + ), + ), + ), + ], + ), + ), ); } } diff --git a/client/lib/features/profile/profile_service.dart b/client/lib/features/profile/profile_service.dart new file mode 100644 index 0000000..09902fa --- /dev/null +++ b/client/lib/features/profile/profile_service.dart @@ -0,0 +1,56 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../../core/auth/auth_provider.dart'; +import '../../shared/models/user.dart'; + +final profileServiceProvider = Provider((ref) { + return ProfileService(ref.read(apiClientProvider)); +}); + +class UpdateProfileRequest { + final String? name; + final int? heightCm; + final double? weightKg; + final int? age; + final String? gender; + final String? activity; + final String? goal; + + const UpdateProfileRequest({ + this.name, + this.heightCm, + this.weightKg, + this.age, + this.gender, + this.activity, + this.goal, + }); + + Map toJson() { + final map = {}; + if (name != null) map['name'] = name; + if (heightCm != null) map['height_cm'] = heightCm; + if (weightKg != null) map['weight_kg'] = weightKg; + if (age != null) map['age'] = age; + if (gender != null) map['gender'] = gender; + if (activity != null) map['activity'] = activity; + if (goal != null) map['goal'] = goal; + return map; + } +} + +class ProfileService { + final ApiClient _client; + ProfileService(this._client); + + Future getProfile() async { + final json = await _client.get('/profile'); + return User.fromJson(json); + } + + Future updateProfile(UpdateProfileRequest req) async { + final json = await _client.put('/profile', data: req.toJson()); + return User.fromJson(json); + } +}