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:
32
client/lib/features/profile/profile_provider.dart
Normal file
32
client/lib/features/profile/profile_provider.dart
Normal 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)),
|
||||
);
|
||||
@@ -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<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 '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<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('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
56
client/lib/features/profile/profile_service.dart
Normal file
56
client/lib/features/profile/profile_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user