Describe ProfileService, profileProvider, ProfileScreen sections (avatar, body params, goal/activity, daily calories, logout), EditProfileSheet, and HomeScreen name integration. No new backend endpoints needed — GET/PUT /profile already exist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
12 KiB
Markdown
304 lines
12 KiB
Markdown
# Итерация 6: Экран профиля
|
||
|
||
**Цель:** заменить заглушку «Раздел в разработке» полноценным экраном профиля — показать данные пользователя, дать возможность редактировать параметры тела и цели, отобразить рассчитанную норму калорий и выйти из аккаунта.
|
||
|
||
**Зависимости:** итерация 0 (авторизация, модель User, `GET /profile`, `PUT /profile`).
|
||
|
||
---
|
||
|
||
## Структура задач
|
||
|
||
```
|
||
6.1 Flutter: ProfileService
|
||
└── GET /profile, PUT /profile
|
||
|
||
6.2 Flutter: profileProvider
|
||
└── StateNotifier, load(), update()
|
||
|
||
6.3 Flutter: ProfileScreen
|
||
├── 6.3.1 Секция аватара и имени
|
||
├── 6.3.2 Секция параметров тела
|
||
├── 6.3.3 Секция цели и активности
|
||
├── 6.3.4 Секция нормы калорий
|
||
└── 6.3.5 Кнопка выхода
|
||
|
||
6.4 Flutter: EditProfileSheet
|
||
└── Форма редактирования всех параметров
|
||
|
||
6.5 Flutter: HomeScreen — имя из профиля
|
||
```
|
||
|
||
---
|
||
|
||
## 6.1 Backend: уже реализовано
|
||
|
||
Новых эндпоинтов не требуется. Оба маршрута существуют:
|
||
|
||
```
|
||
GET /profile — возвращает полный профиль пользователя
|
||
PUT /profile — частичное обновление; если изменяются height/weight/age/gender/activity/goal,
|
||
daily_calories пересчитывается автоматически (Mifflin-St Jeor)
|
||
```
|
||
|
||
### GET /profile — ответ
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"email": "user@example.com",
|
||
"name": "Алексей",
|
||
"avatar_url": null,
|
||
"height_cm": 175,
|
||
"weight_kg": 72.0,
|
||
"age": 28,
|
||
"gender": "male",
|
||
"activity": "moderate",
|
||
"goal": "maintain",
|
||
"daily_calories": 2480,
|
||
"plan": "free",
|
||
"preferences": {}
|
||
}
|
||
```
|
||
|
||
### PUT /profile — запрос (все поля опциональны)
|
||
|
||
```json
|
||
{
|
||
"name": "Алексей",
|
||
"height_cm": 175,
|
||
"weight_kg": 72.0,
|
||
"age": 28,
|
||
"gender": "male",
|
||
"activity": "moderate",
|
||
"goal": "maintain"
|
||
}
|
||
```
|
||
|
||
Допустимые значения:
|
||
- `gender`: `"male"` | `"female"`
|
||
- `activity`: `"low"` | `"moderate"` | `"high"`
|
||
- `goal`: `"lose"` | `"maintain"` | `"gain"`
|
||
|
||
---
|
||
|
||
## 6.2 Flutter: ProfileService
|
||
|
||
```dart
|
||
class ProfileService {
|
||
ProfileService(this._client);
|
||
final ApiClient _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);
|
||
}
|
||
}
|
||
|
||
class UpdateProfileRequest {
|
||
final String? name;
|
||
final int? heightCm;
|
||
final double? weightKg;
|
||
final int? age;
|
||
final String? gender;
|
||
final String? activity;
|
||
final String? goal;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6.3 Flutter: profileProvider
|
||
|
||
```dart
|
||
final profileProvider =
|
||
StateNotifierProvider<ProfileNotifier, AsyncValue<User>>(
|
||
(ref) => ProfileNotifier(ref.read(profileServiceProvider)),
|
||
);
|
||
|
||
class ProfileNotifier extends StateNotifier<AsyncValue<User>> {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6.4 Flutter: ProfileScreen
|
||
|
||
### Макет
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Профиль Изменить │ ← AppBar
|
||
├─────────────────────────────────────┤
|
||
│ │
|
||
│ [○] Алексей │ ← аватар-заглушка + имя
|
||
│ user@example.com │
|
||
│ │
|
||
├─────────────────────────────────────┤
|
||
│ ПАРАМЕТРЫ ТЕЛА │
|
||
│ Рост 175 см › │
|
||
│ Вес 72 кг › │
|
||
│ Возраст 28 лет › │
|
||
│ Пол Мужской › │
|
||
├─────────────────────────────────────┤
|
||
│ ЦЕЛЬ И АКТИВНОСТЬ │
|
||
│ Цель Поддержание › │
|
||
│ Активность Средняя › │
|
||
├─────────────────────────────────────┤
|
||
│ ПИТАНИЕ │
|
||
│ Норма калорий 2 480 ккал │
|
||
│ Рассчитано по параметрам тела │
|
||
├─────────────────────────────────────┤
|
||
│ │
|
||
│ [ Выйти из аккаунта ] │
|
||
│ │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
### 6.3.1 Секция аватара и имени
|
||
|
||
- Круглый контейнер с инициалами (первая буква имени) на фоне `AppColors.primary.withValues(alpha: 0.15)` — если `avatar_url` равен null
|
||
- Под контейнером: имя (`titleMedium`) и email (`bodySmall`, `textSecondary`)
|
||
|
||
### 6.3.2 Секция параметров тела
|
||
|
||
Сгруппированная карточка (`Card`) с `ListTile`-строками в стиле iOS:
|
||
- **Рост** — `${height} см` или «Не задано» серым
|
||
- **Вес** — `${weight} кг` или «Не задано»
|
||
- **Возраст** — `${age} лет` или «Не задано»
|
||
- **Пол** — «Мужской» / «Женский» / «Не задано»
|
||
|
||
Строки — только для чтения. Редактирование открывается кнопкой **Изменить** в AppBar.
|
||
|
||
Если все параметры не заданы — показать inline-баннер:
|
||
> «Укажите параметры тела для расчёта нормы калорий»
|
||
|
||
### 6.3.3 Секция цели и активности
|
||
|
||
Аналогичная карточка:
|
||
- **Цель**: «Похудение» | «Поддержание» | «Набор массы»
|
||
- **Активность**: «Низкая» | «Средняя» | «Высокая»
|
||
|
||
### 6.3.4 Секция нормы калорий
|
||
|
||
Карточка с одной строкой:
|
||
- Если `daily_calories != null` — показать «N ккал/день»
|
||
- Иначе — «Задайте параметры тела»
|
||
|
||
Подпись: «Рассчитано автоматически по формуле Миффлина-Сан Жеора».
|
||
|
||
### 6.3.5 Кнопка выхода
|
||
|
||
`OutlinedButton` в цвете `AppColors.error`, по всей ширине.
|
||
Перед выходом — `AlertDialog` с подтверждением.
|
||
По подтверждению: `ref.read(authProvider.notifier).signOut()`.
|
||
|
||
---
|
||
|
||
## 6.5 Flutter: EditProfileSheet
|
||
|
||
Открывается из кнопки «Изменить» в AppBar — `showModalBottomSheet` с прокрутой.
|
||
|
||
### Поля формы
|
||
|
||
| Поле | Тип ввода | Валидация |
|
||
|------|-----------|-----------|
|
||
| Имя | TextFormField | не пустое |
|
||
| Рост | TextFormField (число) | 100–250 |
|
||
| Вес | TextFormField (десятичное) | 30–300 |
|
||
| Возраст | TextFormField (число) | 10–120 |
|
||
| Пол | SegmentedButton: «Мужской» / «Женский» | — |
|
||
| Цель | SegmentedButton: «Похудение» / «Поддержание» / «Набор» | — |
|
||
| Активность | SegmentedButton: «Низкая» / «Средняя» / «Высокая» | — |
|
||
|
||
### Поведение
|
||
|
||
- Поля предзаполнены текущими значениями из `profileProvider`
|
||
- Кнопка «Сохранить» (FilledButton) — вызывает `profileProvider.notifier.update(...)`
|
||
- При успехе — закрывает sheet, показывает `SnackBar` «Профиль обновлён»
|
||
- При ошибке — показывает `SnackBar` «Не удалось сохранить»
|
||
- Калории пересчитываются на сервере автоматически — UI обновится из ответа
|
||
|
||
### Макет sheet
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ ── (drag handle) │
|
||
│ Редактировать профиль │
|
||
├─────────────────────────────────────┤
|
||
│ Имя │
|
||
│ [Алексей ] │
|
||
│ │
|
||
│ Рост (см) Вес (кг) │
|
||
│ [175 ] [72.0 ] │
|
||
│ │
|
||
│ Возраст (лет) │
|
||
│ [28 ] │
|
||
│ │
|
||
│ Пол │
|
||
│ [ Мужской ● | Женский ] │
|
||
│ │
|
||
│ Цель │
|
||
│ [ Похудение | Поддержание● | Набор ]│
|
||
│ │
|
||
│ Активность │
|
||
│ [ Низкая | Средняя● | Высокая ] │
|
||
│ │
|
||
│ [ Сохранить ] │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 6.6 Flutter: HomeScreen — имя пользователя
|
||
|
||
Приветствие на главном экране («Доброе утро, Алексей!») сейчас использует статичное имя или имя из `authProvider`.
|
||
|
||
После этой итерации: имя берётся из `profileProvider`:
|
||
```dart
|
||
final user = ref.watch(profileProvider).valueOrNull;
|
||
final name = user?.name ?? '';
|
||
```
|
||
|
||
---
|
||
|
||
## Оценка нагрузки
|
||
|
||
| Действие | OpenAI | БД запросов |
|
||
|---|---|---|
|
||
| Открытие вкладки Профиль | 0 | 1 (SELECT users) |
|
||
| Сохранение профиля | 0 | 1–2 (UPDATE + SELECT) |
|
||
|
||
Экран профиля не тратит AI-квоту.
|
||
|
||
---
|
||
|
||
## Результат итерации
|
||
|
||
- Пользователь видит своё имя, параметры тела и цели
|
||
- Может обновить параметры — норма калорий пересчитывается мгновенно
|
||
- Может выйти из аккаунта
|
||
- Приветствие на главном экране показывает реальное имя
|