Files
food-ai/client/lib/features/profile/profile_screen.dart
dbastrikin c8b8c33bcb 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>
2026-02-22 16:58:35 +02:00

582 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../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, WidgetRef ref) {
final state = ref.watch(profileProvider);
return Scaffold(
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<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('Сохранить'),
),
],
),
),
),
],
),
),
);
}
}