feat: implement Iteration 1 — AI recipe recommendations

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>
This commit is contained in:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

7
CLAUDE.md Normal file
View File

@@ -0,0 +1,7 @@
# Project Rules
## Language
- All code comments must be written in **English**.
- All git commit messages must be written in **English**.
- User-facing strings in the app (UI text, error messages) remain in **Russian**.

View File

@@ -13,7 +13,11 @@ import (
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/config"
"github.com/food-ai/backend/internal/database"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/pexels"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/server"
"github.com/food-ai/backend/internal/user"
)
@@ -83,8 +87,27 @@ func run() error {
// Auth middleware
authMW := middleware.Auth(&jwtAdapter{jm: jwtManager})
// External API clients
geminiClient := gemini.NewClient(cfg.GeminiAPIKey)
pexelsClient := pexels.NewClient(cfg.PexelsAPIKey)
// Recommendation domain
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo)
// Saved recipes domain
savedRecipeRepo := savedrecipe.NewRepository(pool)
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
// Router
router := server.NewRouter(pool, authHandler, userHandler, authMW, cfg.AllowedOrigins)
router := server.NewRouter(
pool,
authHandler,
userHandler,
recommendationHandler,
savedRecipeHandler,
authMW,
cfg.AllowedOrigins,
)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),

View File

@@ -20,6 +20,10 @@ type Config struct {
// CORS
AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"`
// External APIs
GeminiAPIKey string `envconfig:"GEMINI_API_KEY" required:"true"`
PexelsAPIKey string `envconfig:"PEXELS_API_KEY" required:"true"`
}
func Load() (*Config, error) {

View File

@@ -0,0 +1,81 @@
package gemini
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
// Groq — OpenAI-compatible API, free tier, no billing required.
groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
groqModel = "llama-3.3-70b-versatile"
maxRetries = 3
)
// Client is an HTTP client for the Groq LLM API (OpenAI-compatible).
type Client struct {
apiKey string
httpClient *http.Client
}
// NewClient creates a new Client.
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
// generateContent sends a user prompt to Groq and returns the assistant text.
func (c *Client) generateContent(ctx context.Context, messages []map[string]string) (string, error) {
body := map[string]any{
"model": groqModel,
"temperature": 0.7,
"messages": messages,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, groqAPIURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("groq API error %d: %s", resp.StatusCode, string(raw))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("empty response from Groq")
}
return result.Choices[0].Message.Content, nil
}

View File

@@ -0,0 +1,186 @@
package gemini
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// RecipeGenerator generates recipes using the Gemini AI.
type RecipeGenerator interface {
GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error)
}
// RecipeRequest contains parameters for recipe generation.
type RecipeRequest struct {
UserGoal string // "weight_loss" | "maintain" | "gain"
DailyCalories int
Restrictions []string // e.g. ["gluten_free", "vegetarian"]
CuisinePrefs []string // e.g. ["russian", "asian"]
Count int
}
// Recipe is a recipe returned by Gemini.
type Recipe struct {
Title string `json:"title"`
Description string `json:"description"`
Cuisine string `json:"cuisine"`
Difficulty string `json:"difficulty"`
PrepTimeMin int `json:"prep_time_min"`
CookTimeMin int `json:"cook_time_min"`
Servings int `json:"servings"`
ImageQuery string `json:"image_query"`
ImageURL string `json:"image_url"`
Ingredients []Ingredient `json:"ingredients"`
Steps []Step `json:"steps"`
Tags []string `json:"tags"`
Nutrition NutritionInfo `json:"nutrition_per_serving"`
}
// Ingredient is a single ingredient in a recipe.
type Ingredient struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
}
// Step is a single preparation step.
type Step struct {
Number int `json:"number"`
Description string `json:"description"`
TimerSeconds *int `json:"timer_seconds"`
}
// NutritionInfo contains approximate nutritional information per serving.
type NutritionInfo struct {
Calories float64 `json:"calories"`
ProteinG float64 `json:"protein_g"`
FatG float64 `json:"fat_g"`
CarbsG float64 `json:"carbs_g"`
Approximate bool `json:"approximate"`
}
// GenerateRecipes generates recipes using the Gemini AI.
// Retries up to maxRetries times only when the response is not valid JSON.
// API-level errors (rate limits, auth, etc.) are returned immediately.
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) {
prompt := buildRecipePrompt(req)
// OpenAI-compatible messages format used by Groq.
messages := []map[string]string{
{"role": "user", "content": prompt},
}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
messages = []map[string]string{
{"role": "user", "content": prompt},
{"role": "user", "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после."},
}
}
text, err := c.generateContent(ctx, messages)
if err != nil {
// API-level error (4xx/5xx): no point retrying immediately.
return nil, err
}
recipes, err := parseRecipesJSON(text)
if err != nil {
// Malformed JSON from the model — retry with a clarifying message.
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
continue
}
for i := range recipes {
recipes[i].Nutrition.Approximate = true
}
return recipes, nil
}
return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr)
}
func buildRecipePrompt(req RecipeRequest) string {
goalRu := map[string]string{
"weight_loss": "похудение",
"maintain": "поддержание веса",
"gain": "набор массы",
}
goal := goalRu[req.UserGoal]
if goal == "" {
goal = "поддержание веса"
}
restrictions := "нет"
if len(req.Restrictions) > 0 {
restrictions = strings.Join(req.Restrictions, ", ")
}
cuisines := "любые"
if len(req.CuisinePrefs) > 0 {
cuisines = strings.Join(req.CuisinePrefs, ", ")
}
count := req.Count
if count <= 0 {
count = 5
}
perMealCalories := req.DailyCalories / 3
if perMealCalories <= 0 {
perMealCalories = 600
}
return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке.
Профиль пользователя:
- Цель: %s
- Дневная норма калорий: %d ккал
- Ограничения: %s
- Предпочтения: %s
Требования к каждому рецепту:
- Калорийность на порцию: не более %d ккал
- Время приготовления: до 60 минут
- Укажи КБЖУ на порцию (приблизительно)
ВАЖНО: поле image_query заполняй ТОЛЬКО на английском языке — оно используется для поиска фото.
Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки:
[{
"title": "Название",
"description": "2-3 предложения",
"cuisine": "russian|asian|european|mediterranean|american|other",
"difficulty": "easy|medium|hard",
"prep_time_min": 10,
"cook_time_min": 20,
"servings": 2,
"image_query": "chicken breast vegetables healthy (ENGLISH ONLY, used for photo search)",
"ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}],
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
"tags": ["высокий белок"],
"nutrition_per_serving": {
"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18
}
}]`, count, goal, req.DailyCalories, restrictions, cuisines, perMealCalories)
}
func parseRecipesJSON(text string) ([]Recipe, error) {
text = strings.TrimSpace(text)
// Strip potential markdown code fences
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
var recipes []Recipe
if err := json.Unmarshal([]byte(text), &recipes); err != nil {
return nil, err
}
return recipes, nil
}

View File

@@ -0,0 +1,28 @@
package ingredient
import (
"encoding/json"
"time"
)
// IngredientMapping is the canonical ingredient record used to link
// user products, recipe ingredients, and Spoonacular data.
type IngredientMapping struct {
ID string `json:"id"`
CanonicalName string `json:"canonical_name"`
CanonicalNameRu *string `json:"canonical_name_ru"`
SpoonacularID *int `json:"spoonacular_id"`
Aliases json.RawMessage `json:"aliases"` // []string
Category *string `json:"category"`
DefaultUnit *string `json:"default_unit"`
CaloriesPer100g *float64 `json:"calories_per_100g"`
ProteinPer100g *float64 `json:"protein_per_100g"`
FatPer100g *float64 `json:"fat_per_100g"`
CarbsPer100g *float64 `json:"carbs_per_100g"`
FiberPer100g *float64 `json:"fiber_per_100g"`
StorageDays *int `json:"storage_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,185 @@
package ingredient
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for ingredient_mappings.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Upsert inserts or updates an ingredient mapping.
// Conflict is resolved on spoonacular_id when set; otherwise a simple insert is done.
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
query := `
INSERT INTO ingredient_mappings (
canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (spoonacular_id) DO UPDATE SET
canonical_name = EXCLUDED.canonical_name,
aliases = EXCLUDED.aliases,
category = EXCLUDED.category,
default_unit = EXCLUDED.default_unit,
calories_per_100g = EXCLUDED.calories_per_100g,
protein_per_100g = EXCLUDED.protein_per_100g,
fat_per_100g = EXCLUDED.fat_per_100g,
carbs_per_100g = EXCLUDED.carbs_per_100g,
fiber_per_100g = EXCLUDED.fiber_per_100g,
storage_days = EXCLUDED.storage_days,
updated_at = now()
RETURNING id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at`
row := r.pool.QueryRow(ctx, query,
m.CanonicalName, m.CanonicalNameRu, m.SpoonacularID, m.Aliases,
m.Category, m.DefaultUnit,
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
m.StorageDays,
)
return scanMapping(row)
}
// GetBySpoonacularID returns an ingredient mapping by Spoonacular ID.
// Returns nil, nil if not found.
func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) {
query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at
FROM ingredient_mappings
WHERE spoonacular_id = $1`
row := r.pool.QueryRow(ctx, query, id)
m, err := scanMapping(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return m, err
}
// GetByID returns an ingredient mapping by UUID.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at
FROM ingredient_mappings
WHERE id = $1`
row := r.pool.QueryRow(ctx, query, id)
m, err := scanMapping(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return m, err
}
// Count returns the total number of ingredient mappings.
func (r *Repository) Count(ctx context.Context) (int, error) {
var n int
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredient_mappings`).Scan(&n); err != nil {
return 0, fmt.Errorf("count ingredient_mappings: %w", err)
}
return n, nil
}
// ListUntranslated returns ingredients without a Russian name, ordered by id.
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*IngredientMapping, error) {
query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at
FROM ingredient_mappings
WHERE canonical_name_ru IS NULL
ORDER BY id
LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("list untranslated: %w", err)
}
defer rows.Close()
return collectMappings(rows)
}
// UpdateTranslation saves the Russian name and adds Russian aliases.
func (r *Repository) UpdateTranslation(ctx context.Context, id, canonicalNameRu string, aliasesRu []string) error {
// Merge new aliases into existing JSONB array without duplicates
query := `
UPDATE ingredient_mappings SET
canonical_name_ru = $2,
aliases = (
SELECT jsonb_agg(DISTINCT elem)
FROM (
SELECT jsonb_array_elements(aliases) AS elem
UNION
SELECT to_jsonb(unnest) FROM unnest($3::text[]) AS unnest
) sub
),
updated_at = now()
WHERE id = $1`
if _, err := r.pool.Exec(ctx, query, id, canonicalNameRu, aliasesRu); err != nil {
return fmt.Errorf("update translation %s: %w", id, err)
}
return nil
}
// --- helpers ---
func scanMapping(row pgx.Row) (*IngredientMapping, error) {
var m IngredientMapping
var aliases []byte
err := row.Scan(
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases,
&m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, err
}
m.Aliases = json.RawMessage(aliases)
return &m, nil
}
func collectMappings(rows pgx.Rows) ([]*IngredientMapping, error) {
var result []*IngredientMapping
for rows.Next() {
var m IngredientMapping
var aliases []byte
if err := rows.Scan(
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases,
&m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan mapping: %w", err)
}
m.Aliases = json.RawMessage(aliases)
result = append(result, &m)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,250 @@
//go:build integration
package ingredient
import (
"context"
"encoding/json"
"testing"
"github.com/food-ai/backend/internal/testutil"
)
func TestIngredientRepository_Upsert_Insert(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 1001
cat := "produce"
unit := "g"
cal := 52.0
m := &IngredientMapping{
CanonicalName: "apple",
SpoonacularID: &id,
Aliases: json.RawMessage(`["apple", "apples"]`),
Category: &cat,
DefaultUnit: &unit,
CaloriesPer100g: &cal,
}
got, err := repo.Upsert(ctx, m)
if err != nil {
t.Fatalf("upsert: %v", err)
}
if got.ID == "" {
t.Error("expected non-empty ID")
}
if got.CanonicalName != "apple" {
t.Errorf("canonical_name: want apple, got %s", got.CanonicalName)
}
if *got.CaloriesPer100g != 52.0 {
t.Errorf("calories: want 52.0, got %v", got.CaloriesPer100g)
}
}
func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 2001
cat := "produce"
unit := "g"
first := &IngredientMapping{
CanonicalName: "banana",
SpoonacularID: &id,
Aliases: json.RawMessage(`["banana"]`),
Category: &cat,
DefaultUnit: &unit,
}
got1, err := repo.Upsert(ctx, first)
if err != nil {
t.Fatalf("first upsert: %v", err)
}
// Update with same spoonacular_id
cal := 89.0
second := &IngredientMapping{
CanonicalName: "banana_updated",
SpoonacularID: &id,
Aliases: json.RawMessage(`["banana", "bananas"]`),
Category: &cat,
DefaultUnit: &unit,
CaloriesPer100g: &cal,
}
got2, err := repo.Upsert(ctx, second)
if err != nil {
t.Fatalf("second upsert: %v", err)
}
if got1.ID != got2.ID {
t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID)
}
if got2.CanonicalName != "banana_updated" {
t.Errorf("canonical_name not updated: got %s", got2.CanonicalName)
}
if got2.CaloriesPer100g == nil || *got2.CaloriesPer100g != 89.0 {
t.Errorf("calories not updated: got %v", got2.CaloriesPer100g)
}
}
func TestIngredientRepository_GetBySpoonacularID_Found(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 3001
cat := "dairy"
unit := "g"
_, err := repo.Upsert(ctx, &IngredientMapping{
CanonicalName: "cheese",
SpoonacularID: &id,
Aliases: json.RawMessage(`["cheese"]`),
Category: &cat,
DefaultUnit: &unit,
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := repo.GetBySpoonacularID(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected non-nil result")
}
if got.CanonicalName != "cheese" {
t.Errorf("want cheese, got %s", got.CanonicalName)
}
}
func TestIngredientRepository_GetBySpoonacularID_NotFound(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
got, err := repo.GetBySpoonacularID(ctx, 99999999)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Error("expected nil result for missing ID")
}
}
func TestIngredientRepository_ListUntranslated(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
cat := "produce"
unit := "g"
// Insert 3 without translation
for i, name := range []string{"carrot", "onion", "garlic"} {
id := 4000 + i
_, err := repo.Upsert(ctx, &IngredientMapping{
CanonicalName: name,
SpoonacularID: &id,
Aliases: json.RawMessage(`[]`),
Category: &cat,
DefaultUnit: &unit,
})
if err != nil {
t.Fatalf("upsert %s: %v", name, err)
}
}
// Insert 1 with translation (shouldn't appear in untranslated list)
id := 4100
ruName := "помидор"
withTranslation := &IngredientMapping{
CanonicalName: "tomato",
CanonicalNameRu: &ruName,
SpoonacularID: &id,
Aliases: json.RawMessage(`[]`),
Category: &cat,
DefaultUnit: &unit,
}
saved, err := repo.Upsert(ctx, withTranslation)
if err != nil {
t.Fatalf("upsert with translation: %v", err)
}
// The upsert doesn't set canonical_name_ru because the UPDATE clause doesn't include it
// We need to manually set it after
if err := repo.UpdateTranslation(ctx, saved.ID, "помидор", []string{"помидор", "томат"}); err != nil {
t.Fatalf("update translation: %v", err)
}
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
if err != nil {
t.Fatalf("list untranslated: %v", err)
}
// Should return the 3 without translation (carrot, onion, garlic)
// The translated tomato should not appear
for _, m := range untranslated {
if m.CanonicalName == "tomato" {
t.Error("translated ingredient should not appear in ListUntranslated")
}
}
if len(untranslated) < 3 {
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
}
}
func TestIngredientRepository_UpdateTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 5001
cat := "meat"
unit := "g"
saved, err := repo.Upsert(ctx, &IngredientMapping{
CanonicalName: "chicken_breast",
SpoonacularID: &id,
Aliases: json.RawMessage(`["chicken breast"]`),
Category: &cat,
DefaultUnit: &unit,
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
err = repo.UpdateTranslation(ctx, saved.ID, "куриная грудка",
[]string{"куриная грудка", "куриное филе"})
if err != nil {
t.Fatalf("update translation: %v", err)
}
got, err := repo.GetByID(ctx, saved.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.CanonicalNameRu == nil || *got.CanonicalNameRu != "куриная грудка" {
t.Errorf("expected canonical_name_ru='куриная грудка', got %v", got.CanonicalNameRu)
}
var aliases []string
if err := json.Unmarshal(got.Aliases, &aliases); err != nil {
t.Fatalf("unmarshal aliases: %v", err)
}
hasRu := false
for _, a := range aliases {
if a == "куриное филе" {
hasRu = true
break
}
}
if !hasRu {
t.Errorf("Russian alias not found in aliases: %v", aliases)
}
}

View File

@@ -0,0 +1,77 @@
package pexels
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const (
pexelsSearchURL = "https://api.pexels.com/v1/search"
defaultPlaceholder = "https://images.pexels.com/photos/1640777/pexels-photo-1640777.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750"
)
// PhotoSearcher can search for a photo by text query.
type PhotoSearcher interface {
SearchPhoto(ctx context.Context, query string) (string, error)
}
// Client is an HTTP client for the Pexels Photos API.
type Client struct {
apiKey string
httpClient *http.Client
}
// NewClient creates a new Pexels client.
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// SearchPhoto searches for a landscape photo matching query.
// Returns a default placeholder URL if no photo is found or on error.
func (c *Client) SearchPhoto(ctx context.Context, query string) (string, error) {
params := url.Values{}
params.Set("query", query)
params.Set("per_page", "1")
params.Set("orientation", "landscape")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pexelsSearchURL+"?"+params.Encode(), nil)
if err != nil {
return defaultPlaceholder, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return defaultPlaceholder, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return defaultPlaceholder, fmt.Errorf("pexels API error: status %d", resp.StatusCode)
}
var result struct {
Photos []struct {
Src struct {
Medium string `json:"medium"`
} `json:"src"`
} `json:"photos"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return defaultPlaceholder, fmt.Errorf("decode response: %w", err)
}
if len(result.Photos) == 0 || result.Photos[0].Src.Medium == "" {
return defaultPlaceholder, nil
}
return result.Photos[0].Src.Medium, nil
}

View File

@@ -0,0 +1,62 @@
package recipe
import (
"encoding/json"
"time"
)
// Recipe is a recipe record in the database.
type Recipe struct {
ID string `json:"id"`
Source string `json:"source"` // spoonacular | ai | user
SpoonacularID *int `json:"spoonacular_id"`
Title string `json:"title"`
TitleRu *string `json:"title_ru"`
Description *string `json:"description"`
DescriptionRu *string `json:"description_ru"`
Cuisine *string `json:"cuisine"`
Difficulty *string `json:"difficulty"` // easy | medium | hard
PrepTimeMin *int `json:"prep_time_min"`
CookTimeMin *int `json:"cook_time_min"`
Servings *int `json:"servings"`
ImageURL *string `json:"image_url"`
CaloriesPerServing *float64 `json:"calories_per_serving"`
ProteinPerServing *float64 `json:"protein_per_serving"`
FatPerServing *float64 `json:"fat_per_serving"`
CarbsPerServing *float64 `json:"carbs_per_serving"`
FiberPerServing *float64 `json:"fiber_per_serving"`
Ingredients json.RawMessage `json:"ingredients"` // []RecipeIngredient
Steps json.RawMessage `json:"steps"` // []RecipeStep
Tags json.RawMessage `json:"tags"` // []string
AvgRating float64 `json:"avg_rating"`
ReviewCount int `json:"review_count"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RecipeIngredient is a single ingredient in a recipe's JSONB array.
type RecipeIngredient struct {
SpoonacularID *int `json:"spoonacular_id"`
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
NameRu *string `json:"name_ru"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
UnitRu *string `json:"unit_ru"`
Optional bool `json:"optional"`
}
// RecipeStep is a single step in a recipe's JSONB array.
type RecipeStep struct {
Number int `json:"number"`
Description string `json:"description"`
DescriptionRu *string `json:"description_ru"`
TimerSeconds *int `json:"timer_seconds"`
ImageURL *string `json:"image_url"`
}

View File

