Files
food-ai/client/lib/features/profile/profile_screen.dart
dbastrikin ddbc8e2bc0 feat: add onboarding flow with visual redesign
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>
2026-03-16 23:13:00 +02:00

636 lines
22 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/locale/language_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 ConsumerWidget {
final User user;
const _ProfileBody({required this.user});
@override
Widget build(BuildContext context, WidgetRef ref) {
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: 16),
// Settings
_SectionLabel('НАСТРОЙКИ'),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow(
'Язык',
ref.watch(supportedLanguagesProvider).valueOrNull?[
user.preferences['language'] as String? ?? 'ru'] ??
'Русский',
),
]),
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;
DateTime? _selectedDob;
String? _gender;
String? _goal;
String? _activity;
String? _language;
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!) : '');
_selectedDob =
u.dateOfBirth != null ? DateTime.tryParse(u.dateOfBirth!) : null;
_gender = u.gender;
_goal = u.goal;
_activity = u.activity;
_language = u.preferences['language'] as String? ?? 'ru';
}
@override
void dispose() {
_nameCtrl.dispose();
_heightCtrl.dispose();
_weightCtrl.dispose();
super.dispose();
}
String _fmt(double w) =>
w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1);
Future<void> _pickDob() async {
final now = DateTime.now();
final initial = _selectedDob ?? DateTime(now.year - 25, now.month, now.day);
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(now.year - 120),
lastDate: DateTime(now.year - 10, now.month, now.day),
);
if (picked != null) setState(() => _selectedDob = picked);
}
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),
dateOfBirth: _selectedDob != null
? '${_selectedDob!.year.toString().padLeft(4, '0')}-'
'${_selectedDob!.month.toString().padLeft(2, '0')}-'
'${_selectedDob!.day.toString().padLeft(2, '0')}'
: null,
gender: _gender,
goal: _goal,
activity: _activity,
language: _language,
);
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),
// Date of birth
InkWell(
onTap: _pickDob,
child: InputDecorator(
decoration:
const InputDecoration(labelText: 'Дата рождения'),
child: Text(
_selectedDob != null
? '${_selectedDob!.day.toString().padLeft(2, '0')}.'
'${_selectedDob!.month.toString().padLeft(2, '0')}.'
'${_selectedDob!.year}'
: 'Не задано',
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
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: 20),
// Language
ref.watch(supportedLanguagesProvider).when(
data: (languages) => DropdownButtonFormField<String>(
value: _language,
decoration: const InputDecoration(
labelText: 'Язык интерфейса'),
items: languages.entries
.map((e) => DropdownMenuItem(
value: e.key,
child: Text(e.value),
))
.toList(),
onChanged: (v) => setState(() => _language = v),
),
loading: () => const Center(
child: CircularProgressIndicator()),
error: (_, __) =>
const Text('Не удалось загрузить языки'),
),
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('Сохранить'),
),
],
),
),
),
],
),
),
);
}
}