From ea8e207a459db0f7ff616503fbbd9b63635c2dd3 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sun, 22 Feb 2026 12:00:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Iteration=204=20=E2=80=94?= =?UTF-8?q?=20menu=20planning,=20shopping=20list,=20diary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Migrations 007 (menu_plans, menu_items, shopping_lists) and 008 (meal_diary) - gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call - internal/menu: model, repository (GetByWeek, SaveMenuInTx, shopping list CRUD), handler (GET/PUT/DELETE /menu, POST /ai/generate-menu, shopping list endpoints) - internal/diary: model, repository, handler (GET/POST/DELETE /diary) - Increase server WriteTimeout to 120s for long AI calls - api_client.go: add patch() and postList() helpers Flutter: - shared/models: menu.dart, shopping_item.dart, diary_entry.dart - features/menu: menu_service.dart, menu_provider.dart (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family) - MenuScreen: 7-day view, week nav, skeleton on generation, generate FAB with confirmation dialog - ShoppingListScreen: items by category, optimistic checkbox toggle - DiaryScreen: daily entries with swipe-to-delete, add-entry sheet - Router: /menu/shopping-list and /menu/diary routes Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/main.go | 14 +- backend/internal/diary/handler.go | 110 ++++ backend/internal/diary/model.go | 33 ++ backend/internal/diary/repository.go | 104 ++++ backend/internal/gemini/menu.go | 168 ++++++ backend/internal/menu/handler.go | 545 ++++++++++++++++++ backend/internal/menu/model.go | 56 ++ backend/internal/menu/repository.go | 306 ++++++++++ backend/internal/server/server.go | 31 +- backend/migrations/007_create_menu_plans.sql | 35 ++ backend/migrations/008_create_meal_diary.sql | 22 + client/lib/core/api/api_client.dart | 11 + client/lib/core/router/app_router.dart | 18 + client/lib/features/menu/diary_screen.dart | 289 ++++++++++ client/lib/features/menu/menu_provider.dart | 147 +++++ client/lib/features/menu/menu_screen.dart | 463 ++++++++++++++- client/lib/features/menu/menu_service.dart | 85 +++ .../features/menu/shopping_list_screen.dart | 247 ++++++++ client/lib/features/scan/scan_screen.dart | 1 - client/lib/shared/models/diary_entry.dart | 56 ++ client/lib/shared/models/menu.dart | 151 +++++ client/lib/shared/models/shopping_item.dart | 46 ++ 22 files changed, 2926 insertions(+), 12 deletions(-) create mode 100644 backend/internal/diary/handler.go create mode 100644 backend/internal/diary/model.go create mode 100644 backend/internal/diary/repository.go create mode 100644 backend/internal/gemini/menu.go create mode 100644 backend/internal/menu/handler.go create mode 100644 backend/internal/menu/model.go create mode 100644 backend/internal/menu/repository.go create mode 100644 backend/migrations/007_create_menu_plans.sql create mode 100644 backend/migrations/008_create_meal_diary.sql create mode 100644 client/lib/features/menu/diary_screen.dart create mode 100644 client/lib/features/menu/menu_provider.dart create mode 100644 client/lib/features/menu/menu_service.dart create mode 100644 client/lib/features/menu/shopping_list_screen.dart create mode 100644 client/lib/shared/models/diary_entry.dart create mode 100644 client/lib/shared/models/menu.dart create mode 100644 client/lib/shared/models/shopping_item.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 90f504d..032bd16 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -13,8 +13,10 @@ 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/diary" "github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/ingredient" + "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/pexels" "github.com/food-ai/backend/internal/product" @@ -112,6 +114,14 @@ func run() error { savedRecipeRepo := savedrecipe.NewRepository(pool) savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo) + // Menu domain + menuRepo := menu.NewRepository(pool) + menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, savedRecipeRepo) + + // Diary domain + diaryRepo := diary.NewRepository(pool) + diaryHandler := diary.NewHandler(diaryRepo) + // Router router := server.NewRouter( pool, @@ -122,6 +132,8 @@ func run() error { ingredientHandler, productHandler, recognitionHandler, + menuHandler, + diaryHandler, authMW, cfg.AllowedOrigins, ) @@ -130,7 +142,7 @@ func run() error { Addr: fmt.Sprintf(":%d", cfg.Port), Handler: router, ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, // menu generation can take ~60s IdleTimeout: 60 * time.Second, } diff --git a/backend/internal/diary/handler.go b/backend/internal/diary/handler.go new file mode 100644 index 0000000..8d8bf2f --- /dev/null +++ b/backend/internal/diary/handler.go @@ -0,0 +1,110 @@ +package diary + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/food-ai/backend/internal/middleware" + "github.com/go-chi/chi/v5" +) + +// Handler handles diary endpoints. +type Handler struct { + repo *Repository +} + +// NewHandler creates a new Handler. +func NewHandler(repo *Repository) *Handler { + return &Handler{repo: repo} +} + +// GetByDate handles GET /diary?date=YYYY-MM-DD +func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + date := r.URL.Query().Get("date") + if date == "" { + writeError(w, http.StatusBadRequest, "date query parameter required (YYYY-MM-DD)") + return + } + + entries, err := h.repo.ListByDate(r.Context(), userID, date) + if err != nil { + slog.Error("list diary by date", "err", err) + writeError(w, http.StatusInternalServerError, "failed to load diary") + return + } + if entries == nil { + entries = []*Entry{} + } + writeJSON(w, http.StatusOK, entries) +} + +// Create handles POST /diary +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req CreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Date == "" || req.Name == "" || req.MealType == "" { + writeError(w, http.StatusBadRequest, "date, meal_type and name are required") + return + } + + entry, err := h.repo.Create(r.Context(), userID, req) + if err != nil { + slog.Error("create diary entry", "err", err) + writeError(w, http.StatusInternalServerError, "failed to create diary entry") + return + } + writeJSON(w, http.StatusCreated, entry) +} + +// Delete handles DELETE /diary/{id} +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + id := chi.URLParam(r, "id") + if err := h.repo.Delete(r.Context(), id, userID); err != nil { + if err == ErrNotFound { + writeError(w, http.StatusNotFound, "diary entry not found") + return + } + slog.Error("delete diary entry", "err", err) + writeError(w, http.StatusInternalServerError, "failed to delete diary entry") + return + } + w.WriteHeader(http.StatusNoContent) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/backend/internal/diary/model.go b/backend/internal/diary/model.go new file mode 100644 index 0000000..a9c366a --- /dev/null +++ b/backend/internal/diary/model.go @@ -0,0 +1,33 @@ +package diary + +import "time" + +// Entry is a single meal diary record. +type Entry struct { + ID string `json:"id"` + Date string `json:"date"` // YYYY-MM-DD + MealType string `json:"meal_type"` + Name string `json:"name"` + Portions float64 `json:"portions"` + Calories *float64 `json:"calories,omitempty"` + ProteinG *float64 `json:"protein_g,omitempty"` + FatG *float64 `json:"fat_g,omitempty"` + CarbsG *float64 `json:"carbs_g,omitempty"` + Source string `json:"source"` + RecipeID *string `json:"recipe_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateRequest is the body for POST /diary. +type CreateRequest struct { + Date string `json:"date"` + MealType string `json:"meal_type"` + Name string `json:"name"` + Portions float64 `json:"portions"` + Calories *float64 `json:"calories"` + ProteinG *float64 `json:"protein_g"` + FatG *float64 `json:"fat_g"` + CarbsG *float64 `json:"carbs_g"` + Source string `json:"source"` + RecipeID *string `json:"recipe_id"` +} diff --git a/backend/internal/diary/repository.go b/backend/internal/diary/repository.go new file mode 100644 index 0000000..623a3f1 --- /dev/null +++ b/backend/internal/diary/repository.go @@ -0,0 +1,104 @@ +package diary + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound is returned when a diary entry does not exist for the user. +var ErrNotFound = errors.New("diary entry not found") + +// Repository handles persistence for meal diary entries. +type Repository struct { + pool *pgxpool.Pool +} + +// NewRepository creates a new Repository. +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +// ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD). +func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, date::text, meal_type, name, portions, + calories, protein_g, fat_g, carbs_g, + source, recipe_id, created_at + FROM meal_diary + WHERE user_id = $1 AND date = $2::date + ORDER BY created_at ASC`, userID, date) + if err != nil { + return nil, fmt.Errorf("list diary: %w", err) + } + defer rows.Close() + + var result []*Entry + for rows.Next() { + e, err := scanEntry(rows) + if err != nil { + return nil, fmt.Errorf("scan diary entry: %w", err) + } + result = append(result, e) + } + return result, rows.Err() +} + +// Create inserts a new diary entry and returns the stored record. +func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) { + portions := req.Portions + if portions <= 0 { + portions = 1 + } + source := req.Source + if source == "" { + source = "manual" + } + + row := r.pool.QueryRow(ctx, ` + INSERT INTO meal_diary (user_id, date, meal_type, name, portions, + calories, protein_g, fat_g, carbs_g, source, recipe_id) + VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, date::text, meal_type, name, portions, + calories, protein_g, fat_g, carbs_g, source, recipe_id, created_at`, + userID, req.Date, req.MealType, req.Name, portions, + req.Calories, req.ProteinG, req.FatG, req.CarbsG, + source, req.RecipeID, + ) + return scanEntry(row) +} + +// Delete removes a diary entry for the given user. +func (r *Repository) Delete(ctx context.Context, id, userID string) error { + tag, err := r.pool.Exec(ctx, + `DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return fmt.Errorf("delete diary entry: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// --- helpers --- + +type scannable interface { + Scan(dest ...any) error +} + +func scanEntry(s scannable) (*Entry, error) { + var e Entry + err := s.Scan( + &e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions, + &e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG, + &e.Source, &e.RecipeID, &e.CreatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return &e, err +} diff --git a/backend/internal/gemini/menu.go b/backend/internal/gemini/menu.go new file mode 100644 index 0000000..01400ef --- /dev/null +++ b/backend/internal/gemini/menu.go @@ -0,0 +1,168 @@ +package gemini + +import ( + "context" + "fmt" + "strings" +) + +// MenuRequest contains parameters for weekly menu generation. +type MenuRequest struct { + UserGoal string + DailyCalories int + Restrictions []string + CuisinePrefs []string + AvailableProducts []string +} + +// DayPlan is the AI-generated plan for a single day. +type DayPlan struct { + Day int `json:"day"` + Meals []MealEntry `json:"meals"` +} + +// MealEntry is a single meal within a day plan. +type MealEntry struct { + MealType string `json:"meal_type"` // breakfast | lunch | dinner + Recipe Recipe `json:"recipe"` +} + +// GenerateMenu asks the model to plan 7 days × 3 meals. +// Returns exactly 7 DayPlan items on success. +func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, error) { + prompt := buildMenuPrompt(req) + messages := []map[string]string{ + {"role": "user", "content": prompt}, + } + + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + messages = append(messages, map[string]string{ + "role": "user", + "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО объект JSON без какого-либо текста до или после.", + }) + } + + text, err := c.generateContent(ctx, messages) + if err != nil { + return nil, err + } + + days, err := parseMenuJSON(text) + if err != nil { + lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err) + continue + } + + for i := range days { + for j := range days[i].Meals { + days[i].Meals[j].Recipe.Nutrition.Approximate = true + } + } + return days, nil + } + + return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr) +} + +func buildMenuPrompt(req MenuRequest) 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, ", ") + } + + calories := req.DailyCalories + if calories <= 0 { + calories = 2000 + } + + productsSection := "" + if len(req.AvailableProducts) > 0 { + productsSection = "\nПродукты в наличии (приоритет — скоро истекают ⚠):\n" + + strings.Join(req.AvailableProducts, "\n") + + "\nПо возможности используй эти продукты.\n" + } + + return fmt.Sprintf(`Ты — диетолог-повар. Составь меню на 7 дней на русском языке. + +Профиль пользователя: +- Цель: %s +- Дневная норма калорий: %d ккал (завтрак 25%%, обед 40%%, ужин 35%%) +- Ограничения: %s +- Предпочтения кухни: %s +%s +Требования: +- 3 приёма пищи в день: breakfast, lunch, dinner +- Не повторять рецепты +- КБЖУ на 1 порцию (приблизительно) +- Поле image_query — ТОЛЬКО на английском языке (для поиска фото) + +Верни ТОЛЬКО валидный JSON без markdown: +{ + "days": [ + { + "day": 1, + "meals": [ + { + "meal_type": "breakfast", + "recipe": { + "title": "Название", + "description": "2-3 предложения", + "cuisine": "russian|asian|european|mediterranean|american|other", + "difficulty": "easy|medium|hard", + "prep_time_min": 5, + "cook_time_min": 10, + "servings": 1, + "image_query": "oatmeal apple breakfast bowl", + "ingredients": [{"name": "Овсянка", "amount": 80, "unit": "г"}], + "steps": [{"number": 1, "description": "...", "timer_seconds": null}], + "tags": ["быстрый завтрак"], + "nutrition_per_serving": { + "calories": 320, "protein_g": 8, "fat_g": 6, "carbs_g": 58 + } + } + }, + {"meal_type": "lunch", "recipe": {...}}, + {"meal_type": "dinner", "recipe": {...}} + ] + } + ] +}`, goal, calories, restrictions, cuisines, productsSection) +} + +func parseMenuJSON(text string) ([]DayPlan, error) { + text = strings.TrimSpace(text) + if strings.HasPrefix(text, "```") { + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + text = strings.TrimSpace(text) + } + + var wrapper struct { + Days []DayPlan `json:"days"` + } + if err := parseJSON(text, &wrapper); err != nil { + return nil, err + } + if len(wrapper.Days) == 0 { + return nil, fmt.Errorf("empty days array in response") + } + return wrapper.Days, nil +} diff --git a/backend/internal/menu/handler.go b/backend/internal/menu/handler.go new file mode 100644 index 0000000..38da9b1 --- /dev/null +++ b/backend/internal/menu/handler.go @@ -0,0 +1,545 @@ +package menu + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "github.com/food-ai/backend/internal/gemini" + "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/savedrecipe" + "github.com/food-ai/backend/internal/user" + "github.com/go-chi/chi/v5" +) + +// PhotoSearcher searches for a photo by query string. +type PhotoSearcher interface { + SearchPhoto(ctx context.Context, query string) (string, error) +} + +// UserLoader loads a user profile by ID. +type UserLoader interface { + GetByID(ctx context.Context, id string) (*user.User, error) +} + +// ProductLister returns human-readable product lines for the AI prompt. +type ProductLister interface { + ListForPrompt(ctx context.Context, userID string) ([]string, error) +} + +// RecipeSaver persists a single recipe and returns the stored record. +type RecipeSaver interface { + Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, error) +} + +// Handler handles menu and shopping-list endpoints. +type Handler struct { + repo *Repository + gemini *gemini.Client + pexels PhotoSearcher + userLoader UserLoader + productLister ProductLister + recipeSaver RecipeSaver +} + +// NewHandler creates a new Handler. +func NewHandler( + repo *Repository, + geminiClient *gemini.Client, + pexels PhotoSearcher, + userLoader UserLoader, + productLister ProductLister, + recipeSaver RecipeSaver, +) *Handler { + return &Handler{ + repo: repo, + gemini: geminiClient, + pexels: pexels, + userLoader: userLoader, + productLister: productLister, + recipeSaver: recipeSaver, + } +} + +// ──────────────────────────────────────────────────────────── +// Menu endpoints +// ──────────────────────────────────────────────────────────── + +// GetMenu handles GET /menu?week=YYYY-WNN +func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + weekStart, err := resolveWeekStart(r.URL.Query().Get("week")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN") + return + } + + plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) + if err != nil { + slog.Error("get menu", "err", err) + writeError(w, http.StatusInternalServerError, "failed to load menu") + return + } + if plan == nil { + // No plan yet — return empty response. + writeJSON(w, http.StatusOK, map[string]any{ + "week_start": weekStart, + "days": nil, + }) + return + } + writeJSON(w, http.StatusOK, plan) +} + +// GenerateMenu handles POST /ai/generate-menu +func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var body struct { + Week string `json:"week"` // optional, defaults to current week + } + _ = json.NewDecoder(r.Body).Decode(&body) + + weekStart, err := resolveWeekStart(body.Week) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid week parameter") + return + } + + // Load user profile. + u, err := h.userLoader.GetByID(r.Context(), userID) + if err != nil { + slog.Error("load user for menu generation", "err", err) + writeError(w, http.StatusInternalServerError, "failed to load user profile") + return + } + + menuReq := buildMenuRequest(u) + + // Attach pantry products. + if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { + menuReq.AvailableProducts = products + } + + // Generate 7-day plan via Groq. + days, err := h.gemini.GenerateMenu(r.Context(), menuReq) + if err != nil { + slog.Error("generate menu", "user_id", userID, "err", err) + writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again") + return + } + + // Fetch Pexels images for all 21 recipes in parallel. + type indexedRecipe struct { + day int + meal int + imageURL string + } + imageResults := make([]indexedRecipe, 0, len(days)*3) + var mu sync.Mutex + var wg sync.WaitGroup + + for di, day := range days { + for mi := range day.Meals { + wg.Add(1) + go func(di, mi int, query string) { + defer wg.Done() + url, err := h.pexels.SearchPhoto(r.Context(), query) + if err != nil { + slog.Warn("pexels search failed", "query", query, "err", err) + } + mu.Lock() + imageResults = append(imageResults, indexedRecipe{di, mi, url}) + mu.Unlock() + }(di, mi, day.Meals[mi].Recipe.ImageQuery) + } + } + wg.Wait() + + for _, res := range imageResults { + days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL + } + + // Save all 21 recipes to saved_recipes. + type savedRef struct { + day int + meal int + recipeID string + } + refs := make([]savedRef, 0, len(days)*3) + for di, day := range days { + for mi, meal := range day.Meals { + saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(meal.Recipe)) + if err != nil { + slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err) + writeError(w, http.StatusInternalServerError, "failed to save recipes") + return + } + refs = append(refs, savedRef{di, mi, saved.ID}) + } + } + + // Build PlanItems list in day/meal order. + planItems := make([]PlanItem, 0, 21) + for _, ref := range refs { + planItems = append(planItems, PlanItem{ + DayOfWeek: days[ref.day].Day, + MealType: days[ref.day].Meals[ref.meal].MealType, + RecipeID: ref.recipeID, + }) + } + + // Persist in a single transaction. + planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) + if err != nil { + slog.Error("save menu plan", "err", err) + writeError(w, http.StatusInternalServerError, "failed to save menu plan") + return + } + + // Auto-generate shopping list. + if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil { + if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil { + slog.Warn("auto-generate shopping list", "err", err) + } + } + + // Return the freshly saved plan. + plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) + if err != nil || plan == nil { + writeError(w, http.StatusInternalServerError, "failed to load generated menu") + return + } + writeJSON(w, http.StatusOK, plan) +} + +// UpdateMenuItem handles PUT /menu/items/{id} +func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + itemID := chi.URLParam(r, "id") + + var body struct { + RecipeID string `json:"recipe_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" { + writeError(w, http.StatusBadRequest, "recipe_id required") + return + } + + if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil { + if err == ErrNotFound { + writeError(w, http.StatusNotFound, "menu item not found") + return + } + slog.Error("update menu item", "err", err) + writeError(w, http.StatusInternalServerError, "failed to update menu item") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// DeleteMenuItem handles DELETE /menu/items/{id} +func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + itemID := chi.URLParam(r, "id") + if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil { + if err == ErrNotFound { + writeError(w, http.StatusNotFound, "menu item not found") + return + } + slog.Error("delete menu item", "err", err) + writeError(w, http.StatusInternalServerError, "failed to delete menu item") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ──────────────────────────────────────────────────────────── +// Shopping list endpoints +// ──────────────────────────────────────────────────────────── + +// GenerateShoppingList handles POST /shopping-list/generate +func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var body struct { + Week string `json:"week"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + + weekStart, err := resolveWeekStart(body.Week) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid week parameter") + return + } + + planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) + if err != nil { + if err == ErrNotFound { + writeError(w, http.StatusNotFound, "no menu plan found for this week") + return + } + slog.Error("get plan id", "err", err) + writeError(w, http.StatusInternalServerError, "failed to find menu plan") + return + } + + items, err := h.buildShoppingList(r.Context(), planID) + if err != nil { + slog.Error("build shopping list", "err", err) + writeError(w, http.StatusInternalServerError, "failed to build shopping list") + return + } + + if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil { + slog.Error("upsert shopping list", "err", err) + writeError(w, http.StatusInternalServerError, "failed to save shopping list") + return + } + writeJSON(w, http.StatusOK, items) +} + +// GetShoppingList handles GET /shopping-list?week=YYYY-WNN +func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + weekStart, err := resolveWeekStart(r.URL.Query().Get("week")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid week parameter") + return + } + + planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) + if err != nil { + if err == ErrNotFound { + writeJSON(w, http.StatusOK, []ShoppingItem{}) + return + } + writeError(w, http.StatusInternalServerError, "failed to find menu plan") + return + } + + items, err := h.repo.GetShoppingList(r.Context(), userID, planID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load shopping list") + return + } + if items == nil { + items = []ShoppingItem{} + } + writeJSON(w, http.StatusOK, items) +} + +// ToggleShoppingItem handles PATCH /shopping-list/items/{index}/check +func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + indexStr := chi.URLParam(r, "index") + var index int + if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 { + writeError(w, http.StatusBadRequest, "invalid item index") + return + } + + var body struct { + Checked bool `json:"checked"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + weekStart, err := resolveWeekStart(r.URL.Query().Get("week")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid week parameter") + return + } + + planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) + if err != nil { + writeError(w, http.StatusNotFound, "menu plan not found") + return + } + + if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil { + slog.Error("toggle shopping item", "err", err) + writeError(w, http.StatusInternalServerError, "failed to update item") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ──────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────── + +// buildShoppingList aggregates all ingredients from a plan's recipes. +func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]ShoppingItem, error) { + rows, err := h.repo.GetIngredientsByPlan(ctx, planID) + if err != nil { + return nil, err + } + + type key struct{ name, unit string } + totals := map[key]float64{} + categories := map[string]string{} // name → category (from meal_type heuristic) + + for _, row := range rows { + var ingredients []struct { + Name string `json:"name"` + Amount float64 `json:"amount"` + Unit string `json:"unit"` + } + if len(row.IngredientsJSON) > 0 { + if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil { + continue + } + } + for _, ing := range ingredients { + k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit} + totals[k] += ing.Amount + if _, ok := categories[k.name]; !ok { + categories[k.name] = "other" + } + } + } + + items := make([]ShoppingItem, 0, len(totals)) + for k, amount := range totals { + items = append(items, ShoppingItem{ + Name: k.name, + Category: categories[k.name], + Amount: amount, + Unit: k.unit, + Checked: false, + InStock: 0, + }) + } + return items, nil +} + +type userPreferences struct { + Cuisines []string `json:"cuisines"` + Restrictions []string `json:"restrictions"` +} + +func buildMenuRequest(u *user.User) gemini.MenuRequest { + req := gemini.MenuRequest{DailyCalories: 2000} + 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 +} + +func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest { + ingJSON, _ := json.Marshal(r.Ingredients) + stepsJSON, _ := json.Marshal(r.Steps) + tagsJSON, _ := json.Marshal(r.Tags) + nutritionJSON, _ := json.Marshal(r.Nutrition) + return savedrecipe.SaveRequest{ + Title: r.Title, + Description: r.Description, + Cuisine: r.Cuisine, + Difficulty: r.Difficulty, + PrepTimeMin: r.PrepTimeMin, + CookTimeMin: r.CookTimeMin, + Servings: r.Servings, + ImageURL: r.ImageURL, + Ingredients: ingJSON, + Steps: stepsJSON, + Tags: tagsJSON, + Nutrition: nutritionJSON, + Source: "menu", + } +} + +// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday. +func resolveWeekStart(week string) (string, error) { + if week == "" { + return currentWeekStart(), nil + } + var year, w int + if _, err := fmt.Sscanf(week, "%d-W%d", &year, &w); err != nil || w < 1 || w > 53 { + return "", fmt.Errorf("invalid week: %q", week) + } + t := mondayOfISOWeek(year, w) + return t.Format("2006-01-02"), nil +} + +func currentWeekStart() string { + now := time.Now().UTC() + year, week := now.ISOWeek() + return mondayOfISOWeek(year, week).Format("2006-01-02") +} + +func mondayOfISOWeek(year, week int) time.Time { + jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) + weekday := int(jan4.Weekday()) + if weekday == 0 { + weekday = 7 + } + monday1 := jan4.AddDate(0, 0, 1-weekday) + return monday1.AddDate(0, 0, (week-1)*7) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/backend/internal/menu/model.go b/backend/internal/menu/model.go new file mode 100644 index 0000000..86f492a --- /dev/null +++ b/backend/internal/menu/model.go @@ -0,0 +1,56 @@ +package menu + +// MenuPlan is a weekly meal plan for a user. +type MenuPlan struct { + ID string `json:"id"` + WeekStart string `json:"week_start"` // YYYY-MM-DD (Monday) + Days []MenuDay `json:"days"` +} + +// MenuDay groups three meal slots for one calendar day. +type MenuDay struct { + Day int `json:"day"` // 1=Monday … 7=Sunday + Date string `json:"date"` + Meals []MealSlot `json:"meals"` + TotalCalories float64 `json:"total_calories"` +} + +// MealSlot holds a single meal within a day. +type MealSlot struct { + ID string `json:"id"` + MealType string `json:"meal_type"` // breakfast | lunch | dinner + Recipe *MenuRecipe `json:"recipe,omitempty"` +} + +// MenuRecipe is a thin projection of a saved recipe used in the menu view. +type MenuRecipe struct { + ID string `json:"id"` + Title string `json:"title"` + ImageURL string `json:"image_url"` + Nutrition NutritionInfo `json:"nutrition_per_serving"` +} + +// NutritionInfo holds macronutrient data. +type NutritionInfo struct { + Calories float64 `json:"calories"` + ProteinG float64 `json:"protein_g"` + FatG float64 `json:"fat_g"` + CarbsG float64 `json:"carbs_g"` +} + +// PlanItem is the input needed to create one menu_items row. +type PlanItem struct { + DayOfWeek int + MealType string + RecipeID string +} + +// ShoppingItem is one entry in the shopping list. +type ShoppingItem struct { + Name string `json:"name"` + Category string `json:"category"` + Amount float64 `json:"amount"` + Unit string `json:"unit"` + Checked bool `json:"checked"` + InStock float64 `json:"in_stock"` +} diff --git a/backend/internal/menu/repository.go b/backend/internal/menu/repository.go new file mode 100644 index 0000000..3a8ae03 --- /dev/null +++ b/backend/internal/menu/repository.go @@ -0,0 +1,306 @@ +package menu + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound is returned when a menu item is not found for the user. +var ErrNotFound = errors.New("menu item not found") + +// Repository handles persistence for menu plans, items, and shopping lists. +type Repository struct { + pool *pgxpool.Pool +} + +// NewRepository creates a new Repository. +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD). +// Returns nil, nil when no plan exists for that week. +func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) { + const q = ` + SELECT mp.id, mp.week_start, + mi.id, mi.day_of_week, mi.meal_type, + sr.id, sr.title, COALESCE(sr.image_url, ''), sr.nutrition + FROM menu_plans mp + LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id + LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id + WHERE mp.user_id = $1 AND mp.week_start::text = $2 + ORDER BY mi.day_of_week, + CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END` + + rows, err := r.pool.Query(ctx, q, userID, weekStart) + if err != nil { + return nil, fmt.Errorf("get menu by week: %w", err) + } + defer rows.Close() + + var plan *MenuPlan + dayMap := map[int]*MenuDay{} + + for rows.Next() { + var ( + planID, planWeekStart string + itemID, mealType *string + dow *int + recipeID, title, imageURL *string + nutritionRaw []byte + ) + if err := rows.Scan( + &planID, &planWeekStart, + &itemID, &dow, &mealType, + &recipeID, &title, &imageURL, &nutritionRaw, + ); err != nil { + return nil, fmt.Errorf("scan menu row: %w", err) + } + + if plan == nil { + plan = &MenuPlan{ID: planID, WeekStart: planWeekStart} + } + + if itemID == nil || dow == nil || mealType == nil { + continue + } + + day, ok := dayMap[*dow] + if !ok { + day = &MenuDay{Day: *dow, Date: dayDate(planWeekStart, *dow)} + dayMap[*dow] = day + } + + slot := MealSlot{ID: *itemID, MealType: *mealType} + if recipeID != nil && title != nil { + var nutrition NutritionInfo + if len(nutritionRaw) > 0 { + _ = json.Unmarshal(nutritionRaw, &nutrition) + } + slot.Recipe = &MenuRecipe{ + ID: *recipeID, + Title: *title, + ImageURL: derefStr(imageURL), + Nutrition: nutrition, + } + day.TotalCalories += nutrition.Calories + } + day.Meals = append(day.Meals, slot) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if plan == nil { + return nil, nil + } + + // Assemble days in order. + for dow := 1; dow <= 7; dow++ { + if d, ok := dayMap[dow]; ok { + plan.Days = append(plan.Days, *d) + } + } + return plan, nil +} + +// SaveMenuInTx upserts a menu_plan row, wipes previous menu_items, and inserts +// the new ones — all in a single transaction. +func (r *Repository) SaveMenuInTx(ctx context.Context, userID, weekStart string, items []PlanItem) (string, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return "", fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + var planID string + err = tx.QueryRow(ctx, ` + INSERT INTO menu_plans (user_id, week_start) + VALUES ($1, $2::date) + ON CONFLICT (user_id, week_start) DO UPDATE SET created_at = now() + RETURNING id`, userID, weekStart).Scan(&planID) + if err != nil { + return "", fmt.Errorf("upsert menu_plan: %w", err) + } + + if _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE menu_plan_id = $1`, planID); err != nil { + return "", fmt.Errorf("delete old menu items: %w", err) + } + + for _, item := range items { + if _, err = tx.Exec(ctx, ` + INSERT INTO menu_items (menu_plan_id, day_of_week, meal_type, recipe_id) + VALUES ($1, $2, $3, $4)`, + planID, item.DayOfWeek, item.MealType, item.RecipeID, + ); err != nil { + return "", fmt.Errorf("insert menu item: %w", err) + } + } + + if err = tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit tx: %w", err) + } + return planID, nil +} + +// UpdateItem replaces the recipe in a menu slot. +func (r *Repository) UpdateItem(ctx context.Context, itemID, userID, recipeID string) error { + tag, err := r.pool.Exec(ctx, ` + UPDATE menu_items mi + SET recipe_id = $3 + FROM menu_plans mp + WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`, + itemID, userID, recipeID, + ) + if err != nil { + return fmt.Errorf("update menu item: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// DeleteItem removes a menu slot. +func (r *Repository) DeleteItem(ctx context.Context, itemID, userID string) error { + tag, err := r.pool.Exec(ctx, ` + DELETE FROM menu_items mi + USING menu_plans mp + WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`, + itemID, userID, + ) + if err != nil { + return fmt.Errorf("delete menu item: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// UpsertShoppingList stores the shopping list for a menu plan. +func (r *Repository) UpsertShoppingList(ctx context.Context, userID, planID string, items []ShoppingItem) error { + raw, err := json.Marshal(items) + if err != nil { + return fmt.Errorf("marshal shopping items: %w", err) + } + _, err = r.pool.Exec(ctx, ` + INSERT INTO shopping_lists (user_id, menu_plan_id, items) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (user_id, menu_plan_id) DO UPDATE + SET items = EXCLUDED.items, generated_at = now()`, + userID, planID, string(raw), + ) + return err +} + +// GetShoppingList returns the shopping list for the user's plan. +func (r *Repository) GetShoppingList(ctx context.Context, userID, planID string) ([]ShoppingItem, error) { + var raw []byte + err := r.pool.QueryRow(ctx, ` + SELECT items FROM shopping_lists + WHERE user_id = $1 AND menu_plan_id = $2`, + userID, planID, + ).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get shopping list: %w", err) + } + var items []ShoppingItem + if err := json.Unmarshal(raw, &items); err != nil { + return nil, fmt.Errorf("unmarshal shopping items: %w", err) + } + return items, nil +} + +// ToggleShoppingItem flips the checked flag for the item at the given index. +func (r *Repository) ToggleShoppingItem(ctx context.Context, userID, planID string, index int, checked bool) error { + tag, err := r.pool.Exec(ctx, ` + UPDATE shopping_lists + SET items = jsonb_set(items, ARRAY[$1::text, 'checked'], to_jsonb($2::boolean)) + WHERE user_id = $3 AND menu_plan_id = $4`, + fmt.Sprintf("%d", index), checked, userID, planID, + ) + if err != nil { + return fmt.Errorf("toggle shopping item: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// GetPlanIDByWeek returns the menu_plan id for the user and given Monday date. +func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart string) (string, error) { + var id string + err := r.pool.QueryRow(ctx, ` + SELECT id FROM menu_plans WHERE user_id = $1 AND week_start::text = $2`, + userID, weekStart, + ).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("get plan id: %w", err) + } + return id, nil +} + +// GetIngredientsByPlan returns all ingredients from all recipes in the plan. +func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) { + rows, err := r.pool.Query(ctx, ` + SELECT sr.ingredients, sr.nutrition, mi.meal_type + FROM menu_items mi + JOIN saved_recipes sr ON sr.id = mi.recipe_id + WHERE mi.menu_plan_id = $1`, planID) + if err != nil { + return nil, fmt.Errorf("get ingredients by plan: %w", err) + } + defer rows.Close() + + var result []ingredientRow + for rows.Next() { + var ingredientsRaw, nutritionRaw []byte + var mealType string + if err := rows.Scan(&ingredientsRaw, &nutritionRaw, &mealType); err != nil { + return nil, err + } + result = append(result, ingredientRow{ + IngredientsJSON: ingredientsRaw, + NutritionJSON: nutritionRaw, + MealType: mealType, + }) + } + return result, rows.Err() +} + +type ingredientRow struct { + IngredientsJSON []byte + NutritionJSON []byte + MealType string +} + +// --- helpers --- + +func dayDate(weekStart string, dow int) string { + t, err := time.Parse("2006-01-02", weekStart) + if err != nil { + return weekStart + } + return t.AddDate(0, 0, dow-1).Format("2006-01-02") +} + +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index a7abe68..d39aa0c 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -5,7 +5,9 @@ import ( "net/http" "github.com/food-ai/backend/internal/auth" + "github.com/food-ai/backend/internal/diary" "github.com/food-ai/backend/internal/ingredient" + "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/recognition" @@ -25,6 +27,8 @@ func NewRouter( ingredientHandler *ingredient.Handler, productHandler *product.Handler, recognitionHandler *recognition.Handler, + menuHandler *menu.Handler, + diaryHandler *diary.Handler, authMiddleware func(http.Handler) http.Handler, allowedOrigins []string, ) *chi.Mux { @@ -44,16 +48,12 @@ func NewRouter( r.Post("/logout", authHandler.Logout) }) - // Public search (still requires auth to prevent scraping) - r.Group(func(r chi.Router) { - r.Use(authMiddleware) - r.Get("/ingredients/search", ingredientHandler.Search) - }) - // Protected r.Group(func(r chi.Router) { r.Use(authMiddleware) + r.Get("/ingredients/search", ingredientHandler.Search) + r.Get("/profile", userHandler.Get) r.Put("/profile", userHandler.Update) @@ -74,10 +74,29 @@ func NewRouter( r.Delete("/{id}", productHandler.Delete) }) + r.Route("/menu", func(r chi.Router) { + r.Get("/", menuHandler.GetMenu) + r.Put("/items/{id}", menuHandler.UpdateMenuItem) + r.Delete("/items/{id}", menuHandler.DeleteMenuItem) + }) + + r.Route("/shopping-list", func(r chi.Router) { + r.Get("/", menuHandler.GetShoppingList) + r.Post("/generate", menuHandler.GenerateShoppingList) + r.Patch("/items/{index}/check", menuHandler.ToggleShoppingItem) + }) + + r.Route("/diary", func(r chi.Router) { + r.Get("/", diaryHandler.GetByDate) + r.Post("/", diaryHandler.Create) + r.Delete("/{id}", diaryHandler.Delete) + }) + r.Route("/ai", func(r chi.Router) { r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt) r.Post("/recognize-products", recognitionHandler.RecognizeProducts) r.Post("/recognize-dish", recognitionHandler.RecognizeDish) + r.Post("/generate-menu", menuHandler.GenerateMenu) }) }) diff --git a/backend/migrations/007_create_menu_plans.sql b/backend/migrations/007_create_menu_plans.sql new file mode 100644 index 0000000..d911bea --- /dev/null +++ b/backend/migrations/007_create_menu_plans.sql @@ -0,0 +1,35 @@ +-- +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, + UNIQUE(menu_plan_id, day_of_week, meal_type) +); + +-- Stores the generated shopping list for a menu plan. +-- items is a JSONB array of {name, category, amount, unit, checked, in_stock}. +CREATE TABLE shopping_lists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + menu_plan_id UUID REFERENCES menu_plans(id) ON DELETE CASCADE, + items JSONB NOT NULL DEFAULT '[]', + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, menu_plan_id) +); + +-- +goose Down +DROP TABLE shopping_lists; +DROP TABLE menu_items; +DROP TABLE menu_plans; diff --git a/backend/migrations/008_create_meal_diary.sql b/backend/migrations/008_create_meal_diary.sql new file mode 100644 index 0000000..9b24056 --- /dev/null +++ b/backend/migrations/008_create_meal_diary.sql @@ -0,0 +1,22 @@ +-- +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', + 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; diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index 0864f71..ebc381e 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -38,6 +38,17 @@ class ApiClient { return response.data; } + Future patch(String path, + {dynamic data, Map? params}) async { + await _dio.patch(path, data: data, queryParameters: params); + } + + /// Posts data and expects a JSON array response. + Future> postList(String path, {dynamic data}) async { + final response = await _dio.post(path, data: data); + return response.data as List; + } + Future> delete(String path) async { final response = await _dio.delete(path); return response.data; diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index b16b044..70378a0 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -12,7 +12,9 @@ import '../../features/scan/scan_screen.dart'; import '../../features/scan/recognition_confirm_screen.dart'; import '../../features/scan/dish_result_screen.dart'; import '../../features/scan/recognition_service.dart'; +import '../../features/menu/diary_screen.dart'; import '../../features/menu/menu_screen.dart'; +import '../../features/menu/shopping_list_screen.dart'; import '../../features/recipes/recipe_detail_screen.dart'; import '../../features/recipes/recipes_screen.dart'; import '../../features/profile/profile_screen.dart'; @@ -62,6 +64,22 @@ final routerProvider = Provider((ref) { path: '/products/add', builder: (_, __) => const AddProductScreen(), ), + // Shopping list — full-screen, no bottom nav. + GoRoute( + path: '/menu/shopping-list', + builder: (context, state) { + final week = state.extra as String? ?? ''; + return ShoppingListScreen(week: week); + }, + ), + // Diary — full-screen, no bottom nav. + GoRoute( + path: '/menu/diary', + builder: (context, state) { + final date = state.extra as String? ?? ''; + return DiaryScreen(date: date); + }, + ), // Scan / recognition flow — all without bottom nav. GoRoute( path: '/scan', diff --git a/client/lib/features/menu/diary_screen.dart b/client/lib/features/menu/diary_screen.dart new file mode 100644 index 0000000..00046e8 --- /dev/null +++ b/client/lib/features/menu/diary_screen.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/models/diary_entry.dart'; +import 'menu_provider.dart'; + +String formatDate(String d) { + try { + final dt = DateTime.parse(d); + const months = [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', + ]; + return '${dt.day} ${months[dt.month - 1]}'; + } catch (_) { + return d; + } +} + +class DiaryScreen extends ConsumerWidget { + final String date; + + const DiaryScreen({super.key, required this.date}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(diaryProvider(date)); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text('Дневник — ${formatDate(date)}'), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddSheet(context, ref), + child: const Icon(Icons.add), + ), + body: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => Center( + child: FilledButton( + onPressed: () => ref.read(diaryProvider(date).notifier).load(), + child: const Text('Повторить'), + ), + ), + data: (entries) => entries.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.book_outlined, + size: 72, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + const Text('Нет записей за этот день'), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: () => _showAddSheet(context, ref), + icon: const Icon(Icons.add), + label: const Text('Добавить запись'), + ), + ], + ), + ) + : _DiaryList(entries: entries, date: date), + ), + ); + } + + void _showAddSheet(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _AddEntrySheet(date: date, ref: ref), + ); + } +} + +class _DiaryList extends ConsumerWidget { + final List entries; + final String date; + + const _DiaryList({required this.entries, required this.date}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Group by meal type. + const order = ['breakfast', 'lunch', 'dinner']; + final grouped = >{}; + for (final e in entries) { + grouped.putIfAbsent(e.mealType, () => []).add(e); + } + + // Total calories for the day. + final totalCal = entries.fold( + 0, + (sum, e) => sum + ((e.calories ?? 0) * e.portions), + ); + + return ListView( + padding: const EdgeInsets.only(bottom: 100), + children: [ + if (totalCal > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + 'Итого за день: ≈${totalCal.toInt()} ккал', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + for (final mealType in [...order, ...grouped.keys.where((k) => !order.contains(k))]) + if (grouped.containsKey(mealType)) ...[ + _MealHeader(mealType: mealType), + for (final entry in grouped[mealType]!) + _EntryTile(entry: entry, date: date), + ], + ], + ); + } +} + +class _MealHeader extends StatelessWidget { + final String mealType; + + const _MealHeader({required this.mealType}); + + @override + Widget build(BuildContext context) { + final entry = DiaryEntry( + id: '', date: '', mealType: mealType, name: '', + portions: 1, source: '', + ); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + entry.mealLabel, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _EntryTile extends ConsumerWidget { + final DiaryEntry entry; + final String date; + + const _EntryTile({required this.entry, required this.date}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cal = entry.calories != null + ? '≈${(entry.calories! * entry.portions).toInt()} ккал' + : ''; + + return Dismissible( + key: ValueKey(entry.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + onDismissed: (_) => + ref.read(diaryProvider(date).notifier).remove(entry.id), + child: ListTile( + title: Text(entry.name), + subtitle: entry.portions != 1 + ? Text('${entry.portions} порций') + : null, + trailing: cal.isNotEmpty + ? Text(cal, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontWeight: FontWeight.w600)) + : null, + ), + ); + } +} + +// ── Add entry bottom sheet ───────────────────────────────────── + +class _AddEntrySheet extends StatefulWidget { + final String date; + final WidgetRef ref; + + const _AddEntrySheet({required this.date, required this.ref}); + + @override + State<_AddEntrySheet> createState() => _AddEntrySheetState(); +} + +class _AddEntrySheetState extends State<_AddEntrySheet> { + final _nameController = TextEditingController(); + final _calController = TextEditingController(); + String _mealType = 'breakfast'; + bool _saving = false; + + static const _mealTypes = [ + ('breakfast', 'Завтрак'), + ('lunch', 'Обед'), + ('dinner', 'Ужин'), + ]; + + @override + void dispose() { + _nameController.dispose(); + _calController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final insets = MediaQuery.viewInsetsOf(context); + return Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Добавить запись', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + DropdownMenu( + initialSelection: _mealType, + expandedInsets: EdgeInsets.zero, + label: const Text('Приём пищи'), + dropdownMenuEntries: _mealTypes + .map((t) => DropdownMenuEntry(value: t.$1, label: t.$2)) + .toList(), + onSelected: (v) => setState(() => _mealType = v ?? _mealType), + ), + const SizedBox(height: 12), + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Название блюда', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _calController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Калории (необязательно)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Добавить'), + ), + ], + ), + ); + } + + Future _save() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + setState(() => _saving = true); + try { + final cal = double.tryParse(_calController.text); + await widget.ref.read(diaryProvider(widget.date).notifier).add({ + 'date': widget.date, + 'meal_type': _mealType, + 'name': name, + 'portions': 1, + if (cal != null) 'calories': cal, + 'source': 'manual', + }); + if (mounted) Navigator.pop(context); + } finally { + if (mounted) setState(() => _saving = false); + } + } +} diff --git a/client/lib/features/menu/menu_provider.dart b/client/lib/features/menu/menu_provider.dart new file mode 100644 index 0000000..01fbe56 --- /dev/null +++ b/client/lib/features/menu/menu_provider.dart @@ -0,0 +1,147 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; +import '../../shared/models/diary_entry.dart'; +import '../../shared/models/menu.dart'; +import '../../shared/models/shopping_item.dart'; +import 'menu_service.dart'; + +// ── Service provider ────────────────────────────────────────── + +final menuServiceProvider = Provider((ref) { + return MenuService(ref.read(apiClientProvider)); +}); + +// ── Current week (state) ────────────────────────────────────── + +/// The ISO week string for the currently displayed week, e.g. "2026-W08". +final currentWeekProvider = StateProvider((ref) { + final now = DateTime.now().toUtc(); + final (y, w) = _isoWeek(now); + return '$y-W${w.toString().padLeft(2, '0')}'; +}); + +(int year, int week) _isoWeek(DateTime dt) { + // Shift to Thursday to get ISO week year. + final thu = dt.add(Duration(days: 4 - (dt.weekday == 7 ? 0 : dt.weekday))); + final jan1 = DateTime.utc(thu.year, 1, 1); + final week = ((thu.difference(jan1).inDays) / 7).ceil(); + return (thu.year, week); +} + +// ── Menu notifier ───────────────────────────────────────────── + +class MenuNotifier extends StateNotifier> { + final MenuService _service; + final String _week; + + MenuNotifier(this._service, this._week) : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _service.getMenu(week: _week)); + } + + Future generate() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _service.generateMenu(week: _week)); + } + + Future updateItem(String itemId, String recipeId) async { + await _service.updateMenuItem(itemId, recipeId); + await load(); + } + + Future deleteItem(String itemId) async { + await _service.deleteMenuItem(itemId); + await load(); + } +} + +final menuProvider = + StateNotifierProvider.family, String>( + (ref, week) => MenuNotifier(ref.read(menuServiceProvider), week), +); + +// ── Shopping list notifier ──────────────────────────────────── + +class ShoppingListNotifier extends StateNotifier>> { + final MenuService _service; + final String _week; + + ShoppingListNotifier(this._service, this._week) + : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _service.getShoppingList(week: _week)); + } + + Future regenerate() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => _service.generateShoppingList(week: _week)); + } + + Future toggle(int index, bool checked) async { + // Optimistic update. + state = state.whenData((items) { + final list = List.from(items); + if (index < list.length) { + list[index] = list[index].copyWith(checked: checked); + } + return list; + }); + try { + await _service.toggleShoppingItem(index, checked, week: _week); + } catch (_) { + // Revert on failure. + await load(); + } + } +} + +final shoppingListProvider = StateNotifierProvider.family< + ShoppingListNotifier, AsyncValue>, String>( + (ref, week) => ShoppingListNotifier(ref.read(menuServiceProvider), week), +); + +// ── Diary notifier ──────────────────────────────────────────── + +class DiaryNotifier extends StateNotifier>> { + final MenuService _service; + final String _date; + + DiaryNotifier(this._service, this._date) : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _service.getDiary(_date)); + } + + Future add(Map body) async { + await _service.createDiaryEntry(body); + await load(); + } + + Future remove(String id) async { + final prev = state; + state = state.whenData((l) => l.where((e) => e.id != id).toList()); + try { + await _service.deleteDiaryEntry(id); + } catch (_) { + state = prev; + } + } +} + +final diaryProvider = + StateNotifierProvider.family>, String>( + (ref, date) => DiaryNotifier(ref.read(menuServiceProvider), date), +); diff --git a/client/lib/features/menu/menu_screen.dart b/client/lib/features/menu/menu_screen.dart index ee1859b..2db19fa 100644 --- a/client/lib/features/menu/menu_screen.dart +++ b/client/lib/features/menu/menu_screen.dart @@ -1,13 +1,468 @@ +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'; -class MenuScreen extends StatelessWidget { +import '../../shared/models/menu.dart'; +import 'menu_provider.dart'; + +class MenuScreen extends ConsumerWidget { const MenuScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final week = ref.watch(currentWeekProvider); + final state = ref.watch(menuProvider(week)); + return Scaffold( - appBar: AppBar(title: const Text('Меню')), - body: const Center(child: Text('Раздел в разработке')), + appBar: AppBar( + title: const Text('Меню'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.read(menuProvider(week).notifier).load(), + ), + ], + bottom: _WeekNavBar(week: week, ref: ref), + ), + body: state.when( + loading: () => const _MenuSkeleton(), + error: (err, _) => _ErrorView( + onRetry: () => ref.read(menuProvider(week).notifier).load(), + ), + data: (plan) => plan == null + ? _EmptyState( + onGenerate: () => + ref.read(menuProvider(week).notifier).generate(), + ) + : _MenuContent(plan: plan, week: week), + ), + floatingActionButton: state.maybeWhen( + data: (_) => _GenerateFab(week: week), + orElse: () => null, + ), + ); + } +} + +// ── Week navigation app-bar bottom ──────────────────────────── + +class _WeekNavBar extends StatelessWidget implements PreferredSizeWidget { + final String week; + final WidgetRef ref; + + const _WeekNavBar({required this.week, required this.ref}); + + @override + Size get preferredSize => const Size.fromHeight(40); + + String _weekLabel(String week) { + try { + final parts = week.split('-W'); + if (parts.length != 2) return week; + final year = int.parse(parts[0]); + final w = int.parse(parts[1]); + final monday = _mondayOfISOWeek(year, w); + final sunday = monday.add(const Duration(days: 6)); + const months = [ + 'янв', 'фев', 'мар', 'апр', 'май', 'июн', + 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', + ]; + return '${monday.day}–${sunday.day} ${months[sunday.month - 1]}'; + } catch (_) { + return week; + } + } + + DateTime _mondayOfISOWeek(int year, int w) { + final jan4 = DateTime.utc(year, 1, 4); + final monday1 = jan4.subtract(Duration(days: jan4.weekday - 1)); + return monday1.add(Duration(days: (w - 1) * 7)); + } + + String _offsetWeek(String week, int offsetWeeks) { + try { + final parts = week.split('-W'); + final year = int.parse(parts[0]); + final w = int.parse(parts[1]); + final monday = _mondayOfISOWeek(year, w); + final newMonday = monday.add(Duration(days: offsetWeeks * 7)); + final (ny, nw) = _isoWeekOf(newMonday); + return '$ny-W${nw.toString().padLeft(2, '0')}'; + } catch (_) { + return week; + } + } + + (int, int) _isoWeekOf(DateTime dt) { + final thu = dt.add(Duration(days: 4 - (dt.weekday == 7 ? 0 : dt.weekday))); + final jan1 = DateTime.utc(thu.year, 1, 1); + final w = ((thu.difference(jan1).inDays) / 7).ceil(); + return (thu.year, w); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + ref.read(currentWeekProvider.notifier).state = + _offsetWeek(week, -1); + }, + ), + Text(_weekLabel(week), style: Theme.of(context).textTheme.bodyMedium), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () { + ref.read(currentWeekProvider.notifier).state = + _offsetWeek(week, 1); + }, + ), + ], + ); + } +} + +// ── Menu content ────────────────────────────────────────────── + +class _MenuContent extends StatelessWidget { + final MenuPlan plan; + final String week; + + const _MenuContent({required this.plan, required this.week}); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.only(bottom: 120), + children: [ + ...plan.days.map((day) => _DayCard(day: day, week: week)), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: OutlinedButton.icon( + onPressed: () => + context.push('/menu/shopping-list', extra: week), + icon: const Icon(Icons.shopping_cart_outlined), + label: const Text('Список покупок'), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: OutlinedButton.icon( + onPressed: () { + final today = DateTime.now(); + final dateStr = + '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; + context.push('/menu/diary', extra: dateStr); + }, + icon: const Icon(Icons.book_outlined), + label: const Text('Дневник питания'), + ), + ), + ], + ); + } +} + +class _DayCard extends StatelessWidget { + final MenuDay day; + final String week; + + const _DayCard({required this.day, required this.week}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '${day.dayName}, ${day.shortDate}', + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const Spacer(), + Text( + '${day.totalCalories.toInt()} ккал', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + const SizedBox(height: 8), + Card( + child: Column( + children: day.meals.asMap().entries.map((entry) { + final index = entry.key; + final slot = entry.value; + return Column( + children: [ + _MealRow(slot: slot, week: week), + if (index < day.meals.length - 1) + const Divider(height: 1), + ], + ); + }).toList(), + ), + ), + ], + ), + ); + } +} + +class _MealRow extends ConsumerWidget { + final MealSlot slot; + final String week; + + const _MealRow({required this.slot, required this.week}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final recipe = slot.recipe; + + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: recipe?.imageUrl.isNotEmpty == true + ? CachedNetworkImage( + imageUrl: recipe!.imageUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => _placeholder(), + ) + : _placeholder(), + ), + title: Row( + children: [ + Text(slot.mealEmoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text(slot.mealLabel, style: theme.textTheme.labelMedium), + if (recipe?.nutrition?.calories != null) ...[ + const Spacer(), + Text( + '≈${recipe!.nutrition!.calories.toInt()} ккал', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ], + ), + subtitle: recipe != null + ? Text(recipe.title, style: theme.textTheme.bodyMedium) + : Text( + 'Не задано', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + trailing: TextButton( + onPressed: () => _showChangeDialog(context, ref), + child: const Text('Изменить'), + ), + ); + } + + Widget _placeholder() => Container( + width: 56, + height: 56, + color: Colors.grey.shade200, + child: const Icon(Icons.restaurant, color: Colors.grey), + ); + + void _showChangeDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('Изменить ${slot.mealLabel.toLowerCase()}?'), + content: const Text( + 'Удалите текущий рецепт из слота — новый появится после следующей генерации.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + if (slot.recipe != null) + TextButton( + onPressed: () async { + Navigator.pop(context); + await ref + .read(menuProvider(week).notifier) + .deleteItem(slot.id); + }, + child: const Text('Убрать рецепт'), + ), + ], + ), + ); + } +} + +// ── Generate FAB ────────────────────────────────────────────── + +class _GenerateFab extends ConsumerWidget { + final String week; + + const _GenerateFab({required this.week}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return FloatingActionButton.extended( + onPressed: () => _confirmGenerate(context, ref), + icon: const Icon(Icons.auto_awesome), + label: const Text('Сгенерировать меню'), + ); + } + + void _confirmGenerate(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Сгенерировать меню?'), + content: const Text( + 'Gemini составит меню на неделю с учётом ваших продуктов и целей. Текущее меню будет заменено.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + Navigator.pop(context); + ref.read(menuProvider(week).notifier).generate(); + }, + child: const Text('Сгенерировать'), + ), + ], + ), + ); + } +} + +// ── Skeleton ────────────────────────────────────────────────── + +class _MenuSkeleton extends StatelessWidget { + const _MenuSkeleton(); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.surfaceContainerHighest; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Составляем меню на неделю...'), + SizedBox(height: 4), + Text( + 'Учитываем ваши продукты и цели', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + for (int i = 0; i < 3; i++) ...[ + _shimmer(color, height: 16, width: 160), + const SizedBox(height: 8), + _shimmer(color, height: 90), + const SizedBox(height: 16), + ], + ], + ); + } + + Widget _shimmer(Color color, {double height = 60, double? width}) => + Container( + height: height, + width: width, + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + ); +} + +// ── Empty state ─────────────────────────────────────────────── + +class _EmptyState extends StatelessWidget { + final VoidCallback onGenerate; + + const _EmptyState({required this.onGenerate}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.calendar_today_outlined, + size: 72, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('Меню не составлено', + style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Нажмите кнопку ниже, чтобы Gemini составил меню на неделю с учётом ваших продуктов', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onGenerate, + icon: const Icon(Icons.auto_awesome), + label: const Text('Сгенерировать меню'), + ), + ], + ), + ), + ); + } +} + +// ── Error view ──────────────────────────────────────────────── + +class _ErrorView extends StatelessWidget { + final VoidCallback onRetry; + + const _ErrorView({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return 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), + FilledButton( + onPressed: onRetry, child: const Text('Повторить')), + ], + ), ); } } diff --git a/client/lib/features/menu/menu_service.dart b/client/lib/features/menu/menu_service.dart new file mode 100644 index 0000000..78efe6a --- /dev/null +++ b/client/lib/features/menu/menu_service.dart @@ -0,0 +1,85 @@ +import '../../core/api/api_client.dart'; +import '../../shared/models/diary_entry.dart'; +import '../../shared/models/menu.dart'; +import '../../shared/models/shopping_item.dart'; + +class MenuService { + final ApiClient _client; + + MenuService(this._client); + + // ── Menu ────────────────────────────────────────────────── + + Future getMenu({String? week}) async { + final params = {}; + if (week != null) params['week'] = week; + final data = await _client.get('/menu', params: params); + // Backend returns {"week_start": "...", "days": null} when no plan exists. + if (data['id'] == null) return null; + return MenuPlan.fromJson(data); + } + + Future generateMenu({String? week}) async { + final body = {}; + if (week != null) body['week'] = week; + final data = await _client.post('/ai/generate-menu', data: body); + return MenuPlan.fromJson(data); + } + + Future updateMenuItem(String itemId, String recipeId) async { + await _client.put('/menu/items/$itemId', data: {'recipe_id': recipeId}); + } + + Future deleteMenuItem(String itemId) async { + await _client.deleteVoid('/menu/items/$itemId'); + } + + // ── Shopping list ───────────────────────────────────────── + + Future> getShoppingList({String? week}) async { + final params = {}; + if (week != null) params['week'] = week; + final data = await _client.getList('/shopping-list', params: params); + return data + .map((e) => ShoppingItem.fromJson(e as Map)) + .toList(); + } + + Future> generateShoppingList({String? week}) async { + final body = {}; + if (week != null) body['week'] = week; + final data = await _client.postList('/shopping-list/generate', data: body); + return data + .map((e) => ShoppingItem.fromJson(e as Map)) + .toList(); + } + + Future toggleShoppingItem(int index, bool checked, + {String? week}) async { + final params = {}; + if (week != null) params['week'] = week; + await _client.patch( + '/shopping-list/items/$index/check', + data: {'checked': checked}, + params: params, + ); + } + + // ── Diary ───────────────────────────────────────────────── + + Future> getDiary(String date) async { + final data = await _client.getList('/diary', params: {'date': date}); + return data + .map((e) => DiaryEntry.fromJson(e as Map)) + .toList(); + } + + Future createDiaryEntry(Map body) async { + final data = await _client.post('/diary', data: body); + return DiaryEntry.fromJson(data); + } + + Future deleteDiaryEntry(String id) async { + await _client.deleteVoid('/diary/$id'); + } +} diff --git a/client/lib/features/menu/shopping_list_screen.dart b/client/lib/features/menu/shopping_list_screen.dart new file mode 100644 index 0000000..5bbbe98 --- /dev/null +++ b/client/lib/features/menu/shopping_list_screen.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/models/shopping_item.dart'; +import 'menu_provider.dart'; + +class ShoppingListScreen extends ConsumerWidget { + final String week; + + const ShoppingListScreen({super.key, required this.week}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(shoppingListProvider(week)); + + return Scaffold( + appBar: AppBar( + title: const Text('Список покупок'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Пересоздать список', + onPressed: () => + ref.read(shoppingListProvider(week).notifier).regenerate(), + ), + ], + ), + body: 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), + FilledButton( + onPressed: () => + ref.read(shoppingListProvider(week).notifier).load(), + child: const Text('Повторить'), + ), + ], + ), + ), + data: (items) => items.isEmpty + ? _EmptyState(week: week) + : _ShoppingList(items: items, week: week), + ), + ); + } +} + +class _ShoppingList extends ConsumerWidget { + final List items; + final String week; + + const _ShoppingList({required this.items, required this.week}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + // Group items by category. + final Map> grouped = {}; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + final cat = _categoryLabel(item.category); + grouped.putIfAbsent(cat, () => []).add((i, item)); + } + + final uncheckedCount = items.where((e) => !e.checked).length; + + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: 80), + children: [ + for (final entry in grouped.entries) ...[ + _SectionHeader(label: entry.key), + for (final (index, item) in entry.value) + _ShoppingTile( + item: item, + index: index, + week: week, + ), + ], + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + Text( + 'Осталось купить: $uncheckedCount', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ); + } + + String _categoryLabel(String category) { + switch (category) { + case 'meat': + return 'Мясо'; + case 'dairy': + return 'Молочное'; + case 'vegetable': + return 'Овощи'; + case 'fruit': + return 'Фрукты'; + case 'grain': + return 'Крупы и злаки'; + case 'seafood': + return 'Морепродукты'; + case 'condiment': + return 'Специи и соусы'; + default: + return 'Прочее'; + } + } +} + +class _SectionHeader extends StatelessWidget { + final String label; + + const _SectionHeader({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _ShoppingTile extends ConsumerWidget { + final ShoppingItem item; + final int index; + final String week; + + const _ShoppingTile({ + required this.item, + required this.index, + required this.week, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final amountStr = item.amount == item.amount.roundToDouble() + ? item.amount.toInt().toString() + : item.amount.toStringAsFixed(1); + + return ListTile( + leading: Checkbox( + value: item.checked, + onChanged: (checked) { + ref + .read(shoppingListProvider(week).notifier) + .toggle(index, checked ?? false); + }, + ), + title: Text( + item.name, + style: TextStyle( + decoration: item.checked ? TextDecoration.lineThrough : null, + color: item.checked ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + subtitle: item.inStock > 0 + ? Text( + '${item.inStock.toStringAsFixed(0)} ${item.unit} есть дома', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.green, + ), + ) + : null, + trailing: Text( + '$amountStr ${item.unit}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _EmptyState extends ConsumerWidget { + final String week; + + const _EmptyState({required this.week}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.shopping_cart_outlined, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + const Text('Список покупок пуст'), + const SizedBox(height: 8), + const Text( + 'Сначала сгенерируйте меню на неделю, затем список покупок сформируется автоматически', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => + ref.read(shoppingListProvider(week).notifier).regenerate(), + icon: const Icon(Icons.refresh), + label: const Text('Сформировать список'), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/features/scan/scan_screen.dart b/client/lib/features/scan/scan_screen.dart index 9e96765..4e4d480 100644 --- a/client/lib/features/scan/scan_screen.dart +++ b/client/lib/features/scan/scan_screen.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; diff --git a/client/lib/shared/models/diary_entry.dart b/client/lib/shared/models/diary_entry.dart new file mode 100644 index 0000000..588a59a --- /dev/null +++ b/client/lib/shared/models/diary_entry.dart @@ -0,0 +1,56 @@ +class DiaryEntry { + final String id; + final String date; + final String mealType; + final String name; + final double portions; + final double? calories; + final double? proteinG; + final double? fatG; + final double? carbsG; + final String source; + final String? recipeId; + + const DiaryEntry({ + required this.id, + required this.date, + required this.mealType, + required this.name, + required this.portions, + this.calories, + this.proteinG, + this.fatG, + this.carbsG, + required this.source, + this.recipeId, + }); + + factory DiaryEntry.fromJson(Map json) { + return DiaryEntry( + id: json['id'] as String? ?? '', + date: json['date'] as String? ?? '', + mealType: json['meal_type'] as String? ?? '', + name: json['name'] as String? ?? '', + portions: (json['portions'] as num?)?.toDouble() ?? 1, + 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(), + source: json['source'] as String? ?? 'manual', + recipeId: json['recipe_id'] as String?, + ); + } + + String get mealLabel { + switch (mealType) { + case 'breakfast': + return 'Завтрак'; + case 'lunch': + return 'Обед'; + case 'dinner': + return 'Ужин'; + default: + return mealType; + } + } +} diff --git a/client/lib/shared/models/menu.dart b/client/lib/shared/models/menu.dart new file mode 100644 index 0000000..b30ecda --- /dev/null +++ b/client/lib/shared/models/menu.dart @@ -0,0 +1,151 @@ +import 'recipe.dart'; + +class MenuPlan { + final String id; + final String weekStart; + final List days; + + const MenuPlan({ + required this.id, + required this.weekStart, + required this.days, + }); + + factory MenuPlan.fromJson(Map json) { + return MenuPlan( + id: json['id'] as String? ?? '', + weekStart: json['week_start'] as String? ?? '', + days: (json['days'] as List? ?? []) + .map((e) => MenuDay.fromJson(e as Map)) + .toList(), + ); + } +} + +class MenuDay { + final int day; + final String date; + final List meals; + final double totalCalories; + + const MenuDay({ + required this.day, + required this.date, + required this.meals, + required this.totalCalories, + }); + + factory MenuDay.fromJson(Map json) { + return MenuDay( + day: json['day'] as int? ?? 0, + date: json['date'] as String? ?? '', + meals: (json['meals'] as List? ?? []) + .map((e) => MealSlot.fromJson(e as Map)) + .toList(), + totalCalories: (json['total_calories'] as num?)?.toDouble() ?? 0, + ); + } + + // Localized day name. + String get dayName { + const names = [ + 'Понедельник', + 'Вторник', + 'Среда', + 'Четверг', + 'Пятница', + 'Суббота', + 'Воскресенье', + ]; + final i = day - 1; + return (i >= 0 && i < names.length) ? names[i] : 'День $day'; + } + + // Short date label like "16 фев". + String get shortDate { + try { + final dt = DateTime.parse(date); + const months = [ + 'янв', 'фев', 'мар', 'апр', 'май', 'июн', + 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', + ]; + return '${dt.day} ${months[dt.month - 1]}'; + } catch (_) { + return date; + } + } +} + +class MealSlot { + final String id; + final String mealType; + final MenuRecipe? recipe; + + const MealSlot({ + required this.id, + required this.mealType, + this.recipe, + }); + + factory MealSlot.fromJson(Map json) { + return MealSlot( + id: json['id'] as String? ?? '', + mealType: json['meal_type'] as String? ?? '', + recipe: json['recipe'] != null + ? MenuRecipe.fromJson(json['recipe'] as Map) + : null, + ); + } + + String get mealLabel { + switch (mealType) { + case 'breakfast': + return 'Завтрак'; + case 'lunch': + return 'Обед'; + case 'dinner': + return 'Ужин'; + default: + return mealType; + } + } + + String get mealEmoji { + switch (mealType) { + case 'breakfast': + return '🌅'; + case 'lunch': + return '☀️'; + case 'dinner': + return '🌙'; + default: + return '🍽️'; + } + } +} + +class MenuRecipe { + final String id; + final String title; + final String imageUrl; + final NutritionInfo? nutrition; + + const MenuRecipe({ + required this.id, + required this.title, + required this.imageUrl, + this.nutrition, + }); + + factory MenuRecipe.fromJson(Map json) { + return MenuRecipe( + id: json['id'] as String? ?? '', + title: json['title'] as String? ?? '', + imageUrl: json['image_url'] as String? ?? '', + nutrition: json['nutrition_per_serving'] != null + ? NutritionInfo.fromJson( + json['nutrition_per_serving'] as Map) + : null, + ); + } +} diff --git a/client/lib/shared/models/shopping_item.dart b/client/lib/shared/models/shopping_item.dart new file mode 100644 index 0000000..b083ee4 --- /dev/null +++ b/client/lib/shared/models/shopping_item.dart @@ -0,0 +1,46 @@ +class ShoppingItem { + final String name; + final String category; + final double amount; + final String unit; + final bool checked; + final double inStock; + + const ShoppingItem({ + required this.name, + required this.category, + required this.amount, + required this.unit, + required this.checked, + required this.inStock, + }); + + factory ShoppingItem.fromJson(Map json) { + return ShoppingItem( + name: json['name'] as String? ?? '', + category: json['category'] as String? ?? 'other', + amount: (json['amount'] as num?)?.toDouble() ?? 0, + unit: json['unit'] as String? ?? '', + checked: json['checked'] as bool? ?? false, + inStock: (json['in_stock'] as num?)?.toDouble() ?? 0, + ); + } + + Map toJson() => { + 'name': name, + 'category': category, + 'amount': amount, + 'unit': unit, + 'checked': checked, + 'in_stock': inStock, + }; + + ShoppingItem copyWith({bool? checked}) => ShoppingItem( + name: name, + category: category, + amount: amount, + unit: unit, + checked: checked ?? this.checked, + inStock: inStock, + ); +}