@@ -0,0 +1,181 @@
package recipe
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for recipes.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Upsert inserts or updates a recipe.
// Conflict is resolved on spoonacular_id.
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
query := `
INSERT INTO recipes (
source, spoonacular_id,
title, description, title_ru, description_ru,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
ON CONFLICT (spoonacular_id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
cuisine = EXCLUDED.cuisine,
difficulty = EXCLUDED.difficulty,
prep_time_min = EXCLUDED.prep_time_min,
cook_time_min = EXCLUDED.cook_time_min,
servings = EXCLUDED.servings,
image_url = EXCLUDED.image_url,
calories_per_serving = EXCLUDED.calories_per_serving,
protein_per_serving = EXCLUDED.protein_per_serving,
fat_per_serving = EXCLUDED.fat_per_serving,
carbs_per_serving = EXCLUDED.carbs_per_serving,
fiber_per_serving = EXCLUDED.fiber_per_serving,
ingredients = EXCLUDED.ingredients,
steps = EXCLUDED.steps,
tags = EXCLUDED.tags,
updated_at = now()
RETURNING id, source, spoonacular_id,
title, description, title_ru, description_ru,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags,
avg_rating, review_count, created_by, created_at, updated_at`
row := r.pool.QueryRow(ctx, query,
recipe.Source, recipe.SpoonacularID,
recipe.Title, recipe.Description, recipe.TitleRu, recipe.DescriptionRu,
recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL,
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
recipe.Ingredients, recipe.Steps, recipe.Tags,
)
return scanRecipe(row)
}
// Count returns the total number of recipes.
func (r *Repository) Count(ctx context.Context) (int, error) {
var n int
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM recipes`).Scan(&n); err != nil {
return 0, fmt.Errorf("count recipes: %w", err)
}
return n, nil
}
// ListUntranslated returns recipes without a Russian title, ordered by review_count DESC.
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*Recipe, error) {
query := `
SELECT id, source, spoonacular_id,
title, description, title_ru, description_ru,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags,
avg_rating, review_count, created_by, created_at, updated_at
FROM recipes
WHERE title_ru IS NULL AND source = 'spoonacular'
ORDER BY review_count DESC
LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("list untranslated recipes: %w", err)
}
defer rows.Close()
return collectRecipes(rows)
}
// UpdateTranslation saves the Russian title, description, and step translations.
func (r *Repository) UpdateTranslation(ctx context.Context, id string, titleRu, descriptionRu *string, steps json.RawMessage) error {
query := `
UPDATE recipes SET
title_ru = $2,
description_ru = $3,
steps = $4,
updated_at = now()
WHERE id = $1`
if _, err := r.pool.Exec(ctx, query, id, titleRu, descriptionRu, steps); err != nil {
return fmt.Errorf("update recipe translation %s: %w", id, err)
}
return nil
}
// --- helpers ---
func scanRecipe(row pgx.Row) (*Recipe, error) {
var rec Recipe
var ingredients, steps, tags []byte
err := row.Scan(
&rec.ID, &rec.Source, &rec.SpoonacularID,
&rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu,
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
&ingredients, &steps, &tags,
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
)
if err != nil {
return nil, err
}
rec.Ingredients = json.RawMessage(ingredients)
rec.Steps = json.RawMessage(steps)
rec.Tags = json.RawMessage(tags)
return &rec, nil
}
func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
var result []*Recipe
for rows.Next() {
var rec Recipe
var ingredients, steps, tags []byte
if err := rows.Scan(
&rec.ID, &rec.Source, &rec.SpoonacularID,
&rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu,
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
&ingredients, &steps, &tags,
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan recipe: %w", err)
}
rec.Ingredients = json.RawMessage(ingredients)
rec.Steps = json.RawMessage(steps)
rec.Tags = json.RawMessage(tags)
result = append(result, &rec)
}
return result, rows.Err()
}
// GetByID returns a recipe by UUID.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
query := `
SELECT id, source, spoonacular_id,
title, description, title_ru, description_ru,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags,
avg_rating, review_count, created_by, created_at, updated_at
FROM recipes
WHERE id = $1`
row := r.pool.QueryRow(ctx, query, id)
rec, err := scanRecipe(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
}

View File

@@ -0,0 +1,311 @@
//go:build integration
package recipe
import (
"context"
"encoding/json"
"testing"
"github.com/food-ai/backend/internal/testutil"
)
func TestRecipeRepository_Upsert_Insert(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 10001
cuisine := "italian"
diff := "easy"
cookTime := 30
servings := 4
rec := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Pasta Carbonara",
Cuisine: &cuisine,
Difficulty: &diff,
CookTimeMin: &cookTime,
Servings: &servings,
Ingredients: json.RawMessage(`[{"name":"pasta","amount":200,"unit":"g"}]`),
Steps: json.RawMessage(`[{"number":1,"description":"Boil pasta"}]`),
Tags: json.RawMessage(`["italian"]`),
}
got, err := repo.Upsert(ctx, rec)
if err != nil {
t.Fatalf("upsert: %v", err)
}
if got.ID == "" {
t.Error("expected non-empty ID")
}
if got.Title != "Pasta Carbonara" {
t.Errorf("title: want Pasta Carbonara, got %s", got.Title)
}
if got.SpoonacularID == nil || *got.SpoonacularID != id {
t.Errorf("spoonacular_id: want %d, got %v", id, got.SpoonacularID)
}
}
func TestRecipeRepository_Upsert_ConflictUpdates(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 20001
cuisine := "mexican"
diff := "medium"
first := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Tacos",
Cuisine: &cuisine,
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
}
got1, err := repo.Upsert(ctx, first)
if err != nil {
t.Fatalf("first upsert: %v", err)
}
second := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Beef Tacos",
Cuisine: &cuisine,
Difficulty: &diff,
Ingredients: json.RawMessage(`[{"name":"beef","amount":300,"unit":"g"}]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
}
got2, err := repo.Upsert(ctx, second)
if err != nil {
t.Fatalf("second upsert: %v", err)
}
if got1.ID != got2.ID {
t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID)
}
if got2.Title != "Beef Tacos" {
t.Errorf("title not updated: got %s", got2.Title)
}
}
func TestRecipeRepository_GetByID_Found(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 30001
diff := "easy"
rec := &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Greek Salad",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`["vegetarian"]`),
}
saved, err := repo.Upsert(ctx, rec)
if err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := repo.GetByID(ctx, saved.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got == nil {
t.Fatal("expected non-nil result")
}
if got.Title != "Greek Salad" {
t.Errorf("want Greek Salad, got %s", got.Title)
}
}
func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
got, err := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Error("expected nil for non-existent ID")
}
}
func TestRecipeRepository_ListUntranslated_Pagination(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
diff := "easy"
for i := 0; i < 5; i++ {
spID := 40000 + i
_, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &spID,
Title: "Recipe " + string(rune('A'+i)),
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert recipe %d: %v", i, err)
}
}
untranslated, err := repo.ListUntranslated(ctx, 3, 0)
if err != nil {
t.Fatalf("list untranslated: %v", err)
}
if len(untranslated) != 3 {
t.Errorf("expected 3 results with limit=3, got %d", len(untranslated))
}
}
func TestRecipeRepository_UpdateTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 50001
diff := "medium"
saved, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Chicken Tikka Masala",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[{"number":1,"description":"Heat oil"}]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
titleRu := "Курица Тикка Масала"
descRu := "Классическое индийское блюдо"
stepsRu := json.RawMessage(`[{"number":1,"description":"Heat oil","description_ru":"Разогрейте масло"}]`)
if err := repo.UpdateTranslation(ctx, saved.ID, &titleRu, &descRu, stepsRu); err != nil {
t.Fatalf("update translation: %v", err)
}
got, err := repo.GetByID(ctx, saved.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.TitleRu == nil || *got.TitleRu != titleRu {
t.Errorf("expected title_ru=%q, got %v", titleRu, got.TitleRu)
}
if got.DescriptionRu == nil || *got.DescriptionRu != descRu {
t.Errorf("expected description_ru=%q, got %v", descRu, got.DescriptionRu)
}
var steps []RecipeStep
if err := json.Unmarshal(got.Steps, &steps); err != nil {
t.Fatalf("unmarshal steps: %v", err)
}
if len(steps) == 0 || steps[0].DescriptionRu == nil || *steps[0].DescriptionRu != "Разогрейте масло" {
t.Errorf("expected description_ru in steps, got %v", steps)
}
}
func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
diff := "easy"
// Insert untranslated
for i := 0; i < 3; i++ {
spID := 60000 + i
_, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &spID,
Title: "Untranslated " + string(rune('A'+i)),
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
}
// Insert translated
spID := 60100
translated, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &spID,
Title: "Translated Recipe",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`[]`),
})
if err != nil {
t.Fatalf("upsert translated: %v", err)
}
titleRu := "Переведённый рецепт"
if err := repo.UpdateTranslation(ctx, translated.ID, &titleRu, nil, translated.Steps); err != nil {
t.Fatalf("update translation: %v", err)
}
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
if err != nil {
t.Fatalf("list untranslated: %v", err)
}
for _, r := range untranslated {
if r.Title == "Translated Recipe" {
t.Error("translated recipe should not appear in ListUntranslated")
}
}
if len(untranslated) < 3 {
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
}
}
func TestRecipeRepository_GIN_Tags(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := NewRepository(pool)
ctx := context.Background()
id := 70001
diff := "easy"
_, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular",
SpoonacularID: &id,
Title: "Veggie Bowl",
Difficulty: &diff,
Ingredients: json.RawMessage(`[]`),
Steps: json.RawMessage(`[]`),
Tags: json.RawMessage(`["vegetarian","gluten-free"]`),
})
if err != nil {
t.Fatalf("upsert: %v", err)
}
// GIN index query: tags @> '["vegetarian"]'
var count int
row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id)
if err := row.Scan(&count); err != nil {
t.Fatalf("query: %v", err)
}
if count != 1 {
t.Errorf("expected 1 vegetarian recipe, got %d", count)
}
}

View File

@@ -0,0 +1,138 @@
package recommendation
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"sync"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/user"
)
// PhotoSearcher can search for a photo by text query.
type PhotoSearcher interface {
SearchPhoto(ctx context.Context, query string) (string, error)
}
// UserLoader can load a user profile by ID.
type UserLoader interface {
GetByID(ctx context.Context, id string) (*user.User, error)
}
// userPreferences is the shape of user.Preferences JSONB.
type userPreferences struct {
Cuisines []string `json:"cuisines"`
Restrictions []string `json:"restrictions"`
}
// Handler handles GET /recommendations.
type Handler struct {
gemini *gemini.Client
pexels PhotoSearcher
userLoader UserLoader
}
// NewHandler creates a new Handler.
func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader) *Handler {
return &Handler{
gemini: geminiClient,
pexels: pexels,
userLoader: userLoader,
}
}
// GetRecommendations handles GET /recommendations?count=5.
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
count := 5
if s := r.URL.Query().Get("count"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 20 {
count = n
}
}
u, err := h.userLoader.GetByID(r.Context(), userID)
if err != nil {
slog.Error("load user for recommendations", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile")
return
}
req := buildRecipeRequest(u, count)
recipes, err := h.gemini.GenerateRecipes(r.Context(), req)
if err != nil {
slog.Error("generate recipes", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again")
return
}
// Fetch Pexels photos in parallel — each goroutine owns a distinct index.
var wg sync.WaitGroup
for i := range recipes {
wg.Add(1)
go func(i int) {
defer wg.Done()
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
if err != nil {
slog.Warn("pexels photo search failed", "query", recipes[i].ImageQuery, "err", err)
}
recipes[i].ImageURL = imageURL
}(i)
}
wg.Wait()
writeJSON(w, http.StatusOK, recipes)
}
func buildRecipeRequest(u *user.User, count int) gemini.RecipeRequest {
req := gemini.RecipeRequest{
Count: count,
DailyCalories: 2000, // sensible default
}
if u.Goal != nil {
req.UserGoal = *u.Goal
}
if u.DailyCalories != nil && *u.DailyCalories > 0 {
req.DailyCalories = *u.DailyCalories
}
if len(u.Preferences) > 0 {
var prefs userPreferences
if err := json.Unmarshal(u.Preferences, &prefs); err == nil {
req.CuisinePrefs = prefs.Cuisines
req.Restrictions = prefs.Restrictions
}
}
return req
}
type errorResponse struct {
Error string `json:"error"`
}
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
slog.Error("write error response", "err", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write JSON response", "err", err)
}
}

View File

@@ -0,0 +1,135 @@
package savedrecipe
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/middleware"
"github.com/go-chi/chi/v5"
)
const maxBodySize = 1 << 20 // 1 MB
// Handler handles HTTP requests for saved recipes.
type Handler struct {
repo *Repository
}
// NewHandler creates a new Handler.
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
// Save handles POST /saved-recipes.
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req SaveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeErrorJSON(w, http.StatusBadRequest, "title is required")
return
}
rec, err := h.repo.Save(r.Context(), userID, req)
if err != nil {
slog.Error("save recipe", "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to save recipe")
return
}
writeJSON(w, http.StatusCreated, rec)
}
// List handles GET /saved-recipes.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
recipes, err := h.repo.List(r.Context(), userID)
if err != nil {
slog.Error("list saved recipes", "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
return
}
if recipes == nil {
recipes = []*SavedRecipe{}
}
writeJSON(w, http.StatusOK, recipes)
}
// GetByID handles GET /saved-recipes/{id}.
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
id := chi.URLParam(r, "id")
rec, err := h.repo.GetByID(r.Context(), userID, id)
if err != nil {
slog.Error("get saved recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to get saved recipe")
return
}
if rec == nil {
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
return
}
writeJSON(w, http.StatusOK, rec)
}
// Delete handles DELETE /saved-recipes/{id}.
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
id := chi.URLParam(r, "id")
if err := h.repo.Delete(r.Context(), userID, id); err != nil {
if errors.Is(err, ErrNotFound) {
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
return
}
slog.Error("delete saved recipe", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete recipe")
return
}
w.WriteHeader(http.StatusNoContent)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
slog.Error("write error response", "err", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write JSON response", "err", err)
}
}

View File

@@ -0,0 +1,43 @@
package savedrecipe
import (
"encoding/json"
"time"
)
// SavedRecipe is a recipe saved by a specific user.
type SavedRecipe struct {
ID string `json:"id"`
UserID string `json:"-"`
Title string `json:"title"`
Description *string `json:"description"`
Cuisine *string `json:"cuisine"`
Difficulty *string `json:"difficulty"`
PrepTimeMin *int `json:"prep_time_min"`
CookTimeMin *int `json:"cook_time_min"`
Servings *int `json:"servings"`
ImageURL *string `json:"image_url"`
Ingredients json.RawMessage `json:"ingredients"`
Steps json.RawMessage `json:"steps"`
Tags json.RawMessage `json:"tags"`
Nutrition json.RawMessage `json:"nutrition_per_serving"`
Source string `json:"source"`
SavedAt time.Time `json:"saved_at"`
}
// SaveRequest is the body for POST /saved-recipes.
type SaveRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Cuisine string `json:"cuisine"`
Difficulty string `json:"difficulty"`
PrepTimeMin int `json:"prep_time_min"`
CookTimeMin int `json:"cook_time_min"`
Servings int `json:"servings"`
ImageURL string `json:"image_url"`
Ingredients json.RawMessage `json:"ingredients"`
Steps json.RawMessage `json:"steps"`
Tags json.RawMessage `json:"tags"`
Nutrition json.RawMessage `json:"nutrition_per_serving"`
Source string `json:"source"`
}

View File

@@ -0,0 +1,173 @@
package savedrecipe
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a saved recipe does not exist for the given user.
var ErrNotFound = errors.New("saved recipe not found")
// Repository handles persistence for saved recipes.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Save persists a recipe for userID and returns the stored record.
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) {
const query = `
INSERT INTO saved_recipes (
user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source, saved_at`
description := nullableStr(req.Description)
cuisine := nullableStr(req.Cuisine)
difficulty := nullableStr(req.Difficulty)
imageURL := nullableStr(req.ImageURL)
prepTime := nullableInt(req.PrepTimeMin)
cookTime := nullableInt(req.CookTimeMin)
servings := nullableInt(req.Servings)
source := req.Source
if source == "" {
source = "ai"
}
ingredients := defaultJSONArray(req.Ingredients)
steps := defaultJSONArray(req.Steps)
tags := defaultJSONArray(req.Tags)
row := r.pool.QueryRow(ctx, query,
userID, req.Title, description, cuisine, difficulty,
prepTime, cookTime, servings, imageURL,
ingredients, steps, tags, req.Nutrition, source,
)
return scanRow(row)
}
// List returns all saved recipes for userID ordered by saved_at DESC.
func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) {
const query = `
SELECT id, user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source, saved_at
FROM saved_recipes
WHERE user_id = $1
ORDER BY saved_at DESC`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("list saved recipes: %w", err)
}
defer rows.Close()
var result []*SavedRecipe
for rows.Next() {
rec, err := scanRows(rows)
if err != nil {
return nil, fmt.Errorf("scan saved recipe: %w", err)
}
result = append(result, rec)
}
return result, rows.Err()
}
// GetByID returns the saved recipe with id for userID, or nil if not found.
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
const query = `
SELECT id, user_id, title, description, cuisine, difficulty,
prep_time_min, cook_time_min, servings, image_url,
ingredients, steps, tags, nutrition, source, saved_at
FROM saved_recipes
WHERE id = $1 AND user_id = $2`
rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
}
// Delete removes the saved recipe with id for userID.
// Returns ErrNotFound if the record does not exist.
func (r *Repository) Delete(ctx context.Context, userID, id string) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM saved_recipes WHERE id = $1 AND user_id = $2`,
id, userID,
)
if err != nil {
return fmt.Errorf("delete saved recipe: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// --- helpers ---
type scannable interface {
Scan(dest ...any) error
}
func scanRow(s scannable) (*SavedRecipe, error) {
var rec SavedRecipe
var ingredients, steps, tags, nutrition []byte
err := s.Scan(
&rec.ID, &rec.UserID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty,
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&ingredients, &steps, &tags, &nutrition,
&rec.Source, &rec.SavedAt,
)
if err != nil {
return nil, err
}
rec.Ingredients = json.RawMessage(ingredients)
rec.Steps = json.RawMessage(steps)
rec.Tags = json.RawMessage(tags)
if len(nutrition) > 0 {
rec.Nutrition = json.RawMessage(nutrition)
}
return &rec, nil
}
// scanRows wraps pgx.Rows to satisfy the scannable interface.
func scanRows(rows pgx.Rows) (*SavedRecipe, error) {
return scanRow(rows)
}
func nullableStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func nullableInt(n int) *int {
if n <= 0 {
return nil
}
return &n
}
func defaultJSONArray(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return json.RawMessage(`[]`)
}
return raw
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/recommendation"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -15,6 +17,8 @@ func NewRouter(
pool *pgxpool.Pool,
authHandler *auth.Handler,
userHandler *user.Handler,
recommendationHandler *recommendation.Handler,
savedRecipeHandler *savedrecipe.Handler,
authMiddleware func(http.Handler) http.Handler,
allowedOrigins []string,
) *chi.Mux {
@@ -37,8 +41,18 @@ func NewRouter(
// Protected
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/profile", userHandler.Get)
r.Put("/profile", userHandler.Update)
r.Get("/recommendations", recommendationHandler.GetRecommendations)
r.Route("/saved-recipes", func(r chi.Router) {
r.Post("/", savedRecipeHandler.Save)
r.Get("/", savedRecipeHandler.List)
r.Get("/{id}", savedRecipeHandler.GetByID)
r.Delete("/{id}", savedRecipeHandler.Delete)
})
})
return r

View File

@@ -0,0 +1,36 @@
-- +goose Up
CREATE TABLE ingredient_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
canonical_name VARCHAR(255) NOT NULL,
canonical_name_ru VARCHAR(255),
spoonacular_id INTEGER UNIQUE,
aliases JSONB NOT NULL DEFAULT '[]'::jsonb,
category VARCHAR(50),
default_unit VARCHAR(20),
-- Nutrients per 100g
calories_per_100g DECIMAL(8, 2),
protein_per_100g DECIMAL(8, 2),
fat_per_100g DECIMAL(8, 2),
carbs_per_100g DECIMAL(8, 2),
fiber_per_100g DECIMAL(8, 2),
storage_days INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ingredient_mappings_aliases
ON ingredient_mappings USING GIN (aliases);
CREATE INDEX idx_ingredient_mappings_canonical_name
ON ingredient_mappings (canonical_name);
CREATE INDEX idx_ingredient_mappings_category
ON ingredient_mappings (category);
-- +goose Down
DROP TABLE IF EXISTS ingredient_mappings;

View File

@@ -0,0 +1,58 @@
-- +goose Up
CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user');
CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard');
CREATE TABLE recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
source recipe_source NOT NULL DEFAULT 'spoonacular',
spoonacular_id INTEGER UNIQUE,
title VARCHAR(500) NOT NULL,
description TEXT,
title_ru VARCHAR(500),
description_ru TEXT,
cuisine VARCHAR(100),
difficulty recipe_difficulty,
prep_time_min INTEGER,
cook_time_min INTEGER,
servings SMALLINT,
image_url TEXT,
calories_per_serving DECIMAL(8, 2),
protein_per_serving DECIMAL(8, 2),
fat_per_serving DECIMAL(8, 2),
carbs_per_serving DECIMAL(8, 2),
fiber_per_serving DECIMAL(8, 2),
ingredients JSONB NOT NULL DEFAULT '[]'::jsonb,
steps JSONB NOT NULL DEFAULT '[]'::jsonb,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
avg_rating DECIMAL(3, 2) NOT NULL DEFAULT 0.0,
review_count INTEGER NOT NULL DEFAULT 0,
created_by UUID REFERENCES users (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_recipes_title_fts ON recipes
USING GIN (to_tsvector('simple',
coalesce(title_ru, '') || ' ' || coalesce(title, '')));
CREATE INDEX idx_recipes_ingredients ON recipes USING GIN (ingredients);
CREATE INDEX idx_recipes_tags ON recipes USING GIN (tags);
CREATE INDEX idx_recipes_cuisine ON recipes (cuisine);
CREATE INDEX idx_recipes_difficulty ON recipes (difficulty);
CREATE INDEX idx_recipes_prep_time ON recipes (prep_time_min);
CREATE INDEX idx_recipes_calories ON recipes (calories_per_serving);
CREATE INDEX idx_recipes_source ON recipes (source);
CREATE INDEX idx_recipes_rating ON recipes (avg_rating DESC);
-- +goose Down
DROP TABLE IF EXISTS recipes;
DROP TYPE IF EXISTS recipe_difficulty;
DROP TYPE IF EXISTS recipe_source;

View File

@@ -0,0 +1,25 @@
-- +goose Up
CREATE TABLE saved_recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
cuisine TEXT,
difficulty TEXT,
prep_time_min INT,
cook_time_min INT,
servings INT,
image_url TEXT,
ingredients JSONB NOT NULL DEFAULT '[]',
steps JSONB NOT NULL DEFAULT '[]',
tags JSONB NOT NULL DEFAULT '[]',
nutrition JSONB,
source TEXT NOT NULL DEFAULT 'ai',
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_saved_recipes_user_id ON saved_recipes(user_id);
CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes(user_id, saved_at DESC);
-- +goose Down
DROP TABLE saved_recipes;

View File

@@ -42,4 +42,16 @@ class ApiClient {
final response = await _dio.delete(path);
return response.data;
}
/// Returns a list for endpoints that respond with a JSON array.
Future<List<dynamic>> getList(String path,
{Map<String, dynamic>? params}) async {
final response = await _dio.get(path, queryParameters: params);
return response.data as List<dynamic>;
}
/// Deletes a resource and expects no response body (204 No Content).
Future<void> deleteVoid(String path) async {
await _dio.delete(path);
}
}

View File

@@ -8,8 +8,11 @@ import '../../features/auth/register_screen.dart';
import '../../features/home/home_screen.dart';
import '../../features/products/products_screen.dart';
import '../../features/menu/menu_screen.dart';
import '../../features/recipes/recipe_detail_screen.dart';
import '../../features/recipes/recipes_screen.dart';
import '../../features/profile/profile_screen.dart';
import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
@@ -34,6 +37,21 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/auth/register',
builder: (_, __) => const RegisterScreen(),
),
// Full-screen recipe detail — shown without the bottom navigation bar.
GoRoute(
path: '/recipe-detail',
builder: (context, state) {
final extra = state.extra;
if (extra is Recipe) {
return RecipeDetailScreen(recipe: extra);
}
if (extra is SavedRecipe) {
return RecipeDetailScreen(saved: extra);
}
// Fallback: pop back if navigated without a valid extra.
return const _InvalidRoute();
},
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
@@ -52,6 +70,18 @@ final routerProvider = Provider<GoRouter>((ref) {
);
});
class _InvalidRoute extends StatelessWidget {
const _InvalidRoute();
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).pop();
});
return const Scaffold(body: SizedBox.shrink());
}
}
class MainShell extends StatelessWidget {
final Widget child;

View File

@@ -0,0 +1,552 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_colors.dart';
import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart';
import 'recipe_provider.dart';
/// Unified detail screen for both recommendation recipes and saved recipes.
///
/// Pass a [Recipe] (from recommendations) or a [SavedRecipe] (from saved list)
/// via GoRouter's `extra` parameter.
class RecipeDetailScreen extends ConsumerStatefulWidget {
final Recipe? recipe;
final SavedRecipe? saved;
const RecipeDetailScreen({super.key, this.recipe, this.saved})
: assert(recipe != null || saved != null,
'Provide either recipe or saved');
@override
ConsumerState<RecipeDetailScreen> createState() => _RecipeDetailScreenState();
}
class _RecipeDetailScreenState extends ConsumerState<RecipeDetailScreen> {
bool _isSaving = false;
// ── Unified accessors ────────────────────────────────────────────────────
String get _title => widget.recipe?.title ?? widget.saved!.title;
String? get _description =>
widget.recipe?.description ?? widget.saved!.description;
String? get _imageUrl =>
widget.recipe?.imageUrl.isNotEmpty == true
? widget.recipe!.imageUrl
: widget.saved?.imageUrl;
String? get _cuisine => widget.recipe?.cuisine ?? widget.saved!.cuisine;
String? get _difficulty =>
widget.recipe?.difficulty ?? widget.saved!.difficulty;
int? get _prepTimeMin =>
widget.recipe?.prepTimeMin ?? widget.saved!.prepTimeMin;
int? get _cookTimeMin =>
widget.recipe?.cookTimeMin ?? widget.saved!.cookTimeMin;
int? get _servings => widget.recipe?.servings ?? widget.saved!.servings;
List<RecipeIngredient> get _ingredients =>
widget.recipe?.ingredients ?? widget.saved!.ingredients;
List<RecipeStep> get _steps =>
widget.recipe?.steps ?? widget.saved!.steps;
List<String> get _tags => widget.recipe?.tags ?? widget.saved!.tags;
NutritionInfo? get _nutrition =>
widget.recipe?.nutrition ?? widget.saved!.nutrition;
bool get _isFromSaved => widget.saved != null;
@override
Widget build(BuildContext context) {
final savedNotifier = ref.watch(savedRecipesProvider.notifier);
final isSaved = _isFromSaved ||
(widget.recipe != null && savedNotifier.isSaved(_title));
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(context),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
_title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
if (_description != null && _description!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_description!,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: AppColors.textSecondary),
),
],
const SizedBox(height: 16),
_MetaChips(
prepTimeMin: _prepTimeMin,
cookTimeMin: _cookTimeMin,
difficulty: _difficulty,
cuisine: _cuisine,
servings: _servings,
),
if (_nutrition != null) ...[
const SizedBox(height: 16),
_NutritionCard(nutrition: _nutrition!),
],
if (_tags.isNotEmpty) ...[
const SizedBox(height: 12),
_TagsRow(tags: _tags),
],
],
),
),
const Divider(height: 32),
_IngredientsSection(ingredients: _ingredients),
const Divider(height: 32),
_StepsSection(steps: _steps),
const SizedBox(height: 24),
// Save / Unsave button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _SaveButton(
isSaved: isSaved,
isLoading: _isSaving,
onPressed: () => _toggleSave(context, isSaved),
),
),
const SizedBox(height: 32),
],
),
),
],
),
);
}
Widget _buildAppBar(BuildContext context) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: _imageUrl != null && _imageUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: _imageUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: Colors.grey[200]),
errorWidget: (_, __, ___) => _PlaceholderImage(),
)
: _PlaceholderImage(),
),
);
}
Future<void> _toggleSave(BuildContext context, bool isSaved) async {
if (_isSaving) return;
HapticFeedback.lightImpact();
setState(() => _isSaving = true);
final notifier = ref.read(savedRecipesProvider.notifier);
try {
if (isSaved) {
final id = _isFromSaved
? widget.saved!.id
: notifier.savedId(_title);
if (id != null) {
final ok = await notifier.delete(id);
if (!ok && context.mounted) {
_showSnack(context, 'Не удалось удалить из сохранённых');
} else if (ok && _isFromSaved && context.mounted) {
Navigator.of(context).pop();
}
}
} else if (widget.recipe != null) {
final saved = await notifier.save(widget.recipe!);
if (saved == null && context.mounted) {
_showSnack(context, 'Не удалось сохранить рецепт');
}
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
void _showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}
// ---------------------------------------------------------------------------
// Sub-widgets
// ---------------------------------------------------------------------------
class _PlaceholderImage extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Center(child: Icon(Icons.restaurant, size: 64)),
);
}
class _MetaChips extends StatelessWidget {
final int? prepTimeMin;
final int? cookTimeMin;
final String? difficulty;
final String? cuisine;
final int? servings;
const _MetaChips({
this.prepTimeMin,
this.cookTimeMin,
this.difficulty,
this.cuisine,
this.servings,
});
@override
Widget build(BuildContext context) {
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (totalMin > 0)
_Chip(icon: Icons.access_time, label: '$totalMin мин'),
if (difficulty != null)
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
if (cuisine != null)
_Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)),
if (servings != null)
_Chip(icon: Icons.people, label: '$servings порц.'),
],
);
}
String _difficultyLabel(String d) => switch (d) {
'easy' => 'Легко',
'medium' => 'Средне',
'hard' => 'Сложно',
_ => d,
};
String _cuisineLabel(String c) => switch (c) {
'russian' => 'Русская',
'asian' => 'Азиатская',
'european' => 'Европейская',
'mediterranean' => 'Средиземноморская',
'american' => 'Американская',
_ => 'Другая',
};
}
class _Chip extends StatelessWidget {
final IconData icon;
final String label;
const _Chip({required this.icon, required this.label});
@override
Widget build(BuildContext context) => Chip(
avatar: Icon(icon, size: 14),
label: Text(label, style: const TextStyle(fontSize: 12)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
);
}
class _NutritionCard extends StatelessWidget {
final NutritionInfo nutrition;
const _NutritionCard({required this.nutrition});
@override
Widget build(BuildContext context) {
return Card(
color: AppColors.primary.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'КБЖУ на порцию',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Tooltip(
message: 'Значения рассчитаны приблизительно с помощью ИИ',
child: Text(
'',
style: TextStyle(
color: AppColors.accent,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_NutCell(
label: 'Калории', value: '${nutrition.calories.round()}'),
_NutCell(
label: 'Белки', value: '${nutrition.proteinG.round()} г'),
_NutCell(
label: 'Жиры', value: '${nutrition.fatG.round()} г'),
_NutCell(
label: 'Углев.', value: '${nutrition.carbsG.round()} г'),
],
),
],
),
),
);
}
}
class _NutCell extends StatelessWidget {
final String label;
final String value;
const _NutCell({required this.label, required this.value});
@override
Widget build(BuildContext context) => Column(
children: [
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
);
}
class _TagsRow extends StatelessWidget {
final List<String> tags;
const _TagsRow({required this.tags});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 6,
runSpacing: 4,
children: tags
.map(
(t) => Chip(
label: Text(t, style: const TextStyle(fontSize: 11)),
backgroundColor: AppColors.primaryLight.withValues(alpha: 0.3),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
)
.toList(),
);
}
}
class _IngredientsSection extends StatelessWidget {
final List<RecipeIngredient> ingredients;
const _IngredientsSection({required this.ingredients});
@override
Widget build(BuildContext context) {
if (ingredients.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ингредиенты',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
...ingredients.map(
(ing) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
const Icon(Icons.circle, size: 6, color: AppColors.primary),
const SizedBox(width: 10),
Expanded(child: Text(ing.name)),
Text(
'${_formatAmount(ing.amount)} ${ing.unit}',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13),
),
],
),
),
),
],
),
);
}
String _formatAmount(double amount) {
if (amount == amount.truncate()) return amount.toInt().toString();
return amount.toStringAsFixed(1);
}
}
class _StepsSection extends StatelessWidget {
final List<RecipeStep> steps;
const _StepsSection({required this.steps});
@override
Widget build(BuildContext context) {
if (steps.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Приготовление',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
...steps.map((step) => _StepTile(step: step)),
],
),
);
}
}
class _StepTile extends StatelessWidget {
final RecipeStep step;
const _StepTile({required this.step});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Step number badge
Container(
width: 28,
height: 28,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${step.number}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(step.description),
if (step.timerSeconds != null) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.timer_outlined,
size: 14, color: AppColors.accent),
const SizedBox(width: 4),
Text(
_formatTimer(step.timerSeconds!),
style: const TextStyle(
color: AppColors.accent, fontSize: 12),
),
],
),
],
],
),
),
],
),
);
}
String _formatTimer(int seconds) {
if (seconds < 60) return '$seconds сек';
final m = seconds ~/ 60;
final s = seconds % 60;
return s == 0 ? '$m мин' : '$m мин $s сек';
}
}
class _SaveButton extends StatelessWidget {
final bool isSaved;
final bool isLoading;
final VoidCallback onPressed;
const _SaveButton({
required this.isSaved,
required this.isLoading,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(isSaved ? Icons.favorite : Icons.favorite_border),
label: Text(isSaved ? 'Сохранено' : 'Сохранить'),
style: ElevatedButton.styleFrom(
backgroundColor:
isSaved ? Colors.red[100] : AppColors.primary,
foregroundColor: isSaved ? Colors.red[800] : Colors.white,
),
),
);
}
}

