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>
8.6 KiB
8.6 KiB
Итерация 2: Управление продуктами
Цель: дать пользователю возможность вести список продуктов — вручную или через автодополнение. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты.
Зависимости: Итерация 1 (рекомендации должны принимать список продуктов).
Ориентир: 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 Миграция
-- 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 ингредиентами:
[
{
"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 Поиск
// 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 Миграция
-- 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
[{
"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):
[{
"name": "Куриная грудка",
"quantity": 500,
"unit": "g",
"mapping_id": "uuid или null"
}]
2.3 Интеграция с рекомендациями
Обновить GET /recommendations: если у пользователя есть продукты — включать их в промпт.
// Если продуктов нет — промпт без них (базовые рекомендации)
// Если продукты есть — добавить секцию в промпт:
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 над иконкой.