Files
food-ai/docs/plans/Iteration_6.md
dbastrikin 79c32f226c docs: add Iteration 6 — profile screen
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>
2026-02-22 15:59:09 +02:00

304 lines
12 KiB
Markdown
Raw Permalink 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.
# Итерация 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 (число) | 100250 |
| Вес | TextFormField (десятичное) | 30300 |
| Возраст | TextFormField (число) | 10120 |
| Пол | 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 | 12 (UPDATE + SELECT) |
Экран профиля не тратит AI-квоту.
---
## Результат итерации
- Пользователь видит своё имя, параметры тела и цели
- Может обновить параметры — норма калорий пересчитывается мгновенно
- Может выйти из аккаунта
- Приветствие на главном экране показывает реальное имя