View File

@@ -0,0 +1,109 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart';
import 'recipe_service.dart';
// ---------------------------------------------------------------------------
// Service provider
// ---------------------------------------------------------------------------
final recipeServiceProvider = Provider<RecipeService>((ref) {
return RecipeService(ref.read(apiClientProvider));
});
// ---------------------------------------------------------------------------
// Recommendations
// ---------------------------------------------------------------------------
class RecommendationsNotifier
extends StateNotifier<AsyncValue<List<Recipe>>> {
final RecipeService _service;
RecommendationsNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
Future<void> load({int count = 5}) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => _service.getRecommendations(count: count),
);
}
}
final recommendationsProvider = StateNotifierProvider<RecommendationsNotifier,
AsyncValue<List<Recipe>>>((ref) {
return RecommendationsNotifier(ref.read(recipeServiceProvider));
});
// ---------------------------------------------------------------------------
// Saved recipes
// ---------------------------------------------------------------------------
class SavedRecipesNotifier
extends StateNotifier<AsyncValue<List<SavedRecipe>>> {
final RecipeService _service;
SavedRecipesNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.getSavedRecipes());
}
/// Saves [recipe] and reloads the list. Returns the saved record or null on error.
Future<SavedRecipe?> save(Recipe recipe) async {
try {
final saved = await _service.saveRecipe(recipe);
await load();
return saved;
} catch (_) {
return null;
}
}
/// Removes the recipe with [id] optimistically and reverts on error.
Future<bool> delete(String id) async {
final previous = state;
state = state.whenData(
(list) => list.where((r) => r.id != id).toList(),
);
try {
await _service.deleteSavedRecipe(id);
return true;
} catch (_) {
state = previous;
return false;
}
}
/// Returns true if any saved recipe has the same title.
bool isSaved(String title) {
return state.whenOrNull(
data: (list) => list.any((r) => r.title == title),
) ??
false;
}
/// Returns the saved recipe ID for the given title, or null.
String? savedId(String title) {
return state.whenOrNull(
data: (list) {
try {
return list.firstWhere((r) => r.title == title).id;
} catch (_) {
return null;
}
},
);
}
}
final savedRecipesProvider = StateNotifierProvider<SavedRecipesNotifier,
AsyncValue<List<SavedRecipe>>>((ref) {
return SavedRecipesNotifier(ref.read(recipeServiceProvider));
});

View File

