From b9b9e9fe11c4fbf895420a234ac12f05adf4767c Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sat, 21 Feb 2026 23:22:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Iteration=202=20=E2=80=94?= =?UTF-8?q?=20product=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - migrations/005: add pg_trgm extension + search indexes on ingredient_mappings - migrations/006: products table with computed expires_at column - ingredient: add Search method (aliases + ILIKE + trgm) + HTTP handler - product: full package — model, repository (CRUD + BatchCreate + ListForPrompt), handler - gemini: add AvailableProducts field to RecipeRequest, include in prompt - recommendation: add ProductLister interface, load user products for personalised prompts - server/main: wire ingredient and product handlers with new routes Flutter: - models: Product, IngredientMapping with json_serializable - ProductService: getProducts, createProduct, updateProduct, deleteProduct, searchIngredients - ProductsNotifier: create/update/delete with optimistic delete - ProductsScreen: expiring-soon section, normal section, swipe-to-delete, edit bottom sheet - AddProductScreen: name field with 300ms debounce autocomplete, qty/unit/days fields - app_router: /products/add route + Badge on Products nav tab showing expiring count - MainShell converted to ConsumerWidget for badge reactivity Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/main.go | 14 +- backend/internal/gemini/recipe.go | 22 +- backend/internal/ingredient/handler.go | 51 ++ backend/internal/ingredient/repository.go | 26 ++ backend/internal/product/handler.go | 137 ++++++ backend/internal/product/model.go | 39 ++ backend/internal/product/repository.go | 188 ++++++++ backend/internal/recommendation/handler.go | 28 +- backend/internal/server/server.go | 18 + .../005_add_ingredient_search_indexes.sql | 12 + backend/migrations/006_create_products.sql | 20 + client/lib/core/router/app_router.dart | 52 ++- .../features/products/add_product_screen.dart | 264 +++++++++++ .../features/products/product_provider.dart | 100 ++++ .../features/products/product_service.dart | 65 +++ .../features/products/products_screen.dart | 437 +++++++++++++++++- .../lib/shared/models/ingredient_mapping.dart | 34 ++ .../shared/models/ingredient_mapping.g.dart | 27 ++ client/lib/shared/models/product.dart | 46 ++ client/lib/shared/models/product.g.dart | 37 ++ 20 files changed, 1585 insertions(+), 32 deletions(-) create mode 100644 backend/internal/ingredient/handler.go create mode 100644 backend/internal/product/handler.go create mode 100644 backend/internal/product/model.go create mode 100644 backend/internal/product/repository.go create mode 100644 backend/migrations/005_add_ingredient_search_indexes.sql create mode 100644 backend/migrations/006_create_products.sql create mode 100644 client/lib/features/products/add_product_screen.dart create mode 100644 client/lib/features/products/product_provider.dart create mode 100644 client/lib/features/products/product_service.dart create mode 100644 client/lib/shared/models/ingredient_mapping.dart create mode 100644 client/lib/shared/models/ingredient_mapping.g.dart create mode 100644 client/lib/shared/models/product.dart create mode 100644 client/lib/shared/models/product.g.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0bb9146..4d31fd7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -14,8 +14,10 @@ import ( "github.com/food-ai/backend/internal/config" "github.com/food-ai/backend/internal/database" "github.com/food-ai/backend/internal/gemini" + "github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/pexels" + "github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/recommendation" "github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/server" @@ -91,8 +93,16 @@ func run() error { geminiClient := gemini.NewClient(cfg.GeminiAPIKey) pexelsClient := pexels.NewClient(cfg.PexelsAPIKey) + // Ingredient domain + ingredientRepo := ingredient.NewRepository(pool) + ingredientHandler := ingredient.NewHandler(ingredientRepo) + + // Product domain + productRepo := product.NewRepository(pool) + productHandler := product.NewHandler(productRepo) + // Recommendation domain - recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo) + recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo) // Saved recipes domain savedRecipeRepo := savedrecipe.NewRepository(pool) @@ -105,6 +115,8 @@ func run() error { userHandler, recommendationHandler, savedRecipeHandler, + ingredientHandler, + productHandler, authMW, cfg.AllowedOrigins, ) diff --git a/backend/internal/gemini/recipe.go b/backend/internal/gemini/recipe.go index 0aca865..ac642e8 100644 --- a/backend/internal/gemini/recipe.go +++ b/backend/internal/gemini/recipe.go @@ -14,11 +14,12 @@ type RecipeGenerator interface { // RecipeRequest contains parameters for recipe generation. type RecipeRequest struct { - UserGoal string // "weight_loss" | "maintain" | "gain" - DailyCalories int - Restrictions []string // e.g. ["gluten_free", "vegetarian"] - CuisinePrefs []string // e.g. ["russian", "asian"] - Count int + UserGoal string // "weight_loss" | "maintain" | "gain" + DailyCalories int + Restrictions []string // e.g. ["gluten_free", "vegetarian"] + CuisinePrefs []string // e.g. ["russian", "asian"] + Count int + AvailableProducts []string // human-readable list of products in user's pantry } // Recipe is a recipe returned by Gemini. @@ -134,6 +135,13 @@ func buildRecipePrompt(req RecipeRequest) string { perMealCalories = 600 } + productsSection := "" + if len(req.AvailableProducts) > 0 { + productsSection = "\nДоступные продукты (приоритет — скоро истекают ⚠):\n" + + strings.Join(req.AvailableProducts, "\n") + + "\nПредпочтительно использовать эти продукты в рецептах.\n" + } + return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке. Профиль пользователя: @@ -141,7 +149,7 @@ func buildRecipePrompt(req RecipeRequest) string { - Дневная норма калорий: %d ккал - Ограничения: %s - Предпочтения: %s - +%s Требования к каждому рецепту: - Калорийность на порцию: не более %d ккал - Время приготовления: до 60 минут @@ -165,7 +173,7 @@ func buildRecipePrompt(req RecipeRequest) string { "nutrition_per_serving": { "calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18 } -}]`, count, goal, req.DailyCalories, restrictions, cuisines, perMealCalories) +}]`, count, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories) } func parseRecipesJSON(text string) ([]Recipe, error) { diff --git a/backend/internal/ingredient/handler.go b/backend/internal/ingredient/handler.go new file mode 100644 index 0000000..8955ef3 --- /dev/null +++ b/backend/internal/ingredient/handler.go @@ -0,0 +1,51 @@ +package ingredient + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" +) + +// Handler handles ingredient HTTP requests. +type Handler struct { + repo *Repository +} + +// NewHandler creates a new Handler. +func NewHandler(repo *Repository) *Handler { + return &Handler{repo: repo} +} + +// Search handles GET /ingredients/search?q=&limit=10. +func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q == "" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("[]")) + return + } + + limit := 10 + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 50 { + limit = n + } + } + + mappings, err := h.repo.Search(r.Context(), q, limit) + if err != nil { + slog.Error("search ingredients", "q", q, "err", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"search failed"}`)) + return + } + + if mappings == nil { + mappings = []*IngredientMapping{} + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(mappings) +} diff --git a/backend/internal/ingredient/repository.go b/backend/internal/ingredient/repository.go index f125c04..617f34b 100644 --- a/backend/internal/ingredient/repository.go +++ b/backend/internal/ingredient/repository.go @@ -94,6 +94,32 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping return m, err } +// Search finds ingredient mappings matching the query string. +// Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity. +func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) { + if limit <= 0 { + limit = 10 + } + q := ` + SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, + category, default_unit, + calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, + storage_days, created_at, updated_at + FROM ingredient_mappings + WHERE aliases @> to_jsonb(lower($1)::text) + OR canonical_name_ru ILIKE '%' || $1 || '%' + OR similarity(canonical_name_ru, $1) > 0.3 + ORDER BY similarity(canonical_name_ru, $1) DESC + LIMIT $2` + + rows, err := r.pool.Query(ctx, q, query, limit) + if err != nil { + return nil, fmt.Errorf("search ingredient_mappings: %w", err) + } + defer rows.Close() + return collectMappings(rows) +} + // Count returns the total number of ingredient mappings. func (r *Repository) Count(ctx context.Context) (int, error) { var n int diff --git a/backend/internal/product/handler.go b/backend/internal/product/handler.go new file mode 100644 index 0000000..36b82fd --- /dev/null +++ b/backend/internal/product/handler.go @@ -0,0 +1,137 @@ +package product + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/food-ai/backend/internal/middleware" + "github.com/go-chi/chi/v5" +) + +// Handler handles /products HTTP requests. +type Handler struct { + repo *Repository +} + +// NewHandler creates a new Handler. +func NewHandler(repo *Repository) *Handler { + return &Handler{repo: repo} +} + +// List handles GET /products. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + products, err := h.repo.List(r.Context(), userID) + if err != nil { + slog.Error("list products", "user_id", userID, "err", err) + writeErrorJSON(w, http.StatusInternalServerError, "failed to list products") + return + } + if products == nil { + products = []*Product{} + } + writeJSON(w, http.StatusOK, products) +} + +// Create handles POST /products. +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + var req CreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeErrorJSON(w, http.StatusBadRequest, "name is required") + return + } + + p, err := h.repo.Create(r.Context(), userID, req) + if err != nil { + slog.Error("create product", "user_id", userID, "err", err) + writeErrorJSON(w, http.StatusInternalServerError, "failed to create product") + return + } + writeJSON(w, http.StatusCreated, p) +} + +// BatchCreate handles POST /products/batch. +func (h *Handler) BatchCreate(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + var items []CreateRequest + if err := json.NewDecoder(r.Body).Decode(&items); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid request body") + return + } + if len(items) == 0 { + writeJSON(w, http.StatusCreated, []*Product{}) + return + } + + products, err := h.repo.BatchCreate(r.Context(), userID, items) + if err != nil { + slog.Error("batch create products", "user_id", userID, "err", err) + writeErrorJSON(w, http.StatusInternalServerError, "failed to create products") + return + } + writeJSON(w, http.StatusCreated, products) +} + +// Update handles PUT /products/{id}. +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + id := chi.URLParam(r, "id") + + var req UpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid request body") + return + } + + p, err := h.repo.Update(r.Context(), id, userID, req) + if errors.Is(err, ErrNotFound) { + writeErrorJSON(w, http.StatusNotFound, "product not found") + return + } + if err != nil { + slog.Error("update product", "id", id, "err", err) + writeErrorJSON(w, http.StatusInternalServerError, "failed to update product") + return + } + writeJSON(w, http.StatusOK, p) +} + +// Delete handles DELETE /products/{id}. +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + id := chi.URLParam(r, "id") + + if err := h.repo.Delete(r.Context(), id, userID); err != nil { + if errors.Is(err, ErrNotFound) { + writeErrorJSON(w, http.StatusNotFound, "product not found") + return + } + slog.Error("delete product", "id", id, "err", err) + writeErrorJSON(w, http.StatusInternalServerError, "failed to delete product") + return + } + w.WriteHeader(http.StatusNoContent) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeErrorJSON(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/backend/internal/product/model.go b/backend/internal/product/model.go new file mode 100644 index 0000000..3d9ed11 --- /dev/null +++ b/backend/internal/product/model.go @@ -0,0 +1,39 @@ +package product + +import "time" + +// Product is a user's food item in their pantry. +type Product struct { + ID string `json:"id"` + UserID string `json:"user_id"` + MappingID *string `json:"mapping_id"` + Name string `json:"name"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + Category *string `json:"category"` + StorageDays int `json:"storage_days"` + AddedAt time.Time `json:"added_at"` + ExpiresAt time.Time `json:"expires_at"` + DaysLeft int `json:"days_left"` + ExpiringSoon bool `json:"expiring_soon"` +} + +// CreateRequest is the body for POST /products. +type CreateRequest struct { + MappingID *string `json:"mapping_id"` + Name string `json:"name"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + Category *string `json:"category"` + StorageDays int `json:"storage_days"` +} + +// UpdateRequest is the body for PUT /products/{id}. +// All fields are optional (nil = keep existing value). +type UpdateRequest struct { + Name *string `json:"name"` + Quantity *float64 `json:"quantity"` + Unit *string `json:"unit"` + Category *string `json:"category"` + StorageDays *int `json:"storage_days"` +} diff --git a/backend/internal/product/repository.go b/backend/internal/product/repository.go new file mode 100644 index 0000000..6d0ed99 --- /dev/null +++ b/backend/internal/product/repository.go @@ -0,0 +1,188 @@ +package product + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound is returned when a product is not found or does not belong to the user. +var ErrNotFound = errors.New("product not found") + +// Repository handles product persistence. +type Repository struct { + pool *pgxpool.Pool +} + +// NewRepository creates a new Repository. +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +const selectCols = `id, user_id, mapping_id, name, quantity, unit, category, storage_days, added_at, expires_at` + +// List returns all products for a user, sorted by expires_at ASC. +func (r *Repository) List(ctx context.Context, userID string) ([]*Product, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+selectCols+` + FROM products + WHERE user_id = $1 + ORDER BY expires_at ASC`, userID) + if err != nil { + return nil, fmt.Errorf("list products: %w", err) + } + defer rows.Close() + return collectProducts(rows) +} + +// Create inserts a new product and returns the created record. +func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Product, error) { + storageDays := req.StorageDays + if storageDays <= 0 { + storageDays = 7 + } + unit := req.Unit + if unit == "" { + unit = "pcs" + } + qty := req.Quantity + if qty <= 0 { + qty = 1 + } + + row := r.pool.QueryRow(ctx, ` + INSERT INTO products (user_id, mapping_id, name, quantity, unit, category, storage_days) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING `+selectCols, + userID, req.MappingID, req.Name, qty, unit, req.Category, storageDays, + ) + return scanProduct(row) +} + +// BatchCreate inserts multiple products sequentially and returns all created records. +func (r *Repository) BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*Product, error) { + var result []*Product + for _, req := range items { + p, err := r.Create(ctx, userID, req) + if err != nil { + return nil, fmt.Errorf("batch create product %q: %w", req.Name, err) + } + result = append(result, p) + } + return result, nil +} + +// Update modifies an existing product. Only non-nil fields are changed. +// Returns ErrNotFound if the product does not exist or belongs to a different user. +func (r *Repository) Update(ctx context.Context, id, userID string, req UpdateRequest) (*Product, error) { + row := r.pool.QueryRow(ctx, ` + UPDATE products SET + name = COALESCE($3, name), + quantity = COALESCE($4, quantity), + unit = COALESCE($5, unit), + category = COALESCE($6, category), + storage_days = COALESCE($7, storage_days) + WHERE id = $1 AND user_id = $2 + RETURNING `+selectCols, + id, userID, req.Name, req.Quantity, req.Unit, req.Category, req.StorageDays, + ) + p, err := scanProduct(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return p, err +} + +// Delete removes a product. Returns ErrNotFound if it does not exist or belongs to a different user. +func (r *Repository) Delete(ctx context.Context, id, userID string) error { + tag, err := r.pool.Exec(ctx, + `DELETE FROM products WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return fmt.Errorf("delete product: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ListForPrompt returns a human-readable list of user's products for the AI prompt. +// Expiring soon items are marked with ⚠. +func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string, error) { + rows, err := r.pool.Query(ctx, ` + SELECT name, quantity, unit, expires_at + FROM products + WHERE user_id = $1 + ORDER BY expires_at ASC`, userID) + if err != nil { + return nil, fmt.Errorf("list products for prompt: %w", err) + } + defer rows.Close() + + var lines []string + now := time.Now() + for rows.Next() { + var name, unit string + var qty float64 + var expiresAt time.Time + if err := rows.Scan(&name, &qty, &unit, &expiresAt); err != nil { + return nil, fmt.Errorf("scan product for prompt: %w", err) + } + daysLeft := int(expiresAt.Sub(now).Hours() / 24) + line := fmt.Sprintf("- %s %.0f %s", name, qty, unit) + switch { + case daysLeft <= 0: + line += " (истекает сегодня ⚠)" + case daysLeft == 1: + line += " (истекает завтра ⚠)" + case daysLeft <= 3: + line += fmt.Sprintf(" (истекает через %d дня ⚠)", daysLeft) + } + lines = append(lines, line) + } + return lines, rows.Err() +} + +// --- helpers --- + +func scanProduct(row pgx.Row) (*Product, error) { + var p Product + err := row.Scan( + &p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit, + &p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt, + ) + if err != nil { + return nil, err + } + computeDaysLeft(&p) + return &p, nil +} + +func collectProducts(rows pgx.Rows) ([]*Product, error) { + var result []*Product + for rows.Next() { + var p Product + if err := rows.Scan( + &p.ID, &p.UserID, &p.MappingID, &p.Name, &p.Quantity, &p.Unit, + &p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt, + ); err != nil { + return nil, fmt.Errorf("scan product: %w", err) + } + computeDaysLeft(&p) + result = append(result, &p) + } + return result, rows.Err() +} + +func computeDaysLeft(p *Product) { + d := int(time.Until(p.ExpiresAt).Hours() / 24) + if d < 0 { + d = 0 + } + p.DaysLeft = d + p.ExpiringSoon = d <= 3 +} diff --git a/backend/internal/recommendation/handler.go b/backend/internal/recommendation/handler.go index 8e453c2..1eb2cde 100644 --- a/backend/internal/recommendation/handler.go +++ b/backend/internal/recommendation/handler.go @@ -23,6 +23,11 @@ type UserLoader interface { GetByID(ctx context.Context, id string) (*user.User, error) } +// ProductLister returns a human-readable list of user's products for the AI prompt. +type ProductLister interface { + ListForPrompt(ctx context.Context, userID string) ([]string, error) +} + // userPreferences is the shape of user.Preferences JSONB. type userPreferences struct { Cuisines []string `json:"cuisines"` @@ -31,17 +36,19 @@ type userPreferences struct { // Handler handles GET /recommendations. type Handler struct { - gemini *gemini.Client - pexels PhotoSearcher - userLoader UserLoader + gemini *gemini.Client + pexels PhotoSearcher + userLoader UserLoader + productLister ProductLister } // NewHandler creates a new Handler. -func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader) *Handler { +func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler { return &Handler{ - gemini: geminiClient, - pexels: pexels, - userLoader: userLoader, + gemini: geminiClient, + pexels: pexels, + userLoader: userLoader, + productLister: productLister, } } @@ -69,6 +76,13 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { req := buildRecipeRequest(u, count) + // Attach available products to personalise the prompt. + if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { + req.AvailableProducts = products + } else { + slog.Warn("load products for recommendations", "user_id", userID, "err", err) + } + recipes, err := h.gemini.GenerateRecipes(r.Context(), req) if err != nil { slog.Error("generate recipes", "user_id", userID, "err", err) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 044a5a3..ea2840d 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -5,7 +5,9 @@ import ( "net/http" "github.com/food-ai/backend/internal/auth" + "github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/recommendation" "github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/user" @@ -19,6 +21,8 @@ func NewRouter( userHandler *user.Handler, recommendationHandler *recommendation.Handler, savedRecipeHandler *savedrecipe.Handler, + ingredientHandler *ingredient.Handler, + productHandler *product.Handler, authMiddleware func(http.Handler) http.Handler, allowedOrigins []string, ) *chi.Mux { @@ -38,6 +42,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) @@ -53,6 +63,14 @@ func NewRouter( r.Get("/{id}", savedRecipeHandler.GetByID) r.Delete("/{id}", savedRecipeHandler.Delete) }) + + r.Route("/products", func(r chi.Router) { + r.Get("/", productHandler.List) + r.Post("/", productHandler.Create) + r.Post("/batch", productHandler.BatchCreate) + r.Put("/{id}", productHandler.Update) + r.Delete("/{id}", productHandler.Delete) + }) }) return r diff --git a/backend/migrations/005_add_ingredient_search_indexes.sql b/backend/migrations/005_add_ingredient_search_indexes.sql new file mode 100644 index 0000000..06838ab --- /dev/null +++ b/backend/migrations/005_add_ingredient_search_indexes.sql @@ -0,0 +1,12 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_ingredient_mappings_canonical_ru_trgm + ON ingredient_mappings USING GIN (canonical_name_ru gin_trgm_ops); + +CREATE INDEX idx_ingredient_mappings_canonical_ru_fts + ON ingredient_mappings USING GIN (to_tsvector('russian', coalesce(canonical_name_ru, ''))); + +-- +goose Down +DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_trgm; +DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_fts; diff --git a/backend/migrations/006_create_products.sql b/backend/migrations/006_create_products.sql new file mode 100644 index 0000000..c22b5e7 --- /dev/null +++ b/backend/migrations/006_create_products.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mapping_id UUID REFERENCES ingredient_mappings(id), + name TEXT NOT NULL, + quantity DECIMAL(10, 2) NOT NULL DEFAULT 1, + unit TEXT NOT NULL DEFAULT 'pcs', + category TEXT, + storage_days INT NOT NULL DEFAULT 7, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ GENERATED ALWAYS AS + (added_at + (storage_days || ' days')::INTERVAL) STORED +); + +CREATE INDEX idx_products_user_id ON products(user_id); +CREATE INDEX idx_products_expires_at ON products(user_id, expires_at); + +-- +goose Down +DROP TABLE products; diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index be20870..d8d6fe1 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -7,10 +7,12 @@ import '../../features/auth/login_screen.dart'; import '../../features/auth/register_screen.dart'; import '../../features/home/home_screen.dart'; import '../../features/products/products_screen.dart'; +import '../../features/products/add_product_screen.dart'; import '../../features/menu/menu_screen.dart'; import '../../features/recipes/recipe_detail_screen.dart'; import '../../features/recipes/recipes_screen.dart'; import '../../features/profile/profile_screen.dart'; +import '../../features/products/product_provider.dart'; import '../../shared/models/recipe.dart'; import '../../shared/models/saved_recipe.dart'; @@ -48,10 +50,14 @@ final routerProvider = Provider((ref) { if (extra is SavedRecipe) { return RecipeDetailScreen(saved: extra); } - // Fallback: pop back if navigated without a valid extra. return const _InvalidRoute(); }, ), + // Add product — shown without the bottom navigation bar. + GoRoute( + path: '/products/add', + builder: (_, __) => const AddProductScreen(), + ), ShellRoute( builder: (context, state, child) => MainShell(child: child), routes: [ @@ -82,7 +88,7 @@ class _InvalidRoute extends StatelessWidget { } } -class MainShell extends StatelessWidget { +class MainShell extends ConsumerWidget { final Widget child; const MainShell({super.key, required this.child}); @@ -96,26 +102,46 @@ class MainShell extends StatelessWidget { ]; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final location = GoRouterState.of(context).matchedLocation; final currentIndex = _tabs.indexOf(location).clamp(0, _tabs.length - 1); + // Count products expiring soon for the badge. + final expiringCount = ref.watch(productsProvider).maybeWhen( + data: (products) => products.where((p) => p.expiringSoon).length, + orElse: () => 0, + ); + return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( currentIndex: currentIndex, onTap: (index) => context.go(_tabs[index]), - items: const [ + items: [ + const BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Главная', + ), BottomNavigationBarItem( - icon: Icon(Icons.home), label: 'Главная'), - BottomNavigationBarItem( - icon: Icon(Icons.kitchen), label: 'Продукты'), - BottomNavigationBarItem( - icon: Icon(Icons.calendar_month), label: 'Меню'), - BottomNavigationBarItem( - icon: Icon(Icons.menu_book), label: 'Рецепты'), - BottomNavigationBarItem( - icon: Icon(Icons.person), label: 'Профиль'), + icon: Badge( + isLabelVisible: expiringCount > 0, + label: Text('$expiringCount'), + child: const Icon(Icons.kitchen), + ), + label: 'Продукты', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.calendar_month), + label: 'Меню', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.menu_book), + label: 'Рецепты', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.person), + label: 'Профиль', + ), ], ), ); diff --git a/client/lib/features/products/add_product_screen.dart b/client/lib/features/products/add_product_screen.dart new file mode 100644 index 0000000..7f1b10a --- /dev/null +++ b/client/lib/features/products/add_product_screen.dart @@ -0,0 +1,264 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/models/ingredient_mapping.dart'; +import 'product_provider.dart'; + +class AddProductScreen extends ConsumerStatefulWidget { + const AddProductScreen({super.key}); + + @override + ConsumerState createState() => _AddProductScreenState(); +} + +class _AddProductScreenState extends ConsumerState { + final _nameController = TextEditingController(); + final _qtyController = TextEditingController(text: '1'); + final _daysController = TextEditingController(text: '7'); + + String _unit = 'шт'; + String? _category; + String? _mappingId; + bool _saving = false; + + // Autocomplete state + List _suggestions = []; + bool _searching = false; + Timer? _debounce; + + static const _units = ['г', 'кг', 'мл', 'л', 'шт']; + + @override + void dispose() { + _nameController.dispose(); + _qtyController.dispose(); + _daysController.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _onNameChanged(String value) { + _debounce?.cancel(); + // Reset mapping if user edits the name after selecting a suggestion + setState(() { + _mappingId = null; + }); + + if (value.trim().isEmpty) { + setState(() => _suggestions = []); + return; + } + + _debounce = Timer(const Duration(milliseconds: 300), () async { + setState(() => _searching = true); + try { + final service = ref.read(productServiceProvider); + final results = await service.searchIngredients(value.trim()); + if (mounted) setState(() => _suggestions = results); + } finally { + if (mounted) setState(() => _searching = false); + } + }); + } + + void _selectSuggestion(IngredientMapping mapping) { + setState(() { + _nameController.text = mapping.displayName; + _mappingId = mapping.id; + _category = mapping.category; + if (mapping.defaultUnit != null) { + // Map backend unit codes to display units + _unit = _mapUnit(mapping.defaultUnit!); + } + if (mapping.storageDays != null) { + _daysController.text = mapping.storageDays.toString(); + } + _suggestions = []; + }); + } + + String _mapUnit(String backendUnit) { + switch (backendUnit.toLowerCase()) { + case 'g': + return 'г'; + case 'kg': + return 'кг'; + case 'ml': + return 'мл'; + case 'l': + return 'л'; + default: + return 'шт'; + } + } + + Future _submit() async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Введите название продукта')), + ); + return; + } + + final qty = double.tryParse(_qtyController.text) ?? 1; + final days = int.tryParse(_daysController.text) ?? 7; + + setState(() => _saving = true); + try { + await ref.read(productsProvider.notifier).create( + name: name, + quantity: qty, + unit: _unit, + category: _category, + storageDays: days, + mappingId: _mappingId, + ); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось добавить продукт')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Добавить продукт')), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Name field with autocomplete dropdown + TextField( + controller: _nameController, + onChanged: _onNameChanged, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: 'Название', + border: const OutlineInputBorder(), + suffixIcon: _searching + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, + ), + ), + + // Autocomplete suggestions + if (_suggestions.isNotEmpty) + Card( + margin: const EdgeInsets.only(top: 4), + child: Column( + children: _suggestions + .map((m) => ListTile( + title: Text(m.displayName), + subtitle: m.category != null + ? Text(_categoryLabel(m.category!)) + : null, + trailing: m.defaultUnit != null + ? Text(m.defaultUnit!, + style: + Theme.of(context).textTheme.bodySmall) + : null, + onTap: () => _selectSuggestion(m), + )) + .toList(), + ), + ), + + const SizedBox(height: 16), + + // Quantity + unit row + Row( + children: [ + Expanded( + child: TextField( + controller: _qtyController, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Количество', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + DropdownButtonHideUnderline( + child: DropdownButton( + value: _units.contains(_unit) ? _unit : _units.last, + items: _units + .map((u) => + DropdownMenuItem(value: u, child: Text(u))) + .toList(), + onChanged: (v) => setState(() => _unit = v!), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Storage days + TextField( + controller: _daysController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Дней хранения', + helperText: 'Срок годности от сегодня', + border: OutlineInputBorder(), + ), + ), + + const SizedBox(height: 24), + + FilledButton( + onPressed: _saving ? null : _submit, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Добавить'), + ), + ], + ), + ), + ); + } + + 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 category; + } + } +} diff --git a/client/lib/features/products/product_provider.dart b/client/lib/features/products/product_provider.dart new file mode 100644 index 0000000..91cdfee --- /dev/null +++ b/client/lib/features/products/product_provider.dart @@ -0,0 +1,100 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; +import '../../shared/models/product.dart'; +import 'product_service.dart'; + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +final productServiceProvider = Provider((ref) { + return ProductService(ref.read(apiClientProvider)); +}); + +final productsProvider = + StateNotifierProvider>>((ref) { + final service = ref.read(productServiceProvider); + return ProductsNotifier(service); +}); + +// --------------------------------------------------------------------------- +// Notifier +// --------------------------------------------------------------------------- + +class ProductsNotifier extends StateNotifier>> { + ProductsNotifier(this._service) : super(const AsyncValue.loading()) { + _load(); + } + + final ProductService _service; + + Future _load() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _service.getProducts()); + } + + Future refresh() => _load(); + + /// Adds a new product and inserts it into the sorted list. + Future create({ + required String name, + required double quantity, + required String unit, + String? category, + int storageDays = 7, + String? mappingId, + }) async { + final p = await _service.createProduct( + name: name, + quantity: quantity, + unit: unit, + category: category, + storageDays: storageDays, + mappingId: mappingId, + ); + state.whenData((products) { + final updated = [...products, p] + ..sort((a, b) => a.expiresAt.compareTo(b.expiresAt)); + state = AsyncValue.data(updated); + }); + } + + /// Updates a product in-place, keeping list sort order. + Future update( + String id, { + String? name, + double? quantity, + String? unit, + String? category, + int? storageDays, + }) async { + final p = await _service.updateProduct( + id, + name: name, + quantity: quantity, + unit: unit, + category: category, + storageDays: storageDays, + ); + state.whenData((products) { + final updated = products.map((e) => e.id == id ? p : e).toList() + ..sort((a, b) => a.expiresAt.compareTo(b.expiresAt)); + state = AsyncValue.data(updated); + }); + } + + /// Optimistically removes the product, restores on error. + Future delete(String id) async { + final previous = state; + state.whenData((products) { + state = AsyncValue.data(products.where((p) => p.id != id).toList()); + }); + try { + await _service.deleteProduct(id); + } catch (_) { + state = previous; + rethrow; + } + } +} diff --git a/client/lib/features/products/product_service.dart b/client/lib/features/products/product_service.dart new file mode 100644 index 0000000..bf483a1 --- /dev/null +++ b/client/lib/features/products/product_service.dart @@ -0,0 +1,65 @@ +import '../../core/api/api_client.dart'; +import '../../shared/models/ingredient_mapping.dart'; +import '../../shared/models/product.dart'; + +class ProductService { + const ProductService(this._client); + + final ApiClient _client; + + Future> getProducts() async { + final list = await _client.getList('/products'); + return list.map((e) => Product.fromJson(e as Map)).toList(); + } + + Future createProduct({ + required String name, + required double quantity, + required String unit, + String? category, + int storageDays = 7, + String? mappingId, + }) async { + final data = await _client.post('/products', data: { + 'name': name, + 'quantity': quantity, + 'unit': unit, + if (category != null) 'category': category, + 'storage_days': storageDays, + if (mappingId != null) 'mapping_id': mappingId, + }); + return Product.fromJson(data); + } + + Future updateProduct( + String id, { + String? name, + double? quantity, + String? unit, + String? category, + int? storageDays, + }) async { + final data = await _client.put('/products/$id', data: { + if (name != null) 'name': name, + if (quantity != null) 'quantity': quantity, + if (unit != null) 'unit': unit, + if (category != null) 'category': category, + if (storageDays != null) 'storage_days': storageDays, + }); + return Product.fromJson(data); + } + + Future deleteProduct(String id) => + _client.deleteVoid('/products/$id'); + + Future> searchIngredients(String query) async { + if (query.isEmpty) return []; + final list = await _client.getList( + '/ingredients/search', + params: {'q': query, 'limit': '10'}, + ); + return list + .map((e) => IngredientMapping.fromJson(e as Map)) + .toList(); + } +} diff --git a/client/lib/features/products/products_screen.dart b/client/lib/features/products/products_screen.dart index 8a70d89..429465e 100644 --- a/client/lib/features/products/products_screen.dart +++ b/client/lib/features/products/products_screen.dart @@ -1,13 +1,442 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; -class ProductsScreen extends StatelessWidget { +import '../../shared/models/product.dart'; +import 'product_provider.dart'; + +class ProductsScreen extends ConsumerWidget { const ProductsScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(productsProvider); + 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(productsProvider.notifier).refresh(), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.push('/products/add'), + icon: const Icon(Icons.add), + label: const Text('Добавить'), + ), + body: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => _ErrorView( + onRetry: () => ref.read(productsProvider.notifier).refresh(), + ), + data: (products) => products.isEmpty + ? _EmptyState( + onAdd: () => context.push('/products/add'), + ) + : _ProductList(products: products), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Product list split into expiring / normal sections +// --------------------------------------------------------------------------- + +class _ProductList extends ConsumerWidget { + const _ProductList({required this.products}); + + final List products; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final expiring = products.where((p) => p.expiringSoon).toList(); + final rest = products.where((p) => !p.expiringSoon).toList(); + + return RefreshIndicator( + onRefresh: () => ref.read(productsProvider.notifier).refresh(), + child: ListView( + padding: const EdgeInsets.only(bottom: 80), + children: [ + if (expiring.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.warning_amber_rounded, + iconColor: Colors.orange, + label: 'Истекает скоро', + ), + ...expiring.map((p) => _ProductTile(product: p)), + ], + if (rest.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.kitchen, + label: 'Все продукты', + ), + ...rest.map((p) => _ProductTile(product: p)), + ], + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.label, + this.icon, + this.iconColor, + }); + + final String label; + final IconData? icon; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: iconColor ?? theme.colorScheme.primary), + const SizedBox(width: 6), + ], + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Single product tile with swipe-to-delete and tap-to-edit +// --------------------------------------------------------------------------- + +class _ProductTile extends ConsumerWidget { + const _ProductTile({required this.product}); + + final Product product; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final daysColor = product.daysLeft <= 1 + ? Colors.red + : product.daysLeft <= 3 + ? Colors.orange + : theme.colorScheme.onSurfaceVariant; + + return Dismissible( + key: ValueKey(product.id), + direction: DismissDirection.endToStart, + background: Container( + color: theme.colorScheme.error, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Icon(Icons.delete_outline, color: theme.colorScheme.onError), + ), + confirmDismiss: (_) async { + return await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Удалить продукт?'), + content: Text('«${product.name}» будет удалён из списка.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Удалить'), + ), + ], + ), + ); + }, + onDismissed: (_) { + ref.read(productsProvider.notifier).delete(product.id); + }, + child: ListTile( + leading: CircleAvatar( + backgroundColor: _categoryColor(product.category, theme).withValues(alpha: 0.15), + child: Text( + _categoryEmoji(product.category), + style: const TextStyle(fontSize: 20), + ), + ), + title: Text(product.name), + subtitle: Text( + '${_formatQty(product.quantity)} ${product.unit}', + style: theme.textTheme.bodySmall, + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _daysLabel(product.daysLeft), + style: theme.textTheme.labelSmall?.copyWith(color: daysColor), + ), + ], + ), + onTap: () => _showEditSheet(context, ref, product), + ), + ); + } + + String _formatQty(double qty) { + if (qty == qty.roundToDouble()) return qty.toInt().toString(); + return qty.toStringAsFixed(1); + } + + String _daysLabel(int days) { + if (days == 0) return 'Истекает сегодня'; + if (days == 1) return 'Остался 1 день'; + if (days <= 4) return 'Осталось $days дня'; + return 'Осталось $days дней'; + } + + Color _categoryColor(String? category, ThemeData theme) { + switch (category) { + case 'meat': + return Colors.red; + case 'dairy': + return Colors.blue; + case 'vegetable': + return Colors.green; + case 'fruit': + return Colors.orange; + case 'grain': + return Colors.amber; + default: + return theme.colorScheme.primary; + } + } + + String _categoryEmoji(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 '🛒'; + } + } + + void _showEditSheet(BuildContext context, WidgetRef ref, Product product) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _EditProductSheet(product: product), + ); + } +} + +// --------------------------------------------------------------------------- +// Edit product bottom sheet +// --------------------------------------------------------------------------- + +class _EditProductSheet extends ConsumerStatefulWidget { + const _EditProductSheet({required this.product}); + + final Product product; + + @override + ConsumerState<_EditProductSheet> createState() => _EditProductSheetState(); +} + +class _EditProductSheetState extends ConsumerState<_EditProductSheet> { + late final _qtyController = + TextEditingController(text: widget.product.quantity.toString()); + late String _unit = widget.product.unit; + late final _daysController = + TextEditingController(text: widget.product.storageDays.toString()); + bool _saving = false; + + static const _units = ['г', 'кг', 'мл', 'л', 'шт']; + + @override + void dispose() { + _qtyController.dispose(); + _daysController.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( + widget.product.name, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _qtyController, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Количество', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + DropdownButton( + value: _units.contains(_unit) ? _unit : _units.first, + items: _units + .map((u) => DropdownMenuItem(value: u, child: Text(u))) + .toList(), + onChanged: (v) => setState(() => _unit = v!), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _daysController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Дней хранения', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Сохранить'), + ), + ], + ), + ); + } + + Future _save() async { + setState(() => _saving = true); + try { + final qty = double.tryParse(_qtyController.text); + final days = int.tryParse(_daysController.text); + await ref.read(productsProvider.notifier).update( + widget.product.id, + quantity: qty, + unit: _unit, + storageDays: days, + ); + if (mounted) Navigator.pop(context); + } finally { + if (mounted) setState(() => _saving = false); + } + } +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.onAdd}); + + final VoidCallback onAdd; + + @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.kitchen_outlined, + size: 72, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Холодильник пуст', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Добавьте продукты вручную — или в Итерации 3 сфотографируйте чек', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add), + label: const Text('Добавить продукт'), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Error view +// --------------------------------------------------------------------------- + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 12), + const Text('Не удалось загрузить продукты'), + const SizedBox(height: 12), + FilledButton( + onPressed: onRetry, + child: const Text('Повторить'), + ), + ], + ), ); } } diff --git a/client/lib/shared/models/ingredient_mapping.dart b/client/lib/shared/models/ingredient_mapping.dart new file mode 100644 index 0000000..4ad41e7 --- /dev/null +++ b/client/lib/shared/models/ingredient_mapping.dart @@ -0,0 +1,34 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'ingredient_mapping.g.dart'; + +@JsonSerializable() +class IngredientMapping { + final String id; + @JsonKey(name: 'canonical_name') + final String canonicalName; + @JsonKey(name: 'canonical_name_ru') + final String? canonicalNameRu; + final String? category; + @JsonKey(name: 'default_unit') + final String? defaultUnit; + @JsonKey(name: 'storage_days') + final int? storageDays; + + const IngredientMapping({ + required this.id, + required this.canonicalName, + this.canonicalNameRu, + this.category, + this.defaultUnit, + this.storageDays, + }); + + /// Display name prefers Russian, falls back to canonical English name. + String get displayName => canonicalNameRu ?? canonicalName; + + factory IngredientMapping.fromJson(Map json) => + _$IngredientMappingFromJson(json); + + Map toJson() => _$IngredientMappingToJson(this); +} diff --git a/client/lib/shared/models/ingredient_mapping.g.dart b/client/lib/shared/models/ingredient_mapping.g.dart new file mode 100644 index 0000000..bfe61e2 --- /dev/null +++ b/client/lib/shared/models/ingredient_mapping.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ingredient_mapping.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +IngredientMapping _$IngredientMappingFromJson(Map json) => + IngredientMapping( + id: json['id'] as String, + canonicalName: json['canonical_name'] as String, + canonicalNameRu: json['canonical_name_ru'] as String?, + category: json['category'] as String?, + defaultUnit: json['default_unit'] as String?, + storageDays: (json['storage_days'] as num?)?.toInt(), + ); + +Map _$IngredientMappingToJson(IngredientMapping instance) => + { + 'id': instance.id, + 'canonical_name': instance.canonicalName, + 'canonical_name_ru': instance.canonicalNameRu, + 'category': instance.category, + 'default_unit': instance.defaultUnit, + 'storage_days': instance.storageDays, + }; diff --git a/client/lib/shared/models/product.dart b/client/lib/shared/models/product.dart new file mode 100644 index 0000000..d4a1a5d --- /dev/null +++ b/client/lib/shared/models/product.dart @@ -0,0 +1,46 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'product.g.dart'; + +@JsonSerializable() +class Product { + final String id; + @JsonKey(name: 'user_id') + final String userId; + @JsonKey(name: 'mapping_id') + final String? mappingId; + final String name; + final double quantity; + final String unit; + final String? category; + @JsonKey(name: 'storage_days') + final int storageDays; + @JsonKey(name: 'added_at') + final DateTime addedAt; + @JsonKey(name: 'expires_at') + final DateTime expiresAt; + @JsonKey(name: 'days_left') + final int daysLeft; + @JsonKey(name: 'expiring_soon') + final bool expiringSoon; + + const Product({ + required this.id, + required this.userId, + this.mappingId, + required this.name, + required this.quantity, + required this.unit, + this.category, + required this.storageDays, + required this.addedAt, + required this.expiresAt, + required this.daysLeft, + required this.expiringSoon, + }); + + factory Product.fromJson(Map json) => + _$ProductFromJson(json); + + Map toJson() => _$ProductToJson(this); +} diff --git a/client/lib/shared/models/product.g.dart b/client/lib/shared/models/product.g.dart new file mode 100644 index 0000000..0b5ebeb --- /dev/null +++ b/client/lib/shared/models/product.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'product.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Product _$ProductFromJson(Map json) => Product( + id: json['id'] as String, + userId: json['user_id'] as String, + mappingId: json['mapping_id'] as String?, + name: json['name'] as String, + quantity: (json['quantity'] as num).toDouble(), + unit: json['unit'] as String, + category: json['category'] as String?, + storageDays: (json['storage_days'] as num).toInt(), + addedAt: DateTime.parse(json['added_at'] as String), + expiresAt: DateTime.parse(json['expires_at'] as String), + daysLeft: (json['days_left'] as num).toInt(), + expiringSoon: json['expiring_soon'] as bool, +); + +Map _$ProductToJson(Product instance) => { + 'id': instance.id, + 'user_id': instance.userId, + 'mapping_id': instance.mappingId, + 'name': instance.name, + 'quantity': instance.quantity, + 'unit': instance.unit, + 'category': instance.category, + 'storage_days': instance.storageDays, + 'added_at': instance.addedAt.toIso8601String(), + 'expires_at': instance.expiresAt.toIso8601String(), + 'days_left': instance.daysLeft, + 'expiring_soon': instance.expiringSoon, +};