Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go
Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()
Project:
- Add CLAUDE.md with English-only rule for comments and commit messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
242 lines
8.6 KiB
Markdown
242 lines
8.6 KiB
Markdown
# Итерация 2: Управление продуктами
|
||
|
||
**Цель:** дать пользователю возможность вести список продуктов — вручную или через автодополнение. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты.
|
||
|
||
**Зависимости:** Итерация 1 (рекомендации должны принимать список продуктов).
|
||
|
||
**Ориентир:** [Summary.md](./Summary.md)
|
||
|
||
---
|
||
|
||
## Структура задач
|
||
|
||
```
|
||
2.1 Backend: ingredient_mappings
|
||
├── 2.1.1 Миграция: таблица ingredient_mappings
|
||
├── 2.1.2 Seed: топ-200 базовых ингредиентов (JSON-файл)
|
||
├── 2.1.3 Repository (поиск по aliases)
|
||
└── 2.1.4 GET /ingredients/search?q=
|
||
|
||
2.2 Backend: products
|
||
├── 2.2.1 Миграция: таблица products
|
||
├── 2.2.2 Repository (CRUD)
|
||
├── 2.2.3 Service layer
|
||
├── 2.2.4 GET /products
|
||
├── 2.2.5 POST /products
|
||
├── 2.2.6 POST /products/batch
|
||
├── 2.2.7 PUT /products/{id}
|
||
├── 2.2.8 DELETE /products/{id}
|
||
└── 2.2.9 GET /products/expiring (скоро истекают)
|
||
|
||
2.3 Backend: интеграция с рекомендациями
|
||
└── 2.3.1 GET /recommendations — добавить продукты в промпт
|
||
|
||
2.4 Flutter: экран продуктов
|
||
├── 2.4.1 ProductsScreen (список с истекающими)
|
||
├── 2.4.2 Форма добавления с автодополнением (debounce 300мс)
|
||
├── 2.4.3 Редактирование (количество, единица, срок)
|
||
└── 2.4.4 Удаление (свайп или кнопка)
|
||
```
|
||
|
||
---
|
||
|
||
## 2.1 Ingredient Mappings
|
||
|
||
### 2.1.1 Миграция
|
||
|
||
```sql
|
||
-- migrations/003_create_ingredient_mappings.sql
|
||
|
||
-- +goose Up
|
||
CREATE TABLE ingredient_mappings (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
canonical_name TEXT NOT NULL UNIQUE,
|
||
canonical_name_ru TEXT NOT NULL,
|
||
aliases JSONB NOT NULL DEFAULT '[]',
|
||
category TEXT NOT NULL,
|
||
default_unit TEXT NOT NULL DEFAULT 'g',
|
||
calories_per_100g DECIMAL(8,2),
|
||
protein_per_100g DECIMAL(8,2),
|
||
fat_per_100g DECIMAL(8,2),
|
||
carbs_per_100g DECIMAL(8,2),
|
||
storage_days INT NOT NULL DEFAULT 7,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN(aliases);
|
||
CREATE INDEX idx_ingredient_mappings_canonical_ru ON ingredient_mappings
|
||
USING GIN(to_tsvector('russian', canonical_name_ru));
|
||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||
CREATE INDEX idx_ingredient_mappings_trgm ON ingredient_mappings
|
||
USING GIN(canonical_name_ru gin_trgm_ops);
|
||
|
||
-- +goose Down
|
||
DROP TABLE ingredient_mappings;
|
||
```
|
||
|
||
### 2.1.2 Seed-данные
|
||
|
||
Файл `migrations/seed_ingredient_mappings.json` с топ-200 ингредиентами:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"canonical_name": "chicken_breast",
|
||
"canonical_name_ru": "куриная грудка",
|
||
"aliases": ["куриное филе", "куриная грудка", "грудка курицы", "chicken breast"],
|
||
"category": "meat",
|
||
"default_unit": "g",
|
||
"calories_per_100g": 165,
|
||
"protein_per_100g": 31,
|
||
"fat_per_100g": 3.6,
|
||
"carbs_per_100g": 0,
|
||
"storage_days": 3
|
||
},
|
||
...
|
||
]
|
||
```
|
||
|
||
Seed применяется отдельным скриптом/Makefile-таргетом `make seed`.
|
||
|
||
### 2.1.3 Поиск
|
||
|
||
```go
|
||
// GET /ingredients/search?q=кур&limit=10
|
||
// Трёхуровневый поиск:
|
||
// 1. Точное совпадение в aliases (@>)
|
||
// 2. ILIKE на canonical_name_ru
|
||
// 3. pg_trgm similarity > 0.3
|
||
SELECT *
|
||
FROM ingredient_mappings
|
||
WHERE aliases @> to_jsonb(lower($1)::text)
|
||
OR canonical_name_ru ILIKE '%' || $1 || '%'
|
||
OR similarity(canonical_name_ru, $1) > 0.3
|
||
ORDER BY similarity(canonical_name_ru, $1) DESC
|
||
LIMIT $2
|
||
```
|
||
|
||
---
|
||
|
||
## 2.2 Products
|
||
|
||
### 2.2.1 Миграция
|
||
|
||
```sql
|
||
-- migrations/004_create_products.sql
|
||
|
||
-- +goose Up
|
||
CREATE TABLE products (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
mapping_id UUID REFERENCES ingredient_mappings(id),
|
||
name TEXT NOT NULL,
|
||
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||
unit TEXT NOT NULL DEFAULT 'pcs',
|
||
category TEXT,
|
||
storage_days INT NOT NULL DEFAULT 7,
|
||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
expires_at TIMESTAMPTZ GENERATED ALWAYS AS
|
||
(added_at + (storage_days || ' days')::INTERVAL) STORED
|
||
);
|
||
|
||
CREATE INDEX idx_products_user_id ON products(user_id);
|
||
CREATE INDEX idx_products_expires_at ON products(user_id, expires_at);
|
||
|
||
-- +goose Down
|
||
DROP TABLE products;
|
||
```
|
||
|
||
### 2.2.2 Эндпоинты
|
||
|
||
**GET /products**
|
||
|
||
```json
|
||
[{
|
||
"id": "uuid",
|
||
"name": "Куриная грудка",
|
||
"quantity": 500,
|
||
"unit": "g",
|
||
"category": "meat",
|
||
"expires_at": "2026-02-24T00:00:00Z",
|
||
"days_left": 3,
|
||
"expiring_soon": true
|
||
}]
|
||
```
|
||
|
||
Сортировка: `expires_at ASC` (сначала истекающие).
|
||
`expiring_soon = true` если `days_left <= 3`.
|
||
|
||
**POST /products/batch**
|
||
|
||
Массовое добавление после распознавания (Итерация 3):
|
||
|
||
```json
|
||
[{
|
||
"name": "Куриная грудка",
|
||
"quantity": 500,
|
||
"unit": "g",
|
||
"mapping_id": "uuid или null"
|
||
}]
|
||
```
|
||
|
||
---
|
||
|
||
## 2.3 Интеграция с рекомендациями
|
||
|
||
Обновить `GET /recommendations`: если у пользователя есть продукты — включать их в промпт.
|
||
|
||
```go
|
||
// Если продуктов нет — промпт без них (базовые рекомендации)
|
||
// Если продукты есть — добавить секцию в промпт:
|
||
|
||
doступные продукты (приоритет — скоро истекают ⚠):
|
||
- Куриная грудка 500г (истекает завтра ⚠)
|
||
- Морковь 3 шт
|
||
- Рис 400г
|
||
- Яйца 4 шт
|
||
|
||
Предпочтительно использовать эти продукты в рецептах.
|
||
```
|
||
|
||
---
|
||
|
||
## 2.4 Flutter: экран продуктов
|
||
|
||
### ProductsScreen
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Мои продукты [+ Добавить] │
|
||
├─────────────────────────────────────┤
|
||
│ ⚠ Истекает скоро │
|
||
│ ┌──────────────────────────────┐ │
|
||
│ │ 🥩 Куриная грудка 500 г │ │
|
||
│ │ Осталось 1 день │ │
|
||
│ └──────────────────────────────┘ │
|
||
│ │
|
||
│ Всё остальное │
|
||
│ ┌──────────────────────────────┐ │
|
||
│ │ 🥕 Морковь 3 шт │ │
|
||
│ │ Осталось 5 дней │ │
|
||
│ └──────────────────────────────┘ │
|
||
│ ┌──────────────────────────────┐ │
|
||
│ │ 🍚 Рис 400 г │ │
|
||
│ │ Осталось 30 дней │ │
|
||
│ └──────────────────────────────┘ │
|
||
├─────────────────────────────────────┤
|
||
│ [Главная] [Продукты●] [Меню] [Рецепты] [Профиль] │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
### Форма добавления
|
||
|
||
- Поле ввода с debounce 300мс → `GET /ingredients/search?q=`
|
||
- Dropdown с результатами (canonical_name_ru + category)
|
||
- При выборе: автозаполнение unit, storage_days из mapping
|
||
- Поля: Количество, Единица (select: г/кг/мл/л/шт)
|
||
- Кнопка «Добавить»
|
||
|
||
### Badge на вкладке «Продукты»
|
||
|
||
Количество продуктов с `days_left <= 3` отображается как badge над иконкой.
|