@@ -0,0 +1,36 @@
import '../../core/api/api_client.dart';
import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart';
class RecipeService {
final ApiClient _apiClient;
RecipeService(this._apiClient);
Future<List<Recipe>> getRecommendations({int count = 5}) async {
final data = await _apiClient.getList(
'/recommendations',
params: {'count': '$count'},
);
return data
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<SavedRecipe>> getSavedRecipes() async {
final data = await _apiClient.getList('/saved-recipes');
return data
.map((e) => SavedRecipe.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<SavedRecipe> saveRecipe(Recipe recipe) async {
final body = recipe.toJson()..['source'] = 'ai';
final response = await _apiClient.post('/saved-recipes', data: body);
return SavedRecipe.fromJson(response);
}
Future<void> deleteSavedRecipe(String id) async {
await _apiClient.deleteVoid('/saved-recipes/$id');
}
}

View File

@@ -1,13 +1,52 @@
import 'package:flutter/material.dart';
class RecipesScreen extends StatelessWidget {
import 'recommendations_screen.dart';
import 'saved_recipes_screen.dart';
/// Root screen for the Recipes tab — two sub-tabs: Recommendations and Saved.
class RecipesScreen extends StatefulWidget {
const RecipesScreen({super.key});
@override
State<RecipesScreen> createState() => _RecipesScreenState();
}
class _RecipesScreenState extends State<RecipesScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Рецепты')),
body: const Center(child: Text('Раздел в разработке')),
appBar: AppBar(
title: const Text('Рецепты'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Рекомендации'),
Tab(text: 'Сохранённые'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
RecommendationsScreen(),
SavedRecipesScreen(),
],
),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/models/recipe.dart';
import 'recipe_provider.dart';
import 'widgets/recipe_card.dart';
import 'widgets/skeleton_card.dart';
class RecommendationsScreen extends ConsumerWidget {
const RecommendationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(recommendationsProvider);
return Scaffold(
// AppBar is owned by RecipesScreen (tab host), but we add the
// refresh action via a floating action button inside this child.
body: state.when(
loading: () => _SkeletonList(),
error: (err, _) => _ErrorView(
message: err.toString(),
onRetry: () =>
ref.read(recommendationsProvider.notifier).load(),
),
data: (recipes) => _RecipeList(recipes: recipes),
),
floatingActionButton: FloatingActionButton(
heroTag: 'refresh_recommendations',
tooltip: 'Обновить рекомендации',
onPressed: state is AsyncLoading
? null
: () => ref.read(recommendationsProvider.notifier).load(),
child: state is AsyncLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
),
);
}
}
// ---------------------------------------------------------------------------
// Skeleton list — shown while AI is generating recipes
// ---------------------------------------------------------------------------
class _SkeletonList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: 3,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (_, __) => const SkeletonCard(),
);
}
}
// ---------------------------------------------------------------------------
// Loaded recipe list
// ---------------------------------------------------------------------------
class _RecipeList extends StatelessWidget {
final List<Recipe> recipes;
const _RecipeList({required this.recipes});
@override
Widget build(BuildContext context) {
if (recipes.isEmpty) {
return const Center(
child: Text('Нет рекомендаций. Нажмите ↻ чтобы получить рецепты.'),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), // room for FAB
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final recipe = recipes[index];
return RecipeCard(
recipe: recipe,
onTap: () => context.push(
'/recipe-detail',
extra: recipe,
),
);
},
);
}
}
// ---------------------------------------------------------------------------
// Error view
// ---------------------------------------------------------------------------
class _ErrorView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorView({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text(
'Не удалось получить рецепты',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
message,
style: const TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Попробовать снова'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,271 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_colors.dart';
import '../../shared/models/saved_recipe.dart';
import 'recipe_provider.dart';
class SavedRecipesScreen extends ConsumerWidget {
const SavedRecipesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(savedRecipesProvider);
return state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text('Не удалось загрузить сохранённые рецепты'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.read(savedRecipesProvider.notifier).load(),
child: const Text('Повторить'),
),
],
),
),
data: (recipes) => recipes.isEmpty
? const _EmptyState()
: _SavedList(recipes: recipes),
);
}
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
class _SavedList extends StatelessWidget {
final List<SavedRecipe> recipes;
const _SavedList({required this.recipes});
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_SavedRecipeItem(recipe: recipes[index]),
);
}
}
// ---------------------------------------------------------------------------
// Single item with swipe-to-delete
// ---------------------------------------------------------------------------
class _SavedRecipeItem extends ConsumerWidget {
final SavedRecipe recipe;
const _SavedRecipeItem({required this.recipe});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: ValueKey(recipe.id),
direction: DismissDirection.endToStart,
background: _DeleteBackground(),
confirmDismiss: (_) => _confirmDelete(context),
onDismissed: (_) async {
final ok =
await ref.read(savedRecipesProvider.notifier).delete(recipe.id);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось удалить рецепт')),
);
}
},
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.push('/recipe-detail', extra: recipe),
child: Row(
children: [
// Thumbnail
_Thumbnail(imageUrl: recipe.imageUrl),
// Info
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (recipe.nutrition != null) ...[
const SizedBox(height: 4),
Text(
'${recipe.nutrition!.calories.round()} ккал · '
'${recipe.nutrition!.proteinG.round()} б · '
'${recipe.nutrition!.fatG.round()} ж · '
'${recipe.nutrition!.carbsG.round()} у',
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
if (recipe.prepTimeMin != null ||
recipe.cookTimeMin != null) ...[
const SizedBox(height: 4),
Text(
_timeLabel(recipe.prepTimeMin, recipe.cookTimeMin),
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
],
],
),
),
),
// Delete button
IconButton(
icon: const Icon(Icons.delete_outline,
color: AppColors.textSecondary),
onPressed: () async {
final confirmed = await _confirmDelete(context);
if (confirmed == true && context.mounted) {
final ok = await ref
.read(savedRecipesProvider.notifier)
.delete(recipe.id);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось удалить рецепт')),
);
}
}
},
),
],
),
),
),
);
}
String _timeLabel(int? prep, int? cook) {
final total = (prep ?? 0) + (cook ?? 0);
return total > 0 ? '$total мин' : '';
}
Future<bool?> _confirmDelete(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить рецепт?'),
content: Text('«${recipe.title}» будет удалён из сохранённых.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Удалить'),
),
],
),
);
}
}
class _Thumbnail extends StatelessWidget {
final String? imageUrl;
const _Thumbnail({this.imageUrl});
@override
Widget build(BuildContext context) {
if (imageUrl == null || imageUrl!.isEmpty) {
return Container(
width: 80,
height: 80,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Icon(Icons.restaurant),
);
}
return CachedNetworkImage(
imageUrl: imageUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(width: 80, height: 80, color: Colors.grey[200]),
errorWidget: (_, __, ___) => Container(
width: 80,
height: 80,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Icon(Icons.restaurant),
),
);
}
}
class _DeleteBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite_border,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Нет сохранённых рецептов',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'Сохраняйте рецепты из рекомендаций,\nнажимая на ♡',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[500]),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,263 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../shared/models/recipe.dart';
import '../recipe_provider.dart';
/// Card shown in the recommendations list.
/// Shows the photo, title, nutrition summary, time and difficulty.
/// The ♡ button saves / unsaves the recipe.
class RecipeCard extends ConsumerWidget {
final Recipe recipe;
final VoidCallback onTap;
const RecipeCard({
super.key,
required this.recipe,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final savedNotifier = ref.watch(savedRecipesProvider.notifier);
final isSaved = ref.watch(
savedRecipesProvider.select(
(_) => savedNotifier.isSaved(recipe.title),
),
);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Photo
Stack(
children: [
_RecipeImage(imageUrl: recipe.imageUrl, title: recipe.title),
// Save button
Positioned(
top: 8,
right: 8,
child: _SaveButton(
isSaved: isSaved,
onPressed: () => _toggleSave(context, ref, isSaved),
),
),
],
),
// Content
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (recipe.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
recipe.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 10),
_MetaRow(recipe: recipe),
if (recipe.nutrition != null) ...[
const SizedBox(height: 8),
_NutritionRow(nutrition: recipe.nutrition!),
],
],
),
),
],
),
),
);
}
Future<void> _toggleSave(
BuildContext context,
WidgetRef ref,
bool isSaved,
) async {
HapticFeedback.lightImpact();
final notifier = ref.read(savedRecipesProvider.notifier);
if (isSaved) {
final id = notifier.savedId(recipe.title);
if (id != null) await notifier.delete(id);
} else {
final saved = await notifier.save(recipe);
if (saved == null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось сохранить рецепт')),
);
}
}
}
}
class _RecipeImage extends StatelessWidget {
final String imageUrl;
final String title;
const _RecipeImage({required this.imageUrl, required this.title});
@override
Widget build(BuildContext context) {
if (imageUrl.isEmpty) {
return Container(
height: 180,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Center(child: Icon(Icons.restaurant, size: 48)),
);
}
return CachedNetworkImage(
imageUrl: imageUrl,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
height: 180,
color: Colors.grey.withValues(alpha: 0.3),
),
errorWidget: (_, __, ___) => Container(
height: 180,
color: AppColors.primaryLight.withValues(alpha: 0.3),
child: const Center(child: Icon(Icons.restaurant, size: 48)),
),
);
}
}
class _SaveButton extends StatelessWidget {
final bool isSaved;
final VoidCallback onPressed;
const _SaveButton({required this.isSaved, required this.onPressed});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black45,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
isSaved ? Icons.favorite : Icons.favorite_border,
color: isSaved ? Colors.red : Colors.white,
size: 22,
),
),
),
);
}
}
class _MetaRow extends StatelessWidget {
final Recipe recipe;
const _MetaRow({required this.recipe});
@override
Widget build(BuildContext context) {
final totalMin = recipe.prepTimeMin + recipe.cookTimeMin;
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
);
return Row(
children: [
const Icon(Icons.access_time, size: 14, color: AppColors.textSecondary),
const SizedBox(width: 3),
Text('$totalMin мин', style: style),
const SizedBox(width: 12),
const Icon(Icons.bar_chart, size: 14, color: AppColors.textSecondary),
const SizedBox(width: 3),
Text(_difficultyLabel(recipe.difficulty), style: style),
if (recipe.cuisine.isNotEmpty) ...[
const SizedBox(width: 12),
const Icon(Icons.public, size: 14, color: AppColors.textSecondary),
const SizedBox(width: 3),
Text(_cuisineLabel(recipe.cuisine), style: style),
],
],
);
}
String _difficultyLabel(String d) => switch (d) {
'easy' => 'Легко',
'medium' => 'Средне',
'hard' => 'Сложно',
_ => d,
};
String _cuisineLabel(String c) => switch (c) {
'russian' => 'Русская',
'asian' => 'Азиатская',
'european' => 'Европейская',
'mediterranean' => 'Средиземноморская',
'american' => 'Американская',
_ => 'Другая',
};
}
class _NutritionRow extends StatelessWidget {
final NutritionInfo nutrition;
const _NutritionRow({required this.nutrition});
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
fontSize: 11,
);
return Row(
children: [
Text('', style: style?.copyWith(color: AppColors.accent)),
_NutItem(label: 'ккал', value: nutrition.calories.round(), style: style),
const SizedBox(width: 8),
_NutItem(label: 'б', value: nutrition.proteinG.round(), style: style),
const SizedBox(width: 8),
_NutItem(label: 'ж', value: nutrition.fatG.round(), style: style),
const SizedBox(width: 8),
_NutItem(label: 'у', value: nutrition.carbsG.round(), style: style),
],
);
}
}
class _NutItem extends StatelessWidget {
final String label;
final int value;
final TextStyle? style;
const _NutItem({required this.label, required this.value, this.style});
@override
Widget build(BuildContext context) => Text(
'$value $label',
style: style,
);
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
/// A pulsing placeholder card shown while recipes are loading from the AI.
class SkeletonCard extends StatefulWidget {
const SkeletonCard({super.key});
@override
State<SkeletonCard> createState() => _SkeletonCardState();
}
class _SkeletonCardState extends State<SkeletonCard>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_anim = Tween<double>(begin: 0.25, end: 0.55).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (context, _) {
final color = Colors.grey.withValues(alpha: _anim.value);
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 180, color: color),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Bar(width: 220, height: 18, color: color),
const SizedBox(height: 8),
_Bar(width: 160, height: 14, color: color),
const SizedBox(height: 12),
Row(
children: [
_Bar(width: 60, height: 12, color: color),
const SizedBox(width: 12),
_Bar(width: 60, height: 12, color: color),
const SizedBox(width: 12),
_Bar(width: 60, height: 12, color: color),
],
),
],
),
),
],
),
);
},
);
}
}
class _Bar extends StatelessWidget {
final double width;
final double height;
final Color color;
const _Bar({required this.width, required this.height, required this.color});
@override
Widget build(BuildContext context) => Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
);
}

View File

@@ -0,0 +1,121 @@
import 'package:json_annotation/json_annotation.dart';
part 'recipe.g.dart';
@JsonSerializable(explicitToJson: true)
class Recipe {
final String title;
final String description;
final String cuisine;
final String difficulty;
@JsonKey(name: 'prep_time_min')
final int prepTimeMin;
@JsonKey(name: 'cook_time_min')
final int cookTimeMin;
final int servings;
@JsonKey(name: 'image_url', defaultValue: '')
final String imageUrl;
@JsonKey(name: 'image_query', defaultValue: '')
final String imageQuery;
@JsonKey(defaultValue: [])
final List<RecipeIngredient> ingredients;
@JsonKey(defaultValue: [])
final List<RecipeStep> steps;
@JsonKey(defaultValue: [])
final List<String> tags;
@JsonKey(name: 'nutrition_per_serving')
final NutritionInfo? nutrition;
const Recipe({
required this.title,
required this.description,
required this.cuisine,
required this.difficulty,
required this.prepTimeMin,
required this.cookTimeMin,
required this.servings,
this.imageUrl = '',
this.imageQuery = '',
this.ingredients = const [],
this.steps = const [],
this.tags = const [],
this.nutrition,
});
factory Recipe.fromJson(Map<String, dynamic> json) => _$RecipeFromJson(json);
Map<String, dynamic> toJson() => _$RecipeToJson(this);
}
@JsonSerializable()
class RecipeIngredient {
final String name;
final double amount;
final String unit;
const RecipeIngredient({
required this.name,
required this.amount,
required this.unit,
});
factory RecipeIngredient.fromJson(Map<String, dynamic> json) =>
_$RecipeIngredientFromJson(json);
Map<String, dynamic> toJson() => _$RecipeIngredientToJson(this);
}
@JsonSerializable()
class RecipeStep {
final int number;
final String description;
@JsonKey(name: 'timer_seconds')
final int? timerSeconds;
const RecipeStep({
required this.number,
required this.description,
this.timerSeconds,
});
factory RecipeStep.fromJson(Map<String, dynamic> json) =>
_$RecipeStepFromJson(json);
Map<String, dynamic> toJson() => _$RecipeStepToJson(this);
}
@JsonSerializable()
class NutritionInfo {
final double calories;
@JsonKey(name: 'protein_g')
final double proteinG;
@JsonKey(name: 'fat_g')
final double fatG;
@JsonKey(name: 'carbs_g')
final double carbsG;
@JsonKey(defaultValue: true)
final bool approximate;
const NutritionInfo({
required this.calories,
required this.proteinG,
required this.fatG,
required this.carbsG,
this.approximate = true,
});
factory NutritionInfo.fromJson(Map<String, dynamic> json) =>
_$NutritionInfoFromJson(json);
Map<String, dynamic> toJson() => _$NutritionInfoToJson(this);
}

View File

