Describe GET /home/summary endpoint (plan, logged calories, expiring products, cached recommendations) and HomeScreen layout with calorie ring, today's meals, expiring banner, and quick actions. Update Summary.md to include iteration 5 and fix provider references from Gemini/Groq to OpenAI/GPT. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
279 lines
11 KiB
Markdown
279 lines
11 KiB
Markdown
# Итерация 5: Главный экран
|
||
|
||
**Цель:** превратить заглушку главного экрана в информационный дашборд — показать сводку на сегодня (плановые приёмы пищи, прогресс калорий), быстрые действия, предупреждение об истекающих продуктах и рекомендации рецептов.
|
||
|
||
**Зависимости:** Итерации 1 (рецепты), 2 (продукты), 4 (меню, дневник).
|
||
|
||
---
|
||
|
||
## Структура задач
|
||
|
||
```
|
||
5.1 Backend: endpoint сводки
|
||
└── GET /home/summary
|
||
|
||
5.2 Flutter: HomeScreen
|
||
├── 5.2.1 Приветствие + дата
|
||
├── 5.2.2 Прогресс калорий (ring)
|
||
├── 5.2.3 Плановые приёмы пищи на сегодня
|
||
├── 5.2.4 Строка быстрых действий
|
||
├── 5.2.5 Баннер истекающих продуктов
|
||
└── 5.2.6 Блок рекомендаций
|
||
|
||
5.3 Flutter: homeProvider
|
||
```
|
||
|
||
---
|
||
|
||
## 5.1 Backend: GET /home/summary
|
||
|
||
Единственный запрос при загрузке главного экрана. Агрегирует данные из нескольких таблиц — без AI-вызовов, без медленных операций.
|
||
|
||
### Запрос
|
||
|
||
```
|
||
GET /home/summary
|
||
Authorization: Bearer <jwt>
|
||
```
|
||
|
||
### Ответ
|
||
|
||
```json
|
||
{
|
||
"today": {
|
||
"date": "2026-02-22",
|
||
"daily_goal": 2000,
|
||
"logged_calories": 650,
|
||
"plan": [
|
||
{
|
||
"meal_type": "breakfast",
|
||
"recipe_title": "Овсяная каша с яблоком",
|
||
"recipe_image_url": "https://...",
|
||
"calories": 320
|
||
},
|
||
{
|
||
"meal_type": "lunch",
|
||
"recipe_title": "Куриный суп",
|
||
"recipe_image_url": "https://...",
|
||
"calories": 480
|
||
},
|
||
{
|
||
"meal_type": "dinner",
|
||
"recipe_title": null,
|
||
"recipe_image_url": null,
|
||
"calories": null
|
||
}
|
||
]
|
||
},
|
||
"expiring_soon": [
|
||
{ "name": "Куриная грудка", "expires_in_days": 1, "quantity": "300 г" },
|
||
{ "name": "Молоко", "expires_in_days": 2, "quantity": "0.5 л" }
|
||
],
|
||
"recommendations": [
|
||
{
|
||
"id": "uuid",
|
||
"title": "Стир-фрай с курицей",
|
||
"image_url": "https://...",
|
||
"calories": 510
|
||
},
|
||
{
|
||
"id": "uuid",
|
||
"title": "Омлет с овощами",
|
||
"image_url": "https://...",
|
||
"calories": 380
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Логика сборки
|
||
|
||
```
|
||
today.plan:
|
||
SELECT mi.meal_type, sr.title, sr.image_url, sr.nutrition->>'calories'
|
||
FROM menu_plans mp
|
||
JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
||
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id
|
||
WHERE mp.user_id = $1
|
||
AND mp.week_start::text = $2 -- понедельник текущей недели
|
||
AND mi.day_of_week = $3 -- номер дня (1–7)
|
||
ORDER BY CASE mi.meal_type WHEN 'breakfast' THEN 1
|
||
WHEN 'lunch' THEN 2
|
||
ELSE 3 END
|
||
|
||
Если план не существует — вернуть три слота с null.
|
||
|
||
today.logged_calories:
|
||
SELECT COALESCE(SUM(calories * portions), 0)
|
||
FROM meal_diary
|
||
WHERE user_id = $1 AND date = $2
|
||
|
||
today.daily_goal:
|
||
users.daily_calories (или 2000 по умолчанию)
|
||
|
||
expiring_soon:
|
||
SELECT name, quantity, unit,
|
||
EXTRACT(EPOCH FROM (expires_at - now())) / 86400 AS expires_in_days
|
||
FROM products
|
||
WHERE user_id = $1
|
||
AND expires_at IS NOT NULL
|
||
AND expires_at <= now() + INTERVAL '3 days'
|
||
AND expires_at >= now()
|
||
ORDER BY expires_at
|
||
LIMIT 5
|
||
|
||
recommendations:
|
||
SELECT id, title, image_url, nutrition->>'calories' AS calories
|
||
FROM saved_recipes
|
||
WHERE user_id = $1 AND source = 'recommendation'
|
||
ORDER BY saved_at DESC
|
||
LIMIT 3
|
||
```
|
||
|
||
Рекомендации берутся из последних результатов `GET /recommendations` (сохранённых в `saved_recipes` с `source='recommendation'`). Новый AI-вызов не производится — главный экран не тратит AI-квоту.
|
||
|
||
---
|
||
|
||
## 5.2 Flutter: HomeScreen
|
||
|
||
### Макет
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Доброе утро, Алексей! 22 февраля │
|
||
├─────────────────────────────────────┤
|
||
│ │
|
||
│ Калории сегодня │
|
||
│ ┌───────┐ │
|
||
│ │ 650 │ 650 / 2000 ккал │
|
||
│ │ ккал │ ▓▓▓▓░░░░░░░░░░ │
|
||
│ └───────┘ осталось 1350 │
|
||
│ │
|
||
├─────────────────────────────────────┤
|
||
│ Приёмы пищи сегодня │
|
||
│ ┌────────────────────────────────┐ │
|
||
│ │ 🌅 Завтрак Овсянка с яблоком │ │
|
||
│ │ 320 ккал ✓ │ │
|
||
│ ├────────────────────────────────┤ │
|
||
│ │ ☀ Обед Куриный суп │ │
|
||
│ │ 480 ккал ✓ │ │
|
||
│ ├────────────────────────────────┤ │
|
||
│ │ 🌙 Ужин Не запланировано │ │
|
||
│ │ → │ │
|
||
│ └────────────────────────────────┘ │
|
||
│ │
|
||
├─────────────────────────────────────┤
|
||
│ ⚠ Истекает срок │
|
||
│ Куриная грудка — 1 день │
|
||
│ Молоко — 2 дня → │
|
||
├─────────────────────────────────────┤
|
||
│ Быстрые действия │
|
||
│ [📷 Сканировать] [✨ Меню] [📖 Дневник] │
|
||
├─────────────────────────────────────┤
|
||
│ Рекомендуем приготовить │
|
||
│ ┌──────────┐ ┌──────────┐ │
|
||
│ │ [фото] │ │ [фото] │ │
|
||
│ │ Стир-фрай│ │ Омлет │ │
|
||
│ │ 510 ккал │ │ 380 ккал │ │
|
||
│ └──────────┘ └──────────┘ │
|
||
│ │
|
||
├─────────────────────────────────────┤
|
||
│ [Главная●] [Продукты] [Меню] [Рецепты] [Профиль] │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
### 5.2.1 Приветствие
|
||
|
||
Время-зависимое:
|
||
- до 12:00 — «Доброе утро»
|
||
- до 18:00 — «Добрый день»
|
||
- до 24:00 — «Добрый вечер»
|
||
|
||
Имя берётся из профиля пользователя.
|
||
|
||
### 5.2.2 Прогресс калорий
|
||
|
||
Кольцевой индикатор (`CustomPaint` или пакет `percent_indicator`):
|
||
- Заполнен на `logged / goal * 100%`
|
||
- Центр: «N ккал»
|
||
- Подпись: «из N ккал», «осталось N ккал»
|
||
- Если цель не задана (daily_goal = 0) — скрыть блок
|
||
|
||
### 5.2.3 Плановые приёмы пищи
|
||
|
||
Карточка с тремя строками (завтрак / обед / ужин).
|
||
|
||
- Если рецепт есть — показать название + калории
|
||
- Если рецепта нет — «Не запланировано» + стрелка → `/menu`
|
||
- Тап по строке с рецептом → `RecipeDetailScreen`
|
||
|
||
### 5.2.4 Быстрые действия
|
||
|
||
Три кнопки в строку:
|
||
- **Сканировать** → `/scan`
|
||
- **Меню** → `/menu`
|
||
- **Дневник** → `/menu/diary?date=today`
|
||
|
||
### 5.2.5 Баннер истекающих продуктов
|
||
|
||
Показывается только если `expiring_soon` не пуст.
|
||
|
||
- Жёлтый/оранжевый фон
|
||
- Список: «Куриная грудка — 1 день», «Молоко — 2 дня»
|
||
- Тап → `/products` (с фильтром «Истекает»)
|
||
|
||
### 5.2.6 Рекомендации
|
||
|
||
Горизонтальный `ListView` с карточками:
|
||
- Фото (CachedNetworkImage)
|
||
- Название
|
||
- «≈N ккал»
|
||
- Тап → `RecipeDetailScreen`
|
||
|
||
Если `recommendations` пуст — показать кнопку «Получить рекомендации» → `/recipes`.
|
||
|
||
---
|
||
|
||
## 5.3 Flutter: homeProvider
|
||
|
||
```dart
|
||
// Модель
|
||
class HomeSummary {
|
||
final TodaySummary today;
|
||
final List<ExpiringSoon> expiringSoon;
|
||
final List<HomeRecipe> recommendations;
|
||
}
|
||
|
||
// Провайдер
|
||
final homeProvider = StateNotifierProvider
|
||
<HomeNotifier, AsyncValue<HomeSummary>>((ref) {
|
||
return HomeNotifier(ref.read(homeServiceProvider));
|
||
});
|
||
|
||
class HomeNotifier extends StateNotifier<AsyncValue<HomeSummary>> {
|
||
HomeNotifier(this._service) : super(const AsyncValue.loading()) {
|
||
load();
|
||
}
|
||
|
||
Future<void> load() async {
|
||
state = const AsyncValue.loading();
|
||
state = await AsyncValue.guard(() => _service.getSummary());
|
||
}
|
||
}
|
||
```
|
||
|
||
Провайдер обновляется:
|
||
- При первом открытии вкладки
|
||
- Через `ref.invalidate(homeProvider)` после сканирования/добавления продуктов или генерации меню
|
||
|
||
---
|
||
|
||
## Оценка нагрузки
|
||
|
||
| Действие | OpenAI | Pexels | БД запросов |
|
||
|---|---|---|---|
|
||
| Открытие главного экрана | 0 | 0 | 4 (plan, diary, expiring, recommendations) |
|
||
| Рекомендации (первый раз) | через `/recommendations` | — | — |
|
||
|
||
Главный экран не тратит AI-квоту. Рекомендации показываются из уже сохранённых рецептов.
|