feat: implement Iteration 1 — AI recipe recommendations
Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go
Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()
Project:
- Add CLAUDE.md with English-only rule for comments and commit messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
CLAUDE.md
Normal file
7
CLAUDE.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Project Rules
|
||||
|
||||
## Language
|
||||
|
||||
- All code comments must be written in **English**.
|
||||
- All git commit messages must be written in **English**.
|
||||
- User-facing strings in the app (UI text, error messages) remain in **Russian**.
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
81
backend/internal/gemini/client.go
Normal file
81
backend/internal/gemini/client.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Groq — OpenAI-compatible API, free tier, no billing required.
|
||||
groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
|
||||
groqModel = "llama-3.3-70b-versatile"
|
||||
maxRetries = 3
|
||||
)
|
||||
|
||||
// Client is an HTTP client for the Groq LLM API (OpenAI-compatible).
|
||||
type Client struct {
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Client.
|
||||
func NewClient(apiKey string) *Client {
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateContent sends a user prompt to Groq and returns the assistant text.
|
||||
func (c *Client) generateContent(ctx context.Context, messages []map[string]string) (string, error) {
|
||||
body := map[string]any{
|
||||
"model": groqModel,
|
||||
"temperature": 0.7,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, groqAPIURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("groq API error %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("empty response from Groq")
|
||||
}
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
186
backend/internal/gemini/recipe.go
Normal file
186
backend/internal/gemini/recipe.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RecipeGenerator generates recipes using the Gemini AI.
|
||||
type RecipeGenerator interface {
|
||||
GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error)
|
||||
}
|
||||
|
||||
// RecipeRequest contains parameters for recipe generation.
|
||||
type RecipeRequest struct {
|
||||
UserGoal string // "weight_loss" | "maintain" | "gain"
|
||||
DailyCalories int
|
||||
Restrictions []string // e.g. ["gluten_free", "vegetarian"]
|
||||
CuisinePrefs []string // e.g. ["russian", "asian"]
|
||||
Count int
|
||||
}
|
||||
|
||||
// Recipe is a recipe returned by Gemini.
|
||||
type Recipe struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Cuisine string `json:"cuisine"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
PrepTimeMin int `json:"prep_time_min"`
|
||||
CookTimeMin int `json:"cook_time_min"`
|
||||
Servings int `json:"servings"`
|
||||
ImageQuery string `json:"image_query"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Ingredients []Ingredient `json:"ingredients"`
|
||||
Steps []Step `json:"steps"`
|
||||
Tags []string `json:"tags"`
|
||||
Nutrition NutritionInfo `json:"nutrition_per_serving"`
|
||||
}
|
||||
|
||||
// Ingredient is a single ingredient in a recipe.
|
||||
type Ingredient struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Step is a single preparation step.
|
||||
type Step struct {
|
||||
Number int `json:"number"`
|
||||
Description string `json:"description"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
}
|
||||
|
||||
// NutritionInfo contains approximate nutritional information per serving.
|
||||
type NutritionInfo struct {
|
||||
Calories float64 `json:"calories"`
|
||||
ProteinG float64 `json:"protein_g"`
|
||||
FatG float64 `json:"fat_g"`
|
||||
CarbsG float64 `json:"carbs_g"`
|
||||
Approximate bool `json:"approximate"`
|
||||
}
|
||||
|
||||
// GenerateRecipes generates recipes using the Gemini AI.
|
||||
// Retries up to maxRetries times only when the response is not valid JSON.
|
||||
// API-level errors (rate limits, auth, etc.) are returned immediately.
|
||||
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) {
|
||||
prompt := buildRecipePrompt(req)
|
||||
|
||||
// OpenAI-compatible messages format used by Groq.
|
||||
messages := []map[string]string{
|
||||
{"role": "user", "content": prompt},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
messages = []map[string]string{
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "user", "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после."},
|
||||
}
|
||||
}
|
||||
|
||||
text, err := c.generateContent(ctx, messages)
|
||||
if err != nil {
|
||||
// API-level error (4xx/5xx): no point retrying immediately.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recipes, err := parseRecipesJSON(text)
|
||||
if err != nil {
|
||||
// Malformed JSON from the model — retry with a clarifying message.
|
||||
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range recipes {
|
||||
recipes[i].Nutrition.Approximate = true
|
||||
}
|
||||
return recipes, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func buildRecipePrompt(req RecipeRequest) string {
|
||||
goalRu := map[string]string{
|
||||
"weight_loss": "похудение",
|
||||
"maintain": "поддержание веса",
|
||||
"gain": "набор массы",
|
||||
}
|
||||
goal := goalRu[req.UserGoal]
|
||||
if goal == "" {
|
||||
goal = "поддержание веса"
|
||||
}
|
||||
|
||||
restrictions := "нет"
|
||||
if len(req.Restrictions) > 0 {
|
||||
restrictions = strings.Join(req.Restrictions, ", ")
|
||||
}
|
||||
|
||||
cuisines := "любые"
|
||||
if len(req.CuisinePrefs) > 0 {
|
||||
cuisines = strings.Join(req.CuisinePrefs, ", ")
|
||||
}
|
||||
|
||||
count := req.Count
|
||||
if count <= 0 {
|
||||
count = 5
|
||||
}
|
||||
|
||||
perMealCalories := req.DailyCalories / 3
|
||||
if perMealCalories <= 0 {
|
||||
perMealCalories = 600
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке.
|
||||
|
||||
Профиль пользователя:
|
||||
- Цель: %s
|
||||
- Дневная норма калорий: %d ккал
|
||||
- Ограничения: %s
|
||||
- Предпочтения: %s
|
||||
|
||||
Требования к каждому рецепту:
|
||||
- Калорийность на порцию: не более %d ккал
|
||||
- Время приготовления: до 60 минут
|
||||
- Укажи КБЖУ на порцию (приблизительно)
|
||||
|
||||
ВАЖНО: поле image_query заполняй ТОЛЬКО на английском языке — оно используется для поиска фото.
|
||||
|
||||
Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки:
|
||||
[{
|
||||
"title": "Название",
|
||||
"description": "2-3 предложения",
|
||||
"cuisine": "russian|asian|european|mediterranean|american|other",
|
||||
"difficulty": "easy|medium|hard",
|
||||
"prep_time_min": 10,
|
||||
"cook_time_min": 20,
|
||||
"servings": 2,
|
||||
"image_query": "chicken breast vegetables healthy (ENGLISH ONLY, used for photo search)",
|
||||
"ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}],
|
||||
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
|
||||
"tags": ["высокий белок"],
|
||||
"nutrition_per_serving": {
|
||||
"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18
|
||||
}
|
||||
}]`, count, goal, req.DailyCalories, restrictions, cuisines, perMealCalories)
|
||||
}
|
||||
|
||||
func parseRecipesJSON(text string) ([]Recipe, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
// Strip potential markdown code fences
|
||||
if strings.HasPrefix(text, "```") {
|
||||
text = strings.TrimPrefix(text, "```json")
|
||||
text = strings.TrimPrefix(text, "```")
|
||||
text = strings.TrimSuffix(text, "```")
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
var recipes []Recipe
|
||||
if err := json.Unmarshal([]byte(text), &recipes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return recipes, nil
|
||||
}
|
||||
28
backend/internal/ingredient/model.go
Normal file
28
backend/internal/ingredient/model.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package ingredient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IngredientMapping is the canonical ingredient record used to link
|
||||
// user products, recipe ingredients, and Spoonacular data.
|
||||
type IngredientMapping struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalName string `json:"canonical_name"`
|
||||
CanonicalNameRu *string `json:"canonical_name_ru"`
|
||||
SpoonacularID *int `json:"spoonacular_id"`
|
||||
Aliases json.RawMessage `json:"aliases"` // []string
|
||||
Category *string `json:"category"`
|
||||
DefaultUnit *string `json:"default_unit"`
|
||||
|
||||
CaloriesPer100g *float64 `json:"calories_per_100g"`
|
||||
ProteinPer100g *float64 `json:"protein_per_100g"`
|
||||
FatPer100g *float64 `json:"fat_per_100g"`
|
||||
CarbsPer100g *float64 `json:"carbs_per_100g"`
|
||||
FiberPer100g *float64 `json:"fiber_per_100g"`
|
||||
|
||||
StorageDays *int `json:"storage_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
185
backend/internal/ingredient/repository.go
Normal file
185
backend/internal/ingredient/repository.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package ingredient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for ingredient_mappings.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// Upsert inserts or updates an ingredient mapping.
|
||||
// Conflict is resolved on spoonacular_id when set; otherwise a simple insert is done.
|
||||
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
|
||||
query := `
|
||||
INSERT INTO ingredient_mappings (
|
||||
canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
||||
category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
storage_days
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (spoonacular_id) DO UPDATE SET
|
||||
canonical_name = EXCLUDED.canonical_name,
|
||||
aliases = EXCLUDED.aliases,
|
||||
category = EXCLUDED.category,
|
||||
default_unit = EXCLUDED.default_unit,
|
||||
calories_per_100g = EXCLUDED.calories_per_100g,
|
||||
protein_per_100g = EXCLUDED.protein_per_100g,
|
||||
fat_per_100g = EXCLUDED.fat_per_100g,
|
||||
carbs_per_100g = EXCLUDED.carbs_per_100g,
|
||||
fiber_per_100g = EXCLUDED.fiber_per_100g,
|
||||
storage_days = EXCLUDED.storage_days,
|
||||
updated_at = now()
|
||||
RETURNING id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
||||
category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
storage_days, created_at, updated_at`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query,
|
||||
m.CanonicalName, m.CanonicalNameRu, m.SpoonacularID, m.Aliases,
|
||||
m.Category, m.DefaultUnit,
|
||||
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
|
||||
m.StorageDays,
|
||||
)
|
||||
return scanMapping(row)
|
||||
}
|
||||
|
||||
// GetBySpoonacularID returns an ingredient mapping by Spoonacular ID.
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) {
|
||||
query := `
|
||||
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
||||
category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
storage_days, created_at, updated_at
|
||||
FROM ingredient_mappings
|
||||
WHERE spoonacular_id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query, id)
|
||||
m, err := scanMapping(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
// GetByID returns an ingredient mapping by UUID.
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
|
||||
query := `
|
||||
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
||||
category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
storage_days, created_at, updated_at
|
||||
FROM ingredient_mappings
|
||||
WHERE id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query, id)
|
||||
m, err := scanMapping(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
// Count returns the total number of ingredient mappings.
|
||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredient_mappings`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count ingredient_mappings: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListUntranslated returns ingredients without a Russian name, ordered by id.
|
||||
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*IngredientMapping, error) {
|
||||
query := `
|
||||
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
||||
category, default_unit,
|
||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||
storage_days, created_at, updated_at
|
||||
FROM ingredient_mappings
|
||||
WHERE canonical_name_ru IS NULL
|
||||
ORDER BY id
|
||||
LIMIT $1 OFFSET $2`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list untranslated: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectMappings(rows)
|
||||
}
|
||||
|
||||
// UpdateTranslation saves the Russian name and adds Russian aliases.
|
||||
func (r *Repository) UpdateTranslation(ctx context.Context, id, canonicalNameRu string, aliasesRu []string) error {
|
||||
// Merge new aliases into existing JSONB array without duplicates
|
||||
query := `
|
||||
UPDATE ingredient_mappings SET
|
||||
canonical_name_ru = $2,
|
||||
aliases = (
|
||||
SELECT jsonb_agg(DISTINCT elem)
|
||||
FROM (
|
||||
SELECT jsonb_array_elements(aliases) AS elem
|
||||
UNION
|
||||
SELECT to_jsonb(unnest) FROM unnest($3::text[]) AS unnest
|
||||
) sub
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE id = $1`
|
||||
|
||||
if _, err := r.pool.Exec(ctx, query, id, canonicalNameRu, aliasesRu); err != nil {
|
||||
return fmt.Errorf("update translation %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func scanMapping(row pgx.Row) (*IngredientMapping, error) {
|
||||
var m IngredientMapping
|
||||
var aliases []byte
|
||||
|
||||
err := row.Scan(
|
||||
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases,
|
||||
&m.Category, &m.DefaultUnit,
|
||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Aliases = json.RawMessage(aliases)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func collectMappings(rows pgx.Rows) ([]*IngredientMapping, error) {
|
||||
var result []*IngredientMapping
|
||||
for rows.Next() {
|
||||
var m IngredientMapping
|
||||
var aliases []byte
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases,
|
||||
&m.Category, &m.DefaultUnit,
|
||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan mapping: %w", err)
|
||||
}
|
||||
m.Aliases = json.RawMessage(aliases)
|
||||
result = append(result, &m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
250
backend/internal/ingredient/repository_integration_test.go
Normal file
250
backend/internal/ingredient/repository_integration_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
//go:build integration
|
||||
|
||||
package ingredient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/food-ai/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestIngredientRepository_Upsert_Insert(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 1001
|
||||
cat := "produce"
|
||||
unit := "g"
|
||||
cal := 52.0
|
||||
|
||||
m := &IngredientMapping{
|
||||
CanonicalName: "apple",
|
||||
SpoonacularID: &id,
|
||||
Aliases: json.RawMessage(`["apple", "apples"]`),
|
||||
Category: &cat,
|
||||
DefaultUnit: &unit,
|
||||
CaloriesPer100g: &cal,
|
||||
}
|
||||
|
||||
got, err := repo.Upsert(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
if got.ID == "" {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
if got.CanonicalName != "apple" {
|
||||
t.Errorf("canonical_name: want apple, got %s", got.CanonicalName)
|
||||
}
|
||||
if *got.CaloriesPer100g != 52.0 {
|
||||
t.Errorf("calories: want 52.0, got %v", got.CaloriesPer100g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 2001
|
||||
cat := "produce"
|
||||
unit := "g"
|
||||
|
||||
first := &IngredientMapping{
|
||||
CanonicalName: "banana",
|
||||
SpoonacularID: &id,
|
||||
Aliases: json.RawMessage(`["banana"]`),
|
||||
Category: &cat,
|
||||
DefaultUnit: &unit,
|
||||
}
|
||||
got1, err := repo.Upsert(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatalf("first upsert: %v", err)
|
||||
}
|
||||
|
||||
// Update with same spoonacular_id
|
||||
cal := 89.0
|
||||
second := &IngredientMapping{
|
||||
CanonicalName: "banana_updated",
|
||||
SpoonacularID: &id,
|
||||
Aliases: json.RawMessage(`["banana", "bananas"]`),
|
||||
Category: &cat,
|
||||
DefaultUnit: &unit,
|
||||
CaloriesPer100g: &cal,
|
||||
}
|
||||
got2, err := repo.Upsert(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatalf("second upsert: %v", err)
|
||||
}
|
||||
|
||||
if got1.ID != got2.ID {
|
||||
t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID)
|
||||
}
|
||||
if got2.CanonicalName != "banana_updated" {
|
||||
t.Errorf("canonical_name not updated: got %s", got2.CanonicalName)
|
||||
}
|
||||
if got2.CaloriesPer100g == nil || *got2.CaloriesPer100g != 89.0 {
|
||||
t.Errorf("calories not updated: got %v", got2.CaloriesPer100g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngredientRepository_GetBySpoonacularID_Found(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 3001
|
||||
cat := "dairy"
|
||||
unit := "g"
|
||||
|
||||
_, err := repo.Upsert(ctx, &IngredientMapping{
|
||||
CanonicalName: "cheese",
|
||||
SpoonacularID: &id,
|
||||
Aliases: json.RawMessage(`["cheese"]`),
|
||||
Category: &cat,
|
||||
DefaultUnit: &unit,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetBySpoonacularID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if got.CanonicalName != "cheese" {
|
||||
t.Errorf("want cheese, got %s", got.CanonicalName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngredientRepository_GetBySpoonacularID_NotFound(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := repo.GetBySpoonacularID(ctx, 99999999)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Error("expected nil result for missing ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngredientRepository_ListUntranslated(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
cat := "produce"
|
||||
unit := "g"
|
||||
|
||||
// Insert 3 without translation
|
||||
for i, name := range []string{"carrot", "onion", "garlic"} {
|
||||
id := 4000 + i
|
||||
_, err := repo.Upsert(ctx, &IngredientMapping{
|
||||
CanonicalName: name,
|
||||
SpoonacularID: &id,
|
||||
Aliases: json.RawMessage(`[]`),
|
||||
Category: &cat,
|
||||
DefaultUnit: &unit,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 1 with translation (shouldn't appear in untranslated list)
|
||||
id := 4100
|
||||
ruName := "помидор"
|
||||
withTranslation := &IngredientMapping{
|
||||
CanonicalName: "tomato",
|
||||
CanonicalNameRu: &ruName,
|
||||
SpoonacularID: &id,
|
||||
Aliases: json.RawMessage(`[]`),
|
||||
Category: &cat,
|
||||
DefaultUnit: &unit,
|
||||
}
|
||||
saved, err := repo.Upsert(ctx, withTranslation)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert with translation: %v", err)
|
||||
}
|
||||
// The upsert doesn't set canonical_name_ru because the UPDATE clause doesn't include it
|
||||
// We need to manually set it after
|
||||
if err := repo.UpdateTranslation(ctx, saved.ID, "помидор", []string{"помидор", "томат"}); err != nil {
|
||||
t.Fatalf("update translation: %v", err)
|
||||
}
|
||||
|
||||
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list untranslated: %v", err)
|
||||
}
|
||||
|
||||
// Should return the 3 without translation (carrot, onion, garlic)
|
||||
// The translated tomato should not appear
|
||||
for _, m := range untranslated {
|
||||
if m.CanonicalName == "tomato" {
|
||||
t.Error("translated ingredient should not appear in ListUntranslated")
|
||||
}
|
||||
}
|
||||
if len(untranslated) < 3 {
|
||||
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngredientRepository_UpdateTranslation(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 5001
|
||||
cat := "meat"
|
||||
unit := "g"
|
||||
|
||||
saved, err := repo.Upsert(ctx, &IngredientMapping{
|
||||
CanonicalName: "chicken_breast",
|
||||
SpoonacularID: &id,
|
||||
Aliases: json.RawMessage(`["chicken breast"]`),
|
||||
Category: &cat,
|
||||
DefaultUnit: &unit,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
err = repo.UpdateTranslation(ctx, saved.ID, "куриная грудка",
|
||||
[]string{"куриная грудка", "куриное филе"})
|
||||
if err != nil {
|
||||
t.Fatalf("update translation: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, saved.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.CanonicalNameRu == nil || *got.CanonicalNameRu != "куриная грудка" {
|
||||
t.Errorf("expected canonical_name_ru='куриная грудка', got %v", got.CanonicalNameRu)
|
||||
}
|
||||
|
||||
var aliases []string
|
||||
if err := json.Unmarshal(got.Aliases, &aliases); err != nil {
|
||||
t.Fatalf("unmarshal aliases: %v", err)
|
||||
}
|
||||
|
||||
hasRu := false
|
||||
for _, a := range aliases {
|
||||
if a == "куриное филе" {
|
||||
hasRu = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRu {
|
||||
t.Errorf("Russian alias not found in aliases: %v", aliases)
|
||||
}
|
||||
}
|
||||
77
backend/internal/pexels/client.go
Normal file
77
backend/internal/pexels/client.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package pexels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pexelsSearchURL = "https://api.pexels.com/v1/search"
|
||||
defaultPlaceholder = "https://images.pexels.com/photos/1640777/pexels-photo-1640777.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750"
|
||||
)
|
||||
|
||||
// PhotoSearcher can search for a photo by text query.
|
||||
type PhotoSearcher interface {
|
||||
SearchPhoto(ctx context.Context, query string) (string, error)
|
||||
}
|
||||
|
||||
// Client is an HTTP client for the Pexels Photos API.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Pexels client.
|
||||
func NewClient(apiKey string) *Client {
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SearchPhoto searches for a landscape photo matching query.
|
||||
// Returns a default placeholder URL if no photo is found or on error.
|
||||
func (c *Client) SearchPhoto(ctx context.Context, query string) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("per_page", "1")
|
||||
params.Set("orientation", "landscape")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pexelsSearchURL+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return defaultPlaceholder, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", c.apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return defaultPlaceholder, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return defaultPlaceholder, fmt.Errorf("pexels API error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Photos []struct {
|
||||
Src struct {
|
||||
Medium string `json:"medium"`
|
||||
} `json:"src"`
|
||||
} `json:"photos"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return defaultPlaceholder, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Photos) == 0 || result.Photos[0].Src.Medium == "" {
|
||||
return defaultPlaceholder, nil
|
||||
}
|
||||
return result.Photos[0].Src.Medium, nil
|
||||
}
|
||||
62
backend/internal/recipe/model.go
Normal file
62
backend/internal/recipe/model.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Recipe is a recipe record in the database.
|
||||
type Recipe struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"` // spoonacular | ai | user
|
||||
SpoonacularID *int `json:"spoonacular_id"`
|
||||
|
||||
Title string `json:"title"`
|
||||
TitleRu *string `json:"title_ru"`
|
||||
Description *string `json:"description"`
|
||||
DescriptionRu *string `json:"description_ru"`
|
||||
|
||||
Cuisine *string `json:"cuisine"`
|
||||
Difficulty *string `json:"difficulty"` // easy | medium | hard
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
|
||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||
FatPerServing *float64 `json:"fat_per_serving"`
|
||||
CarbsPerServing *float64 `json:"carbs_per_serving"`
|
||||
FiberPerServing *float64 `json:"fiber_per_serving"`
|
||||
|
||||
Ingredients json.RawMessage `json:"ingredients"` // []RecipeIngredient
|
||||
Steps json.RawMessage `json:"steps"` // []RecipeStep
|
||||
Tags json.RawMessage `json:"tags"` // []string
|
||||
|
||||
AvgRating float64 `json:"avg_rating"`
|
||||
ReviewCount int `json:"review_count"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RecipeIngredient is a single ingredient in a recipe's JSONB array.
|
||||
type RecipeIngredient struct {
|
||||
SpoonacularID *int `json:"spoonacular_id"`
|
||||
MappingID *string `json:"mapping_id"`
|
||||
Name string `json:"name"`
|
||||
NameRu *string `json:"name_ru"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"`
|
||||
UnitRu *string `json:"unit_ru"`
|
||||
Optional bool `json:"optional"`
|
||||
}
|
||||
|
||||
// RecipeStep is a single step in a recipe's JSONB array.
|
||||
type RecipeStep struct {
|
||||
Number int `json:"number"`
|
||||
Description string `json:"description"`
|
||||
DescriptionRu *string `json:"description_ru"`
|
||||
TimerSeconds *int `json:"timer_seconds"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
}
|
||||
181
backend/internal/recipe/repository.go
Normal file
181
backend/internal/recipe/repository.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Repository handles persistence for recipes.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// Upsert inserts or updates a recipe.
|
||||
// Conflict is resolved on spoonacular_id.
|
||||
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
|
||||
query := `
|
||||
INSERT INTO recipes (
|
||||
source, spoonacular_id,
|
||||
title, description, title_ru, description_ru,
|
||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||
ingredients, steps, tags
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
ON CONFLICT (spoonacular_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
cuisine = EXCLUDED.cuisine,
|
||||
difficulty = EXCLUDED.difficulty,
|
||||
prep_time_min = EXCLUDED.prep_time_min,
|
||||
cook_time_min = EXCLUDED.cook_time_min,
|
||||
servings = EXCLUDED.servings,
|
||||
image_url = EXCLUDED.image_url,
|
||||
calories_per_serving = EXCLUDED.calories_per_serving,
|
||||
protein_per_serving = EXCLUDED.protein_per_serving,
|
||||
fat_per_serving = EXCLUDED.fat_per_serving,
|
||||
carbs_per_serving = EXCLUDED.carbs_per_serving,
|
||||
fiber_per_serving = EXCLUDED.fiber_per_serving,
|
||||
ingredients = EXCLUDED.ingredients,
|
||||
steps = EXCLUDED.steps,
|
||||
tags = EXCLUDED.tags,
|
||||
updated_at = now()
|
||||
RETURNING id, source, spoonacular_id,
|
||||
title, description, title_ru, description_ru,
|
||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||
ingredients, steps, tags,
|
||||
avg_rating, review_count, created_by, created_at, updated_at`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query,
|
||||
recipe.Source, recipe.SpoonacularID,
|
||||
recipe.Title, recipe.Description, recipe.TitleRu, recipe.DescriptionRu,
|
||||
recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL,
|
||||
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
|
||||
recipe.Ingredients, recipe.Steps, recipe.Tags,
|
||||
)
|
||||
return scanRecipe(row)
|
||||
}
|
||||
|
||||
// Count returns the total number of recipes.
|
||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM recipes`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count recipes: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListUntranslated returns recipes without a Russian title, ordered by review_count DESC.
|
||||
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*Recipe, error) {
|
||||
query := `
|
||||
SELECT id, source, spoonacular_id,
|
||||
title, description, title_ru, description_ru,
|
||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||
ingredients, steps, tags,
|
||||
avg_rating, review_count, created_by, created_at, updated_at
|
||||
FROM recipes
|
||||
WHERE title_ru IS NULL AND source = 'spoonacular'
|
||||
ORDER BY review_count DESC
|
||||
LIMIT $1 OFFSET $2`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list untranslated recipes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectRecipes(rows)
|
||||
}
|
||||
|
||||
// UpdateTranslation saves the Russian title, description, and step translations.
|
||||
func (r *Repository) UpdateTranslation(ctx context.Context, id string, titleRu, descriptionRu *string, steps json.RawMessage) error {
|
||||
query := `
|
||||
UPDATE recipes SET
|
||||
title_ru = $2,
|
||||
description_ru = $3,
|
||||
steps = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1`
|
||||
|
||||
if _, err := r.pool.Exec(ctx, query, id, titleRu, descriptionRu, steps); err != nil {
|
||||
return fmt.Errorf("update recipe translation %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func scanRecipe(row pgx.Row) (*Recipe, error) {
|
||||
var rec Recipe
|
||||
var ingredients, steps, tags []byte
|
||||
|
||||
err := row.Scan(
|
||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
||||
&rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu,
|
||||
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
||||
&ingredients, &steps, &tags,
|
||||
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Ingredients = json.RawMessage(ingredients)
|
||||
rec.Steps = json.RawMessage(steps)
|
||||
rec.Tags = json.RawMessage(tags)
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
|
||||
var result []*Recipe
|
||||
for rows.Next() {
|
||||
var rec Recipe
|
||||
var ingredients, steps, tags []byte
|
||||
if err := rows.Scan(
|
||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
||||
&rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu,
|
||||
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
||||
&ingredients, &steps, &tags,
|
||||
&rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan recipe: %w", err)
|
||||
}
|
||||
rec.Ingredients = json.RawMessage(ingredients)
|
||||
rec.Steps = json.RawMessage(steps)
|
||||
rec.Tags = json.RawMessage(tags)
|
||||
result = append(result, &rec)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetByID returns a recipe by UUID.
|
||||
// Returns nil, nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
|
||||
query := `
|
||||
SELECT id, source, spoonacular_id,
|
||||
title, description, title_ru, description_ru,
|
||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
||||
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||
ingredients, steps, tags,
|
||||
avg_rating, review_count, created_by, created_at, updated_at
|
||||
FROM recipes
|
||||
WHERE id = $1`
|
||||
|
||||
row := r.pool.QueryRow(ctx, query, id)
|
||||
rec, err := scanRecipe(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return rec, err
|
||||
}
|
||||
311
backend/internal/recipe/repository_integration_test.go
Normal file
311
backend/internal/recipe/repository_integration_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
//go:build integration
|
||||
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/food-ai/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestRecipeRepository_Upsert_Insert(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 10001
|
||||
cuisine := "italian"
|
||||
diff := "easy"
|
||||
cookTime := 30
|
||||
servings := 4
|
||||
|
||||
rec := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Pasta Carbonara",
|
||||
Cuisine: &cuisine,
|
||||
Difficulty: &diff,
|
||||
CookTimeMin: &cookTime,
|
||||
Servings: &servings,
|
||||
Ingredients: json.RawMessage(`[{"name":"pasta","amount":200,"unit":"g"}]`),
|
||||
Steps: json.RawMessage(`[{"number":1,"description":"Boil pasta"}]`),
|
||||
Tags: json.RawMessage(`["italian"]`),
|
||||
}
|
||||
|
||||
got, err := repo.Upsert(ctx, rec)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
if got.ID == "" {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
if got.Title != "Pasta Carbonara" {
|
||||
t.Errorf("title: want Pasta Carbonara, got %s", got.Title)
|
||||
}
|
||||
if got.SpoonacularID == nil || *got.SpoonacularID != id {
|
||||
t.Errorf("spoonacular_id: want %d, got %v", id, got.SpoonacularID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_Upsert_ConflictUpdates(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 20001
|
||||
cuisine := "mexican"
|
||||
diff := "medium"
|
||||
|
||||
first := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Tacos",
|
||||
Cuisine: &cuisine,
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
}
|
||||
got1, err := repo.Upsert(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatalf("first upsert: %v", err)
|
||||
}
|
||||
|
||||
second := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Beef Tacos",
|
||||
Cuisine: &cuisine,
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[{"name":"beef","amount":300,"unit":"g"}]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
}
|
||||
got2, err := repo.Upsert(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatalf("second upsert: %v", err)
|
||||
}
|
||||
|
||||
if got1.ID != got2.ID {
|
||||
t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID)
|
||||
}
|
||||
if got2.Title != "Beef Tacos" {
|
||||
t.Errorf("title not updated: got %s", got2.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_GetByID_Found(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 30001
|
||||
diff := "easy"
|
||||
rec := &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Greek Salad",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`["vegetarian"]`),
|
||||
}
|
||||
saved, err := repo.Upsert(ctx, rec)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, saved.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if got.Title != "Greek Salad" {
|
||||
t.Errorf("want Greek Salad, got %s", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Error("expected nil for non-existent ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_ListUntranslated_Pagination(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
diff := "easy"
|
||||
for i := 0; i < 5; i++ {
|
||||
spID := 40000 + i
|
||||
_, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &spID,
|
||||
Title: "Recipe " + string(rune('A'+i)),
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert recipe %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
untranslated, err := repo.ListUntranslated(ctx, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list untranslated: %v", err)
|
||||
}
|
||||
if len(untranslated) != 3 {
|
||||
t.Errorf("expected 3 results with limit=3, got %d", len(untranslated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_UpdateTranslation(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 50001
|
||||
diff := "medium"
|
||||
saved, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Chicken Tikka Masala",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[{"number":1,"description":"Heat oil"}]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
titleRu := "Курица Тикка Масала"
|
||||
descRu := "Классическое индийское блюдо"
|
||||
stepsRu := json.RawMessage(`[{"number":1,"description":"Heat oil","description_ru":"Разогрейте масло"}]`)
|
||||
|
||||
if err := repo.UpdateTranslation(ctx, saved.ID, &titleRu, &descRu, stepsRu); err != nil {
|
||||
t.Fatalf("update translation: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, saved.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.TitleRu == nil || *got.TitleRu != titleRu {
|
||||
t.Errorf("expected title_ru=%q, got %v", titleRu, got.TitleRu)
|
||||
}
|
||||
if got.DescriptionRu == nil || *got.DescriptionRu != descRu {
|
||||
t.Errorf("expected description_ru=%q, got %v", descRu, got.DescriptionRu)
|
||||
}
|
||||
|
||||
var steps []RecipeStep
|
||||
if err := json.Unmarshal(got.Steps, &steps); err != nil {
|
||||
t.Fatalf("unmarshal steps: %v", err)
|
||||
}
|
||||
if len(steps) == 0 || steps[0].DescriptionRu == nil || *steps[0].DescriptionRu != "Разогрейте масло" {
|
||||
t.Errorf("expected description_ru in steps, got %v", steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
diff := "easy"
|
||||
|
||||
// Insert untranslated
|
||||
for i := 0; i < 3; i++ {
|
||||
spID := 60000 + i
|
||||
_, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &spID,
|
||||
Title: "Untranslated " + string(rune('A'+i)),
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert translated
|
||||
spID := 60100
|
||||
translated, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &spID,
|
||||
Title: "Translated Recipe",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`[]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert translated: %v", err)
|
||||
}
|
||||
titleRu := "Переведённый рецепт"
|
||||
if err := repo.UpdateTranslation(ctx, translated.ID, &titleRu, nil, translated.Steps); err != nil {
|
||||
t.Fatalf("update translation: %v", err)
|
||||
}
|
||||
|
||||
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list untranslated: %v", err)
|
||||
}
|
||||
for _, r := range untranslated {
|
||||
if r.Title == "Translated Recipe" {
|
||||
t.Error("translated recipe should not appear in ListUntranslated")
|
||||
}
|
||||
}
|
||||
if len(untranslated) < 3 {
|
||||
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeRepository_GIN_Tags(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
id := 70001
|
||||
diff := "easy"
|
||||
_, err := repo.Upsert(ctx, &Recipe{
|
||||
Source: "spoonacular",
|
||||
SpoonacularID: &id,
|
||||
Title: "Veggie Bowl",
|
||||
Difficulty: &diff,
|
||||
Ingredients: json.RawMessage(`[]`),
|
||||
Steps: json.RawMessage(`[]`),
|
||||
Tags: json.RawMessage(`["vegetarian","gluten-free"]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
// GIN index query: tags @> '["vegetarian"]'
|
||||
var count int
|
||||
row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id)
|
||||
if err := row.Scan(&count); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 vegetarian recipe, got %d", count)
|
||||
}
|
||||
}
|
||||
138
backend/internal/recommendation/handler.go
Normal file
138
backend/internal/recommendation/handler.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/food-ai/backend/internal/gemini"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
)
|
||||
|
||||
// PhotoSearcher can search for a photo by text query.
|
||||
type PhotoSearcher interface {
|
||||
SearchPhoto(ctx context.Context, query string) (string, error)
|
||||
}
|
||||
|
||||
// UserLoader can load a user profile by ID.
|
||||
type UserLoader interface {
|
||||
GetByID(ctx context.Context, id string) (*user.User, error)
|
||||
}
|
||||
|
||||
// userPreferences is the shape of user.Preferences JSONB.
|
||||
type userPreferences struct {
|
||||
Cuisines []string `json:"cuisines"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
}
|
||||
|
||||
// Handler handles GET /recommendations.
|
||||
type Handler struct {
|
||||
gemini *gemini.Client
|
||||
pexels PhotoSearcher
|
||||
userLoader UserLoader
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader) *Handler {
|
||||
return &Handler{
|
||||
gemini: geminiClient,
|
||||
pexels: pexels,
|
||||
userLoader: userLoader,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecommendations handles GET /recommendations?count=5.
|
||||
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
count := 5
|
||||
if s := r.URL.Query().Get("count"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 20 {
|
||||
count = n
|
||||
}
|
||||
}
|
||||
|
||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("load user for recommendations", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to load user profile")
|
||||
return
|
||||
}
|
||||
|
||||
req := buildRecipeRequest(u, count)
|
||||
|
||||
recipes, err := h.gemini.GenerateRecipes(r.Context(), req)
|
||||
if err != nil {
|
||||
slog.Error("generate recipes", "user_id", userID, "err", err)
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch Pexels photos in parallel — each goroutine owns a distinct index.
|
||||
var wg sync.WaitGroup
|
||||
for i := range recipes {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery)
|
||||
if err != nil {
|
||||
slog.Warn("pexels photo search failed", "query", recipes[i].ImageQuery, "err", err)
|
||||
}
|
||||
recipes[i].ImageURL = imageURL
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
writeJSON(w, http.StatusOK, recipes)
|
||||
}
|
||||
|
||||
func buildRecipeRequest(u *user.User, count int) gemini.RecipeRequest {
|
||||
req := gemini.RecipeRequest{
|
||||
Count: count,
|
||||
DailyCalories: 2000, // sensible default
|
||||
}
|
||||
|
||||
if u.Goal != nil {
|
||||
req.UserGoal = *u.Goal
|
||||
}
|
||||
if u.DailyCalories != nil && *u.DailyCalories > 0 {
|
||||
req.DailyCalories = *u.DailyCalories
|
||||
}
|
||||
|
||||
if len(u.Preferences) > 0 {
|
||||
var prefs userPreferences
|
||||
if err := json.Unmarshal(u.Preferences, &prefs); err == nil {
|
||||
req.CuisinePrefs = prefs.Cuisines
|
||||
req.Restrictions = prefs.Restrictions
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
135
backend/internal/savedrecipe/handler.go
Normal file
135
backend/internal/savedrecipe/handler.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package savedrecipe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const maxBodySize = 1 << 20 // 1 MB
|
||||
|
||||
// Handler handles HTTP requests for saved recipes.
|
||||
type Handler struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo *Repository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// Save handles POST /saved-recipes.
|
||||
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
var req SaveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := h.repo.Save(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
slog.Error("save recipe", "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to save recipe")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rec)
|
||||
}
|
||||
|
||||
// List handles GET /saved-recipes.
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
recipes, err := h.repo.List(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("list saved recipes", "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to list saved recipes")
|
||||
return
|
||||
}
|
||||
|
||||
if recipes == nil {
|
||||
recipes = []*SavedRecipe{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, recipes)
|
||||
}
|
||||
|
||||
// GetByID handles GET /saved-recipes/{id}.
|
||||
func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
rec, err := h.repo.GetByID(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
slog.Error("get saved recipe", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to get saved recipe")
|
||||
return
|
||||
}
|
||||
if rec == nil {
|
||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /saved-recipes/{id}.
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.repo.Delete(r.Context(), userID, id); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeErrorJSON(w, http.StatusNotFound, "recipe not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete saved recipe", "id", id, "err", err)
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete recipe")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
43
backend/internal/savedrecipe/model.go
Normal file
43
backend/internal/savedrecipe/model.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package savedrecipe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SavedRecipe is a recipe saved by a specific user.
|
||||
type SavedRecipe struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
PrepTimeMin *int `json:"prep_time_min"`
|
||||
CookTimeMin *int `json:"cook_time_min"`
|
||||
Servings *int `json:"servings"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
Ingredients json.RawMessage `json:"ingredients"`
|
||||
Steps json.RawMessage `json:"steps"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
Nutrition json.RawMessage `json:"nutrition_per_serving"`
|
||||
Source string `json:"source"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
}
|
||||
|
||||
// SaveRequest is the body for POST /saved-recipes.
|
||||
type SaveRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Cuisine string `json:"cuisine"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
PrepTimeMin int `json:"prep_time_min"`
|
||||
CookTimeMin int `json:"cook_time_min"`
|
||||
Servings int `json:"servings"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Ingredients json.RawMessage `json:"ingredients"`
|
||||
Steps json.RawMessage `json:"steps"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
Nutrition json.RawMessage `json:"nutrition_per_serving"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
173
backend/internal/savedrecipe/repository.go
Normal file
173
backend/internal/savedrecipe/repository.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package savedrecipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a saved recipe does not exist for the given user.
|
||||
var ErrNotFound = errors.New("saved recipe not found")
|
||||
|
||||
// Repository handles persistence for saved recipes.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new Repository.
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// Save persists a recipe for userID and returns the stored record.
|
||||
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) {
|
||||
const query = `
|
||||
INSERT INTO saved_recipes (
|
||||
user_id, title, description, cuisine, difficulty,
|
||||
prep_time_min, cook_time_min, servings, image_url,
|
||||
ingredients, steps, tags, nutrition, source
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, user_id, title, description, cuisine, difficulty,
|
||||
prep_time_min, cook_time_min, servings, image_url,
|
||||
ingredients, steps, tags, nutrition, source, saved_at`
|
||||
|
||||
description := nullableStr(req.Description)
|
||||
cuisine := nullableStr(req.Cuisine)
|
||||
difficulty := nullableStr(req.Difficulty)
|
||||
imageURL := nullableStr(req.ImageURL)
|
||||
prepTime := nullableInt(req.PrepTimeMin)
|
||||
cookTime := nullableInt(req.CookTimeMin)
|
||||
servings := nullableInt(req.Servings)
|
||||
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = "ai"
|
||||
}
|
||||
|
||||
ingredients := defaultJSONArray(req.Ingredients)
|
||||
steps := defaultJSONArray(req.Steps)
|
||||
tags := defaultJSONArray(req.Tags)
|
||||
|
||||
row := r.pool.QueryRow(ctx, query,
|
||||
userID, req.Title, description, cuisine, difficulty,
|
||||
prepTime, cookTime, servings, imageURL,
|
||||
ingredients, steps, tags, req.Nutrition, source,
|
||||
)
|
||||
return scanRow(row)
|
||||
}
|
||||
|
||||
// List returns all saved recipes for userID ordered by saved_at DESC.
|
||||
func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) {
|
||||
const query = `
|
||||
SELECT id, user_id, title, description, cuisine, difficulty,
|
||||
prep_time_min, cook_time_min, servings, image_url,
|
||||
ingredients, steps, tags, nutrition, source, saved_at
|
||||
FROM saved_recipes
|
||||
WHERE user_id = $1
|
||||
ORDER BY saved_at DESC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list saved recipes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*SavedRecipe
|
||||
for rows.Next() {
|
||||
rec, err := scanRows(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan saved recipe: %w", err)
|
||||
}
|
||||
result = append(result, rec)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetByID returns the saved recipe with id for userID, or nil if not found.
|
||||
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
|
||||
const query = `
|
||||
SELECT id, user_id, title, description, cuisine, difficulty,
|
||||
prep_time_min, cook_time_min, servings, image_url,
|
||||
ingredients, steps, tags, nutrition, source, saved_at
|
||||
FROM saved_recipes
|
||||
WHERE id = $1 AND user_id = $2`
|
||||
|
||||
rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return rec, err
|
||||
}
|
||||
|
||||
// Delete removes the saved recipe with id for userID.
|
||||
// Returns ErrNotFound if the record does not exist.
|
||||
func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM saved_recipes WHERE id = $1 AND user_id = $2`,
|
||||
id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete saved recipe: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanRow(s scannable) (*SavedRecipe, error) {
|
||||
var rec SavedRecipe
|
||||
var ingredients, steps, tags, nutrition []byte
|
||||
err := s.Scan(
|
||||
&rec.ID, &rec.UserID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty,
|
||||
&rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||
&ingredients, &steps, &tags, &nutrition,
|
||||
&rec.Source, &rec.SavedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Ingredients = json.RawMessage(ingredients)
|
||||
rec.Steps = json.RawMessage(steps)
|
||||
rec.Tags = json.RawMessage(tags)
|
||||
if len(nutrition) > 0 {
|
||||
rec.Nutrition = json.RawMessage(nutrition)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// scanRows wraps pgx.Rows to satisfy the scannable interface.
|
||||
func scanRows(rows pgx.Rows) (*SavedRecipe, error) {
|
||||
return scanRow(rows)
|
||||
}
|
||||
|
||||
func nullableStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func nullableInt(n int) *int {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
func defaultJSONArray(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return json.RawMessage(`[]`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -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
|
||||
|
||||
36
backend/migrations/002_create_ingredient_mappings.sql
Normal file
36
backend/migrations/002_create_ingredient_mappings.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE ingredient_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
canonical_name VARCHAR(255) NOT NULL,
|
||||
canonical_name_ru VARCHAR(255),
|
||||
spoonacular_id INTEGER UNIQUE,
|
||||
|
||||
aliases JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
category VARCHAR(50),
|
||||
default_unit VARCHAR(20),
|
||||
|
||||
-- Nutrients per 100g
|
||||
calories_per_100g DECIMAL(8, 2),
|
||||
protein_per_100g DECIMAL(8, 2),
|
||||
fat_per_100g DECIMAL(8, 2),
|
||||
carbs_per_100g DECIMAL(8, 2),
|
||||
fiber_per_100g DECIMAL(8, 2),
|
||||
|
||||
storage_days INTEGER,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ingredient_mappings_aliases
|
||||
ON ingredient_mappings USING GIN (aliases);
|
||||
|
||||
CREATE INDEX idx_ingredient_mappings_canonical_name
|
||||
ON ingredient_mappings (canonical_name);
|
||||
|
||||
CREATE INDEX idx_ingredient_mappings_category
|
||||
ON ingredient_mappings (category);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS ingredient_mappings;
|
||||
58
backend/migrations/003_create_recipes.sql
Normal file
58
backend/migrations/003_create_recipes.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- +goose Up
|
||||
CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user');
|
||||
CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard');
|
||||
|
||||
CREATE TABLE recipes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
source recipe_source NOT NULL DEFAULT 'spoonacular',
|
||||
spoonacular_id INTEGER UNIQUE,
|
||||
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
title_ru VARCHAR(500),
|
||||
description_ru TEXT,
|
||||
|
||||
cuisine VARCHAR(100),
|
||||
difficulty recipe_difficulty,
|
||||
prep_time_min INTEGER,
|
||||
cook_time_min INTEGER,
|
||||
servings SMALLINT,
|
||||
image_url TEXT,
|
||||
|
||||
calories_per_serving DECIMAL(8, 2),
|
||||
protein_per_serving DECIMAL(8, 2),
|
||||
fat_per_serving DECIMAL(8, 2),
|
||||
carbs_per_serving DECIMAL(8, 2),
|
||||
fiber_per_serving DECIMAL(8, 2),
|
||||
|
||||
ingredients JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
steps JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
avg_rating DECIMAL(3, 2) NOT NULL DEFAULT 0.0,
|
||||
review_count INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
created_by UUID REFERENCES users (id) ON DELETE SET NULL,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_recipes_title_fts ON recipes
|
||||
USING GIN (to_tsvector('simple',
|
||||
coalesce(title_ru, '') || ' ' || coalesce(title, '')));
|
||||
|
||||
CREATE INDEX idx_recipes_ingredients ON recipes USING GIN (ingredients);
|
||||
CREATE INDEX idx_recipes_tags ON recipes USING GIN (tags);
|
||||
|
||||
CREATE INDEX idx_recipes_cuisine ON recipes (cuisine);
|
||||
CREATE INDEX idx_recipes_difficulty ON recipes (difficulty);
|
||||
CREATE INDEX idx_recipes_prep_time ON recipes (prep_time_min);
|
||||
CREATE INDEX idx_recipes_calories ON recipes (calories_per_serving);
|
||||
CREATE INDEX idx_recipes_source ON recipes (source);
|
||||
CREATE INDEX idx_recipes_rating ON recipes (avg_rating DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS recipes;
|
||||
DROP TYPE IF EXISTS recipe_difficulty;
|
||||
DROP TYPE IF EXISTS recipe_source;
|
||||
25
backend/migrations/004_create_saved_recipes.sql
Normal file
25
backend/migrations/004_create_saved_recipes.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE saved_recipes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
cuisine TEXT,
|
||||
difficulty TEXT,
|
||||
prep_time_min INT,
|
||||
cook_time_min INT,
|
||||
servings INT,
|
||||
image_url TEXT,
|
||||
ingredients JSONB NOT NULL DEFAULT '[]',
|
||||
steps JSONB NOT NULL DEFAULT '[]',
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
nutrition JSONB,
|
||||
source TEXT NOT NULL DEFAULT 'ai',
|
||||
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_recipes_user_id ON saved_recipes(user_id);
|
||||
CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes(user_id, saved_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE saved_recipes;
|
||||
@@ -42,4 +42,16 @@ class ApiClient {
|
||||
final response = await _dio.delete(path);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/// Returns a list for endpoints that respond with a JSON array.
|
||||
Future<List<dynamic>> getList(String path,
|
||||
{Map<String, dynamic>? params}) async {
|
||||
final response = await _dio.get(path, queryParameters: params);
|
||||
return response.data as List<dynamic>;
|
||||
}
|
||||
|
||||
/// Deletes a resource and expects no response body (204 No Content).
|
||||
Future<void> deleteVoid(String path) async {
|
||||
await _dio.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ import '../../features/auth/register_screen.dart';
|
||||
import '../../features/home/home_screen.dart';
|
||||
import '../../features/products/products_screen.dart';
|
||||
import '../../features/menu/menu_screen.dart';
|
||||
import '../../features/recipes/recipe_detail_screen.dart';
|
||||
import '../../features/recipes/recipes_screen.dart';
|
||||
import '../../features/profile/profile_screen.dart';
|
||||
import '../../shared/models/recipe.dart';
|
||||
import '../../shared/models/saved_recipe.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
@@ -34,6 +37,21 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/auth/register',
|
||||
builder: (_, __) => const RegisterScreen(),
|
||||
),
|
||||
// Full-screen recipe detail — shown without the bottom navigation bar.
|
||||
GoRoute(
|
||||
path: '/recipe-detail',
|
||||
builder: (context, state) {
|
||||
final extra = state.extra;
|
||||
if (extra is Recipe) {
|
||||
return RecipeDetailScreen(recipe: extra);
|
||||
}
|
||||
if (extra is SavedRecipe) {
|
||||
return RecipeDetailScreen(saved: extra);
|
||||
}
|
||||
// Fallback: pop back if navigated without a valid extra.
|
||||
return const _InvalidRoute();
|
||||
},
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => MainShell(child: child),
|
||||
routes: [
|
||||
@@ -52,6 +70,18 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
class _InvalidRoute extends StatelessWidget {
|
||||
const _InvalidRoute();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
});
|
||||
return const Scaffold(body: SizedBox.shrink());
|
||||
}
|
||||
}
|
||||
|
||||
class MainShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
|
||||
552
client/lib/features/recipes/recipe_detail_screen.dart
Normal file
552
client/lib/features/recipes/recipe_detail_screen.dart
Normal file
@@ -0,0 +1,552 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../shared/models/recipe.dart';
|
||||
import '../../shared/models/saved_recipe.dart';
|
||||
import 'recipe_provider.dart';
|
||||
|
||||
/// Unified detail screen for both recommendation recipes and saved recipes.
|
||||
///
|
||||
/// Pass a [Recipe] (from recommendations) or a [SavedRecipe] (from saved list)
|
||||
/// via GoRouter's `extra` parameter.
|
||||
class RecipeDetailScreen extends ConsumerStatefulWidget {
|
||||
final Recipe? recipe;
|
||||
final SavedRecipe? saved;
|
||||
|
||||
const RecipeDetailScreen({super.key, this.recipe, this.saved})
|
||||
: assert(recipe != null || saved != null,
|
||||
'Provide either recipe or saved');
|
||||
|
||||
@override
|
||||
ConsumerState<RecipeDetailScreen> createState() => _RecipeDetailScreenState();
|
||||
}
|
||||
|
||||
class _RecipeDetailScreenState extends ConsumerState<RecipeDetailScreen> {
|
||||
bool _isSaving = false;
|
||||
|
||||
// ── Unified accessors ────────────────────────────────────────────────────
|
||||
|
||||
String get _title => widget.recipe?.title ?? widget.saved!.title;
|
||||
String? get _description =>
|
||||
widget.recipe?.description ?? widget.saved!.description;
|
||||
String? get _imageUrl =>
|
||||
widget.recipe?.imageUrl.isNotEmpty == true
|
||||
? widget.recipe!.imageUrl
|
||||
: widget.saved?.imageUrl;
|
||||
String? get _cuisine => widget.recipe?.cuisine ?? widget.saved!.cuisine;
|
||||
String? get _difficulty =>
|
||||
widget.recipe?.difficulty ?? widget.saved!.difficulty;
|
||||
int? get _prepTimeMin =>
|
||||
widget.recipe?.prepTimeMin ?? widget.saved!.prepTimeMin;
|
||||
int? get _cookTimeMin =>
|
||||
widget.recipe?.cookTimeMin ?? widget.saved!.cookTimeMin;
|
||||
int? get _servings => widget.recipe?.servings ?? widget.saved!.servings;
|
||||
List<RecipeIngredient> get _ingredients =>
|
||||
widget.recipe?.ingredients ?? widget.saved!.ingredients;
|
||||
List<RecipeStep> get _steps =>
|
||||
widget.recipe?.steps ?? widget.saved!.steps;
|
||||
List<String> get _tags => widget.recipe?.tags ?? widget.saved!.tags;
|
||||
NutritionInfo? get _nutrition =>
|
||||
widget.recipe?.nutrition ?? widget.saved!.nutrition;
|
||||
|
||||
bool get _isFromSaved => widget.saved != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final savedNotifier = ref.watch(savedRecipesProvider.notifier);
|
||||
final isSaved = _isFromSaved ||
|
||||
(widget.recipe != null && savedNotifier.isSaved(_title));
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
_title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (_description != null && _description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_description!,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_MetaChips(
|
||||
prepTimeMin: _prepTimeMin,
|
||||
cookTimeMin: _cookTimeMin,
|
||||
difficulty: _difficulty,
|
||||
cuisine: _cuisine,
|
||||
servings: _servings,
|
||||
),
|
||||
if (_nutrition != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_NutritionCard(nutrition: _nutrition!),
|
||||
],
|
||||
if (_tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_TagsRow(tags: _tags),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
_IngredientsSection(ingredients: _ingredients),
|
||||
const Divider(height: 32),
|
||||
_StepsSection(steps: _steps),
|
||||
const SizedBox(height: 24),
|
||||
// Save / Unsave button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _SaveButton(
|
||||
isSaved: isSaved,
|
||||
isLoading: _isSaving,
|
||||
onPressed: () => _toggleSave(context, isSaved),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: _imageUrl != null && _imageUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
||||
errorWidget: (_, __, ___) => _PlaceholderImage(),
|
||||
)
|
||||
: _PlaceholderImage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleSave(BuildContext context, bool isSaved) async {
|
||||
if (_isSaving) return;
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final notifier = ref.read(savedRecipesProvider.notifier);
|
||||
|
||||
try {
|
||||
if (isSaved) {
|
||||
final id = _isFromSaved
|
||||
? widget.saved!.id
|
||||
: notifier.savedId(_title);
|
||||
if (id != null) {
|
||||
final ok = await notifier.delete(id);
|
||||
if (!ok && context.mounted) {
|
||||
_showSnack(context, 'Не удалось удалить из сохранённых');
|
||||
} else if (ok && _isFromSaved && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} else if (widget.recipe != null) {
|
||||
final saved = await notifier.save(widget.recipe!);
|
||||
if (saved == null && context.mounted) {
|
||||
_showSnack(context, 'Не удалось сохранить рецепт');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-widgets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _PlaceholderImage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
color: AppColors.primaryLight.withValues(alpha: 0.3),
|
||||
child: const Center(child: Icon(Icons.restaurant, size: 64)),
|
||||
);
|
||||
}
|
||||
|
||||
class _MetaChips extends StatelessWidget {
|
||||
final int? prepTimeMin;
|
||||
final int? cookTimeMin;
|
||||
final String? difficulty;
|
||||
final String? cuisine;
|
||||
final int? servings;
|
||||
|
||||
const _MetaChips({
|
||||
this.prepTimeMin,
|
||||
this.cookTimeMin,
|
||||
this.difficulty,
|
||||
this.cuisine,
|
||||
this.servings,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (totalMin > 0)
|
||||
_Chip(icon: Icons.access_time, label: '$totalMin мин'),
|
||||
if (difficulty != null)
|
||||
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
|
||||
if (cuisine != null)
|
||||
_Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)),
|
||||
if (servings != null)
|
||||
_Chip(icon: Icons.people, label: '$servings порц.'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _difficultyLabel(String d) => switch (d) {
|
||||
'easy' => 'Легко',
|
||||
'medium' => 'Средне',
|
||||
'hard' => 'Сложно',
|
||||
_ => d,
|
||||
};
|
||||
|
||||
String _cuisineLabel(String c) => switch (c) {
|
||||
'russian' => 'Русская',
|
||||
'asian' => 'Азиатская',
|
||||
'european' => 'Европейская',
|
||||
'mediterranean' => 'Средиземноморская',
|
||||
'american' => 'Американская',
|
||||
_ => 'Другая',
|
||||
};
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const _Chip({required this.icon, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Chip(
|
||||
avatar: Icon(icon, size: 14),
|
||||
label: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
|
||||
class _NutritionCard extends StatelessWidget {
|
||||
final NutritionInfo nutrition;
|
||||
|
||||
const _NutritionCard({required this.nutrition});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'КБЖУ на порцию',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: 'Значения рассчитаны приблизительно с помощью ИИ',
|
||||
child: Text(
|
||||
'≈',
|
||||
style: TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_NutCell(
|
||||
label: 'Калории', value: '${nutrition.calories.round()}'),
|
||||
_NutCell(
|
||||
label: 'Белки', value: '${nutrition.proteinG.round()} г'),
|
||||
_NutCell(
|
||||
label: 'Жиры', value: '${nutrition.fatG.round()} г'),
|
||||
_NutCell(
|
||||
label: 'Углев.', value: '${nutrition.carbsG.round()} г'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NutCell extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _NutCell({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _TagsRow extends StatelessWidget {
|
||||
final List<String> tags;
|
||||
|
||||
const _TagsRow({required this.tags});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: tags
|
||||
.map(
|
||||
(t) => Chip(
|
||||
label: Text(t, style: const TextStyle(fontSize: 11)),
|
||||
backgroundColor: AppColors.primaryLight.withValues(alpha: 0.3),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IngredientsSection extends StatelessWidget {
|
||||
final List<RecipeIngredient> ingredients;
|
||||
|
||||
const _IngredientsSection({required this.ingredients});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ingredients.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ингредиенты',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...ingredients.map(
|
||||
(ing) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.circle, size: 6, color: AppColors.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(ing.name)),
|
||||
Text(
|
||||
'${_formatAmount(ing.amount)} ${ing.unit}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatAmount(double amount) {
|
||||
if (amount == amount.truncate()) return amount.toInt().toString();
|
||||
return amount.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepsSection extends StatelessWidget {
|
||||
final List<RecipeStep> steps;
|
||||
|
||||
const _StepsSection({required this.steps});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (steps.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Приготовление',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...steps.map((step) => _StepTile(step: step)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepTile extends StatelessWidget {
|
||||
final RecipeStep step;
|
||||
|
||||
const _StepTile({required this.step});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Step number badge
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${step.number}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(step.description),
|
||||
if (step.timerSeconds != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.timer_outlined,
|
||||
size: 14, color: AppColors.accent),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTimer(step.timerSeconds!),
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTimer(int seconds) {
|
||||
if (seconds < 60) return '$seconds сек';
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return s == 0 ? '$m мин' : '$m мин $s сек';
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
final bool isSaved;
|
||||
final bool isLoading;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _SaveButton({
|
||||
required this.isSaved,
|
||||
required this.isLoading,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(isSaved ? Icons.favorite : Icons.favorite_border),
|
||||
label: Text(isSaved ? 'Сохранено' : 'Сохранить'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
isSaved ? Colors.red[100] : AppColors.primary,
|
||||
foregroundColor: isSaved ? Colors.red[800] : Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
client/lib/features/recipes/recipe_provider.dart
Normal file
109
client/lib/features/recipes/recipe_provider.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
import '../../shared/models/recipe.dart';
|
||||
import '../../shared/models/saved_recipe.dart';
|
||||
import 'recipe_service.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final recipeServiceProvider = Provider<RecipeService>((ref) {
|
||||
return RecipeService(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recommendations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecommendationsNotifier
|
||||
extends StateNotifier<AsyncValue<List<Recipe>>> {
|
||||
final RecipeService _service;
|
||||
|
||||
RecommendationsNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load({int count = 5}) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => _service.getRecommendations(count: count),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final recommendationsProvider = StateNotifierProvider<RecommendationsNotifier,
|
||||
AsyncValue<List<Recipe>>>((ref) {
|
||||
return RecommendationsNotifier(ref.read(recipeServiceProvider));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saved recipes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SavedRecipesNotifier
|
||||
extends StateNotifier<AsyncValue<List<SavedRecipe>>> {
|
||||
final RecipeService _service;
|
||||
|
||||
SavedRecipesNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _service.getSavedRecipes());
|
||||
}
|
||||
|
||||
/// Saves [recipe] and reloads the list. Returns the saved record or null on error.
|
||||
Future<SavedRecipe?> save(Recipe recipe) async {
|
||||
try {
|
||||
final saved = await _service.saveRecipe(recipe);
|
||||
await load();
|
||||
return saved;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the recipe with [id] optimistically and reverts on error.
|
||||
Future<bool> delete(String id) async {
|
||||
final previous = state;
|
||||
state = state.whenData(
|
||||
(list) => list.where((r) => r.id != id).toList(),
|
||||
);
|
||||
try {
|
||||
await _service.deleteSavedRecipe(id);
|
||||
return true;
|
||||
} catch (_) {
|
||||
state = previous;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any saved recipe has the same title.
|
||||
bool isSaved(String title) {
|
||||
return state.whenOrNull(
|
||||
data: (list) => list.any((r) => r.title == title),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// Returns the saved recipe ID for the given title, or null.
|
||||
String? savedId(String title) {
|
||||
return state.whenOrNull(
|
||||
data: (list) {
|
||||
try {
|
||||
return list.firstWhere((r) => r.title == title).id;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final savedRecipesProvider = StateNotifierProvider<SavedRecipesNotifier,
|
||||
AsyncValue<List<SavedRecipe>>>((ref) {
|
||||
return SavedRecipesNotifier(ref.read(recipeServiceProvider));
|
||||
});
|
||||
36
client/lib/features/recipes/recipe_service.dart
Normal file
36
client/lib/features/recipes/recipe_service.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../shared/models/recipe.dart';
|
||||
import '../../shared/models/saved_recipe.dart';
|
||||
|
||||
class RecipeService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
RecipeService(this._apiClient);
|
||||
|
||||
Future<List<Recipe>> getRecommendations({int count = 5}) async {
|
||||
final data = await _apiClient.getList(
|
||||
'/recommendations',
|
||||
params: {'count': '$count'},
|
||||
);
|
||||
return data
|
||||
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<SavedRecipe>> getSavedRecipes() async {
|
||||
final data = await _apiClient.getList('/saved-recipes');
|
||||
return data
|
||||
.map((e) => SavedRecipe.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<SavedRecipe> saveRecipe(Recipe recipe) async {
|
||||
final body = recipe.toJson()..['source'] = 'ai';
|
||||
final response = await _apiClient.post('/saved-recipes', data: body);
|
||||
return SavedRecipe.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> deleteSavedRecipe(String id) async {
|
||||
await _apiClient.deleteVoid('/saved-recipes/$id');
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RecipesScreen extends StatelessWidget {
|
||||
import 'recommendations_screen.dart';
|
||||
import 'saved_recipes_screen.dart';
|
||||
|
||||
/// Root screen for the Recipes tab — two sub-tabs: Recommendations and Saved.
|
||||
class RecipesScreen extends StatefulWidget {
|
||||
const RecipesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RecipesScreen> createState() => _RecipesScreenState();
|
||||
}
|
||||
|
||||
class _RecipesScreenState extends State<RecipesScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Рецепты')),
|
||||
body: const Center(child: Text('Раздел в разработке')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Рецепты'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Рекомендации'),
|
||||
Tab(text: 'Сохранённые'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: const [
|
||||
RecommendationsScreen(),
|
||||
SavedRecipesScreen(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
142
client/lib/features/recipes/recommendations_screen.dart
Normal file
142
client/lib/features/recipes/recommendations_screen.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../shared/models/recipe.dart';
|
||||
import 'recipe_provider.dart';
|
||||
import 'widgets/recipe_card.dart';
|
||||
import 'widgets/skeleton_card.dart';
|
||||
|
||||
class RecommendationsScreen extends ConsumerWidget {
|
||||
const RecommendationsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(recommendationsProvider);
|
||||
|
||||
return Scaffold(
|
||||
// AppBar is owned by RecipesScreen (tab host), but we add the
|
||||
// refresh action via a floating action button inside this child.
|
||||
body: state.when(
|
||||
loading: () => _SkeletonList(),
|
||||
error: (err, _) => _ErrorView(
|
||||
message: err.toString(),
|
||||
onRetry: () =>
|
||||
ref.read(recommendationsProvider.notifier).load(),
|
||||
),
|
||||
data: (recipes) => _RecipeList(recipes: recipes),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'refresh_recommendations',
|
||||
tooltip: 'Обновить рекомендации',
|
||||
onPressed: state is AsyncLoading
|
||||
? null
|
||||
: () => ref.read(recommendationsProvider.notifier).load(),
|
||||
child: state is AsyncLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton list — shown while AI is generating recipes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SkeletonList extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: 3,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (_, __) => const SkeletonCard(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loaded recipe list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _RecipeList extends StatelessWidget {
|
||||
final List<Recipe> recipes;
|
||||
|
||||
const _RecipeList({required this.recipes});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (recipes.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Нет рекомендаций. Нажмите ↻ чтобы получить рецепты.'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), // room for FAB
|
||||
itemCount: recipes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final recipe = recipes[index];
|
||||
return RecipeCard(
|
||||
recipe: recipe,
|
||||
onTap: () => context.push(
|
||||
'/recipe-detail',
|
||||
extra: recipe,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Не удалось получить рецепты',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Попробовать снова'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
271
client/lib/features/recipes/saved_recipes_screen.dart
Normal file
271
client/lib/features/recipes/saved_recipes_screen.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../shared/models/saved_recipe.dart';
|
||||
import 'recipe_provider.dart';
|
||||
|
||||
class SavedRecipesScreen extends ConsumerWidget {
|
||||
const SavedRecipesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(savedRecipesProvider);
|
||||
|
||||
return state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Не удалось загрузить сохранённые рецепты'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.read(savedRecipesProvider.notifier).load(),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (recipes) => recipes.isEmpty
|
||||
? const _EmptyState()
|
||||
: _SavedList(recipes: recipes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SavedList extends StatelessWidget {
|
||||
final List<SavedRecipe> recipes;
|
||||
|
||||
const _SavedList({required this.recipes});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: recipes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) =>
|
||||
_SavedRecipeItem(recipe: recipes[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single item with swipe-to-delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SavedRecipeItem extends ConsumerWidget {
|
||||
final SavedRecipe recipe;
|
||||
|
||||
const _SavedRecipeItem({required this.recipe});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Dismissible(
|
||||
key: ValueKey(recipe.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: _DeleteBackground(),
|
||||
confirmDismiss: (_) => _confirmDelete(context),
|
||||
onDismissed: (_) async {
|
||||
final ok =
|
||||
await ref.read(savedRecipesProvider.notifier).delete(recipe.id);
|
||||
if (!ok && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Не удалось удалить рецепт')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/recipe-detail', extra: recipe),
|
||||
child: Row(
|
||||
children: [
|
||||
// Thumbnail
|
||||
_Thumbnail(imageUrl: recipe.imageUrl),
|
||||
// Info
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
recipe.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (recipe.nutrition != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'≈ ${recipe.nutrition!.calories.round()} ккал · '
|
||||
'${recipe.nutrition!.proteinG.round()} б · '
|
||||
'${recipe.nutrition!.fatG.round()} ж · '
|
||||
'${recipe.nutrition!.carbsG.round()} у',
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
if (recipe.prepTimeMin != null ||
|
||||
recipe.cookTimeMin != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_timeLabel(recipe.prepTimeMin, recipe.cookTimeMin),
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Delete button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: AppColors.textSecondary),
|
||||
onPressed: () async {
|
||||
final confirmed = await _confirmDelete(context);
|
||||
if (confirmed == true && context.mounted) {
|
||||
final ok = await ref
|
||||
.read(savedRecipesProvider.notifier)
|
||||
.delete(recipe.id);
|
||||
if (!ok && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Не удалось удалить рецепт')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _timeLabel(int? prep, int? cook) {
|
||||
final total = (prep ?? 0) + (cook ?? 0);
|
||||
return total > 0 ? '$total мин' : '';
|
||||
}
|
||||
|
||||
Future<bool?> _confirmDelete(BuildContext context) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Удалить рецепт?'),
|
||||
content: Text('«${recipe.title}» будет удалён из сохранённых.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumbnail extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
|
||||
const _Thumbnail({this.imageUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (imageUrl == null || imageUrl!.isEmpty) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: AppColors.primaryLight.withValues(alpha: 0.3),
|
||||
child: const Icon(Icons.restaurant),
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl!,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) =>
|
||||
Container(width: 80, height: 80, color: Colors.grey[200]),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: AppColors.primaryLight.withValues(alpha: 0.3),
|
||||
child: const Icon(Icons.restaurant),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteBackground extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: AppColors.error,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite_border,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Нет сохранённых рецептов',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Сохраняйте рецепты из рекомендаций,\nнажимая на ♡',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
client/lib/features/recipes/widgets/recipe_card.dart
Normal file
263
client/lib/features/recipes/widgets/recipe_card.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../shared/models/recipe.dart';
|
||||
import '../recipe_provider.dart';
|
||||
|
||||
/// Card shown in the recommendations list.
|
||||
/// Shows the photo, title, nutrition summary, time and difficulty.
|
||||
/// The ♡ button saves / unsaves the recipe.
|
||||
class RecipeCard extends ConsumerWidget {
|
||||
final Recipe recipe;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const RecipeCard({
|
||||
super.key,
|
||||
required this.recipe,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final savedNotifier = ref.watch(savedRecipesProvider.notifier);
|
||||
final isSaved = ref.watch(
|
||||
savedRecipesProvider.select(
|
||||
(_) => savedNotifier.isSaved(recipe.title),
|
||||
),
|
||||
);
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Photo
|
||||
Stack(
|
||||
children: [
|
||||
_RecipeImage(imageUrl: recipe.imageUrl, title: recipe.title),
|
||||
// Save button
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: _SaveButton(
|
||||
isSaved: isSaved,
|
||||
onPressed: () => _toggleSave(context, ref, isSaved),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
recipe.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (recipe.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
recipe.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
_MetaRow(recipe: recipe),
|
||||
if (recipe.nutrition != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_NutritionRow(nutrition: recipe.nutrition!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleSave(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
bool isSaved,
|
||||
) async {
|
||||
HapticFeedback.lightImpact();
|
||||
final notifier = ref.read(savedRecipesProvider.notifier);
|
||||
|
||||
if (isSaved) {
|
||||
final id = notifier.savedId(recipe.title);
|
||||
if (id != null) await notifier.delete(id);
|
||||
} else {
|
||||
final saved = await notifier.save(recipe);
|
||||
if (saved == null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Не удалось сохранить рецепт')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RecipeImage extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
final String title;
|
||||
|
||||
const _RecipeImage({required this.imageUrl, required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (imageUrl.isEmpty) {
|
||||
return Container(
|
||||
height: 180,
|
||||
color: AppColors.primaryLight.withValues(alpha: 0.3),
|
||||
child: const Center(child: Icon(Icons.restaurant, size: 48)),
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
height: 180,
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
height: 180,
|
||||
color: AppColors.primaryLight.withValues(alpha: 0.3),
|
||||
child: const Center(child: Icon(Icons.restaurant, size: 48)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
final bool isSaved;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _SaveButton({required this.isSaved, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
isSaved ? Icons.favorite : Icons.favorite_border,
|
||||
color: isSaved ? Colors.red : Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaRow extends StatelessWidget {
|
||||
final Recipe recipe;
|
||||
|
||||
const _MetaRow({required this.recipe});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalMin = recipe.prepTimeMin + recipe.cookTimeMin;
|
||||
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.access_time, size: 14, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 3),
|
||||
Text('$totalMin мин', style: style),
|
||||
const SizedBox(width: 12),
|
||||
const Icon(Icons.bar_chart, size: 14, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 3),
|
||||
Text(_difficultyLabel(recipe.difficulty), style: style),
|
||||
if (recipe.cuisine.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
const Icon(Icons.public, size: 14, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 3),
|
||||
Text(_cuisineLabel(recipe.cuisine), style: style),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _difficultyLabel(String d) => switch (d) {
|
||||
'easy' => 'Легко',
|
||||
'medium' => 'Средне',
|
||||
'hard' => 'Сложно',
|
||||
_ => d,
|
||||
};
|
||||
|
||||
String _cuisineLabel(String c) => switch (c) {
|
||||
'russian' => 'Русская',
|
||||
'asian' => 'Азиатская',
|
||||
'european' => 'Европейская',
|
||||
'mediterranean' => 'Средиземноморская',
|
||||
'american' => 'Американская',
|
||||
_ => 'Другая',
|
||||
};
|
||||
}
|
||||
|
||||
class _NutritionRow extends StatelessWidget {
|
||||
final NutritionInfo nutrition;
|
||||
|
||||
const _NutritionRow({required this.nutrition});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 11,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
Text('≈ ', style: style?.copyWith(color: AppColors.accent)),
|
||||
_NutItem(label: 'ккал', value: nutrition.calories.round(), style: style),
|
||||
const SizedBox(width: 8),
|
||||
_NutItem(label: 'б', value: nutrition.proteinG.round(), style: style),
|
||||
const SizedBox(width: 8),
|
||||
_NutItem(label: 'ж', value: nutrition.fatG.round(), style: style),
|
||||
const SizedBox(width: 8),
|
||||
_NutItem(label: 'у', value: nutrition.carbsG.round(), style: style),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NutItem extends StatelessWidget {
|
||||
final String label;
|
||||
final int value;
|
||||
final TextStyle? style;
|
||||
|
||||
const _NutItem({required this.label, required this.value, this.style});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
'$value $label',
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
91
client/lib/features/recipes/widgets/skeleton_card.dart
Normal file
91
client/lib/features/recipes/widgets/skeleton_card.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A pulsing placeholder card shown while recipes are loading from the AI.
|
||||
class SkeletonCard extends StatefulWidget {
|
||||
const SkeletonCard({super.key});
|
||||
|
||||
@override
|
||||
State<SkeletonCard> createState() => _SkeletonCardState();
|
||||
}
|
||||
|
||||
class _SkeletonCardState extends State<SkeletonCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
_anim = Tween<double>(begin: 0.25, end: 0.55).animate(
|
||||
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (context, _) {
|
||||
final color = Colors.grey.withValues(alpha: _anim.value);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(height: 180, color: color),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Bar(width: 220, height: 18, color: color),
|
||||
const SizedBox(height: 8),
|
||||
_Bar(width: 160, height: 14, color: color),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_Bar(width: 60, height: 12, color: color),
|
||||
const SizedBox(width: 12),
|
||||
_Bar(width: 60, height: 12, color: color),
|
||||
const SizedBox(width: 12),
|
||||
_Bar(width: 60, height: 12, color: color),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Bar extends StatelessWidget {
|
||||
final double width;
|
||||
final double height;
|
||||
final Color color;
|
||||
|
||||
const _Bar({required this.width, required this.height, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}
|
||||
121
client/lib/shared/models/recipe.dart
Normal file
121
client/lib/shared/models/recipe.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'recipe.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class Recipe {
|
||||
final String title;
|
||||
final String description;
|
||||
final String cuisine;
|
||||
final String difficulty;
|
||||
|
||||
@JsonKey(name: 'prep_time_min')
|
||||
final int prepTimeMin;
|
||||
|
||||
@JsonKey(name: 'cook_time_min')
|
||||
final int cookTimeMin;
|
||||
|
||||
final int servings;
|
||||
|
||||
@JsonKey(name: 'image_url', defaultValue: '')
|
||||
final String imageUrl;
|
||||
|
||||
@JsonKey(name: 'image_query', defaultValue: '')
|
||||
final String imageQuery;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<RecipeIngredient> ingredients;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<RecipeStep> steps;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<String> tags;
|
||||
|
||||
@JsonKey(name: 'nutrition_per_serving')
|
||||
final NutritionInfo? nutrition;
|
||||
|
||||
const Recipe({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.cuisine,
|
||||
required this.difficulty,
|
||||
required this.prepTimeMin,
|
||||
required this.cookTimeMin,
|
||||
required this.servings,
|
||||
this.imageUrl = '',
|
||||
this.imageQuery = '',
|
||||
this.ingredients = const [],
|
||||
this.steps = const [],
|
||||
this.tags = const [],
|
||||
this.nutrition,
|
||||
});
|
||||
|
||||
factory Recipe.fromJson(Map<String, dynamic> json) => _$RecipeFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$RecipeToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RecipeIngredient {
|
||||
final String name;
|
||||
final double amount;
|
||||
final String unit;
|
||||
|
||||
const RecipeIngredient({
|
||||
required this.name,
|
||||
required this.amount,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
factory RecipeIngredient.fromJson(Map<String, dynamic> json) =>
|
||||
_$RecipeIngredientFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$RecipeIngredientToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RecipeStep {
|
||||
final int number;
|
||||
final String description;
|
||||
|
||||
@JsonKey(name: 'timer_seconds')
|
||||
final int? timerSeconds;
|
||||
|
||||
const RecipeStep({
|
||||
required this.number,
|
||||
required this.description,
|
||||
this.timerSeconds,
|
||||
});
|
||||
|
||||
factory RecipeStep.fromJson(Map<String, dynamic> json) =>
|
||||
_$RecipeStepFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$RecipeStepToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class NutritionInfo {
|
||||
final double calories;
|
||||
|
||||
@JsonKey(name: 'protein_g')
|
||||
final double proteinG;
|
||||
|
||||
@JsonKey(name: 'fat_g')
|
||||
final double fatG;
|
||||
|
||||
@JsonKey(name: 'carbs_g')
|
||||
final double carbsG;
|
||||
|
||||
@JsonKey(defaultValue: true)
|
||||
final bool approximate;
|
||||
|
||||
const NutritionInfo({
|
||||
required this.calories,
|
||||
required this.proteinG,
|
||||
required this.fatG,
|
||||
required this.carbsG,
|
||||
this.approximate = true,
|
||||
});
|
||||
|
||||
factory NutritionInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$NutritionInfoFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$NutritionInfoToJson(this);
|
||||
}
|
||||
97
client/lib/shared/models/recipe.g.dart
Normal file
97
client/lib/shared/models/recipe.g.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'recipe.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Recipe _$RecipeFromJson(Map<String, dynamic> json) => Recipe(
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
cuisine: json['cuisine'] as String,
|
||||
difficulty: json['difficulty'] as String,
|
||||
prepTimeMin: (json['prep_time_min'] as num).toInt(),
|
||||
cookTimeMin: (json['cook_time_min'] as num).toInt(),
|
||||
servings: (json['servings'] as num).toInt(),
|
||||
imageUrl: json['image_url'] as String? ?? '',
|
||||
imageQuery: json['image_query'] as String? ?? '',
|
||||
ingredients:
|
||||
(json['ingredients'] as List<dynamic>?)
|
||||
?.map((e) => RecipeIngredient.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
steps:
|
||||
(json['steps'] as List<dynamic>?)
|
||||
?.map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
tags:
|
||||
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
|
||||
nutrition: json['nutrition_per_serving'] == null
|
||||
? null
|
||||
: NutritionInfo.fromJson(
|
||||
json['nutrition_per_serving'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RecipeToJson(Recipe instance) => <String, dynamic>{
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'cuisine': instance.cuisine,
|
||||
'difficulty': instance.difficulty,
|
||||
'prep_time_min': instance.prepTimeMin,
|
||||
'cook_time_min': instance.cookTimeMin,
|
||||
'servings': instance.servings,
|
||||
'image_url': instance.imageUrl,
|
||||
'image_query': instance.imageQuery,
|
||||
'ingredients': instance.ingredients.map((e) => e.toJson()).toList(),
|
||||
'steps': instance.steps.map((e) => e.toJson()).toList(),
|
||||
'tags': instance.tags,
|
||||
'nutrition_per_serving': instance.nutrition?.toJson(),
|
||||
};
|
||||
|
||||
RecipeIngredient _$RecipeIngredientFromJson(Map<String, dynamic> json) =>
|
||||
RecipeIngredient(
|
||||
name: json['name'] as String,
|
||||
amount: (json['amount'] as num).toDouble(),
|
||||
unit: json['unit'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RecipeIngredientToJson(RecipeIngredient instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'amount': instance.amount,
|
||||
'unit': instance.unit,
|
||||
};
|
||||
|
||||
RecipeStep _$RecipeStepFromJson(Map<String, dynamic> json) => RecipeStep(
|
||||
number: (json['number'] as num).toInt(),
|
||||
description: json['description'] as String,
|
||||
timerSeconds: (json['timer_seconds'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RecipeStepToJson(RecipeStep instance) =>
|
||||
<String, dynamic>{
|
||||
'number': instance.number,
|
||||
'description': instance.description,
|
||||
'timer_seconds': instance.timerSeconds,
|
||||
};
|
||||
|
||||
NutritionInfo _$NutritionInfoFromJson(Map<String, dynamic> json) =>
|
||||
NutritionInfo(
|
||||
calories: (json['calories'] as num).toDouble(),
|
||||
proteinG: (json['protein_g'] as num).toDouble(),
|
||||
fatG: (json['fat_g'] as num).toDouble(),
|
||||
carbsG: (json['carbs_g'] as num).toDouble(),
|
||||
approximate: json['approximate'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$NutritionInfoToJson(NutritionInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'calories': instance.calories,
|
||||
'protein_g': instance.proteinG,
|
||||
'fat_g': instance.fatG,
|
||||
'carbs_g': instance.carbsG,
|
||||
'approximate': instance.approximate,
|
||||
};
|
||||
64
client/lib/shared/models/saved_recipe.dart
Normal file
64
client/lib/shared/models/saved_recipe.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'recipe.dart';
|
||||
|
||||
part 'saved_recipe.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class SavedRecipe {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String? cuisine;
|
||||
final String? difficulty;
|
||||
|
||||
@JsonKey(name: 'prep_time_min')
|
||||
final int? prepTimeMin;
|
||||
|
||||
@JsonKey(name: 'cook_time_min')
|
||||
final int? cookTimeMin;
|
||||
|
||||
final int? servings;
|
||||
|
||||
@JsonKey(name: 'image_url')
|
||||
final String? imageUrl;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<RecipeIngredient> ingredients;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<RecipeStep> steps;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<String> tags;
|
||||
|
||||
@JsonKey(name: 'nutrition_per_serving')
|
||||
final NutritionInfo? nutrition;
|
||||
|
||||
final String source;
|
||||
|
||||
@JsonKey(name: 'saved_at')
|
||||
final DateTime savedAt;
|
||||
|
||||
const SavedRecipe({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.cuisine,
|
||||
this.difficulty,
|
||||
this.prepTimeMin,
|
||||
this.cookTimeMin,
|
||||
this.servings,
|
||||
this.imageUrl,
|
||||
this.ingredients = const [],
|
||||
this.steps = const [],
|
||||
this.tags = const [],
|
||||
this.nutrition,
|
||||
required this.source,
|
||||
required this.savedAt,
|
||||
});
|
||||
|
||||
factory SavedRecipe.fromJson(Map<String, dynamic> json) =>
|
||||
_$SavedRecipeFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SavedRecipeToJson(this);
|
||||
}
|
||||
57
client/lib/shared/models/saved_recipe.g.dart
Normal file
57
client/lib/shared/models/saved_recipe.g.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'saved_recipe.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SavedRecipe _$SavedRecipeFromJson(Map<String, dynamic> json) => SavedRecipe(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
cuisine: json['cuisine'] as String?,
|
||||
difficulty: json['difficulty'] as String?,
|
||||
prepTimeMin: (json['prep_time_min'] as num?)?.toInt(),
|
||||
cookTimeMin: (json['cook_time_min'] as num?)?.toInt(),
|
||||
servings: (json['servings'] as num?)?.toInt(),
|
||||
imageUrl: json['image_url'] as String?,
|
||||
ingredients:
|
||||
(json['ingredients'] as List<dynamic>?)
|
||||
?.map((e) => RecipeIngredient.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
steps:
|
||||
(json['steps'] as List<dynamic>?)
|
||||
?.map((e) => RecipeStep.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
tags:
|
||||
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
|
||||
nutrition: json['nutrition_per_serving'] == null
|
||||
? null
|
||||
: NutritionInfo.fromJson(
|
||||
json['nutrition_per_serving'] as Map<String, dynamic>,
|
||||
),
|
||||
source: json['source'] as String,
|
||||
savedAt: DateTime.parse(json['saved_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SavedRecipeToJson(SavedRecipe instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'cuisine': instance.cuisine,
|
||||
'difficulty': instance.difficulty,
|
||||
'prep_time_min': instance.prepTimeMin,
|
||||
'cook_time_min': instance.cookTimeMin,
|
||||
'servings': instance.servings,
|
||||
'image_url': instance.imageUrl,
|
||||
'ingredients': instance.ingredients.map((e) => e.toJson()).toList(),
|
||||
'steps': instance.steps.map((e) => e.toJson()).toList(),
|
||||
'tags': instance.tags,
|
||||
'nutrition_per_serving': instance.nutrition?.toJson(),
|
||||
'source': instance.source,
|
||||
'saved_at': instance.savedAt.toIso8601String(),
|
||||
};
|
||||
644
docs/Flow.md
Normal file
644
docs/Flow.md
Normal file
@@ -0,0 +1,644 @@
|
||||
# Flow: взаимодействие пользователя → бэкенда → сторонних API
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Архитектура системы](#1-архитектура-системы)
|
||||
2. [Ключевой принцип: сторонние API только там где нужны](#2-ключевой-принцип)
|
||||
3. [Flow 1: Аутентификация](#3-flow-1-аутентификация)
|
||||
4. [Flow 2: Обновление токена](#4-flow-2-обновление-токена)
|
||||
5. [Flow 3: Профиль пользователя](#5-flow-3-профиль-пользователя)
|
||||
6. [Flow 4: Рекомендации рецептов](#6-flow-4-рекомендации-рецептов)
|
||||
7. [Flow 5: Сохранённые рецепты](#7-flow-5-сохранённые-рецепты)
|
||||
8. [Flow 6: Управление продуктами (Итерация 2)](#8-flow-6-управление-продуктами-итерация-2)
|
||||
9. [Flow 7: Распознавание продуктов (Итерация 3)](#9-flow-7-распознавание-продуктов-итерация-3)
|
||||
10. [Flow 8: Планирование меню (Итерация 4)](#10-flow-8-планирование-меню-итерация-4)
|
||||
11. [Анализ потребления сторонних API](#11-анализ-потребления-сторонних-api)
|
||||
12. [Количество запросов к бэкенду по сценариям](#12-количество-запросов-к-бэкенду-по-сценариям)
|
||||
13. [Сводная таблица](#13-сводная-таблица)
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура системы
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Flutter Client │
|
||||
│ (Android / iOS / Web) │
|
||||
│ - Firebase Auth (Google / Apple / Email) │
|
||||
│ - Dio HTTP Client + Auth Interceptor │
|
||||
│ - FlutterSecureStorage (токены) │
|
||||
│ - Riverpod (состояние) │
|
||||
└────────────────────────┬───────────────────────────────────────────┘
|
||||
│ HTTPS, Bearer JWT
|
||||
│
|
||||
┌────────────────────────▼───────────────────────────────────────────┐
|
||||
│ Go Backend (chi v5) │
|
||||
│ - JWT middleware (HS256, верификация без Firebase в рантайме) │
|
||||
│ - Gemini API (генерация рекомендаций рецептов) │
|
||||
│ - Pexels API (подбор фотографий к рецептам) │
|
||||
│ - Firebase Admin SDK (только при логине) │
|
||||
│ - Калькулятор КБЖУ (Mifflin-St Jeor, локально) │
|
||||
└──────┬─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────▼──────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL 15 │
|
||||
│ users · saved_recipes · products (Iter.2) · ingredient_mappings │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Сторонние API:
|
||||
┌────────────────────┐ ┌─────────────────────────────────────┐
|
||||
│ Firebase Auth │ │ Google Gemini 2.0 Flash │
|
||||
│ только логин │ │ генерация рецептов-рекомендаций │
|
||||
└────────────────────┘ └─────────────────────────────────────┘
|
||||
┌────────────────────┐
|
||||
│ Pexels API │
|
||||
│ фото к рецептам │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Ключевой принцип
|
||||
|
||||
**Сторонние API вызываются только при конкретных пользовательских действиях**, а не фоновых задачах:
|
||||
|
||||
| API | Когда вызывается | Частота |
|
||||
|-----|-----------------|---------|
|
||||
| **Firebase Auth** | Только `POST /auth/login` | 1 раз за сессию |
|
||||
| **Gemini** | `GET /recommendations`, `POST /ai/recognize-*`, `POST /ai/generate-menu` | По запросу пользователя |
|
||||
| **Pexels** | Внутри рекомендаций и генерации меню | 1 вызов на рецепт |
|
||||
|
||||
Все токены, профили и сохранённые рецепты хранятся в PostgreSQL и раздаются **без внешних вызовов**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Flow 1: Аутентификация
|
||||
|
||||
### 3.1 Первый вход (Google OAuth)
|
||||
|
||||
```
|
||||
Пользователь Flutter Firebase Go Backend PostgreSQL
|
||||
│ │ │ │ │
|
||||
│── тапает "Войти" ─►│ │ │ │
|
||||
│ │── signInWithGoogle()►│ │ │
|
||||
│ │◄── idToken ─────────│ │ │
|
||||
│ │ │ │ │
|
||||
│ │── POST /auth/login ─────────────────────►│ │
|
||||
│ │ { firebase_token: idToken } │ │
|
||||
│ │ │◄── VerifyIDToken() ─│ │
|
||||
│ │ │──► { uid, email } ──►│ │
|
||||
│ │ │ │── UPSERT users ──►│
|
||||
│ │ │ │◄── User{id, plan}─│
|
||||
│ │ │ │── генерирует JWT │
|
||||
│ │ │ │── UPDATE refresh ─►│
|
||||
│ │◄── { access_token, refresh_token, user } ─│ │
|
||||
│ │── сохраняет в SecureStorage │ │
|
||||
│◄── Home Screen ───│ │ │ │
|
||||
```
|
||||
|
||||
**Запросы на бэкенд:** 1
|
||||
**Вызовов Firebase:** 1 (VerifyIDToken — серверная верификация)
|
||||
**SQL:** 2 (UPSERT users + UPDATE refresh_token)
|
||||
|
||||
### 3.2 Последующие запросы (с JWT)
|
||||
|
||||
```
|
||||
Flutter ── GET /profile ──► middleware.Auth
|
||||
└── ValidateAccessToken() — локально, HMAC HS256
|
||||
Firebase НЕ вызывается
|
||||
──► handler ──► SELECT users WHERE id=$1
|
||||
```
|
||||
|
||||
**Вызовов Firebase:** 0 (JWT верифицируется локально по секрету)
|
||||
|
||||
---
|
||||
|
||||
## 4. Flow 2: Обновление токена
|
||||
|
||||
Происходит автоматически в `AuthInterceptor` при 401 или истечении токена (15 мин TTL).
|
||||
|
||||
```
|
||||
Flutter (AuthInterceptor) Go Backend PostgreSQL
|
||||
│ │ │
|
||||
│── POST /auth/refresh ──────►│ │
|
||||
│ { refresh_token } │── SELECT users WHERE ──►│
|
||||
│ │ refresh_token=$1 AND │
|
||||
│ │ token_expires_at>now()│
|
||||
│ │◄── User ───────────────│
|
||||
│ │── новый JWT + UUID │
|
||||
│ │── UPDATE users ────────►│
|
||||
│◄── { access_token, │ │
|
||||
│ refresh_token } │ │
|
||||
│── повторяет исходный запрос│ │
|
||||
```
|
||||
|
||||
**Запросов к Firebase:** 0
|
||||
**SQL:** 2 (SELECT + UPDATE)
|
||||
|
||||
---
|
||||
|
||||
## 5. Flow 3: Профиль пользователя
|
||||
|
||||
### Просмотр
|
||||
|
||||
```
|
||||
Flutter → GET /profile → SELECT * FROM users WHERE id=$1
|
||||
```
|
||||
|
||||
**Запросов на бэкенд:** 1 | **SQL:** 1
|
||||
|
||||
### Обновление (онбординг)
|
||||
|
||||
```
|
||||
Flutter → PUT /profile → Go Backend
|
||||
{ height_cm, weight_kg, │
|
||||
age, gender, │── Mifflin-St Jeor (локально):
|
||||
activity, goal } │ BMR = 10W + 6.25H - 5A + offset
|
||||
│ TDEE = BMR × activity_factor
|
||||
│ Calories = TDEE ± goal_delta
|
||||
│── UPDATE users SET daily_calories=...
|
||||
```
|
||||
|
||||
**Запросов к сторонним API:** 0 (всё считается на Go)
|
||||
**Запросов на бэкенд:** 1 | **SQL:** 1
|
||||
|
||||
---
|
||||
|
||||
## 6. Flow 4: Рекомендации рецептов
|
||||
|
||||
Центральный flow приложения. Вызывается когда пользователь открывает экран рекомендаций.
|
||||
|
||||
### 6.1 Полный flow
|
||||
|
||||
```
|
||||
Пользователь Flutter Go Backend Gemini Pexels PostgreSQL
|
||||
│ │ │ │ │ │
|
||||
│── открывает │ │ │ │ │
|
||||
│ экран ───►│ │ │ │ │
|
||||
│ │── GET /recommendations ─────────►│ │ │
|
||||
│ │ ?count=5 │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ │── SELECT user ──────────────────────────────── ►│
|
||||
│ │ │ profile + products (Iter.2) ◄────────────────│
|
||||
│ │ │ │ │ │
|
||||
│ │ │── GenerateContent(prompt) ────►│ │
|
||||
│ │ │ prompt содержит: │ │
|
||||
│ │ │ - цель пользователя │ │
|
||||
│ │ │ - дневные калории │ │
|
||||
│ │ │ - список продуктов (Iter.2) │ │
|
||||
│ │ │ - N=5 рецептов │ │
|
||||
│ │ │◄── JSON: [Recipe×5] ───────────│ │
|
||||
│ │ │ каждый с image_query │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ для каждого рецепта: │ │
|
||||
│ │ │── GET /v1/search?query=... ─────────────────── ►│
|
||||
│ │ │ Authorization: Pexels key │ │
|
||||
│ │ │◄── { photos[0].src.medium } ───────────────────│
|
||||
│ │ │ │ │ │
|
||||
│◄── [Recipe×5 c image_url] ─│ │ │ │
|
||||
```
|
||||
|
||||
### 6.2 Структура промпта для Gemini
|
||||
|
||||
```
|
||||
Ты — диетолог-повар. Предложи {N} рецептов на русском языке.
|
||||
|
||||
Профиль пользователя:
|
||||
- Цель: похудение
|
||||
- Дневная норма калорий: 1800 ккал
|
||||
- Ограничения: без глютена (если есть в preferences)
|
||||
|
||||
[Итерация 2+] Доступные продукты:
|
||||
- куриная грудка (500г)
|
||||
- помидоры (3 шт)
|
||||
- ...
|
||||
|
||||
Требования к каждому рецепту:
|
||||
- Калорийность на порцию: не более 600 ккал
|
||||
- Время приготовления: до 40 минут
|
||||
- Укажи КБЖУ на порцию (приблизительно)
|
||||
|
||||
Верни ТОЛЬКО валидный JSON массив без markdown:
|
||||
[{
|
||||
"title": "Название рецепта",
|
||||
"description": "Краткое описание (2-3 предложения)",
|
||||
"cuisine": "mediterranean",
|
||||
"difficulty": "easy|medium|hard",
|
||||
"prep_time_min": 10,
|
||||
"cook_time_min": 20,
|
||||
"servings": 2,
|
||||
"image_query": "grilled chicken breast vegetables mediterranean",
|
||||
"ingredients": [
|
||||
{ "name": "Куриная грудка", "amount": 300, "unit": "г" }
|
||||
],
|
||||
"steps": [
|
||||
{ "number": 1, "description": "Нарежьте курицу...", "timer_seconds": null }
|
||||
],
|
||||
"tags": ["без глютена", "высокий белок"],
|
||||
"nutrition_per_serving": {
|
||||
"calories": 420,
|
||||
"protein_g": 48,
|
||||
"fat_g": 12,
|
||||
"carbs_g": 18
|
||||
}
|
||||
}]
|
||||
```
|
||||
|
||||
### 6.3 Кэширование рекомендаций
|
||||
|
||||
Рекомендации **не сохраняются** автоматически. При каждом открытии экрана генерируются заново. Это дает:
|
||||
- Свежесть контента
|
||||
- Учёт изменившихся продуктов (Итерация 2)
|
||||
|
||||
Возможная оптимизация в будущем: кэшировать последний набор рекомендаций в Redis/памяти на 30 минут, инвалидировать при обновлении продуктов.
|
||||
|
||||
---
|
||||
|
||||
## 7. Flow 5: Сохранённые рецепты
|
||||
|
||||
### 7.1 Сохранить рекомендацию
|
||||
|
||||
```
|
||||
Пользователь тапает ❤️ на рецепте
|
||||
|
||||
Flutter → POST /saved-recipes → PostgreSQL
|
||||
{ полный JSON рецепта } └── INSERT INTO saved_recipes
|
||||
(user_id, title, steps,
|
||||
ingredients, nutrition,
|
||||
image_url, ...)
|
||||
← { id, saved_at }
|
||||
```
|
||||
|
||||
**Запросов на бэкенд:** 1
|
||||
**Вызовов Gemini/Pexels:** 0 (данные уже есть в клиенте)
|
||||
**SQL:** 1
|
||||
|
||||
### 7.2 Список сохранённых
|
||||
|
||||
```
|
||||
Flutter → GET /saved-recipes → SELECT * FROM saved_recipes
|
||||
WHERE user_id=$1
|
||||
ORDER BY saved_at DESC
|
||||
```
|
||||
|
||||
**Запросов на бэкенд:** 1 | **SQL:** 1
|
||||
|
||||
### 7.3 Удалить из сохранённых
|
||||
|
||||
```
|
||||
Flutter → DELETE /saved-recipes/{id} → DELETE FROM saved_recipes
|
||||
WHERE id=$1 AND user_id=$2
|
||||
```
|
||||
|
||||
**Запросов на бэкенд:** 1 | **SQL:** 1
|
||||
|
||||
---
|
||||
|
||||
## 8. Flow 6: Управление продуктами (Итерация 2)
|
||||
|
||||
### 8.1 Открытие списка продуктов
|
||||
|
||||
```
|
||||
Flutter → GET /products → SELECT * FROM products
|
||||
WHERE user_id=$1
|
||||
ORDER BY expires_at ASC
|
||||
```
|
||||
|
||||
**Запросов на бэкенд:** 1
|
||||
|
||||
### 8.2 Добавление продукта с автодополнением
|
||||
|
||||
```
|
||||
Пользователь вводит "кур" (debounce 300мс)
|
||||
│
|
||||
Flutter → GET /ingredients/search?q=кур → PostgreSQL
|
||||
├── ILIKE на canonical_name_ru
|
||||
├── GIN на aliases
|
||||
└── pg_trgm similarity
|
||||
|
||||
Пользователь выбирает "Куриная грудка"
|
||||
│ поля автозаполняются локально
|
||||
|
||||
Flutter → POST /products → INSERT INTO products
|
||||
{ mapping_id, expires_at GENERATED ALWAYS AS
|
||||
name, quantity, (added_at + storage_days days)
|
||||
unit, category,
|
||||
storage_days }
|
||||
```
|
||||
|
||||
**Запросов на бэкенд:** 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.
|
||||
89
docs/TODO.md
Normal file
89
docs/TODO.md
Normal file
@@ -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 по плану
|
||||
326
docs/plans/Iteration_1.md
Normal file
326
docs/plans/Iteration_1.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# Итерация 1: AI-рекомендации рецептов
|
||||
|
||||
**Цель:** реализовать ключевую функцию — персонализированные рецепты, сгенерированные Gemini с фотографиями из Pexels, и возможность их сохранять.
|
||||
|
||||
**Зависимости:** Итерация 0 (авторизация, профиль, БД).
|
||||
|
||||
**Ориентир:** [Summary.md](./Summary.md)
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
1.1 Backend: Gemini-клиент
|
||||
├── 1.1.1 Пакет internal/gemini (интерфейс + адаптер)
|
||||
├── 1.1.2 GenerateRecipes(ctx, prompt) → []Recipe
|
||||
└── 1.1.3 Retry-стратегия (невалидный JSON → повтор с уточнением)
|
||||
|
||||
1.2 Backend: Pexels-клиент
|
||||
├── 1.2.1 Пакет internal/pexels
|
||||
└── 1.2.2 SearchPhoto(ctx, query) → image_url
|
||||
|
||||
1.3 Backend: saved_recipes
|
||||
├── 1.3.1 Миграция: таблица saved_recipes
|
||||
├── 1.3.2 Repository (CRUD)
|
||||
└── 1.3.3 Service layer
|
||||
|
||||
1.4 Backend: эндпоинты рекомендаций
|
||||
├── 1.4.1 GET /recommendations?count=5
|
||||
└── 1.4.2 Формирование промпта из профиля пользователя
|
||||
|
||||
1.5 Backend: эндпоинты saved_recipes
|
||||
├── 1.5.1 POST /saved-recipes
|
||||
├── 1.5.2 GET /saved-recipes
|
||||
├── 1.5.3 GET /saved-recipes/{id}
|
||||
└── 1.5.4 DELETE /saved-recipes/{id}
|
||||
|
||||
1.6 Flutter: экран рекомендаций
|
||||
├── 1.6.1 RecommendationsScreen (список карточек)
|
||||
├── 1.6.2 Skeleton-загрузка (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.
|
||||
241
docs/plans/Iteration_2.md
Normal file
241
docs/plans/Iteration_2.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Итерация 2: Управление продуктами
|
||||
|
||||
**Цель:** дать пользователю возможность вести список продуктов — вручную или через автодополнение. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты.
|
||||
|
||||
**Зависимости:** Итерация 1 (рекомендации должны принимать список продуктов).
|
||||
|
||||
**Ориентир:** [Summary.md](./Summary.md)
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
2.1 Backend: ingredient_mappings
|
||||
├── 2.1.1 Миграция: таблица ingredient_mappings
|
||||
├── 2.1.2 Seed: топ-200 базовых ингредиентов (JSON-файл)
|
||||
├── 2.1.3 Repository (поиск по aliases)
|
||||
└── 2.1.4 GET /ingredients/search?q=
|
||||
|
||||
2.2 Backend: products
|
||||
├── 2.2.1 Миграция: таблица products
|
||||
├── 2.2.2 Repository (CRUD)
|
||||
├── 2.2.3 Service layer
|
||||
├── 2.2.4 GET /products
|
||||
├── 2.2.5 POST /products
|
||||
├── 2.2.6 POST /products/batch
|
||||
├── 2.2.7 PUT /products/{id}
|
||||
├── 2.2.8 DELETE /products/{id}
|
||||
└── 2.2.9 GET /products/expiring (скоро истекают)
|
||||
|
||||
2.3 Backend: интеграция с рекомендациями
|
||||
└── 2.3.1 GET /recommendations — добавить продукты в промпт
|
||||
|
||||
2.4 Flutter: экран продуктов
|
||||
├── 2.4.1 ProductsScreen (список с истекающими)
|
||||
├── 2.4.2 Форма добавления с автодополнением (debounce 300мс)
|
||||
├── 2.4.3 Редактирование (количество, единица, срок)
|
||||
└── 2.4.4 Удаление (свайп или кнопка)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.1 Ingredient Mappings
|
||||
|
||||
### 2.1.1 Миграция
|
||||
|
||||
```sql
|
||||
-- migrations/003_create_ingredient_mappings.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE ingredient_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
canonical_name TEXT NOT NULL UNIQUE,
|
||||
canonical_name_ru TEXT NOT NULL,
|
||||
aliases JSONB NOT NULL DEFAULT '[]',
|
||||
category TEXT NOT NULL,
|
||||
default_unit TEXT NOT NULL DEFAULT 'g',
|
||||
calories_per_100g DECIMAL(8,2),
|
||||
protein_per_100g DECIMAL(8,2),
|
||||
fat_per_100g DECIMAL(8,2),
|
||||
carbs_per_100g DECIMAL(8,2),
|
||||
storage_days INT NOT NULL DEFAULT 7,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN(aliases);
|
||||
CREATE INDEX idx_ingredient_mappings_canonical_ru ON ingredient_mappings
|
||||
USING GIN(to_tsvector('russian', canonical_name_ru));
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE INDEX idx_ingredient_mappings_trgm ON ingredient_mappings
|
||||
USING GIN(canonical_name_ru gin_trgm_ops);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE ingredient_mappings;
|
||||
```
|
||||
|
||||
### 2.1.2 Seed-данные
|
||||
|
||||
Файл `migrations/seed_ingredient_mappings.json` с топ-200 ингредиентами:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"canonical_name": "chicken_breast",
|
||||
"canonical_name_ru": "куриная грудка",
|
||||
"aliases": ["куриное филе", "куриная грудка", "грудка курицы", "chicken breast"],
|
||||
"category": "meat",
|
||||
"default_unit": "g",
|
||||
"calories_per_100g": 165,
|
||||
"protein_per_100g": 31,
|
||||
"fat_per_100g": 3.6,
|
||||
"carbs_per_100g": 0,
|
||||
"storage_days": 3
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Seed применяется отдельным скриптом/Makefile-таргетом `make seed`.
|
||||
|
||||
### 2.1.3 Поиск
|
||||
|
||||
```go
|
||||
// GET /ingredients/search?q=кур&limit=10
|
||||
// Трёхуровневый поиск:
|
||||
// 1. Точное совпадение в aliases (@>)
|
||||
// 2. ILIKE на canonical_name_ru
|
||||
// 3. pg_trgm similarity > 0.3
|
||||
SELECT *
|
||||
FROM ingredient_mappings
|
||||
WHERE aliases @> to_jsonb(lower($1)::text)
|
||||
OR canonical_name_ru ILIKE '%' || $1 || '%'
|
||||
OR similarity(canonical_name_ru, $1) > 0.3
|
||||
ORDER BY similarity(canonical_name_ru, $1) DESC
|
||||
LIMIT $2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Products
|
||||
|
||||
### 2.2.1 Миграция
|
||||
|
||||
```sql
|
||||
-- migrations/004_create_products.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mapping_id UUID REFERENCES ingredient_mappings(id),
|
||||
name TEXT NOT NULL,
|
||||
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||
unit TEXT NOT NULL DEFAULT 'pcs',
|
||||
category TEXT,
|
||||
storage_days INT NOT NULL DEFAULT 7,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ GENERATED ALWAYS AS
|
||||
(added_at + (storage_days || ' days')::INTERVAL) STORED
|
||||
);
|
||||
|
||||
CREATE INDEX idx_products_user_id ON products(user_id);
|
||||
CREATE INDEX idx_products_expires_at ON products(user_id, expires_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE products;
|
||||
```
|
||||
|
||||
### 2.2.2 Эндпоинты
|
||||
|
||||
**GET /products**
|
||||
|
||||
```json
|
||||
[{
|
||||
"id": "uuid",
|
||||
"name": "Куриная грудка",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"category": "meat",
|
||||
"expires_at": "2026-02-24T00:00:00Z",
|
||||
"days_left": 3,
|
||||
"expiring_soon": true
|
||||
}]
|
||||
```
|
||||
|
||||
Сортировка: `expires_at ASC` (сначала истекающие).
|
||||
`expiring_soon = true` если `days_left <= 3`.
|
||||
|
||||
**POST /products/batch**
|
||||
|
||||
Массовое добавление после распознавания (Итерация 3):
|
||||
|
||||
```json
|
||||
[{
|
||||
"name": "Куриная грудка",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"mapping_id": "uuid или null"
|
||||
}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Интеграция с рекомендациями
|
||||
|
||||
Обновить `GET /recommendations`: если у пользователя есть продукты — включать их в промпт.
|
||||
|
||||
```go
|
||||
// Если продуктов нет — промпт без них (базовые рекомендации)
|
||||
// Если продукты есть — добавить секцию в промпт:
|
||||
|
||||
doступные продукты (приоритет — скоро истекают ⚠):
|
||||
- Куриная грудка 500г (истекает завтра ⚠)
|
||||
- Морковь 3 шт
|
||||
- Рис 400г
|
||||
- Яйца 4 шт
|
||||
|
||||
Предпочтительно использовать эти продукты в рецептах.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Flutter: экран продуктов
|
||||
|
||||
### ProductsScreen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Мои продукты [+ Добавить] │
|
||||
├─────────────────────────────────────┤
|
||||
│ ⚠ Истекает скоро │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🥩 Куриная грудка 500 г │ │
|
||||
│ │ Осталось 1 день │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ Всё остальное │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🥕 Морковь 3 шт │ │
|
||||
│ │ Осталось 5 дней │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🍚 Рис 400 г │ │
|
||||
│ │ Осталось 30 дней │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [Главная] [Продукты●] [Меню] [Рецепты] [Профиль] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Форма добавления
|
||||
|
||||
- Поле ввода с debounce 300мс → `GET /ingredients/search?q=`
|
||||
- Dropdown с результатами (canonical_name_ru + category)
|
||||
- При выборе: автозаполнение unit, storage_days из mapping
|
||||
- Поля: Количество, Единица (select: г/кг/мл/л/шт)
|
||||
- Кнопка «Добавить»
|
||||
|
||||
### Badge на вкладке «Продукты»
|
||||
|
||||
Количество продуктов с `days_left <= 3` отображается как badge над иконкой.
|
||||
328
docs/plans/Iteration_3.md
Normal file
328
docs/plans/Iteration_3.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Итерация 3: Распознавание продуктов (Gemini Vision)
|
||||
|
||||
**Цель:** пользователь фотографирует чек, холодильник или готовое блюдо — Gemini Vision автоматически определяет продукты и их количество, после чего пользователь подтверждает список и добавляет в запасы.
|
||||
|
||||
**Зависимости:** Итерация 2 (управление продуктами, ingredient_mappings).
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
3.1 Backend: AI-распознавание чека
|
||||
├── 3.1.1 POST /ai/recognize-receipt (multipart: image)
|
||||
├── 3.1.2 Промпт для OCR чека
|
||||
└── 3.1.3 Fuzzy match результатов по ingredient_mappings
|
||||
|
||||
3.2 Backend: AI-распознавание фото продуктов
|
||||
├── 3.2.1 POST /ai/recognize-products (multipart: 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/день) остаётся в запасе
|
||||
367
docs/plans/Iteration_4.md
Normal file
367
docs/plans/Iteration_4.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# Итерация 4: Планирование меню
|
||||
|
||||
**Цель:** пользователь запрашивает меню на неделю — Gemini генерирует полный план питания (21 приём пищи) с учётом продуктов, целей и предпочтений. Из меню автоматически формируется список покупок.
|
||||
|
||||
**Зависимости:** Итерация 2 (продукты), Итерация 1 (сохранённые рецепты).
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
4.1 Backend: таблицы меню
|
||||
├── 4.1.1 Миграция: menu_plans, menu_items
|
||||
└── 4.1.2 Миграция: meal_diary
|
||||
|
||||
4.2 Backend: генерация меню
|
||||
├── 4.2.1 POST /ai/generate-menu
|
||||
├── 4.2.2 Промпт для Gemini (7 дней × 3 приёма)
|
||||
├── 4.2.3 Параллельный Pexels для 21 рецепта
|
||||
└── 4.2.4 Сохранение в menu_plans + menu_items + saved_recipes
|
||||
|
||||
4.3 Backend: CRUD меню
|
||||
├── 4.3.1 GET /menu?week=YYYY-WNN
|
||||
├── 4.3.2 PUT /menu/items/{id} (заменить рецепт)
|
||||
└── 4.3.3 DELETE /menu/items/{id}
|
||||
|
||||
4.4 Backend: список покупок
|
||||
├── 4.4.1 POST /shopping-list/generate (из меню)
|
||||
├── 4.4.2 GET /shopping-list
|
||||
└── 4.4.3 PATCH /shopping-list/items/{index}/check
|
||||
|
||||
4.5 Backend: дневник питания
|
||||
├── 4.5.1 GET /diary?date=YYYY-MM-DD
|
||||
├── 4.5.2 POST /diary (добавить запись)
|
||||
└── 4.5.3 DELETE /diary/{id}
|
||||
|
||||
4.6 Flutter: экран меню
|
||||
├── 4.6.1 MenuScreen (7-дневный вид)
|
||||
├── 4.6.2 Кнопка «Сгенерировать меню»
|
||||
├── 4.6.3 Редактирование слота (смена рецепта)
|
||||
└── 4.6.4 Skeleton на время генерации (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.
|
||||
@@ -5,16 +5,12 @@
|
||||
| # | Итерация | Цель | Зависит от |
|
||||
|---|----------|------|------------|
|
||||
| 0 | Фундамент | Go-проект, БД, авторизация, Flutter-каркас | — |
|
||||
| 1 | Справочник ингредиентов и рецепты | Наполнение БД рецептами, маппинг ингредиентов | 0 |
|
||||
| 2 | Управление продуктами | CRUD продуктов, сроки хранения | 0 |
|
||||
| 3 | AI-ядро | Очереди, Gemini-адаптер, rate limiter, budget guard | 0 |
|
||||
| 4 | AI-распознавание | OCR чека, фото продуктов, фото блюд | 2, 3 |
|
||||
| 5 | Каталог рецептов | Поиск, фильтры, «из моих продуктов», замены | 1, 2 |
|
||||
| 6 | Планирование меню | Меню на неделю, AI-генерация, список покупок | 3, 5 |
|
||||
| 7 | Дневник питания | Записи, порции, трекер воды, калории | 5 |
|
||||
| 8 | Режим готовки | Пошаговая готовка, таймеры | 5 |
|
||||
| 9 | Рекомендации и статистика | Рекомендации на главной, графики, тренды | 6, 7 |
|
||||
| 10 | Полировка | Онбординг, пустые состояния, уведомления, отзывы | 9 |
|
||||
| 1 | AI-рекомендации рецептов | Gemini генерирует рецепты, Pexels фото, сохранение рецептов | 0 |
|
||||
| 2 | Управление продуктами | CRUD продуктов, сроки хранения, ingredient_mappings | 0 |
|
||||
| 3 | Распознавание продуктов | OCR чека, фото продуктов, фото блюд (Gemini Vision) | 1, 2 |
|
||||
| 4 | Планирование меню | Меню на неделю, AI-генерация, список покупок, дневник | 1, 2 |
|
||||
|
||||
Дальнейшие итерации определяются приоритетами после MVP. Функциональность из TODO.md (дневник статистики, режим готовки, полировка) — следующий горизонт.
|
||||
|
||||
## Карта зависимостей
|
||||
|
||||
@@ -23,57 +19,28 @@
|
||||
│ 0. Фундамент │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────────┐ ┌───────────┐ ┌────────────────┐
|
||||
│ 1. Справочник │ │ 2. Продук-│ │ 3. AI-ядро │
|
||||
│ ингредиентов │ │ ты │ │ (очереди, │
|
||||
│ + рецепты │ │ │ │ Gemini) │
|
||||
└───────┬────────┘ └─────┬─────┘ └───────┬────────┘
|
||||
│ │ │
|
||||
│ ┌────┴────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌────┴──────────┘
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌──────────────────┐
|
||||
│ │ 4. AI-распозна- │
|
||||
│ │ вание │
|
||||
│ └──────────────────┘
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 5. Каталог │
|
||||
│ рецептов │
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ 6. Меню │ │ 7. Днев- │ │ 8. Режим │
|
||||
│ + список │ │ ник пита-│ │ готовки │
|
||||
│ покупок │ │ ния │ │ │
|
||||
└─────┬────┘ └─────┬────┘ └──────────┘
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌──────────────────┐
|
||||
│ 1. AI-рекомендации │ │ 2. Продукты │
|
||||
│ (Gemini+Pexels) │ │ + ingredient_ │
|
||||
│ saved_recipes │ │ mappings │
|
||||
└──────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
└──────┬─────┘
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 9. Рекоменда- │
|
||||
│ ции + стат-ка │
|
||||
└───────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 10. Полировка │
|
||||
└────────────────┘
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌─────────────────────┐
|
||||
│ 3. Распознавание │ │ 4. Планирование │
|
||||
│ продуктов │ │ меню │
|
||||
│ (Gemini Vision) │ │ (Gemini+Pexels) │
|
||||
└────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
**Параллельная разработка:** итерации 1, 2, 3 могут выполняться параллельно. Итерации 6, 7, 8 — тоже параллельно после завершения 5.
|
||||
**Параллельная разработка:** итерации 1 и 2 могут выполняться параллельно. Итерации 3 и 4 — тоже параллельно после завершения 1 и 2.
|
||||
|
||||
---
|
||||
|
||||
@@ -115,33 +82,46 @@
|
||||
|
||||
---
|
||||
|
||||
## Итерация 1: Справочник ингредиентов и рецепты
|
||||
## Итерация 1: AI-рекомендации рецептов
|
||||
|
||||
**Цель:** наполнить БД каноническими ингредиентами и рецептами из Spoonacular. Это фундамент для всех фичей, связанных с рецептами, поиском и маппингом продуктов.
|
||||
> **Детальный план:** [Iteration_1.md](./Iteration_1.md)
|
||||
|
||||
**Цель:** реализовать ключевую функцию — персонализированные рецепты, сгенерированные Gemini, с фотографиями из Pexels и возможностью сохранять понравившиеся.
|
||||
|
||||
**Зависимости:** итерация 0.
|
||||
|
||||
### User Stories
|
||||
|
||||
#### Backend
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 1.1 | Таблица ingredient_mappings | Миграция: id, canonical_name, spoonacular_id, aliases (JSONB), category, default_unit, нутриенты на 100г, storage_days. Индексы: GIN по aliases, UNIQUE по spoonacular_id |
|
||||
| 1.2 | Импорт ингредиентов из Spoonacular | CLI-команда / джоб: запрос Spoonacular Ingredient API → сохранение ~1000 базовых ингредиентов в ingredient_mappings |
|
||||
| 1.3 | Таблица recipes | Миграция: id, source, spoonacular_id, title, description, cuisine, difficulty, prep_time_min, калории, БЖУ, servings, image_url, ingredients (JSONB), steps (JSONB), tags (JSONB), avg_rating, review_count, created_by. Индексы: GIN по ingredients, full-text по title |
|
||||
| 1.4 | Импорт рецептов из Spoonacular | CLI-команда / джоб: импорт 5 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 — полный цикл: сфотографировал чек → получил меню → список покупок → дневник питания.
|
||||
|
||||
Reference in New Issue
Block a user