feat: implement Iteration 4 — menu planning, shopping list, diary
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
110
backend/internal/diary/handler.go
Normal file
110
backend/internal/diary/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
33
backend/internal/diary/model.go
Normal file
33
backend/internal/diary/model.go
Normal file
@@ -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"`
|
||||
}
|
||||
104
backend/internal/diary/repository.go
Normal file
104
backend/internal/diary/repository.go
Normal file
@@ -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
|
||||
}
|
||||
168
backend/internal/gemini/menu.go
Normal file
168
backend/internal/gemini/menu.go
Normal file
@@ -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
|
||||
}
|
||||
545
backend/internal/menu/handler.go
Normal file
545
backend/internal/menu/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
56
backend/internal/menu/model.go
Normal file
56
backend/internal/menu/model.go
Normal file
@@ -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"`
|
||||
}
|
||||
306
backend/internal/menu/repository.go
Normal file
306
backend/internal/menu/repository.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
35
backend/migrations/007_create_menu_plans.sql
Normal file
35
backend/migrations/007_create_menu_plans.sql
Normal file
@@ -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;
|
||||
22
backend/migrations/008_create_meal_diary.sql
Normal file
22
backend/migrations/008_create_meal_diary.sql
Normal file
@@ -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;
|
||||
@@ -38,6 +38,17 @@ class ApiClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Future<void> patch(String path,
|
||||
{dynamic data, Map<String, dynamic>? params}) async {
|
||||
await _dio.patch(path, data: data, queryParameters: params);
|
||||
}
|
||||
|
||||
/// Posts data and expects a JSON array response.
|
||||
Future<List<dynamic>> postList(String path, {dynamic data}) async {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as List<dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> delete(String path) async {
|
||||
final response = await _dio.delete(path);
|
||||
return response.data;
|
||||
|
||||
@@ -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<GoRouter>((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',
|
||||
|
||||
289
client/lib/features/menu/diary_screen.dart
Normal file
289
client/lib/features/menu/diary_screen.dart
Normal file
@@ -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<DiaryEntry> 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 = <String, List<DiaryEntry>>{};
|
||||
for (final e in entries) {
|
||||
grouped.putIfAbsent(e.mealType, () => []).add(e);
|
||||
}
|
||||
|
||||
// Total calories for the day.
|
||||
final totalCal = entries.fold<double>(
|
||||
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<String>(
|
||||
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<void> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
147
client/lib/features/menu/menu_provider.dart
Normal file
147
client/lib/features/menu/menu_provider.dart
Normal file
@@ -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<MenuService>((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<String>((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<AsyncValue<MenuPlan?>> {
|
||||
final MenuService _service;
|
||||
final String _week;
|
||||
|
||||
MenuNotifier(this._service, this._week) : super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _service.getMenu(week: _week));
|
||||
}
|
||||
|
||||
Future<void> generate() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _service.generateMenu(week: _week));
|
||||
}
|
||||
|
||||
Future<void> updateItem(String itemId, String recipeId) async {
|
||||
await _service.updateMenuItem(itemId, recipeId);
|
||||
await load();
|
||||
}
|
||||
|
||||
Future<void> deleteItem(String itemId) async {
|
||||
await _service.deleteMenuItem(itemId);
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
final menuProvider =
|
||||
StateNotifierProvider.family<MenuNotifier, AsyncValue<MenuPlan?>, String>(
|
||||
(ref, week) => MenuNotifier(ref.read(menuServiceProvider), week),
|
||||
);
|
||||
|
||||
// ── Shopping list notifier ────────────────────────────────────
|
||||
|
||||
class ShoppingListNotifier extends StateNotifier<AsyncValue<List<ShoppingItem>>> {
|
||||
final MenuService _service;
|
||||
final String _week;
|
||||
|
||||
ShoppingListNotifier(this._service, this._week)
|
||||
: super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _service.getShoppingList(week: _week));
|
||||
}
|
||||
|
||||
Future<void> regenerate() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => _service.generateShoppingList(week: _week));
|
||||
}
|
||||
|
||||
Future<void> toggle(int index, bool checked) async {
|
||||
// Optimistic update.
|
||||
state = state.whenData((items) {
|
||||
final list = List<ShoppingItem>.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<List<ShoppingItem>>, String>(
|
||||
(ref, week) => ShoppingListNotifier(ref.read(menuServiceProvider), week),
|
||||
);
|
||||
|
||||
// ── Diary notifier ────────────────────────────────────────────
|
||||
|
||||
class DiaryNotifier extends StateNotifier<AsyncValue<List<DiaryEntry>>> {
|
||||
final MenuService _service;
|
||||
final String _date;
|
||||
|
||||
DiaryNotifier(this._service, this._date) : super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _service.getDiary(_date));
|
||||
}
|
||||
|
||||
Future<void> add(Map<String, dynamic> body) async {
|
||||
await _service.createDiaryEntry(body);
|
||||
await load();
|
||||
}
|
||||
|
||||
Future<void> 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<DiaryNotifier, AsyncValue<List<DiaryEntry>>, String>(
|
||||
(ref, date) => DiaryNotifier(ref.read(menuServiceProvider), date),
|
||||
);
|
||||
@@ -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('Повторить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
85
client/lib/features/menu/menu_service.dart
Normal file
85
client/lib/features/menu/menu_service.dart
Normal file
@@ -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<MenuPlan?> getMenu({String? week}) async {
|
||||
final params = <String, dynamic>{};
|
||||
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<MenuPlan> generateMenu({String? week}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (week != null) body['week'] = week;
|
||||
final data = await _client.post('/ai/generate-menu', data: body);
|
||||
return MenuPlan.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> updateMenuItem(String itemId, String recipeId) async {
|
||||
await _client.put('/menu/items/$itemId', data: {'recipe_id': recipeId});
|
||||
}
|
||||
|
||||
Future<void> deleteMenuItem(String itemId) async {
|
||||
await _client.deleteVoid('/menu/items/$itemId');
|
||||
}
|
||||
|
||||
// ── Shopping list ─────────────────────────────────────────
|
||||
|
||||
Future<List<ShoppingItem>> getShoppingList({String? week}) async {
|
||||
final params = <String, dynamic>{};
|
||||
if (week != null) params['week'] = week;
|
||||
final data = await _client.getList('/shopping-list', params: params);
|
||||
return data
|
||||
.map((e) => ShoppingItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<ShoppingItem>> generateShoppingList({String? week}) async {
|
||||
final body = <String, dynamic>{};
|
||||
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<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> toggleShoppingItem(int index, bool checked,
|
||||
{String? week}) async {
|
||||
final params = <String, dynamic>{};
|
||||
if (week != null) params['week'] = week;
|
||||
await _client.patch(
|
||||
'/shopping-list/items/$index/check',
|
||||
data: {'checked': checked},
|
||||
params: params,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Diary ─────────────────────────────────────────────────
|
||||
|
||||
Future<List<DiaryEntry>> getDiary(String date) async {
|
||||
final data = await _client.getList('/diary', params: {'date': date});
|
||||
return data
|
||||
.map((e) => DiaryEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<DiaryEntry> createDiaryEntry(Map<String, dynamic> body) async {
|
||||
final data = await _client.post('/diary', data: body);
|
||||
return DiaryEntry.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> deleteDiaryEntry(String id) async {
|
||||
await _client.deleteVoid('/diary/$id');
|
||||
}
|
||||
}
|
||||
247
client/lib/features/menu/shopping_list_screen.dart
Normal file
247
client/lib/features/menu/shopping_list_screen.dart
Normal file
@@ -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<ShoppingItem> 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<String, List<(int, ShoppingItem)>> 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('Сформировать список'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
56
client/lib/shared/models/diary_entry.dart
Normal file
56
client/lib/shared/models/diary_entry.dart
Normal file
@@ -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<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
client/lib/shared/models/menu.dart
Normal file
151
client/lib/shared/models/menu.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'recipe.dart';
|
||||
|
||||
class MenuPlan {
|
||||
final String id;
|
||||
final String weekStart;
|
||||
final List<MenuDay> days;
|
||||
|
||||
const MenuPlan({
|
||||
required this.id,
|
||||
required this.weekStart,
|
||||
required this.days,
|
||||
});
|
||||
|
||||
factory MenuPlan.fromJson(Map<String, dynamic> json) {
|
||||
return MenuPlan(
|
||||
id: json['id'] as String? ?? '',
|
||||
weekStart: json['week_start'] as String? ?? '',
|
||||
days: (json['days'] as List<dynamic>? ?? [])
|
||||
.map((e) => MenuDay.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MenuDay {
|
||||
final int day;
|
||||
final String date;
|
||||
final List<MealSlot> meals;
|
||||
final double totalCalories;
|
||||
|
||||
const MenuDay({
|
||||
required this.day,
|
||||
required this.date,
|
||||
required this.meals,
|
||||
required this.totalCalories,
|
||||
});
|
||||
|
||||
factory MenuDay.fromJson(Map<String, dynamic> json) {
|
||||
return MenuDay(
|
||||
day: json['day'] as int? ?? 0,
|
||||
date: json['date'] as String? ?? '',
|
||||
meals: (json['meals'] as List<dynamic>? ?? [])
|
||||
.map((e) => MealSlot.fromJson(e as Map<String, dynamic>))
|
||||
.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<String, dynamic> json) {
|
||||
return MealSlot(
|
||||
id: json['id'] as String? ?? '',
|
||||
mealType: json['meal_type'] as String? ?? '',
|
||||
recipe: json['recipe'] != null
|
||||
? MenuRecipe.fromJson(json['recipe'] as Map<String, dynamic>)
|
||||
: 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<String, dynamic> 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<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
46
client/lib/shared/models/shopping_item.dart
Normal file
46
client/lib/shared/models/shopping_item.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user