feat: implement Iteration 6 — profile screen

Add ProfileService (GET/PUT /profile), ProfileNotifier provider,
and full ProfileScreen with body-params, goal/activity, daily-calories
sections and logout confirmation. EditProfileSheet lets user update
name, height, weight, age, gender, goal and activity; backend
auto-recalculates daily_calories via Mifflin-St Jeor.
HomeScreen greeting now shows the user's real name from profileProvider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-22 16:58:35 +02:00
parent 79c32f226c
commit c8b8c33bcb
4 changed files with 672 additions and 7 deletions

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../shared/models/home_summary.dart'; import '../../shared/models/home_summary.dart';
import '../profile/profile_provider.dart';
import 'home_provider.dart'; import 'home_provider.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerWidget {
@@ -12,6 +13,7 @@ class HomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(homeProvider); final state = ref.watch(homeProvider);
final userName = ref.watch(profileProvider).valueOrNull?.name;
return Scaffold( return Scaffold(
body: state.when( body: state.when(
@@ -26,7 +28,7 @@ class HomeScreen extends ConsumerWidget {
onRefresh: () => ref.read(homeProvider.notifier).load(), onRefresh: () => ref.read(homeProvider.notifier).load(),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
_AppBar(summary: summary), _AppBar(summary: summary, userName: userName),
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
sliver: SliverList( sliver: SliverList(
@@ -62,15 +64,22 @@ class HomeScreen extends ConsumerWidget {
class _AppBar extends StatelessWidget { class _AppBar extends StatelessWidget {
final HomeSummary summary; 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; final hour = DateTime.now().hour;
if (hour < 12) return 'Доброе утро'; if (hour < 12) return 'Доброе утро';
if (hour < 18) return 'Добрый день'; if (hour < 18) return 'Добрый день';
return 'Добрый вечер'; return 'Добрый вечер';
} }
String get _greeting {
final name = userName;
if (name != null && name.isNotEmpty) return '$_greetingBase, $name!';
return _greetingBase;
}
String get _dateLabel { String get _dateLabel {
final now = DateTime.now(); final now = DateTime.now();
const months = [ const months = [

View File

@@ -0,0 +1,32 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/models/user.dart';
import 'profile_service.dart';
class ProfileNotifier extends StateNotifier<AsyncValue<User>> {
final ProfileService _service;
ProfileNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.getProfile());
}
Future<bool> update(UpdateProfileRequest req) async {
try {
final updated = await _service.updateProfile(req);
state = AsyncValue.data(updated);
return true;
} catch (_) {
return false;
}
}
}
final profileProvider =
StateNotifierProvider<ProfileNotifier, AsyncValue<User>>(
(ref) => ProfileNotifier(ref.read(profileServiceProvider)),
);

View File

@@ -1,13 +1,581 @@
import 'package:flutter/material.dart'; 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}); const ProfileScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(profileProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Профиль')), appBar: AppBar(
body: const Center(child: Text('Раздел в разработке')), 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<Widget> 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<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
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<EditProfileSheet> createState() => _EditProfileSheetState();
}
class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
final _formKey = GlobalKey<FormState>();
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<void> _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 '100250';
}
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 '30300';
}
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 '10120';
return null;
},
),
const SizedBox(height: 20),
// Gender
Text('Пол', style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SegmentedButton<String>(
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<String>(
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<String>(
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('Сохранить'),
),
],
),
),
),
],
),
),
); );
} }
} }

View File

@@ -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<ProfileService>((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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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<User> getProfile() async {
final json = await _client.get('/profile');
return User.fromJson(json);
}
Future<User> updateProfile(UpdateProfileRequest req) async {
final json = await _client.put('/profile', data: req.toJson());
return User.fromJson(json);
}
}