From e57ff8e06c9d108195fbacade72d8b7ceec11d36 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sat, 21 Feb 2026 22:43:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Iteration=201=20=E2=80=94?= =?UTF-8?q?=20AI=20recipe=20recommendations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 7 + backend/cmd/server/main.go | 25 +- backend/internal/config/config.go | 4 + backend/internal/gemini/client.go | 81 +++ backend/internal/gemini/recipe.go | 186 +++++ backend/internal/ingredient/model.go | 28 + backend/internal/ingredient/repository.go | 185 +++++ .../ingredient/repository_integration_test.go | 250 +++++++ backend/internal/pexels/client.go | 77 +++ backend/internal/recipe/model.go | 62 ++ backend/internal/recipe/repository.go | 181 +++++ .../recipe/repository_integration_test.go | 311 +++++++++ backend/internal/recommendation/handler.go | 138 ++++ backend/internal/savedrecipe/handler.go | 135 ++++ backend/internal/savedrecipe/model.go | 43 ++ backend/internal/savedrecipe/repository.go | 173 +++++ backend/internal/server/server.go | 14 + .../002_create_ingredient_mappings.sql | 36 + backend/migrations/003_create_recipes.sql | 58 ++ .../migrations/004_create_saved_recipes.sql | 25 + client/lib/core/api/api_client.dart | 12 + client/lib/core/router/app_router.dart | 30 + .../recipes/recipe_detail_screen.dart | 552 +++++++++++++++ .../lib/features/recipes/recipe_provider.dart | 109 +++ .../lib/features/recipes/recipe_service.dart | 36 + .../lib/features/recipes/recipes_screen.dart | 45 +- .../recipes/recommendations_screen.dart | 142 ++++ .../recipes/saved_recipes_screen.dart | 271 ++++++++ .../features/recipes/widgets/recipe_card.dart | 263 +++++++ .../recipes/widgets/skeleton_card.dart | 91 +++ client/lib/shared/models/recipe.dart | 121 ++++ client/lib/shared/models/recipe.g.dart | 97 +++ client/lib/shared/models/saved_recipe.dart | 64 ++ client/lib/shared/models/saved_recipe.g.dart | 57 ++ docs/Flow.md | 644 ++++++++++++++++++ docs/TODO.md | 89 +++ docs/plans/Iteration_1.md | 326 +++++++++ docs/plans/Iteration_2.md | 241 +++++++ docs/plans/Iteration_3.md | 328 +++++++++ docs/plans/Iteration_4.md | 367 ++++++++++ docs/plans/Summary.md | 443 +++--------- 41 files changed, 5994 insertions(+), 353 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/internal/gemini/client.go create mode 100644 backend/internal/gemini/recipe.go create mode 100644 backend/internal/ingredient/model.go create mode 100644 backend/internal/ingredient/repository.go create mode 100644 backend/internal/ingredient/repository_integration_test.go create mode 100644 backend/internal/pexels/client.go create mode 100644 backend/internal/recipe/model.go create mode 100644 backend/internal/recipe/repository.go create mode 100644 backend/internal/recipe/repository_integration_test.go create mode 100644 backend/internal/recommendation/handler.go create mode 100644 backend/internal/savedrecipe/handler.go create mode 100644 backend/internal/savedrecipe/model.go create mode 100644 backend/internal/savedrecipe/repository.go create mode 100644 backend/migrations/002_create_ingredient_mappings.sql create mode 100644 backend/migrations/003_create_recipes.sql create mode 100644 backend/migrations/004_create_saved_recipes.sql create mode 100644 client/lib/features/recipes/recipe_detail_screen.dart create mode 100644 client/lib/features/recipes/recipe_provider.dart create mode 100644 client/lib/features/recipes/recipe_service.dart create mode 100644 client/lib/features/recipes/recommendations_screen.dart create mode 100644 client/lib/features/recipes/saved_recipes_screen.dart create mode 100644 client/lib/features/recipes/widgets/recipe_card.dart create mode 100644 client/lib/features/recipes/widgets/skeleton_card.dart create mode 100644 client/lib/shared/models/recipe.dart create mode 100644 client/lib/shared/models/recipe.g.dart create mode 100644 client/lib/shared/models/saved_recipe.dart create mode 100644 client/lib/shared/models/saved_recipe.g.dart create mode 100644 docs/Flow.md create mode 100644 docs/TODO.md create mode 100644 docs/plans/Iteration_1.md create mode 100644 docs/plans/Iteration_2.md create mode 100644 docs/plans/Iteration_3.md create mode 100644 docs/plans/Iteration_4.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bbee303 --- /dev/null +++ b/CLAUDE.md @@ -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**. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 688c6c9..0bb9146 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0197c97..2b5f530 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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) { diff --git a/backend/internal/gemini/client.go b/backend/internal/gemini/client.go new file mode 100644 index 0000000..0062e3b --- /dev/null +++ b/backend/internal/gemini/client.go @@ -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 +} diff --git a/backend/internal/gemini/recipe.go b/backend/internal/gemini/recipe.go new file mode 100644 index 0000000..0aca865 --- /dev/null +++ b/backend/internal/gemini/recipe.go @@ -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 +} diff --git a/backend/internal/ingredient/model.go b/backend/internal/ingredient/model.go new file mode 100644 index 0000000..d468e88 --- /dev/null +++ b/backend/internal/ingredient/model.go @@ -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"` +} diff --git a/backend/internal/ingredient/repository.go b/backend/internal/ingredient/repository.go new file mode 100644 index 0000000..f125c04 --- /dev/null +++ b/backend/internal/ingredient/repository.go @@ -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() +} diff --git a/backend/internal/ingredient/repository_integration_test.go b/backend/internal/ingredient/repository_integration_test.go new file mode 100644 index 0000000..e756f84 --- /dev/null +++ b/backend/internal/ingredient/repository_integration_test.go @@ -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) + } +} diff --git a/backend/internal/pexels/client.go b/backend/internal/pexels/client.go new file mode 100644 index 0000000..f18e20c --- /dev/null +++ b/backend/internal/pexels/client.go @@ -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 +} diff --git a/backend/internal/recipe/model.go b/backend/internal/recipe/model.go new file mode 100644 index 0000000..aeb2e80 --- /dev/null +++ b/backend/internal/recipe/model.go @@ -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"` +} diff --git a/backend/internal/recipe/repository.go b/backend/internal/recipe/repository.go new file mode 100644 index 0000000..a9e687e --- /dev/null +++ b/backend/internal/recipe/repository.go @@ -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 +} diff --git a/backend/internal/recipe/repository_integration_test.go b/backend/internal/recipe/repository_integration_test.go new file mode 100644 index 0000000..3e4b4c7 --- /dev/null +++ b/backend/internal/recipe/repository_integration_test.go @@ -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) + } +} diff --git a/backend/internal/recommendation/handler.go b/backend/internal/recommendation/handler.go new file mode 100644 index 0000000..8e453c2 --- /dev/null +++ b/backend/internal/recommendation/handler.go @@ -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) + } +} diff --git a/backend/internal/savedrecipe/handler.go b/backend/internal/savedrecipe/handler.go new file mode 100644 index 0000000..757aa7f --- /dev/null +++ b/backend/internal/savedrecipe/handler.go @@ -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) + } +} diff --git a/backend/internal/savedrecipe/model.go b/backend/internal/savedrecipe/model.go new file mode 100644 index 0000000..99bffd8 --- /dev/null +++ b/backend/internal/savedrecipe/model.go @@ -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"` +} diff --git a/backend/internal/savedrecipe/repository.go b/backend/internal/savedrecipe/repository.go new file mode 100644 index 0000000..4a86d67 --- /dev/null +++ b/backend/internal/savedrecipe/repository.go @@ -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 +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 85c1502..044a5a3 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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 diff --git a/backend/migrations/002_create_ingredient_mappings.sql b/backend/migrations/002_create_ingredient_mappings.sql new file mode 100644 index 0000000..5faa8b4 --- /dev/null +++ b/backend/migrations/002_create_ingredient_mappings.sql @@ -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; diff --git a/backend/migrations/003_create_recipes.sql b/backend/migrations/003_create_recipes.sql new file mode 100644 index 0000000..5352593 --- /dev/null +++ b/backend/migrations/003_create_recipes.sql @@ -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; diff --git a/backend/migrations/004_create_saved_recipes.sql b/backend/migrations/004_create_saved_recipes.sql new file mode 100644 index 0000000..024fded --- /dev/null +++ b/backend/migrations/004_create_saved_recipes.sql @@ -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; diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index ef43164..de79512 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -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> getList(String path, + {Map? params}) async { + final response = await _dio.get(path, queryParameters: params); + return response.data as List; + } + + /// Deletes a resource and expects no response body (204 No Content). + Future deleteVoid(String path) async { + await _dio.delete(path); + } } diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 371b28b..be20870 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -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((ref) { final authState = ref.watch(authProvider); @@ -34,6 +37,21 @@ final routerProvider = Provider((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((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; diff --git a/client/lib/features/recipes/recipe_detail_screen.dart b/client/lib/features/recipes/recipe_detail_screen.dart new file mode 100644 index 0000000..c619e00 --- /dev/null +++ b/client/lib/features/recipes/recipe_detail_screen.dart @@ -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 createState() => _RecipeDetailScreenState(); +} + +class _RecipeDetailScreenState extends ConsumerState { + 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 get _ingredients => + widget.recipe?.ingredients ?? widget.saved!.ingredients; + List get _steps => + widget.recipe?.steps ?? widget.saved!.steps; + List 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 _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 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 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 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, + ), + ), + ); + } +} diff --git a/client/lib/features/recipes/recipe_provider.dart b/client/lib/features/recipes/recipe_provider.dart new file mode 100644 index 0000000..7c7cb68 --- /dev/null +++ b/client/lib/features/recipes/recipe_provider.dart @@ -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((ref) { + return RecipeService(ref.read(apiClientProvider)); +}); + +// --------------------------------------------------------------------------- +// Recommendations +// --------------------------------------------------------------------------- + +class RecommendationsNotifier + extends StateNotifier>> { + final RecipeService _service; + + RecommendationsNotifier(this._service) : super(const AsyncValue.loading()) { + load(); + } + + Future load({int count = 5}) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => _service.getRecommendations(count: count), + ); + } +} + +final recommendationsProvider = StateNotifierProvider>>((ref) { + return RecommendationsNotifier(ref.read(recipeServiceProvider)); +}); + +// --------------------------------------------------------------------------- +// Saved recipes +// --------------------------------------------------------------------------- + +class SavedRecipesNotifier + extends StateNotifier>> { + final RecipeService _service; + + SavedRecipesNotifier(this._service) : super(const AsyncValue.loading()) { + load(); + } + + Future 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 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 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>>((ref) { + return SavedRecipesNotifier(ref.read(recipeServiceProvider)); +}); diff --git a/client/lib/features/recipes/recipe_service.dart b/client/lib/features/recipes/recipe_service.dart new file mode 100644 index 0000000..2845244 --- /dev/null +++ b/client/lib/features/recipes/recipe_service.dart @@ -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> getRecommendations({int count = 5}) async { + final data = await _apiClient.getList( + '/recommendations', + params: {'count': '$count'}, + ); + return data + .map((e) => Recipe.fromJson(e as Map)) + .toList(); + } + + Future> getSavedRecipes() async { + final data = await _apiClient.getList('/saved-recipes'); + return data + .map((e) => SavedRecipe.fromJson(e as Map)) + .toList(); + } + + Future saveRecipe(Recipe recipe) async { + final body = recipe.toJson()..['source'] = 'ai'; + final response = await _apiClient.post('/saved-recipes', data: body); + return SavedRecipe.fromJson(response); + } + + Future deleteSavedRecipe(String id) async { + await _apiClient.deleteVoid('/saved-recipes/$id'); + } +} diff --git a/client/lib/features/recipes/recipes_screen.dart b/client/lib/features/recipes/recipes_screen.dart index 35a54a5..20304b9 100644 --- a/client/lib/features/recipes/recipes_screen.dart +++ b/client/lib/features/recipes/recipes_screen.dart @@ -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 createState() => _RecipesScreenState(); +} + +class _RecipesScreenState extends State + 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(), + ], + ), ); } } diff --git a/client/lib/features/recipes/recommendations_screen.dart b/client/lib/features/recipes/recommendations_screen.dart new file mode 100644 index 0000000..08cd754 --- /dev/null +++ b/client/lib/features/recipes/recommendations_screen.dart @@ -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 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('Попробовать снова'), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/features/recipes/saved_recipes_screen.dart b/client/lib/features/recipes/saved_recipes_screen.dart new file mode 100644 index 0000000..f18a1d8 --- /dev/null +++ b/client/lib/features/recipes/saved_recipes_screen.dart @@ -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 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 _confirmDelete(BuildContext context) { + return showDialog( + 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]), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/features/recipes/widgets/recipe_card.dart b/client/lib/features/recipes/widgets/recipe_card.dart new file mode 100644 index 0000000..25d8e72 --- /dev/null +++ b/client/lib/features/recipes/widgets/recipe_card.dart @@ -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 _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, + ); +} diff --git a/client/lib/features/recipes/widgets/skeleton_card.dart b/client/lib/features/recipes/widgets/skeleton_card.dart new file mode 100644 index 0000000..fae11f9 --- /dev/null +++ b/client/lib/features/recipes/widgets/skeleton_card.dart @@ -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 createState() => _SkeletonCardState(); +} + +class _SkeletonCardState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _anim = Tween(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), + ), + ); +} diff --git a/client/lib/shared/models/recipe.dart b/client/lib/shared/models/recipe.dart new file mode 100644 index 0000000..67759d0 --- /dev/null +++ b/client/lib/shared/models/recipe.dart @@ -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 ingredients; + + @JsonKey(defaultValue: []) + final List steps; + + @JsonKey(defaultValue: []) + final List 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 json) => _$RecipeFromJson(json); + Map 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 json) => + _$RecipeIngredientFromJson(json); + Map 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 json) => + _$RecipeStepFromJson(json); + Map 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 json) => + _$NutritionInfoFromJson(json); + Map toJson() => _$NutritionInfoToJson(this); +} diff --git a/client/lib/shared/models/recipe.g.dart b/client/lib/shared/models/recipe.g.dart new file mode 100644 index 0000000..a94ec8d --- /dev/null +++ b/client/lib/shared/models/recipe.g.dart @@ -0,0 +1,97 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recipe.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Recipe _$RecipeFromJson(Map 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?) + ?.map((e) => RecipeIngredient.fromJson(e as Map)) + .toList() ?? + [], + steps: + (json['steps'] as List?) + ?.map((e) => RecipeStep.fromJson(e as Map)) + .toList() ?? + [], + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], + nutrition: json['nutrition_per_serving'] == null + ? null + : NutritionInfo.fromJson( + json['nutrition_per_serving'] as Map, + ), +); + +Map _$RecipeToJson(Recipe instance) => { + '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 json) => + RecipeIngredient( + name: json['name'] as String, + amount: (json['amount'] as num).toDouble(), + unit: json['unit'] as String, + ); + +Map _$RecipeIngredientToJson(RecipeIngredient instance) => + { + 'name': instance.name, + 'amount': instance.amount, + 'unit': instance.unit, + }; + +RecipeStep _$RecipeStepFromJson(Map json) => RecipeStep( + number: (json['number'] as num).toInt(), + description: json['description'] as String, + timerSeconds: (json['timer_seconds'] as num?)?.toInt(), +); + +Map _$RecipeStepToJson(RecipeStep instance) => + { + 'number': instance.number, + 'description': instance.description, + 'timer_seconds': instance.timerSeconds, + }; + +NutritionInfo _$NutritionInfoFromJson(Map 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 _$NutritionInfoToJson(NutritionInfo instance) => + { + 'calories': instance.calories, + 'protein_g': instance.proteinG, + 'fat_g': instance.fatG, + 'carbs_g': instance.carbsG, + 'approximate': instance.approximate, + }; diff --git a/client/lib/shared/models/saved_recipe.dart b/client/lib/shared/models/saved_recipe.dart new file mode 100644 index 0000000..40d8edc --- /dev/null +++ b/client/lib/shared/models/saved_recipe.dart @@ -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 ingredients; + + @JsonKey(defaultValue: []) + final List steps; + + @JsonKey(defaultValue: []) + final List 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 json) => + _$SavedRecipeFromJson(json); + Map toJson() => _$SavedRecipeToJson(this); +} diff --git a/client/lib/shared/models/saved_recipe.g.dart b/client/lib/shared/models/saved_recipe.g.dart new file mode 100644 index 0000000..a041e83 --- /dev/null +++ b/client/lib/shared/models/saved_recipe.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'saved_recipe.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SavedRecipe _$SavedRecipeFromJson(Map 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?) + ?.map((e) => RecipeIngredient.fromJson(e as Map)) + .toList() ?? + [], + steps: + (json['steps'] as List?) + ?.map((e) => RecipeStep.fromJson(e as Map)) + .toList() ?? + [], + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], + nutrition: json['nutrition_per_serving'] == null + ? null + : NutritionInfo.fromJson( + json['nutrition_per_serving'] as Map, + ), + source: json['source'] as String, + savedAt: DateTime.parse(json['saved_at'] as String), +); + +Map _$SavedRecipeToJson(SavedRecipe instance) => + { + '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(), + }; diff --git a/docs/Flow.md b/docs/Flow.md new file mode 100644 index 0000000..7c14c3d --- /dev/null +++ b/docs/Flow.md @@ -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 } +``` + +**Запросов на бэкенд:** 3–5 (поиск, 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 Распознавание фото продуктов (холодильник/стол) + +Аналогичен чеку, но без цены. Поддерживает несколько фото: + +``` +Пользователь делает 1–3 фото холодильника + │ +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:** 1–3 (по числу фото, параллельно) + +### 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 +``` + +**Запросов к бэкенду:** 1–3 | **Gemini:** 0 | **SQL:** 1–2 + +### 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 токены (промпт + продукты) | ~500–800 | +| Output токены (5 рецептов JSON) | ~1 500–2 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 | 0–1 | +| Открыть рекомендации | GET /recommendations | 1 | +| Сохранить рецепт | POST /saved-recipes | 1 | +| Открыть сохранённые | GET /saved-recipes | 1 | +| **Итого** | **3–4** | **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 (~1–3 сек) +4. Pexels.Search(image_query) × 5 (параллельно) → 5 Pexels req (параллельно) +5. Формирование ответа и отдача → 0 SQL + +Итого: 1–2 SQL + 1 Gemini + 5 Pexels +Время ответа: ~2–4 секунды (доминирует 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 | 1–3 (фото) | — | +| 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 | 1–3 | 0 | +| Распознавание блюда | 2 | 0 | 1 | 0 | +| Генерация меню (неделя) | 1 | 0 | 1 | до 21 | +| Просмотр меню | 1 | 0 | 0 | 0 | +| Список покупок из меню | 1 | 0 | 0 | 0 | + +### Ключевые выводы + +1. **Критические пути требуют Gemini + Pexels:** рекомендации (2–4 сек), распознавание продуктов (1–3 сек), генерация меню (5–10 сек). Во всех случаях нужна 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. diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..f68e7aa --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,89 @@ +# TODO: будущие улучшения + +Функционал, сознательно отложенный. Основные AI-фичи (рекомендации, распознавание, меню) реализуются через Gemini в итерациях 1–4. + +--- + +## База данных рецептов и нутриентов + +### Верифицированная база нутриентов + +Сейчас КБЖУ генерирует Gemini (приблизительно, помечается «≈» в UI). Для пользователей с медицинскими показаниями (диабет, ожирение) нужна точность: + +- **USDA FoodData Central** — государственная база США, бесплатно, 300K+ продуктов, верифицированы лабораторно + - API: `api.nal.usda.gov/fdc/v1/` (ключ бесплатный) + - Данные используются как reference при генерации Gemini +- **Open Food Facts** — community база для упакованных продуктов со штрих-кодами + +### Постоянная база рецептов с поиском + +Сейчас рецепты генерируются on-demand и хранятся только если сохранены. В будущем: + +- Постоянная база 5K–50K рецептов с 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 по плану diff --git a/docs/plans/Iteration_1.md b/docs/plans/Iteration_1.md new file mode 100644 index 0000000..72b74e6 --- /dev/null +++ b/docs/plans/Iteration_1.md @@ -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-загрузка (2–4 сек) + └── 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 (~1–3 сек) +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.6–1.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. diff --git a/docs/plans/Iteration_2.md b/docs/plans/Iteration_2.md new file mode 100644 index 0000000..87bce52 --- /dev/null +++ b/docs/plans/Iteration_2.md @@ -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 над иконкой. diff --git a/docs/plans/Iteration_3.md b/docs/plans/Iteration_3.md new file mode 100644 index 0000000..cb6bdff --- /dev/null +++ b/docs/plans/Iteration_3.md @@ -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: 1–3 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.0–1.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.0–1.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.0–1.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 сканирование/день: +- 100–300 дополнительных Gemini-запросов/день +- Free tier (1 500/день) остаётся в запасе diff --git a/docs/plans/Iteration_4.md b/docs/plans/Iteration_4.md new file mode 100644 index 0000000..8c0b944 --- /dev/null +++ b/docs/plans/Iteration_4.md @@ -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 на время генерации (5–10 сек) + +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 + +``` +┌─────────────────────────────────────┐ +│ Меню [← Пред] [След →]│ +│ Неделя 16–22 февраля │ +├─────────────────────────────────────┤ +│ │ +│ Понедельник, 16 фев 1 780 ккал │ +│ ┌──────────────────────────────┐ │ +│ │ 🌅 Завтрак ≈320 ккал │ │ +│ │ [фото] Овсянка с яблоком │ │ +│ │ [Изменить]│ │ +│ ├──────────────────────────────┤ │ +│ │ ☀ Обед ≈680 ккал │ │ +│ │ [фото] Куриный суп │ │ +│ │ [Изменить]│ │ +│ ├──────────────────────────────┤ │ +│ │ 🌙 Ужин ≈780 ккал │ │ +│ │ [фото] Рис с овощами │ │ +│ │ [Изменить]│ │ +│ └──────────────────────────────┘ │ +│ │ +│ Вторник, 17 фев 1 820 ккал │ +│ ┌──────────────────────────────┐ │ +│ │ ... │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ ✨ Сгенерировать новое меню │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ 🛒 Список покупок │ │ +│ └─────────────────────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню●] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Skeleton при генерации (5–10 сек) + +``` +┌─────────────────────────────────────┐ +│ Меню │ +├─────────────────────────────────────┤ +│ │ +│ Составляем меню на неделю... │ +│ Учитываем ваши продукты и цели │ +│ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ │ +│ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +## 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. diff --git a/docs/plans/Summary.md b/docs/plans/Summary.md index a155fcd..062e196 100644 --- a/docs/plans/Summary.md +++ b/docs/plans/Summary.md @@ -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. Режим │ -│ + список │ │ ник пита-│ │ готовки │ -│ покупок │ │ ния │ │ │ -└─────┬────┘ └─────┬────┘ └──────────┘ - │ │ - └──────┬─────┘ - │ - ▼ - ┌────────────────┐ - │ 9. Рекоменда- │ - │ ции + стат-ка │ - └───────┬────────┘ - │ - ▼ - ┌────────────────┐ - │ 10. Полировка │ - └────────────────┘ + ┌────────────┴────────────┐ + │ │ + ▼ ▼ + ┌────────────────────┐ ┌──────────────────┐ + │ 1. AI-рекомендации │ │ 2. Продукты │ + │ (Gemini+Pexels) │ │ + ingredient_ │ + │ saved_recipes │ │ mappings │ + └──────────┬─────────┘ └────────┬─────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌──────────┴──────────┐ + │ │ + ▼ ▼ + ┌────────────────────┐ ┌─────────────────────┐ + │ 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 000–10 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 000–10 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: 1–3 фото → параллельные 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:** итерации 0–2 (авторизация + рекомендации + продукты) — пользователь получает персонализированные рецепты. -### 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.5x–3x при добавлении из рецепта. Пересчёт калорий/БЖУ | -| 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 - -Минимально жизнеспособный продукт — итерации **0–6**: - -- Авторизация, продукты, AI-распознавание, рецепты, меню, список покупок -- Позволяет пройти основной пользовательский сценарий: купил продукты → сфотографировал чек → получил меню → составил список покупок -- **68 stories** из 110 (62%) - -Итерации 7–10 — расширение до полного продукта. +**Полный продукт:** итерации 0–4 — полный цикл: сфотографировал чек → получил меню → список покупок → дневник питания.