@@ -0,0 +1,97 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recipe.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Recipe _$RecipeFromJson(Map<String, dynamic> json) => Recipe(
title: json['title'] as String,
description: json['description'] as String,
cuisine: json['cuisine'] as String,
difficulty: json['difficulty'] as String,
prepTimeMin: (json['prep_time_min'] as num).toInt(),
cookTimeMin: (json['cook_time_min'] as num).toInt(),
servings: (json['servings'] as num).toInt(),
imageUrl: json['image_url'] as String? ?? '',
imageQuery: json['image_query'] as String? ?? '',
ingredients:
(json['ingredients'] as List<dynamic>?)
?.map((e) => RecipeIngredient.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
steps:
(json['steps'] as List<dynamic>?)
?.map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
tags:
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
nutrition: json['nutrition_per_serving'] == null
? null
: NutritionInfo.fromJson(
json['nutrition_per_serving'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
'title': instance.title,
'description': instance.description,
'cuisine': instance.cuisine,
'difficulty': instance.difficulty,
'prep_time_min': instance.prepTimeMin,
'cook_time_min': instance.cookTimeMin,
'servings': instance.servings,
'image_url': instance.imageUrl,
'image_query': instance.imageQuery,
'ingredients': instance.ingredients.map((e) => e.toJson()).toList(),
'steps': instance.steps.map((e) => e.toJson()).toList(),
'tags': instance.tags,
'nutrition_per_serving': instance.nutrition?.toJson(),
};
RecipeIngredient _$RecipeIngredientFromJson(Map<String, dynamic> json) =>
RecipeIngredient(
name: json['name'] as String,
amount: (json['amount'] as num).toDouble(),
unit: json['unit'] as String,
);
Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) =>
<String, dynamic>{
'name': instance.name,
'amount': instance.amount,
'unit': instance.unit,
};
RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
number: (json['number'] as num).toInt(),
description: json['description'] as String,
timerSeconds: (json['timer_seconds'] as num?)?.toInt(),
);
Map<String, dynamic> _$RecipeStepToJson(RecipeStep instance) =>
<String, dynamic>{
'number': instance.number,
'description': instance.description,
'timer_seconds': instance.timerSeconds,
};
NutritionInfo _$NutritionInfoFromJson(Map<String, dynamic> json) =>
NutritionInfo(
calories: (json['calories'] as num).toDouble(),
proteinG: (json['protein_g'] as num).toDouble(),
fatG: (json['fat_g'] as num).toDouble(),
carbsG: (json['carbs_g'] as num).toDouble(),
approximate: json['approximate'] as bool? ?? true,
);
Map<String, dynamic> _$NutritionInfoToJson(NutritionInfo instance) =>
<String, dynamic>{
'calories': instance.calories,
'protein_g': instance.proteinG,
'fat_g': instance.fatG,
'carbs_g': instance.carbsG,
'approximate': instance.approximate,
};

View File

@@ -0,0 +1,64 @@
import 'package:json_annotation/json_annotation.dart';
import 'recipe.dart';
part 'saved_recipe.g.dart';
@JsonSerializable(explicitToJson: true)
class SavedRecipe {
final String id;
final String title;
final String? description;
final String? cuisine;
final String? difficulty;
@JsonKey(name: 'prep_time_min')
final int? prepTimeMin;
@JsonKey(name: 'cook_time_min')
final int? cookTimeMin;
final int? servings;
@JsonKey(name: 'image_url')
final String? imageUrl;
@JsonKey(defaultValue: [])
final List<RecipeIngredient> ingredients;
@JsonKey(defaultValue: [])
final List<RecipeStep> steps;
@JsonKey(defaultValue: [])
final List<String> tags;
@JsonKey(name: 'nutrition_per_serving')
final NutritionInfo? nutrition;
final String source;
@JsonKey(name: 'saved_at')
final DateTime savedAt;
const SavedRecipe({
required this.id,
required this.title,
this.description,
this.cuisine,
this.difficulty,
this.prepTimeMin,
this.cookTimeMin,
this.servings,
this.imageUrl,
this.ingredients = const [],
this.steps = const [],
this.tags = const [],
this.nutrition,
required this.source,
required this.savedAt,
});
factory SavedRecipe.fromJson(Map<String, dynamic> json) =>
_$SavedRecipeFromJson(json);
Map<String, dynamic> toJson() => _$SavedRecipeToJson(this);
}

View File

@@ -0,0 +1,57 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'saved_recipe.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SavedRecipe _$SavedRecipeFromJson(Map<String, dynamic> json) => SavedRecipe(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String?,
cuisine: json['cuisine'] as String?,
difficulty: json['difficulty'] as String?,
prepTimeMin: (json['prep_time_min'] as num?)?.toInt(),
cookTimeMin: (json['cook_time_min'] as num?)?.toInt(),
servings: (json['servings'] as num?)?.toInt(),
imageUrl: json['image_url'] as String?,
ingredients:
(json['ingredients'] as List<dynamic>?)
?.map((e) => RecipeIngredient.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
steps:
(json['steps'] as List<dynamic>?)
?.map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
tags:
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
nutrition: json['nutrition_per_serving'] == null
? null
: NutritionInfo.fromJson(
json['nutrition_per_serving'] as Map<String, dynamic>,
),
source: json['source'] as String,
savedAt: DateTime.parse(json['saved_at'] as String),
);
Map<String, dynamic> _$SavedRecipeToJson(SavedRecipe instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'description': instance.description,
'cuisine': instance.cuisine,
'difficulty': instance.difficulty,
'prep_time_min': instance.prepTimeMin,
'cook_time_min': instance.cookTimeMin,
'servings': instance.servings,
'image_url': instance.imageUrl,
'ingredients': instance.ingredients.map((e) => e.toJson()).toList(),
'steps': instance.steps.map((e) => e.toJson()).toList(),
'tags': instance.tags,
'nutrition_per_serving': instance.nutrition?.toJson(),
'source': instance.source,
'saved_at': instance.savedAt.toIso8601String(),
};

644
docs/Flow.md Normal file
View File

@@ -0,0 +1,644 @@
# Flow: взаимодействие пользователя → бэкенда → сторонних API
## Содержание
1. [Архитектура системы](#1-архитектура-системы)
2. [Ключевой принцип: сторонние API только там где нужны](#2-ключевой-принцип)
3. [Flow 1: Аутентификация](#3-flow-1-аутентификация)
4. [Flow 2: Обновление токена](#4-flow-2-обновление-токена)
5. [Flow 3: Профиль пользователя](#5-flow-3-профиль-пользователя)
6. [Flow 4: Рекомендации рецептов](#6-flow-4-рекомендации-рецептов)
7. [Flow 5: Сохранённые рецепты](#7-flow-5-сохранённые-рецепты)
8. [Flow 6: Управление продуктами (Итерация 2)](#8-flow-6-управление-продуктами-итерация-2)
9. [Flow 7: Распознавание продуктов (Итерация 3)](#9-flow-7-распознавание-продуктов-итерация-3)
10. [Flow 8: Планирование меню (Итерация 4)](#10-flow-8-планирование-меню-итерация-4)
11. [Анализ потребления сторонних API](#11-анализ-потребления-сторонних-api)
12. [Количество запросов к бэкенду по сценариям](#12-количество-запросов-к-бэкенду-по-сценариям)
13. [Сводная таблица](#13-сводная-таблица)
---
## 1. Архитектура системы
```
┌────────────────────────────────────────────────────────────────────┐
│ Flutter Client │
│ (Android / iOS / Web) │
│ - Firebase Auth (Google / Apple / Email) │
│ - Dio HTTP Client + Auth Interceptor │
│ - FlutterSecureStorage (токены) │
│ - Riverpod (состояние) │
└────────────────────────┬───────────────────────────────────────────┘
│ HTTPS, Bearer JWT
┌────────────────────────▼───────────────────────────────────────────┐
│ Go Backend (chi v5) │
│ - JWT middleware (HS256, верификация без Firebase в рантайме) │
│ - Gemini API (генерация рекомендаций рецептов) │
│ - Pexels API (подбор фотографий к рецептам) │
│ - Firebase Admin SDK (только при логине) │
│ - Калькулятор КБЖУ (Mifflin-St Jeor, локально) │
└──────┬─────────────────────────────────────────────────────────────┘
┌──────▼──────────────────────────────────────────────────────────────┐
│ PostgreSQL 15 │
│ users · saved_recipes · products (Iter.2) · ingredient_mappings │
└─────────────────────────────────────────────────────────────────────┘
Сторонние API:
┌────────────────────┐ ┌─────────────────────────────────────┐
│ Firebase Auth │ │ Google Gemini 2.0 Flash │
│ только логин │ │ генерация рецептов-рекомендаций │
└────────────────────┘ └─────────────────────────────────────┘
┌────────────────────┐
│ Pexels API │
│ фото к рецептам │
└────────────────────┘
```
---
## 2. Ключевой принцип
**Сторонние API вызываются только при конкретных пользовательских действиях**, а не фоновых задачах:
| API | Когда вызывается | Частота |
|-----|-----------------|---------|
| **Firebase Auth** | Только `POST /auth/login` | 1 раз за сессию |
| **Gemini** | `GET /recommendations`, `POST /ai/recognize-*`, `POST /ai/generate-menu` | По запросу пользователя |
| **Pexels** | Внутри рекомендаций и генерации меню | 1 вызов на рецепт |
Все токены, профили и сохранённые рецепты хранятся в PostgreSQL и раздаются **без внешних вызовов**.
---
## 3. Flow 1: Аутентификация
### 3.1 Первый вход (Google OAuth)
```
Пользователь Flutter Firebase Go Backend PostgreSQL
│ │ │ │ │
│── тапает "Войти" ─►│ │ │ │
│ │── signInWithGoogle()►│ │ │
│ │◄── idToken ─────────│ │ │
│ │ │ │ │
│ │── POST /auth/login ─────────────────────►│ │
│ │ { firebase_token: idToken } │ │
│ │ │◄── VerifyIDToken() ─│ │
│ │ │──► { uid, email } ──►│ │
│ │ │ │── UPSERT users ──►│
│ │ │ │◄── User{id, plan}─│
│ │ │ │── генерирует JWT │
│ │ │ │── UPDATE refresh ─►│
│ │◄── { access_token, refresh_token, user } ─│ │
│ │── сохраняет в SecureStorage │ │
│◄── Home Screen ───│ │ │ │
```
**Запросы на бэкенд:** 1
**Вызовов Firebase:** 1 (VerifyIDToken — серверная верификация)
**SQL:** 2 (UPSERT users + UPDATE refresh_token)
### 3.2 Последующие запросы (с JWT)
```
Flutter ── GET /profile ──► middleware.Auth
└── ValidateAccessToken() — локально, HMAC HS256
Firebase НЕ вызывается
──► handler ──► SELECT users WHERE id=$1
```
**Вызовов Firebase:** 0 (JWT верифицируется локально по секрету)
---
## 4. Flow 2: Обновление токена
Происходит автоматически в `AuthInterceptor` при 401 или истечении токена (15 мин TTL).
```
Flutter (AuthInterceptor) Go Backend PostgreSQL
│ │ │
│── POST /auth/refresh ──────►│ │
│ { refresh_token } │── SELECT users WHERE ──►│
│ │ refresh_token=$1 AND │
│ │ token_expires_at>now()│
│ │◄── User ───────────────│
│ │── новый JWT + UUID │
│ │── UPDATE users ────────►│
│◄── { access_token, │ │
│ refresh_token } │ │
│── повторяет исходный запрос│ │
```
**Запросов к Firebase:** 0
**SQL:** 2 (SELECT + UPDATE)
---
## 5. Flow 3: Профиль пользователя
### Просмотр
```
Flutter → GET /profile → SELECT * FROM users WHERE id=$1
```
**Запросов на бэкенд:** 1 | **SQL:** 1
### Обновление (онбординг)
```
Flutter → PUT /profile → Go Backend
{ height_cm, weight_kg, │
age, gender, │── Mifflin-St Jeor (локально):
activity, goal } │ BMR = 10W + 6.25H - 5A + offset
│ TDEE = BMR × activity_factor
│ Calories = TDEE ± goal_delta
│── UPDATE users SET daily_calories=...
```
**Запросов к сторонним API:** 0 (всё считается на Go)
**Запросов на бэкенд:** 1 | **SQL:** 1
---
## 6. Flow 4: Рекомендации рецептов
Центральный flow приложения. Вызывается когда пользователь открывает экран рекомендаций.
### 6.1 Полный flow
```
Пользователь Flutter Go Backend Gemini Pexels PostgreSQL
│ │ │ │ │ │
│── открывает │ │ │ │ │
│ экран ───►│ │ │ │ │
│ │── GET /recommendations ─────────►│ │ │
│ │ ?count=5 │ │ │
│ │ │ │ │
│ │ │── SELECT user ──────────────────────────────── ►│
│ │ │ profile + products (Iter.2) ◄────────────────│
│ │ │ │ │ │
│ │ │── GenerateContent(prompt) ────►│ │
│ │ │ prompt содержит: │ │
│ │ │ - цель пользователя │ │
│ │ │ - дневные калории │ │
│ │ │ - список продуктов (Iter.2) │ │
│ │ │ - N=5 рецептов │ │
│ │ │◄── JSON: [Recipe×5] ───────────│ │
│ │ │ каждый с image_query │ │
│ │ │ │ │ │
│ │ │ для каждого рецепта: │ │
│ │ │── GET /v1/search?query=... ─────────────────── ►│
│ │ │ Authorization: Pexels key │ │
│ │ │◄── { photos[0].src.medium } ───────────────────│
│ │ │ │ │ │
│◄── [Recipe×5 c image_url] ─│ │ │ │
```
### 6.2 Структура промпта для Gemini
```
Ты — диетолог-повар. Предложи {N} рецептов на русском языке.
Профиль пользователя:
- Цель: похудение
- Дневная норма калорий: 1800 ккал
- Ограничения: без глютена (если есть в preferences)
[Итерация 2+] Доступные продукты:
- куриная грудка (500г)
- помидоры (3 шт)
- ...
Требования к каждому рецепту:
- Калорийность на порцию: не более 600 ккал
- Время приготовления: до 40 минут
- Укажи КБЖУ на порцию (приблизительно)
Верни ТОЛЬКО валидный JSON массив без markdown:
[{
"title": "Название рецепта",
"description": "Краткое описание (2-3 предложения)",
"cuisine": "mediterranean",
"difficulty": "easy|medium|hard",
"prep_time_min": 10,
"cook_time_min": 20,
"servings": 2,
"image_query": "grilled chicken breast vegetables mediterranean",
"ingredients": [
{ "name": "Куриная грудка", "amount": 300, "unit": "г" }
],
"steps": [
{ "number": 1, "description": "Нарежьте курицу...", "timer_seconds": null }
],
"tags": ["без глютена", "высокий белок"],
"nutrition_per_serving": {
"calories": 420,
"protein_g": 48,
"fat_g": 12,
"carbs_g": 18
}
}]
```
### 6.3 Кэширование рекомендаций
Рекомендации **не сохраняются** автоматически. При каждом открытии экрана генерируются заново. Это дает:
- Свежесть контента
- Учёт изменившихся продуктов (Итерация 2)
Возможная оптимизация в будущем: кэшировать последний набор рекомендаций в Redis/памяти на 30 минут, инвалидировать при обновлении продуктов.
---
## 7. Flow 5: Сохранённые рецепты
### 7.1 Сохранить рекомендацию
```
Пользователь тапает ❤️ на рецепте
Flutter → POST /saved-recipes → PostgreSQL
{ полный JSON рецепта } └── INSERT INTO saved_recipes
(user_id, title, steps,
ingredients, nutrition,
image_url, ...)
← { id, saved_at }
```
**Запросов на бэкенд:** 1
**Вызовов Gemini/Pexels:** 0 (данные уже есть в клиенте)
**SQL:** 1
### 7.2 Список сохранённых
```
Flutter → GET /saved-recipes → SELECT * FROM saved_recipes
WHERE user_id=$1
ORDER BY saved_at DESC
```
**Запросов на бэкенд:** 1 | **SQL:** 1
### 7.3 Удалить из сохранённых
```
Flutter → DELETE /saved-recipes/{id} → DELETE FROM saved_recipes
WHERE id=$1 AND user_id=$2
```
**Запросов на бэкенд:** 1 | **SQL:** 1
---
## 8. Flow 6: Управление продуктами (Итерация 2)
### 8.1 Открытие списка продуктов
```
Flutter → GET /products → SELECT * FROM products
WHERE user_id=$1
ORDER BY expires_at ASC
```
**Запросов на бэкенд:** 1
### 8.2 Добавление продукта с автодополнением
```
Пользователь вводит "кур" (debounce 300мс)
Flutter → GET /ingredients/search?q=кур → PostgreSQL
├── ILIKE на canonical_name_ru
├── GIN на aliases
└── pg_trgm similarity
Пользователь выбирает "Куриная грудка"
│ поля автозаполняются локально
Flutter → POST /products → INSERT INTO products
{ mapping_id, expires_at GENERATED ALWAYS AS
name, quantity, (added_at + storage_days days)
unit, category,
storage_days }
```
**Запросов на бэкенд:** 35 (поиск, debounce) + 1 (создание)
**Вызовов сторонних API:** 0
### 8.3 Связь продуктов с рекомендациями (Итерация 2+)
После того как у пользователя есть продукты, `GET /recommendations` включает их в промпт для Gemini. Рекомендации становятся персонализированными: "что приготовить из того, что есть".
---
## 9. Flow 7: Распознавание продуктов (Итерация 3)
Пользователь фотографирует чек, холодильник или готовое блюдо — Gemini Vision распознаёт содержимое и заполняет список продуктов.
### 9.1 Распознавание чека
```
Пользователь Flutter Go Backend Gemini PostgreSQL
│ │ │ │ │
│─ фото чека►│ │ │ │
│ │── POST /ai/recognize-receipt ─────────►│ │
│ │ multipart/form-data: image │ │
│ │ │── GenerateContent ►│ │
│ │ │ prompt: OCR чека │ │
│ │ │◄── JSON: [{name, │ │
│ │ │ qty, unit, │ │
│ │ │ category, │ │
│ │ │ confidence}] │ │
│ │ │ │ │
│ │ │ fuzzy match по ingredient_mappings►│
│ │ │◄── mapping_id ─────────────────────│
│ │ │ │ │
│◄── [{name, mapping_id, qty, │ │ │
│ unit, storage_days}] ──────│ │ │
│ │ │ │ │
│─ подтверждает список ─────────►│ │ │
│ POST /products/batch │── INSERT products ►│ │
```
**Запросов на бэкенд:** 2 (recognize + batch insert)
**Gemini:** 1 (vision)
**SQL:** 1 SELECT (fuzzy match) + 1 INSERT batch
### 9.2 Распознавание фото продуктов (холодильник/стол)
Аналогичен чеку, но без цены. Поддерживает несколько фото:
```
Пользователь делает 13 фото холодильника
Flutter → POST /ai/recognize-products → Gemini Vision
(multipart, несколько фото) └── анализирует каждое фото
└── объединяет результаты
(дедупликация по canonical_name)
Backend:
1. Для каждого фото → 1 Gemini-запрос (параллельно)
2. Объединение списков, дедупликация (суммирование количества)
3. Fuzzy match по ingredient_mappings
4. Возврат клиенту для подтверждения
5. POST /products/batch → INSERT
```
**Gemini:** 13 (по числу фото, параллельно)
### 9.3 Распознавание блюда (фото → калории)
```
Пользователь Flutter Go Backend Gemini PostgreSQL
│ │ │ │ │
│─ фото блюда►│ │ │ │
│ │── POST /ai/recognize-dish ────────────►│ │
│ │ │── GenerateContent ►│ │
│ │ │ prompt: распознай│ │
│ │ │ блюдо, КБЖУ │ │
│ │ │◄── {dish_name, │ │
│ │ │ weight_g, │ │
│ │ │ calories, │ │
│ │ │ protein, fat, │ │
│ │ │ carbs, │ │
│ │ │ confidence} │ │
│ │ │ │ │
│ │ │ Опционально: поиск│ │
│ │ │ в saved_recipes │ │
│ │ │ по dish_name │ │
│ │ │ │ │
│◄── {dish, calories, КБЖУ≈, │ │ │
│ matched_recipe?} ──────────│ │ │
│ │ │ │ │
│─ добавить в дневник? ─────────►│ │ │
│ POST /diary │── INSERT meal_diary►│ │
```
**Запросов на бэкенд:** 2 (recognize + diary)
**Gemini:** 1 (vision)
---
## 10. Flow 8: Планирование меню (Итерация 4)
Пользователь запрашивает меню на неделю — Gemini генерирует полный план питания с рецептами на основе продуктов, профиля и целей пользователя.
### 10.1 Генерация меню
```
Пользователь Flutter Go Backend Gemini Pexels PostgreSQL
│ │ │ │ │ │
│─ «Составить │ │ │ │ │
│ меню» ──►│ │ │ │ │
│ │── POST /ai/generate-menu ─────────────►│ │ │
│ │ { period: "week", │ │ │
│ │ meals_per_day: 3 } │ │ │
│ │ │ │ │ │
│ │ SELECT user profile + products ────────────────────────►│
│ │ ◄── {goal, КБЖУ, products list} ───────────────────────│
│ │ │ │ │ │
│ │ │── GenerateContent ►│ │ │
│ │ │ промпт: │ │ │
│ │ │ - профиль юзера │ │ │
│ │ │ - продукты │ │ │
│ │ │ - период 7 дней │ │ │
│ │ │ - 3 приёма/день │ │ │
│ │ │◄── JSON: 21 recipe │ │ │
│ │ │ каждый с │ │ │
│ │ │ image_query │ │ │
│ │ │ │ │ │
│ │ │ для каждого рецепта (параллельно): │
│ │ │── GET /v1/search?query=... ──────────────────────►│
│ │ │◄── photo_url ────────────────────────────────────│
│ │ │ │ │ │
│ │ │── INSERT menu_plans + menu_items ───────────────►│
│ │ │── INSERT saved_recipes (рецепты меню) ──────────►│
│ │ │ │ │ │
│◄── {menu_plan_id, │ │ │ │
│ days: [{day, meals: [ │ │ │ │
│ {meal_type, recipe} │ │ │ │
│ ]}]} ──────────────────────│ │ │ │
```
**Gemini:** 1 (большой промпт, ~21 рецепт)
**Pexels:** до 21 (параллельно; на практике повторяющиеся query кешируются)
**SQL:** 1 SELECT + batch INSERT menu_plans/items + batch INSERT saved_recipes
### 10.2 Просмотр и редактирование меню
```
Flutter → GET /menu?week=2026-W08 → SELECT menu_plans, menu_items WHERE user_id=$1 AND week_start=$2
LEFT JOIN saved_recipes ON menu_items.recipe_id
Flutter → PUT /menu/items/{id} → UPDATE menu_items SET recipe_id=$1
Flutter → DELETE /menu/items/{id} → DELETE menu_items WHERE id=$1 AND user_id=$2
```
**Запросов к бэкенду:** 13 | **Gemini:** 0 | **SQL:** 12
### 10.3 Список покупок из меню
```
Flutter → POST /shopping-list/generate → Go Backend
{ menu_plan_id } ├── SELECT menu_items JOIN saved_recipes
│ WHERE menu_plan_id=$1
├── Агрегация ингредиентов:
│ суммирование по canonical_name
│ вычитание того, что уже есть в products
└── INSERT/UPDATE shopping_lists
Gemini НЕ участвует. Чистая SQL-агрегация.
```
**Gemini:** 0 | **SQL:** 3
---
## 11. Анализ потребления сторонних API
### 9.1 Firebase Auth
| Сценарий | Вызовов |
|----------|---------|
| Логин | 1 (VerifyIDToken) |
| Обычный запрос с JWT | **0** |
| Refresh токена | **0** |
### 9.2 Gemini (Google Gemini 2.0 Flash)
**Тарифы Free tier:**
| Параметр | Значение |
|----------|----------|
| RPM | 15 (Flash) / 30 (Flash-Lite) |
| Запросов/день | 1 500 |
| Токенов/минуту | 1 000 000 |
**Расход на запрос рекомендаций (5 рецептов):**
| Метрика | Значение |
|---------|----------|
| Input токены (промпт + продукты) | ~500800 |
| Output токены (5 рецептов JSON) | ~1 5002 500 |
| Gemini-запросов | **1** |
| Стоимость (Flash, платный) | ~$0.0003 |
**Дневное потребление при 100 активных пользователях (каждый открывает рекомендации 3 раза/день):**
- 100 × 3 = 300 Gemini-запросов/день
- Free tier: 1 500/день → **хватает на ~5× текущую нагрузку**
- Платный Flash: ~$0.09/день = ~$2.7/мес
### 9.3 Pexels API
**Тарифы:**
| Тариф | Запросов/час | Запросов/мес |
|-------|-------------|-------------|
| Free | 200 | 20 000 |
**Расход:**
- 1 рекомендация = 5 рецептов = **5 Pexels-запросов**
- 100 пользователей × 3 рекомендации = 300 запросов/час пик
- ⚠️ **200 req/hour лимит может стать узким местом при пиковой нагрузке**
**Стратегия:** кэшировать image_url в `saved_recipes`, для несохранённых рекомендаций — запрашивать при генерации. При росте нагрузки — кэшировать по `image_query` в Redis (большинство запросов повторяются: "grilled chicken", "pasta carbonara", etc.).
---
## 12. Количество запросов к бэкенду по сценариям
### Первый запуск (новый пользователь)
| Шаг | Endpoint | Сторонний API |
|-----|----------|---------------|
| Вход через Google | POST /auth/login | Firebase (1×) |
| Загрузка профиля | GET /profile | — |
| Онбординг | PUT /profile | — |
| Первые рекомендации | GET /recommendations | Gemini (1×) + Pexels (5×) |
| **Итого** | **4** | **Firebase×1, Gemini×1, Pexels×5** |
### Обычная сессия
| Шаг | Endpoint | Кол-во |
|-----|----------|--------|
| Refresh токена (если истёк) | POST /auth/refresh | 01 |
| Открыть рекомендации | GET /recommendations | 1 |
| Сохранить рецепт | POST /saved-recipes | 1 |
| Открыть сохранённые | GET /saved-recipes | 1 |
| **Итого** | **34** | **Gemini×1, Pexels×5** |
### Сценарий: пользователь не взаимодействует с рекомендациями
```
Открывает приложение → просматривает сохранённые рецепты
Запросов: 1 GET /saved-recipes
Сторонних API: 0
SQL: 1
```
### Детальный breakdown: GET /recommendations
```
1. SELECT users WHERE id=$1 → 1 SQL
2. [Iter.2+] SELECT products WHERE user_id=$1 → 1 SQL
3. Gemini.GenerateContent(prompt) → 1 Gemini req (~13 сек)
4. Pexels.Search(image_query) × 5 (параллельно) → 5 Pexels req (параллельно)
5. Формирование ответа и отдача → 0 SQL
Итого: 12 SQL + 1 Gemini + 5 Pexels
Время ответа: ~24 секунды (доминирует Gemini latency)
```
---
## 13. Сводная таблица
### Сторонние API в рантайме
| API | Trigger | Вызовов на запрос | Free tier (день) |
|-----|---------|------------------|-----------------|
| Firebase Auth | POST /auth/login | 1 | Без ограничений |
| Gemini Flash | GET /recommendations | 1 | 1 500 |
| Gemini Flash | POST /ai/recognize-receipt | 1 | — |
| Gemini Flash | POST /ai/recognize-products | 13 (фото) | — |
| Gemini Flash | POST /ai/recognize-dish | 1 | — |
| Gemini Flash | POST /ai/generate-menu | 1 | — |
| Pexels | GET /recommendations | 5 (параллельно) | ~667 рекомендаций |
| Pexels | POST /ai/generate-menu | до 21 (параллельно) | — |
### Запросы к бэкенду
| Сценарий | Бэкенд | Firebase | Gemini | Pexels |
|----------|--------|----------|--------|--------|
| Первый вход | 1 | 1 | 0 | 0 |
| Просмотр профиля | 1 | 0 | 0 | 0 |
| Обновление профиля | 1 | 0 | 0 | 0 |
| Рекомендации | 1 | 0 | 1 | 5 |
| Сохранить рецепт | 1 | 0 | 0 | 0 |
| Список сохранённых | 1 | 0 | 0 | 0 |
| Удалить из сохранённых | 1 | 0 | 0 | 0 |
| Refresh токена | 1 | 0 | 0 | 0 |
| Распознавание чека | 2 | 0 | 1 | 0 |
| Распознавание фото продуктов | 2 | 0 | 13 | 0 |
| Распознавание блюда | 2 | 0 | 1 | 0 |
| Генерация меню (неделя) | 1 | 0 | 1 | до 21 |
| Просмотр меню | 1 | 0 | 0 | 0 |
| Список покупок из меню | 1 | 0 | 0 | 0 |
### Ключевые выводы
1. **Критические пути требуют Gemini + Pexels:** рекомендации (24 сек), распознавание продуктов (13 сек), генерация меню (510 сек). Во всех случаях нужна skeleton-загрузка в UI.
2. **Pexels — потенциальный bottleneck** при масштабировании (200 req/hour). Особенно при генерации меню (до 21 вызова). Решается кэшированием image_url по query-строке в Redis.
3. **Всё остальное работает без внешних зависимостей** — отказ Gemini/Pexels не роняет авторизацию, профиль, сохранённые рецепты, меню (просмотр/редактирование).
4. **КБЖУ приблизительные** — Gemini генерирует оценочные значения. Для MVP этого достаточно; точные данные требуют интеграции с верифицированной базой (USDA FoodData Central, см. TODO.md).
5. **Gemini Free tier (1 500 req/day):** распознавание продуктов (3 AI-операции) + рекомендации (1) + меню (1) = ~5 Gemini-запросов на активного пользователя. Free tier хватает на 300 DAU.

89
docs/TODO.md Normal file
View File

@@ -0,0 +1,89 @@
# TODO: будущие улучшения
Функционал, сознательно отложенный. Основные AI-фичи (рекомендации, распознавание, меню) реализуются через Gemini в итерациях 14.
---
## База данных рецептов и нутриентов
### Верифицированная база нутриентов
Сейчас КБЖУ генерирует Gemini (приблизительно, помечается «≈» в UI). Для пользователей с медицинскими показаниями (диабет, ожирение) нужна точность:
- **USDA FoodData Central** — государственная база США, бесплатно, 300K+ продуктов, верифицированы лабораторно
- API: `api.nal.usda.gov/fdc/v1/` (ключ бесплатный)
- Данные используются как reference при генерации Gemini
- **Open Food Facts** — community база для упакованных продуктов со штрих-кодами
### Постоянная база рецептов с поиском
Сейчас рецепты генерируются on-demand и хранятся только если сохранены. В будущем:
- Постоянная база 5K50K рецептов с FTS-поиском
- Фильтрация, рейтинги, отзывы, история просмотров
- Каталог с пагинацией
- Возможные источники: Spoonacular (коммерческая лицензия), собственная редакция + Gemini
---
## Функциональность
### Поиск по рецептам
Когда будет постоянная база — полноценный поиск:
- Full-text search по названию и ингредиентам (PostgreSQL tsvector, индексы уже в схеме)
- Фильтры: кухня, сложность, время, КБЖУ, диетические теги
- "Что можно приготовить из этих продуктов" — SQL-запрос по mapping_id
### Дневник питания и статистика
- Запись что съедено за день (из рецепта, из меню, вручную, фото)
- Автоподсчёт КБЖУ за день, прогресс к норме
- Графики за день / неделю / месяц
- Быстрое добавление перекусов через поиск
### Рейтинги и отзывы рецептов
- Оценка и отзыв после готовки
- Поля `avg_rating` и `review_count` уже есть в схеме `recipes`
- Реализовать когда появится постоянная база
### Шаблоны меню
- Сохранить удачное меню как шаблон (например «Рабочая неделя»)
- Повторное применение с учётом текущих продуктов
### Пользовательские рецепты
- Создать и сохранить собственный рецепт
- Доступен в личном каталоге, не виден другим (или можно поделиться)
---
## Технический долг
### Кэширование
- **Redis** для кэша Pexels image_url по query-строке (сейчас: новый Pexels-запрос при каждой генерации)
- **Кэш рекомендаций** на 30 минут — не перегенерировать если продукты не изменились
### Оффлайн-режим
- Кэшировать последние рекомендации и меню локально (Hive/SharedPreferences)
- Сохранённые рецепты — полностью оффлайн
### Уведомления
- Push-уведомления о продуктах, срок которых истекает завтра
- Напоминание приготовить по плану меню
### Монетизация
- **Free tier:** N рекомендаций/день, без меню на неделю
- **Premium:** неограниченные рекомендации, планировщик меню, расширенная аналитика, приоритетная очередь Gemini
### Масштабирование Gemini при росте
При 10 000 DAU × 5 AI-запросов/день = 50 000 запросов/день:
- Gemini Flash: ~$0.0003/запрос → **$15/день = $450/мес**
- Оптимизация: батчинг, кэширование, rate limiting по плану

326
docs/plans/Iteration_1.md Normal file
View File

@@ -0,0 +1,326 @@
# Итерация 1: AI-рекомендации рецептов
**Цель:** реализовать ключевую функцию — персонализированные рецепты, сгенерированные Gemini с фотографиями из Pexels, и возможность их сохранять.
**Зависимости:** Итерация 0 (авторизация, профиль, БД).
**Ориентир:** [Summary.md](./Summary.md)
---
## Структура задач
```
1.1 Backend: Gemini-клиент
├── 1.1.1 Пакет internal/gemini (интерфейс + адаптер)
├── 1.1.2 GenerateRecipes(ctx, prompt) → []Recipe
└── 1.1.3 Retry-стратегия (невалидный JSON → повтор с уточнением)
1.2 Backend: Pexels-клиент
├── 1.2.1 Пакет internal/pexels
└── 1.2.2 SearchPhoto(ctx, query) → image_url
1.3 Backend: saved_recipes
├── 1.3.1 Миграция: таблица saved_recipes
├── 1.3.2 Repository (CRUD)
└── 1.3.3 Service layer
1.4 Backend: эндпоинты рекомендаций
├── 1.4.1 GET /recommendations?count=5
└── 1.4.2 Формирование промпта из профиля пользователя
1.5 Backend: эндпоинты saved_recipes
├── 1.5.1 POST /saved-recipes
├── 1.5.2 GET /saved-recipes
├── 1.5.3 GET /saved-recipes/{id}
└── 1.5.4 DELETE /saved-recipes/{id}
1.6 Flutter: экран рекомендаций
├── 1.6.1 RecommendationsScreen (список карточек)
├── 1.6.2 Skeleton-загрузка (24 сек)
└── 1.6.3 Кнопка сохранить (♡)
1.7 Flutter: карточка рецепта
└── 1.7.1 RecipeDetailScreen (фото, КБЖУ≈, ингредиенты, шаги)
1.8 Flutter: сохранённые рецепты
└── 1.8.1 SavedRecipesScreen (список с удалением)
```
---
## 1.1 Gemini-клиент
### 1.1.1 Структура пакета
```
internal/
└── gemini/
├── client.go # HTTP-клиент к Gemini API
├── recipe.go # GenerateRecipes()
└── client_test.go
```
### 1.1.2 Интерфейс
```go
type RecipeGenerator interface {
GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error)
}
type RecipeRequest struct {
UserGoal string // "weight_loss" | "maintain" | "gain"
DailyCalories int
Restrictions []string // ["gluten_free", "vegetarian"]
CuisinePrefs []string // ["russian", "asian"]
Count int
}
type Recipe struct {
Title string
Description string
Cuisine string
Difficulty string // "easy" | "medium" | "hard"
PrepTimeMin int
CookTimeMin int
Servings int
ImageQuery string // EN, для Pexels
Ingredients []Ingredient
Steps []Step
Tags []string
Nutrition NutritionInfo // приблизительно
}
```
### 1.1.3 Промпт для рекомендаций
```
Ты — диетолог-повар. Предложи {N} рецептов на русском языке.
Профиль пользователя:
- Цель: {goal_ru}
- Дневная норма калорий: {calories} ккал
- Ограничения: {restrictions или "нет"}
- Предпочтения: {cuisines или "любые"}
Требования к каждому рецепту:
- Калорийность на порцию: не более {per_meal_calories} ккал
- Время приготовления: до 60 минут
- Укажи КБЖУ на порцию (приблизительно)
Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки:
[{
"title": "Название",
"description": "2-3 предложения",
"cuisine": "russian|asian|european|mediterranean|american|other",
"difficulty": "easy|medium|hard",
"prep_time_min": 10,
"cook_time_min": 20,
"servings": 2,
"image_query": "dish name ingredients style",
"ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}],
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
"tags": ["высокий белок"],
"nutrition_per_serving": {
"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18
}
}]
```
### 1.1.4 Retry-стратегия
При получении невалидного JSON:
1. Первая попытка: обычный промпт
2. При ошибке парсинга — повтор с явным уточнением: «Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после.»
3. Максимум 3 попытки, затем HTTP 503
---
## 1.2 Pexels-клиент
### 1.2.1 Пакет
```
internal/
└── pexels/
├── client.go # HTTP-клиент к Pexels API
└── client_test.go
```
### 1.2.2 Поиск фото
```go
type PhotoSearcher interface {
SearchPhoto(ctx context.Context, query string) (string, error) // image_url
}
```
- Запрос: `GET https://api.pexels.com/v1/search?query={query}&per_page=1&orientation=landscape`
- Авторизация: `Authorization: {PEXELS_API_KEY}`
- Берём `photos[0].src.medium` (~1200×630)
- При пустом ответе — возвращаем дефолтное фото (placeholder)
### 1.2.3 Параллельный запрос для рецептов
```go
// Для каждого рецепта — параллельный запрос к Pexels
var wg sync.WaitGroup
for i, recipe := range recipes {
wg.Add(1)
go func(i int, r Recipe) {
defer wg.Done()
url, _ := pexels.SearchPhoto(ctx, r.ImageQuery)
recipes[i].ImageURL = url
}(i, recipe)
}
wg.Wait()
```
---
## 1.3 Миграция: saved_recipes
```sql
-- migrations/002_create_saved_recipes.sql
-- +goose Up
CREATE TABLE saved_recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
cuisine TEXT,
difficulty TEXT,
prep_time_min INT,
cook_time_min INT,
servings INT,
image_url TEXT,
ingredients JSONB NOT NULL DEFAULT '[]',
steps JSONB NOT NULL DEFAULT '[]',
tags JSONB NOT NULL DEFAULT '[]',
nutrition JSONB, -- {calories, protein_g, fat_g, carbs_g}
source TEXT NOT NULL DEFAULT 'ai',
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_saved_recipes_user_id ON saved_recipes(user_id);
CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes(user_id, saved_at DESC);
-- +goose Down
DROP TABLE saved_recipes;
```
---
## 1.4 GET /recommendations
### Flow
```
1. Получить JWT из middleware → user_id
2. SELECT users WHERE id=$1 → профиль (goal, daily_calories, preferences)
3. Формирование промпта из профиля
4. gemini.GenerateRecipes(ctx, req) → []Recipe (~13 сек)
5. Для каждого рецепта: pexels.SearchPhoto(ctx, image_query) (параллельно)
6. Вернуть JSON-массив рецептов с image_url
```
### Request / Response
```
GET /recommendations?count=5
Response 200:
[{
"title": "Куриная грудка с овощами",
"description": "Лёгкое блюдо высокого белка...",
"cuisine": "european",
"difficulty": "easy",
"prep_time_min": 10,
"cook_time_min": 25,
"servings": 2,
"image_url": "https://images.pexels.com/...",
"ingredients": [...],
"steps": [...],
"tags": ["высокий белок", "без глютена"],
"nutrition_per_serving": {
"calories": 420,
"protein_g": 48,
"fat_g": 12,
"carbs_g": 18,
"approximate": true
}
}]
```
---
## 1.5 Эндпоинты saved_recipes
| Метод | Путь | Описание |
|-------|------|----------|
| POST | `/saved-recipes` | Сохранить рецепт из рекомендаций |
| GET | `/saved-recipes` | Список сохранённых (по saved_at DESC) |
| GET | `/saved-recipes/{id}` | Один сохранённый рецепт |
| DELETE | `/saved-recipes/{id}` | Удалить из сохранённых |
POST `/saved-recipes` принимает полный объект рецепта (уже с image_url) и сохраняет в БД. Gemini и Pexels не вызываются.
---
## 1.61.8 Flutter
### RecommendationsScreen
- При открытии вкладки: `GET /recommendations?count=5`
- Skeleton-анимация пока идёт запрос
- Карточка: фото (CachedNetworkImage), название, КБЖУ≈, время, сложность
- Иконка ♡: тап → POST /saved-recipes → haptic feedback, иконка заполняется
- Кнопка 🔄 в AppBar: перегенерировать рекомендации
### RecipeDetailScreen
- Фото сверху (hero animation)
- Метаданные: время, сложность, кухня
- КБЖУ-блок с пометкой «≈ приблизительно» (тап → tooltip)
- Список ингредиентов с количеством
- Нумерованные шаги (с таймером если `timer_seconds != null`)
- Кнопка «Сохранить» / «Сохранено»
### SavedRecipesScreen
- GET /saved-recipes (пагинация)
- Карточки с фото, название, КБЖУ
- Свайп для удаления или кнопка корзины
- Пустое состояние: «Сохраните рецепты, которые вам понравились»
---
## Конфигурация
Новые переменные в `.env`:
```
GEMINI_API_KEY=your-gemini-api-key
PEXELS_API_KEY=your-pexels-api-key
```
Обновить `internal/config/config.go`:
```go
type Config struct {
// ...
GeminiAPIKey string `envconfig:"GEMINI_API_KEY" required:"true"`
PexelsAPIKey string `envconfig:"PEXELS_API_KEY" required:"true"`
}
```
---
## Зависимости Go
```
go get github.com/google/generative-ai-go/genai@latest
```
Pexels — чистый HTTP (net/http), без SDK.

241
docs/plans/Iteration_2.md Normal file
View File

@@ -0,0 +1,241 @@
# Итерация 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 над иконкой.

328
docs/plans/Iteration_3.md Normal file
View File

@@ -0,0 +1,328 @@
# Итерация 3: Распознавание продуктов (Gemini Vision)
**Цель:** пользователь фотографирует чек, холодильник или готовое блюдо — Gemini Vision автоматически определяет продукты и их количество, после чего пользователь подтверждает список и добавляет в запасы.
**Зависимости:** Итерация 2 (управление продуктами, ingredient_mappings).
---
## Структура задач
```
3.1 Backend: AI-распознавание чека
├── 3.1.1 POST /ai/recognize-receipt (multipart: image)
├── 3.1.2 Промпт для OCR чека
└── 3.1.3 Fuzzy match результатов по ingredient_mappings
3.2 Backend: AI-распознавание фото продуктов
├── 3.2.1 POST /ai/recognize-products (multipart: 13 images)
├── 3.2.2 Параллельные Gemini-запросы для нескольких фото
└── 3.2.3 Дедупликация и объединение результатов
3.3 Backend: AI-распознавание блюда
├── 3.3.1 POST /ai/recognize-dish (multipart: image)
└── 3.3.2 Промпт для определения блюда и КБЖУ
3.4 Backend: нераспознанные продукты → Gemini
└── 3.4.1 Если fuzzy match не найден → запрос к Gemini для классификации
→ сохранение нового маппинга в ingredient_mappings
3.5 S3: загрузка фото
├── 3.5.1 Конфигурация S3-совместимого хранилища (MinIO/Cloud Storage)
└── 3.5.2 Сохранение фото на период обработки (TTL 24h)
3.6 Flutter: экран сканирования
├── 3.6.1 ScanScreen (камера или галерея)
├── 3.6.2 Выбор режима: чек / продукты / блюдо
├── 3.6.3 Экран подтверждения списка продуктов
└── 3.6.4 Экран результата распознавания блюда
```
---
## 3.1 Распознавание чека
### Промпт
```
Ты — OCR-система для чеков из продуктовых магазинов.
Проанализируй фото чека и извлеки список продуктов питания.
Для каждого продукта определи:
- name: название на русском языке (очисти от артикулов и кодов)
- quantity: количество (число)
- unit: единица (г, кг, мл, л, шт, уп)
- category: dairy | meat | produce | bakery | frozen | beverages | other
- confidence: 0.01.0
Позиции, которые не являются едой (бытовая химия, табак), пропусти.
Позиции с непонятным текстом добавь в unrecognized.
Верни ТОЛЬКО валидный JSON без markdown:
{
"items": [
{"name": "Молоко 2.5%", "quantity": 1, "unit": "л",
"category": "dairy", "confidence": 0.95}
],
"unrecognized": [
{"raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0}
]
}
```
### Fuzzy match
После получения списка от Gemini — для каждого item:
```go
// 1. Поиск в ingredient_mappings
mapping, found := ingredientRepo.FuzzyMatch(ctx, item.Name)
// 2. Нашли — используем mapping_id, подставляем дефолты (unit, storage_days)
// 3. Не нашли → разовый запрос к Gemini для классификации:
if !found {
mapping = gemini.ClassifyIngredient(ctx, item.Name)
ingredientRepo.Save(ctx, mapping) // новая строка в ingredient_mappings
}
```
### Response
```json
{
"items": [
{
"name": "Куриная грудка",
"quantity": 500,
"unit": "г",
"category": "meat",
"mapping_id": "uuid",
"storage_days": 3,
"confidence": 0.95
}
],
"unrecognized": [
{ "raw_text": "ТОВ АРТИК 1ШТ" }
]
}
```
Клиент показывает список для подтверждения, затем вызывает `POST /products/batch`.
---
## 3.2 Распознавание фото продуктов
### Промпт (на каждое фото)
```
Ты — система распознавания продуктов питания.
Посмотри на фото и определи все видимые продукты питания.
Для каждого продукта оцени:
- name: название на русском языке
- quantity: приблизительное количество (число)
- unit: единица (г, кг, мл, л, шт)
- category: dairy | meat | produce | bakery | frozen | beverages | other
- confidence: 0.01.0
Только продукты питания. Упаковки без содержимого — пропусти.
Верни ТОЛЬКО валидный JSON:
{
"items": [
{"name": "Яйца", "quantity": 10, "unit": "шт",
"category": "dairy", "confidence": 0.9}
]
}
```
### Обработка нескольких фото
```go
// Параллельные запросы к Gemini (по одному на фото)
results := make([][]RecognizedItem, len(images))
var wg sync.WaitGroup
for i, img := range images {
wg.Add(1)
go func(i int, img []byte) {
defer wg.Done()
results[i], _ = gemini.RecognizeProducts(ctx, img)
}(i, img)
}
wg.Wait()
// Дедупликация: если canonical_name совпадает → суммируем quantity
merged := mergeAndDeduplicate(results)
```
---
## 3.3 Распознавание блюда
### Промпт
```
Ты — диетолог и кулинарный эксперт.
Посмотри на фото блюда и определи:
- dish_name: название блюда на русском языке
- weight_grams: приблизительный вес порции в граммах
- calories: калорийность порции (приблизительно)
- protein_g, fat_g, carbs_g: БЖУ на порцию
- confidence: 0.01.0
- similar_dishes: до 3 похожих блюд (для поиска рецептов)
Верни ТОЛЬКО валидный JSON:
{
"dish_name": "Паста Карбонара",
"weight_grams": 350,
"calories": 520,
"protein_g": 22,
"fat_g": 26,
"carbs_g": 48,
"confidence": 0.85,
"similar_dishes": ["Паста с беконом", "Спагетти"]
}
```
### Response-flow
```
POST /ai/recognize-dish → Gemini → {dish_name, calories≈, КБЖУ≈}
Поиск в saved_recipes по dish_name (optional)
Response: {dish_name, nutrition≈, matched_recipe?: {id, title}}
```
Клиент показывает результат с возможностью добавить в дневник питания.
---
## 3.4 Классификация нераспознанного ингредиента
Когда fuzzy match не нашёл маппинг:
```
Промпт:
"Классифицируй продукт питания: '{name}'.
Ответь JSON:
{
'canonical_name': 'turkey_breast',
'canonical_name_ru': 'грудка индейки',
'category': 'meat',
'default_unit': 'g',
'calories_per_100g': 135,
'protein_per_100g': 29,
'fat_per_100g': 1,
'carbs_per_100g': 0,
'storage_days': 3,
'aliases': ['грудка индейки', 'филе индейки', 'turkey breast']
}"
```
Результат сохраняется в `ingredient_mappings`. Следующий пользователь с тем же продуктом — AI не вызывается.
---
## 3.5 S3-хранилище для фото
Фотографии загружаются на S3 (MinIO локально / Cloud Storage в проде):
```
1. Клиент: PUT /upload → presigned URL
2. Клиент загружает фото напрямую на S3
3. Клиент: POST /ai/recognize-receipt { s3_key: "..." }
4. Backend: скачивает фото с S3, отправляет в Gemini, удаляет (TTL 24h)
```
Альтернатива для MVP: принимать base64 в теле запроса (для начала проще).
---
## 3.6 Flutter: экран сканирования
### ScanScreen (точка входа)
```
┌─────────────────────────────────────┐
│ [←] Добавить продукты │
├─────────────────────────────────────┤
│ │
│ Выберите способ │
│ │
│ ┌───────────────────────────────┐ │
│ │ 🧾 Сфотографировать чек │ │
│ │ Распознаем все продукты │ │
│ └───────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ 🥦 Сфотографировать продукты │ │
│ │ Холодильник, стол, полка │ │
│ └───────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ ✏️ Добавить вручную │ │
│ └───────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
### Экран подтверждения
После распознавания — список с возможностью:
- Редактировать количество/единицу каждого пункта
- Удалить нераспознанные или ошибочные
- Добавить вручную
- Кнопка «Добавить всё» → POST /products/batch
### Экран результата блюда
```
┌─────────────────────────────────────┐
│ [←] Распознано блюдо │
├─────────────────────────────────────┤
│ [фото блюда] │
│ │
│ Паста Карбонара │
│ Уверенность: 85% │
│ │
│ ≈ 520 ккал (приблизительно) │
│ Б: 22г · Ж: 26г · У: 48г
Вес порции: ~350г
│ │
│ ┌───────────────────────────────┐ │
│ │ 📓 Добавить в дневник │ │
│ └───────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ 📖 Открыть рецепт │ │
│ └───────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
---
## Новые конфигурационные переменные
```
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=food-ai-uploads
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
```
## Оценка нагрузки на Gemini
| Действие | Gemini-вызовов |
|----------|---------------|
| Чек (1 фото) | 1 vision |
| Фото продуктов (3 фото) | 3 vision (параллельно) |
| Блюдо | 1 vision |
| Нераспознанный ингредиент | 1 текст (разово) |
При 100 активных пользователях × 1 сканирование/день:
- 100300 дополнительных Gemini-запросов/день
- Free tier (1 500/день) остаётся в запасе

367
docs/plans/Iteration_4.md Normal file
View File

@@ -0,0 +1,367 @@
# Итерация 4: Планирование меню
**Цель:** пользователь запрашивает меню на неделю — Gemini генерирует полный план питания (21 приём пищи) с учётом продуктов, целей и предпочтений. Из меню автоматически формируется список покупок.
**Зависимости:** Итерация 2 (продукты), Итерация 1 (сохранённые рецепты).
---
## Структура задач
```
4.1 Backend: таблицы меню
├── 4.1.1 Миграция: menu_plans, menu_items
└── 4.1.2 Миграция: meal_diary
4.2 Backend: генерация меню
├── 4.2.1 POST /ai/generate-menu
├── 4.2.2 Промпт для Gemini (7 дней × 3 приёма)
├── 4.2.3 Параллельный Pexels для 21 рецепта
└── 4.2.4 Сохранение в menu_plans + menu_items + saved_recipes
4.3 Backend: CRUD меню
├── 4.3.1 GET /menu?week=YYYY-WNN
├── 4.3.2 PUT /menu/items/{id} (заменить рецепт)
└── 4.3.3 DELETE /menu/items/{id}
4.4 Backend: список покупок
├── 4.4.1 POST /shopping-list/generate (из меню)
├── 4.4.2 GET /shopping-list
└── 4.4.3 PATCH /shopping-list/items/{index}/check
4.5 Backend: дневник питания
├── 4.5.1 GET /diary?date=YYYY-MM-DD
├── 4.5.2 POST /diary (добавить запись)
└── 4.5.3 DELETE /diary/{id}
4.6 Flutter: экран меню
├── 4.6.1 MenuScreen (7-дневный вид)
├── 4.6.2 Кнопка «Сгенерировать меню»
├── 4.6.3 Редактирование слота (смена рецепта)
└── 4.6.4 Skeleton на время генерации (510 сек)
4.7 Flutter: список покупок
└── 4.7.1 ShoppingListScreen (с галочками)
4.8 Flutter: дневник питания
├── 4.8.1 DiaryScreen (записи за день)
└── 4.8.2 Добавление записи (из меню / вручную)
```
---
## 4.1 Миграции
### menu_plans и menu_items
```sql
-- migrations/005_create_menu_plans.sql
-- +goose Up
CREATE TABLE menu_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
week_start DATE NOT NULL, -- понедельник недели
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, week_start)
);
CREATE TABLE menu_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')),
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
recipe_data JSONB, -- snapshot рецепта (если saved_recipe удалён)
UNIQUE(menu_plan_id, day_of_week, meal_type)
);
-- +goose Down
DROP TABLE menu_items;
DROP TABLE menu_plans;
```
### meal_diary
```sql
-- migrations/006_create_meal_diary.sql
-- +goose Up
CREATE TABLE meal_diary (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
meal_type TEXT NOT NULL,
name TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
calories DECIMAL(8,2),
protein_g DECIMAL(8,2),
fat_g DECIMAL(8,2),
carbs_g DECIMAL(8,2),
source TEXT NOT NULL DEFAULT 'manual', -- manual|recipe|menu|photo
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_meal_diary_user_date ON meal_diary(user_id, date);
-- +goose Down
DROP TABLE meal_diary;
```
---
## 4.2 Генерация меню
### 4.2.1 Промпт для Gemini
```
Ты — диетолог-повар. Составь меню на 7 дней для пользователя.
Профиль:
- Цель: {goal_ru}
- Дневная норма калорий: {calories} ккал
- Ограничения: {restrictions или "нет"}
- Предпочтения кухни: {cuisines или "любые"}
Продукты в наличии (приоритет — скоро истекают ⚠):
{products_list}
Требования:
- 3 приёма пищи в день: завтрак (25% КБЖУ), обед (40%), ужин (35%)
- Разнообразие: не повторять рецепты
- По возможности использовать имеющиеся продукты
- КБЖУ рассчитать на 1 порцию (приблизительно)
Верни ТОЛЬКО валидный JSON без markdown:
{
"days": [
{
"day": 1,
"meals": [
{
"meal_type": "breakfast",
"recipe": {
"title": "Овсяная каша с яблоком",
"description": "...",
"cuisine": "european",
"difficulty": "easy",
"prep_time_min": 5,
"cook_time_min": 10,
"servings": 1,
"image_query": "oatmeal apple breakfast bowl",
"ingredients": [...],
"steps": [...],
"tags": [...],
"nutrition_per_serving": {
"calories": 320, "protein_g": 8,
"fat_g": 6, "carbs_g": 58
}
}
},
{ "meal_type": "lunch", "recipe": {...} },
{ "meal_type": "dinner", "recipe": {...} }
]
},
...
]
}
```
### 4.2.2 Параллельный Pexels
21 Pexels-запрос выполняется параллельно (горутины). С учётом лимита 200 req/hour — для одного меню это 10% дневного бюджета. Повторяющиеся image_query (между пользователями) следует кэшировать в будущем (Redis, см. TODO.md).
### 4.2.3 Сохранение
```go
// Транзакция:
// 1. INSERT menu_plans (upsert по user_id + week_start)
// 2. INSERT saved_recipes для каждого из 21 рецептов
// 3. INSERT menu_items (21 записи с recipe_id → saved_recipes)
```
---
## 4.3 CRUD меню
### GET /menu?week=2026-W08
```json
{
"id": "uuid",
"week_start": "2026-02-16",
"days": [
{
"day": 1,
"date": "2026-02-16",
"meals": [
{
"id": "menu_item_uuid",
"meal_type": "breakfast",
"recipe": {
"id": "saved_recipe_uuid",
"title": "Овсяная каша с яблоком",
"image_url": "...",
"calories": 320,
"nutrition_per_serving": {...}
}
}
],
"total_calories": 1780
}
]
}
```
### PUT /menu/items/{id}
Заменить рецепт в слоте меню. Тело: `{ "recipe_id": "uuid" }` — ID существующего сохранённого рецепта, или запрос нового от Gemini.
---
## 4.4 Список покупок
### POST /shopping-list/generate
```go
// 1. SELECT menu_items JOIN saved_recipes WHERE menu_plan_id=$1
// 2. Извлечь все ингредиенты из всех рецептов
// 3. Агрегация по canonical_name (через ingredient_mappings):
// суммирование количества для одинаковых ингредиентов
// 4. Вычесть уже имеющееся в products (где quantity > 0)
// 5. INSERT/UPSERT shopping_lists
```
Gemini не участвует — чистая SQL-агрегация.
### GET /shopping-list
```json
[
{
"name": "Куриная грудка",
"category": "meat",
"amount": 1200,
"unit": "г",
"checked": false,
"in_stock": 0
},
{
"name": "Яйца",
"category": "dairy",
"amount": 12,
"unit": "шт",
"checked": false,
"in_stock": 4
}
]
```
---
## 4.6 Flutter: экран меню
### MenuScreen
```
┌─────────────────────────────────────┐
│ Меню [← Пред] [След →]│
│ Неделя 1622 февраля │
├─────────────────────────────────────┤
│ │
│ Понедельник, 16 фев 1 780 ккал │
│ ┌──────────────────────────────┐ │
│ │ 🌅 Завтрак ≈320 ккал │ │
│ │ [фото] Овсянка с яблоком │ │
│ │ [Изменить]│ │
│ ├──────────────────────────────┤ │
│ │ ☀ Обед ≈680 ккал │ │
│ │ [фото] Куриный суп │ │
│ │ [Изменить]│ │
│ ├──────────────────────────────┤ │
│ │ 🌙 Ужин ≈780 ккал │ │
│ │ [фото] Рис с овощами │ │
│ │ [Изменить]│ │
│ └──────────────────────────────┘ │
│ │
│ Вторник, 17 фев 1 820 ккал │
│ ┌──────────────────────────────┐ │
│ │ ... │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ Сгенерировать новое меню │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ 🛒 Список покупок │ │
│ └─────────────────────────────┘ │
│ │
├─────────────────────────────────────┤
│ [Главная] [Продукты] [Меню●] [Рецепты] [Профиль] │
└─────────────────────────────────────┘
```
### Skeleton при генерации (510 сек)
```
┌─────────────────────────────────────┐
│ Меню │
├─────────────────────────────────────┤
│ │
│ Составляем меню на неделю... │
│ Учитываем ваши продукты и цели │
│ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ │
│ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
│ │
└─────────────────────────────────────┘
```
---
## 4.7 Flutter: список покупок
```
┌─────────────────────────────────────┐
│ [←] Список покупок [Поделиться]│
├─────────────────────────────────────┤
│ │
│ Мясо │
│ ☐ Куриная грудка 1.2 кг │
│ ☐ Фарш говяжий 500 г
│ │
│ Молочное │
│ ☑ Яйца 12 шт │
│ (4 шт есть дома) │
│ ☐ Молоко 1 л │
│ │
│ Овощи │
│ ☐ Морковь 3 шт │
│ ☐ Лук репчатый 4 шт │
│ │
│ ┌───────────────────────────────┐ │
│ │ + Добавить вручную │ │
│ └───────────────────────────────┘ │
│ │
├─────────────────────────────────────┤
│ Осталось купить: 8 позиций │
└─────────────────────────────────────┘
```
---
## Оценка нагрузки
| Действие | Gemini | Pexels |
|----------|--------|--------|
| Генерация меню (неделя) | 1 (большой промпт) | до 21 |
| Просмотр/редактирование меню | 0 | 0 |
| Список покупок | 0 | 0 |
| Дневник питания | 0 | 0 |
Генерация меню — самый тяжёлый запрос по Pexels. Происходит 1 раз в неделю на пользователя. При 100 DAU × 1/7 = ~14 генераций/день × 21 Pexels = 294 Pexels-запроса/день — в пределах лимита 200 req/hour.

View File

@@ -5,16 +5,12 @@
| # | Итерация | Цель | Зависит от |
|---|----------|------|------------|
| 0 | Фундамент | Go-проект, БД, авторизация, Flutter-каркас | — |
| 1 | Справочник ингредиентов и рецепты | Наполнение БД рецептами, маппинг ингредиентов | 0 |
| 2 | Управление продуктами | CRUD продуктов, сроки хранения | 0 |
| 3 | AI-ядро | Очереди, Gemini-адаптер, rate limiter, budget guard | 0 |
| 4 | AI-распознавание | OCR чека, фото продуктов, фото блюд | 2, 3 |
| 5 | Каталог рецептов | Поиск, фильтры, «из моих продуктов», замены | 1, 2 |
| 6 | Планирование меню | Меню на неделю, AI-генерация, список покупок | 3, 5 |
| 7 | Дневник питания | Записи, порции, трекер воды, калории | 5 |
| 8 | Режим готовки | Пошаговая готовка, таймеры | 5 |
| 9 | Рекомендации и статистика | Рекомендации на главной, графики, тренды | 6, 7 |
| 10 | Полировка | Онбординг, пустые состояния, уведомления, отзывы | 9 |
| 1 | AI-рекомендации рецептов | Gemini генерирует рецепты, Pexels фото, сохранение рецептов | 0 |
| 2 | Управление продуктами | CRUD продуктов, сроки хранения, ingredient_mappings | 0 |
| 3 | Распознавание продуктов | OCR чека, фото продуктов, фото блюд (Gemini Vision) | 1, 2 |
| 4 | Планирование меню | Меню на неделю, AI-генерация, список покупок, дневник | 1, 2 |
Дальнейшие итерации определяются приоритетами после MVP. Функциональность из TODO.md (дневник статистики, режим готовки, полировка) — следующий горизонт.
## Карта зависимостей
@@ -23,57 +19,28 @@
│ 0. Фундамент │
└──────┬───────┘
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌───────────┐ ┌────────────────┐
│ 1. Справочник │ │ 2. Продук-│ │ 3. AI-ядро │
│ ингредиентов │ │ ты │ │ (очереди, │
│ + рецепты │ │ │ │ Gemini) │
└───────┬────────┘ └─────┬─────┘ └───────┬────────┘
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ │ ┌────┴──────────┘
│ │ │
│ ▼ ▼
│ ┌──────────────────┐
│ │ 4. AI-распозна- │
│ │ вание │
│ └──────────────────┘
┌────────────────────────┐
│ │
└─────┬─────┘
┌────────────────┐
│ 5. Каталог
│ рецептов │
└───────┬────────┘
┌──────────┼──────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 6. Меню │ │ 7. Днев- │ │ 8. Режим │
│ + список │ │ ник пита-│ │ готовки │
│ покупок │ │ ния │ │ │
└─────┬────┘ └─────┬────┘ └──────────┘
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ 1. AI-рекомендации │ 2. Продукты │
│ (Gemini+Pexels) │ │ + ingredient_ │
saved_recipes │ │ mappings
└──────────┬─────────┘ └────────┬─────────┘
│ │
└───────────┘
└────────────┬────────────┘
┌────────────────┐
│ 9. Рекоменда-
│ ции + стат-ка │
└───────┬────────┘
────────────────
│ 10. Полировка │
└────────────────┘
┌──────────┴──────────┐
│ │
┌────────────────────┐ ┌─────────────────────┐
│ 3. Распознавание │ │ 4. Планирование │
│ продуктов │ │ меню
(Gemini Vision) │ │ (Gemini+Pexels) │
└────────────────────┘ └─────────────────────┘
```
**Параллельная разработка:** итерации 1, 2, 3 могут выполняться параллельно. Итерации 6, 7, 8 — тоже параллельно после завершения 5.
**Параллельная разработка:** итерации 1 и 2 могут выполняться параллельно. Итерации 3 и 4 — тоже параллельно после завершения 1 и 2.
---
@@ -115,33 +82,46 @@
---
## Итерация 1: Справочник ингредиентов и рецепты
## Итерация 1: AI-рекомендации рецептов
**Цель:** наполнить БД каноническими ингредиентами и рецептами из Spoonacular. Это фундамент для всех фичей, связанных с рецептами, поиском и маппингом продуктов.
> **Детальный план:** [Iteration_1.md](./Iteration_1.md)
**Цель:** реализовать ключевую функцию — персонализированные рецепты, сгенерированные Gemini, с фотографиями из Pexels и возможностью сохранять понравившиеся.
**Зависимости:** итерация 0.
### User Stories
#### Backend
| ID | Story | Описание |
|----|-------|----------|
| 1.1 | Таблица ingredient_mappings | Миграция: id, canonical_name, spoonacular_id, aliases (JSONB), category, default_unit, нутриенты на 100г, storage_days. Индексы: GIN по aliases, UNIQUE по spoonacular_id |
| 1.2 | Импорт ингредиентов из Spoonacular | CLI-команда / джоб: запрос Spoonacular Ingredient API → сохранение ~1000 базовых ингредиентов в ingredient_mappings |
| 1.3 | Таблица recipes | Миграция: id, source, spoonacular_id, title, description, cuisine, difficulty, prep_time_min, калории, БЖУ, servings, image_url, ingredients (JSONB), steps (JSONB), tags (JSONB), avg_rating, review_count, created_by. Индексы: GIN по ingredients, full-text по title |
| 1.4 | Импорт рецептов из Spoonacular | CLI-команда / джоб: импорт 5 00010 000 популярных рецептов. Маппинг ингредиентов рецепта на ingredient_mappings через spoonacular_id |
| 1.5 | Перевод рецептов | Batch-джоб: перевод title, description, steps через Gemini Flash-Lite. Результат сохраняется в БД (поля title_ru, description_ru или отдельная таблица переводов) |
| 1.6 | Базовая локализация aliases | Перевод aliases топ-200 ингредиентов на русский. Batch через Gemini или ручной маппинг |
| 1.1 | Gemini-клиент | Пакет internal/gemini. Интерфейс RecipeGenerator. GenerateRecipes(prompt) → []Recipe. Retry на невалидный JSON |
| 1.2 | Pexels-клиент | Пакет internal/pexels. SearchPhoto(query) → image_url. Параллельные запросы |
| 1.3 | Таблица saved_recipes | Миграция: id, user_id, title, description, cuisine, difficulty, prep/cook_time_min, servings, image_url, ingredients (JSONB), steps (JSONB), tags (JSONB), nutrition (JSONB), source, saved_at |
| 1.4 | GET /recommendations | Формирует промпт из профиля пользователя → Gemini → Pexels → ответ с image_url |
| 1.5 | CRUD saved_recipes | POST /saved-recipes, GET /saved-recipes, GET /saved-recipes/{id}, DELETE /saved-recipes/{id} |
#### Flutter
| ID | Story | Описание |
|----|-------|----------|
| 1.6 | RecommendationsScreen | Список карточек, skeleton-загрузка, кнопка 🔄 для перегенерации |
| 1.7 | RecipeDetailScreen | Фото, КБЖУ≈, ингредиенты, шаги, кнопка сохранить |
| 1.8 | SavedRecipesScreen | Список с удалением, пустое состояние |
### Результат итерации
- БД содержит ~1 000 ингредиентов с русскими алиасами и нутриентами
- БД содержит 5 00010 000 рецептов с переводами, ингредиентами, шагами, нутриентами
- Каждый ингредиент рецепта связан с ingredient_mappings через spoonacular_id
- Пользователь открывает вкладку «Рецепты» и видит 5 персонализированных рецептов с фото
- Может сохранить рецепт, просмотреть детали, удалить из сохранённых
- КБЖУ помечены «≈» как приблизительные
---
## Итерация 2: Управление продуктами
**Цель:** пользователь может вести список своих продуктов вручную — добавлять, редактировать, удалять, отслеживать сроки.
> **Детальный план:** [Iteration_2.md](./Iteration_2.md)
**Цель:** пользователь может вести список своих продуктов вручную — добавлять через автодополнение (ingredient_mappings), редактировать, удалять, отслеживать сроки. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты.
**Зависимости:** итерация 0.
@@ -177,39 +157,13 @@
---
## Итерация 3: AI-ядро
## Итерация 3: Распознавание продуктов
**Цель:** построить инфраструктуру для AI-запросов: очереди, rate limiter, budget guard, адаптер Gemini. После итерации можно отправлять AI-запросы через API с контролем расхода.
> **Детальный план:** [Iteration_3.md](./Iteration_3.md)
**Зависимости:** итерация 0.
**Цель:** пользователь фотографирует чек, холодильник или блюдо — Gemini Vision распознаёт продукты и помогает заполнить список запасов.
### User Stories
| ID | Story | Описание |
|----|-------|----------|
| 3.1 | AI Service интерфейсы | Go-интерфейсы: FoodRecognizer, RecipeGenerator, MenuPlanner, NutritionEstimator. Структуры запросов и ответов |
| 3.2 | Gemini-адаптер | Реализация интерфейсов через Gemini API (google/generative-ai-go). Structured JSON output. Обработка ошибок, retries |
| 3.3 | Таблица ai_tasks | Миграция: id, user_id, task_type, status, priority, input/output_tokens, estimated_cost, queue/process_time_ms, created_at, completed_at. Индексы по user_id, status, created_at |
| 3.4 | Priority Queue Manager | Две очереди (chan в Go): paid (N воркеров), free (1 воркер). Распределение RPM между очередями. Горутины-воркеры |
| 3.5 | Rate Limiter (per-user) | Token bucket на горутинах. Конфигурируемые лимиты по тарифу (free: 20 req/час, paid: 100 req/час). HTTP 429 при превышении |
| 3.6 | Budget Guard | Подсчёт дневных затрат по ai_tasks. Пороги: 80% → warn, 100% → free stop, 120% → all stop. Счётчик сбрасывается в полночь |
| 3.7 | AI API эндпоинты (заглушки) | `POST /ai/recognize-receipt`, `/ai/recognize-products`, `/ai/recognize-dish`, `/ai/suggest-recipes`, `/ai/generate-menu`, `/ai/substitute`. Возвращают task_id (HTTP 202). `GET /ai/tasks/{id}` для polling |
| 3.8 | Логирование и мониторинг | Каждый AI-запрос логируется в ai_tasks с токенами и стоимостью. Эндпоинт `/admin/ai-stats` для просмотра затрат |
### Результат итерации
- AI-запросы проходят через очередь с приоритетами
- Paid-пользователи обслуживаются быстрее
- Расход бюджета контролируется, при превышении — graceful degradation
- Все запросы логируются с точной стоимостью
- API эндпоинты принимают запросы и возвращают результат через polling
---
## Итерация 4: AI-распознавание
**Цель:** пользователь может фотографировать чеки, продукты и блюда — AI распознаёт и предлагает результат для корректировки.
**Зависимости:** итерации 2 (продукты), 3 (AI-ядро).
**Зависимости:** итерации 1, 2.
### User Stories
@@ -217,36 +171,34 @@
| ID | Story | Описание |
|----|-------|----------|
| 4.1 | OCR чека | Реализация FoodRecognizer.RecognizeReceipt: фото → Gemini Flash (vision) → structured JSON (name, quantity, unit, category, price, confidence). Маппинг результатов на ingredient_mappings |
| 4.2 | Распознавание продуктов (фото) | FoodRecognizer.RecognizeProducts: фото → Gemini Flash → JSON. Поддержка мультифото (объединение результатов, дедупликация). Маппинг на ingredient_mappings |
| 4.3 | Распознавание блюда | FoodRecognizer.RecognizeDish: фото → Gemini Flash → dish_name, weight, calories, БЖУ, confidence. Full-text search по recipes.title для привязки к рецепту из БД |
| 4.4 | Авто-маппинг нераспознанных | Если fuzzy match по aliases не нашёл ингредиент → разовый запрос к Gemini: определить canonical_name → сохранить в ingredient_mappings. Следующий запрос с таким же продуктом — без AI |
| 4.5 | Загрузка фото | Эндпоинт для multipart upload фото. Сохранение в S3. Передача URL в AI-задачу |
| 3.1 | OCR чека | POST /ai/recognize-receipt: фото → Gemini Flash (vision) → JSON (name, qty, unit, confidence). Fuzzy match по ingredient_mappings |
| 3.2 | Фото продуктов | POST /ai/recognize-products: 13 фото → параллельные Gemini-запросы → дедупликация → JSON |
| 3.3 | Распознавание блюда | POST /ai/recognize-dish: фото → Gemini → {dish_name, weight_g, КБЖУ, confidence} |
| 3.4 | Авто-маппинг | Нераспознанный продукт → Gemini классифицирует → сохраняет в ingredient_mappings |
| 3.5 | S3 / multipart | Загрузка фото: multipart или presigned URL |
#### Flutter
| ID | Story | Описание |
|----|-------|----------|
| 4.6 | Экран камеры (чек) | Видоискатель, кнопка съёмки, выбор из галереи. Отправка на backend |
| 4.7 | Экран камеры (еда) | Переключатель «Готовое блюдо» / «Продукты». Съёмка, отправка |
| 4.8 | Экран загрузки AI | Анимация «Распознаём...» с индикатором. Polling по task_id |
| 4.9 | Экран корректировки (чек/фото продуктов) | Список распознанных продуктов. Инлайн-редактирование: название, количество, единица, категория, срок хранения. Чекбоксы, удаление, добавление вручную, «Сделать ещё фото». Предупреждения о дубликатах. CTA «Добавить в мои продукты» |
| 4.10 | Экран результата (фото блюда) | Фото, название, калории, БЖУ. Подтверждение / корректировка. Слайдер порции. Выбор приёма пищи. CTA «Записать в дневник» |
| 4.11 | Обработка ошибок AI | Экран «Не удалось распознать» → «Переснять» / «Ввести вручную» |
| 3.6 | ScanScreen | Выбор режима: чек / продукты / блюдо. Камера + галерея |
| 3.7 | Экран подтверждения | Список с инлайн-редактированием, удалением, «Добавить ещё фото», CTA «В запасы» |
| 3.8 | Экран результата блюда | Фото, КБЖУ≈, кнопки «В дневник» / «Открыть рецепт» |
### Результат итерации
- Пользователь фотографирует чек → получает список продуктов → корректирует → добавляет в запасы
- Фотографирует холодильник (несколько фото) → то же
- Фотографирует блюдо → видит калории и БЖУ → может записать в дневник
- Нераспознанные ингредиенты автоматически добавляются в справочник
- Сфотографировал чек → список продуктов → подтвердил → добавил в запасы
- Сфотографировал холодильник → то же
- Сфотографировал блюдо → КБЖУ → можно добавить в дневник
---
## Итерация 5: Каталог рецептов
## Итерация 4: Планирование меню
**Цель:** пользователь может просматривать, искать и фильтровать рецепты. Видит, какие ингредиенты есть в запасах, а каких не хватает. Может добавить рецепт в избранное.
> **Детальный план:** [Iteration_4.md](./Iteration_4.md)
**Зависимости:** итерации 1 (рецепты в БД), 2 (продукты для проверки наличия).
**Цель:** пользователь получает полное меню на неделю от Gemini с учётом продуктов, целей и предпочтений. Автоматически формируется список покупок.
**Зависимости:** итерации 1, 2.
### User Stories
@@ -254,246 +206,39 @@
| ID | Story | Описание |
|----|-------|----------|
| 5.1 | Поиск и фильтрация рецептов | `GET /recipes` — фильтры: cuisine, difficulty, prep_time, calories_max, meal_type, diet_tags. Full-text search по title. Пагинация (cursor-based) |
| 5.2 | «Из моих продуктов» | Фильтр: сопоставление ingredients[].mapping_id с products.mapping_id пользователя. Ранжирование по доле совпадения. На каждом рецепте: «Есть всё ✓» / «-N прод.» |
| 5.3 | Карточка рецепта с наличием | `GET /recipes/{id}` — рецепт + для каждого ингредиента: есть ✅ / нет ❌ / замена 🔄. Итог: «Всё есть» / «Не хватает N» |
| 5.4 | Замены ингредиентов | При ❌ — поиск замены: сначала в таблице ingredient_substitutions, затем (если нет) — запрос к Gemini, результат кешируется |
| 5.5 | Избранное | `POST /recipes/{id}/favorite`, `DELETE /recipes/{id}/favorite`. Таблица favorites (user_id, recipe_id). `GET /recipes?favorite=true` |
| 5.6 | Дозапрос Spoonacular | Если в локальной БД мало результатов по фильтрам — запрос к Spoonacular API (findByIngredients, complexSearch). Новые рецепты сохраняются в БД |
| 4.1 | Таблицы menu_plans, menu_items | Миграции. menu_items → saved_recipes |
| 4.2 | Таблица meal_diary | Миграция. Записи приёмов пищи |
| 4.3 | POST /ai/generate-menu | Gemini генерирует 21 рецепт, Pexels параллельно, сохранение в БД |
| 4.4 | Menu CRUD | GET /menu?week=, PUT /menu/items/{id}, DELETE /menu/items/{id} |
| 4.5 | Shopping list | POST /shopping-list/generate (SQL-агрегация без Gemini), GET, PATCH check |
#### Flutter
| ID | Story | Описание |
|----|-------|----------|
| 5.7 | Экран каталога рецептов | Сетка 2 колонки, поиск, chip-фильтры, кнопка «Из моих продуктов», панель фильтров (bottom sheet), бесконечный скролл |
| 5.8 | Карточка рецепта | Фото, рейтинг, метаинформация (время/сложность/кухня), калории/БЖУ, регулятор порций, список ингредиентов с ✅/❌/🔄, описание. CTA «Начать готовить», «Добавить в меню» |
| 5.9 | Замены ингредиентов | Строка «→ Замена: пармезан (есть)» под ингредиентом с 🔄 |
| 5.10 | Кнопка «Добавить в список покупок» | Недостающие ингредиенты → формирование позиций для списка покупок |
| 4.6 | MenuScreen | 7-дневный вид, skeleton на генерацию, кнопка «Сгенерировать» |
| 4.7 | Замена рецепта | Тап «Изменить» → выбор из saved_recipes или перегенерация |
| 4.8 | ShoppingListScreen | Список по категориям, чекбоксы, «Поделиться» |
| 4.9 | DiaryScreen | Записи за день, «+ Добавить» |
### Результат итерации
- Пользователь ищет рецепты, фильтрует по кухне/сложности/времени/калориям
- Видит, что можно приготовить из имеющихся продуктов
- Для каждого рецепта — отметки наличия ингредиентов и предложения замен
- Может добавить рецепт в избранное
- Пользователь получает меню на неделю одним запросом к Gemini
- Все рецепты меню сохраняются в saved_recipes
- Из меню автоматически формируется список покупок (то, чего нет в запасах)
- Ведётся дневник питания
---
## Итерация 6: Планирование меню
## Итоги
**Цель:** пользователь может составлять меню на неделю — вручную или через AI-генерацию. Формируется список покупок.
| Итерация | Цель | Ключевые API |
|----------|------|-------------|
| 0. Фундамент | Auth, профиль, каркас | Firebase |
| 1. AI-рекомендации | Рецепты + сохранение | Gemini, Pexels |
| 2. Продукты | CRUD запасов, ingredient_mappings | — |
| 3. Распознавание | OCR чека, фото продуктов/блюда | Gemini Vision |
| 4. Меню | Недельное меню, список покупок | Gemini, Pexels |
**Зависимости:** итерации 3 (AI-ядро для генерации), 5 (каталог рецептов).
**MVP:** итерации 02 (авторизация + рекомендации + продукты) — пользователь получает персонализированные рецепты.
### User Stories
#### Backend
| ID | Story | Описание |
|----|-------|----------|
| 6.1 | Таблицы menu_plans и menu_items | Миграции. menu_plans: id, user_id, week_start, template_name. menu_items: id, menu_plan_id, day_of_week, meal_type, recipe_id, servings |
| 6.2 | Menu CRUD | `GET /menu?week=`, `POST /menu/items`, `PUT /menu/items/{id}`, `DELETE /menu/items/{id}`. Подсчёт калорий за день/неделю |
| 6.3 | AI-генерация меню | `POST /ai/generate-menu`: backend отбирает кандидатов из БД (SQL по фильтрам + наличие ингредиентов) → формирует промпт с recipe_id → Gemini ранжирует → backend сохраняет menu_items |
| 6.4 | Шаблоны меню | `POST /menu/templates` (сохранить), `GET /menu/templates` (список), `POST /menu/from-template/{id}` (применить). История прошлых меню |
| 6.5 | Таблица shopping_lists | Миграция: id, user_id, menu_plan_id, items (JSONB). Автогенерация из меню: ингредиенты рецептов имеющиеся продукты = список |
| 6.6 | Shopping list API | `GET /shopping-list`, `POST /shopping-list/generate`, `PUT /shopping-list/items/{idx}`, `PATCH /shopping-list/items/{idx}/check`, `POST /shopping-list/items` (ручная позиция) |
#### Flutter
| ID | Story | Описание |
|----|-------|----------|
| 6.7 | Экран меню | Понедельный календарь, слоты по приёмам пищи, калорийность за день, drag-and-drop, контекстное меню (···), пустые слоты с подсказками |
| 6.8 | Добавление блюда в слот | Модалка выбора дня + приёма пищи. Переход в каталог рецептов для выбора |
| 6.9 | AI-генерация | Кнопка ⚡ → экран параметров (период, кухня, сложность, из моих продуктов, калории) → генерация → отображение результата с возможностью заменить отдельные блюда |
| 6.10 | Шаблоны и история | Выпадающее меню: сохранить как шаблон, загрузить из шаблона, из истории |
| 6.11 | Экран списка покупок | Список по категориям, чекбоксы, свайп-удаление, ручное добавление, итого, «Поделиться», «Пересчитать из меню» |
| 6.12 | Переходный экран «Составить меню?» | После добавления продуктов (чек/фото) → предложение сгенерировать меню с выбором параметров |
### Результат итерации
- Пользователь составляет меню на неделю — вручную или AI-генерацией
- AI подбирает рецепты из нашей БД с учётом продуктов, калорий, предпочтений
- Формируется список покупок (автоматически из меню запасы)
- Можно сохранять шаблоны и повторять удачные меню
- После сканирования чека — плавный переход к генерации меню
---
## Итерация 7: Дневник питания
**Цель:** пользователь ведёт учёт съеденного — записывает приёмы пищи, отслеживает калории и БЖУ, регулирует порции.
**Зависимости:** итерация 5 (рецепты для добавления из каталога).
### User Stories
#### Backend
| ID | Story | Описание |
|----|-------|----------|
| 7.1 | Таблица meal_diary | Миграция: id, user_id, date, meal_type, recipe_id (nullable), name, portions, calories, protein, fat, carbs, source (menu/photo/manual/recipe), created_at |
| 7.2 | Diary CRUD | `GET /diary?date=`, `POST /diary`, `PUT /diary/{id}`, `DELETE /diary/{id}`. Подсчёт итогов дня (калории, БЖУ) |
| 7.3 | Из меню в дневник | При отметке «съедено» на главном экране → автосоздание записи в дневнике. Списание ингредиентов из продуктов |
| 7.4 | Трекер воды | Таблица water_tracker (user_id, date, glasses). `GET /stats/water?date=`, `PUT /stats/water` |
| 7.5 | База продуктов для быстрого поиска | Endpoint для поиска по ingredient_mappings: `GET /ingredients/search?q=банан` → название + нутриенты на порцию. Для перекусов без рецепта |
#### Flutter
| ID | Story | Описание |
|----|-------|----------|
| 7.6 | Экран дневника питания | Навигация по дням, круговой прогресс калорий, прогресс-бары БЖУ, приёмы пищи, порции, «+ Добавить» |
| 7.7 | Модалка добавления | Варианты: сфотографировать, из меню, из каталога, из избранного, быстрый поиск продукта, вручную |
| 7.8 | Указание порции | Слайдер 0.5x3x при добавлении из рецепта. Пересчёт калорий/БЖУ |
| 7.9 | Быстрый поиск продукта | Поле поиска → результаты из ingredient_mappings → тап → добавить в дневник с указанием количества |
| 7.10 | Трекер воды | Ряд стаканов внизу дневника, тап = +1/-1 |
| 7.11 | Главный экран — карточка калорий | Круговой прогресс, тап → переход в дневник |
| 7.12 | Главный экран — «Сегодня в меню» | Список из menu_items на сегодня с чекбоксами «съедено» |
### Результат итерации
- Пользователь записывает приёмы пищи: из меню, каталога, фото или вручную
- Регулирует порции, видит калории и БЖУ за день
- На главном экране — прогресс калорий и чекбоксы «съедено»
- Трекер воды
---
## Итерация 8: Режим готовки
**Цель:** пользователь готовит блюдо по пошаговой инструкции с таймерами. После завершения — запись в дневник и оценка.
**Зависимости:** итерация 5 (карточка рецепта для запуска).
### User Stories
| ID | Story | Описание |
|----|-------|----------|
| 8.1 | Экран пошаговой готовки | Фото шага, заголовок, описание (крупный шрифт), навигация «Назад»/«Далее», свайп, точечный индикатор прогресса |
| 8.2 | Таймеры | Кнопка «Запустить таймер» на шагах с timer_seconds. Обратный отсчёт. Пауза, стоп. Несколько таймеров параллельно |
| 8.3 | Панель активных таймеров | Фиксирована внизу. Показывает все запущенные таймеры с оставшимся временем |
| 8.4 | Уведомления таймера | Push-уведомление + звук при завершении таймера. Модалка «Готово!» |
| 8.5 | Keep screen on | Экран не гаснет в режиме готовки (wakelock) |
| 8.6 | Закрытие с подтверждением | Кнопка ✕ → «Прервать готовку?» |
| 8.7 | Экран завершения | «Приятного аппетита!» → «Записать в дневник» (с выбором порций), «Оценить рецепт», «Поделиться фото». Автосписание ингредиентов из запасов |
### Результат итерации
- Пользователь готовит по шагам с фото и описанием
- Запускает таймеры (параллельно), получает уведомления
- По завершении — запись в дневник, оценка рецепта, списание продуктов
---
## Итерация 9: Рекомендации и статистика
**Цель:** приложение проактивно рекомендует рецепты. Пользователь видит аналитику своего питания.
**Зависимости:** итерации 6 (меню), 7 (дневник — данные для статистики).
### User Stories
#### Рекомендации
| ID | Story | Описание |
|----|-------|----------|
| 9.1 | Рекомендации на главном экране | Карусель «Рекомендуем приготовить». Алгоритм: (1) рецепты из продуктов с истекающим сроком, (2) полное совпадение ингредиентов, (3) предпочтения кухни. Endpoint: `GET /recipes/recommended` |
| 9.2 | «Готовили недавно» | Секция на главном экране и в каталоге. Endpoint: `GET /recipes/recent` — последние 5 приготовленных (из meal_diary с source=recipe) |
| 9.3 | Секция «Для вас» в каталоге | Персональные рекомендации на основе: оценок, предпочтений кухонь, истории. Endpoint: `GET /recipes/recommended?section=personal` |
| 9.4 | Подсказки в пустых слотах меню | При пустом слоте меню — рекомендация на основе оставшихся калорий + продукты с истекающим сроком |
#### Статистика
| ID | Story | Описание |
|----|-------|----------|
| 9.5 | Endpoint статистики | `GET /stats?period=week|month|3months` — калории/БЖУ по дням, средние, тренды, самые частые блюда. Агрегация по meal_diary |
| 9.6 | Экран статистики | Переключатель периода, столбчатая диаграмма калорий, stacked bar БЖУ, тренды (↑↓→), топ блюд |
| 9.7 | Переход из главного экрана | Тап по прогресс-бару калорий → дневник или статистика |
### Результат итерации
- На главном экране — рекомендации (приоритет на истекающие продукты) и «Готовили недавно»
- В каталоге — секция «Для вас»
- В меню — умные подсказки в пустых слотах
- Графики калорий и БЖУ за неделю/месяц/3 месяца
---
## Итерация 10: Полировка
**Цель:** довести приложение до продуктового качества — онбординг, пустые состояния, уведомления, отзывы, переходные экраны.
**Зависимости:** итерация 9.
### User Stories
#### Онбординг
| ID | Story | Описание |
|----|-------|----------|
| 10.1 | Экраны онбординга | 5 шагов: приветствие (свайп-карточки), параметры тела, цель + расчёт нормы, ограничения + предпочтения кухонь, предложение добавить продукты |
| 10.2 | Сохранение данных онбординга | `PUT /profile` с параметрами из онбординга. Сохранение предпочтений кухонь в preferences |
| 10.3 | Флаг прохождения онбординга | Показывать только при первом входе. Флаг в secure storage |
#### Пустые состояния и ошибки
| ID | Story | Описание |
|----|-------|----------|
| 10.4 | Пустые состояния всех экранов | Иллюстрация + текст + CTA для: продуктов, меню, дневника, статистики, рецептов (избранные) |
| 10.5 | Состояния ошибок | Нет сети (баннер + оффлайн-данные), ошибка AI (переснять / ввести вручную), ошибка сервера (повторить) |
| 10.6 | Toast с отменой | При удалении записи из дневника, продукта — toast «Удалено» + кнопка «Отменить» (5 сек) |
#### Уведомления
| ID | Story | Описание |
|----|-------|----------|
| 10.7 | Push-уведомления (FCM) | Интеграция Firebase Cloud Messaging. Flutter: запрос разрешений, обработка |
| 10.8 | Уведомления о сроках продуктов | Backend: cron-джоб утром → push «Молоко — осталось 1 день. Использовать в рецепте?» |
| 10.9 | Напоминания о приёмах пищи | По расписанию (настраиваемое): «Время обеда! В меню: ...» |
| 10.10 | Вечернее напоминание о воде | «Вы выпили 5 из 8 стаканов воды сегодня» |
#### Отзывы
| ID | Story | Описание |
|----|-------|----------|
| 10.11 | Таблица reviews | Миграция: id, user_id, recipe_id, rating, text, photo_url, created_at. Пересчёт avg_rating, review_count в recipes |
| 10.12 | API отзывов | `GET /recipes/{id}/reviews` (пагинация), `POST /recipes/{id}/reviews` |
| 10.13 | UI отзывов | Секция в карточке рецепта, модалка написания отзыва (звёзды + текст + фото), полный список отзывов |
#### Профиль
| ID | Story | Описание |
|----|-------|----------|
| 10.14 | Экран профиля | Аватар, параметры, цель, ограничения, предпочтения кухонь, ссылки (статистика, избранное, отзывы, сроки хранения, настройки) |
| 10.15 | Настройки приложения | Экран: уведомления (вкл/выкл по типам), тема (светлая/тёмная/системная), норма воды, язык |
### Результат итерации
- Новый пользователь проходит онбординг и сразу получает персонализированный опыт
- Все экраны имеют осмысленные пустые состояния
- Ошибки обрабатываются gracefully
- Push-уведомления о сроках, приёмах пищи, воде
- Можно оставлять отзывы к рецептам
- Полноценный профиль с настройками
---
## Итоги по объёму
| Итерация | Backend stories | Flutter stories | Всего |
|----------|----------------|-----------------|-------|
| 0. Фундамент | 6 | 4 | 10 |
| 1. Ингредиенты + рецепты | 6 | 0 | 6 |
| 2. Продукты | 6 | 6 | 12 |
| 3. AI-ядро | 8 | 0 | 8 |
| 4. AI-распознавание | 5 | 6 | 11 |
| 5. Каталог рецептов | 6 | 4 | 10 |
| 6. Меню + покупки | 6 | 6 | 12 |
| 7. Дневник питания | 5 | 7 | 12 |
| 8. Режим готовки | 0 | 7 | 7 |
| 9. Рекомендации + стат-ка | 4 | 3 | 7 |
| 10. Полировка | 5 | 10 | 15 |
| **Итого** | **57** | **53** | **110** |
## Приоритеты для MVP
Минимально жизнеспособный продукт — итерации **06**:
- Авторизация, продукты, AI-распознавание, рецепты, меню, список покупок
- Позволяет пройти основной пользовательский сценарий: купил продукты → сфотографировал чек → получил меню → составил список покупок
- **68 stories** из 110 (62%)
Итерации 710 — расширение до полного продукта.
**Полный продукт:** итерации 04 — полный цикл: сфотографировал чек → получил меню → список покупок → дневник питания.