feat: implement Iteration 2 — product management
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ type RecipeRequest struct {
|
||||
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) {
|
||||
|
||||
51
backend/internal/ingredient/handler.go
Normal file
51
backend/internal/ingredient/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
137
backend/internal/product/handler.go
Normal file
137
backend/internal/product/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
39
backend/internal/product/model.go
Normal file
39
backend/internal/product/model.go
Normal file
@@ -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"`
|
||||
}
|
||||
188
backend/internal/product/repository.go
Normal file
188
backend/internal/product/repository.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -34,14 +39,16 @@ type Handler struct {
|
||||
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,
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
12
backend/migrations/005_add_ingredient_search_indexes.sql
Normal file
12
backend/migrations/005_add_ingredient_search_indexes.sql
Normal file
@@ -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;
|
||||
20
backend/migrations/006_create_products.sql
Normal file
20
backend/migrations/006_create_products.sql
Normal file
@@ -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;
|
||||
@@ -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<GoRouter>((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: 'Профиль',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
264
client/lib/features/products/add_product_screen.dart
Normal file
264
client/lib/features/products/add_product_screen.dart
Normal file
@@ -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<AddProductScreen> createState() => _AddProductScreenState();
|
||||
}
|
||||
|
||||
class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
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<IngredientMapping> _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<void> _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<String>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
client/lib/features/products/product_provider.dart
Normal file
100
client/lib/features/products/product_provider.dart
Normal file
@@ -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<ProductService>((ref) {
|
||||
return ProductService(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
final productsProvider =
|
||||
StateNotifierProvider<ProductsNotifier, AsyncValue<List<Product>>>((ref) {
|
||||
final service = ref.read(productServiceProvider);
|
||||
return ProductsNotifier(service);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
|
||||
ProductsNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
_load();
|
||||
}
|
||||
|
||||
final ProductService _service;
|
||||
|
||||
Future<void> _load() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => _service.getProducts());
|
||||
}
|
||||
|
||||
Future<void> refresh() => _load();
|
||||
|
||||
/// Adds a new product and inserts it into the sorted list.
|
||||
Future<void> 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<void> 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<void> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
client/lib/features/products/product_service.dart
Normal file
65
client/lib/features/products/product_service.dart
Normal file
@@ -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<List<Product>> getProducts() async {
|
||||
final list = await _client.getList('/products');
|
||||
return list.map((e) => Product.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Product> 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<Product> 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<void> deleteProduct(String id) =>
|
||||
_client.deleteVoid('/products/$id');
|
||||
|
||||
Future<List<IngredientMapping>> 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<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -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<Product> 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<bool>(
|
||||
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<String>(
|
||||
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<void> _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('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
34
client/lib/shared/models/ingredient_mapping.dart
Normal file
34
client/lib/shared/models/ingredient_mapping.dart
Normal file
@@ -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<String, dynamic> json) =>
|
||||
_$IngredientMappingFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$IngredientMappingToJson(this);
|
||||
}
|
||||
27
client/lib/shared/models/ingredient_mapping.g.dart
Normal file
27
client/lib/shared/models/ingredient_mapping.g.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ingredient_mapping.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
IngredientMapping _$IngredientMappingFromJson(Map<String, dynamic> 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<String, dynamic> _$IngredientMappingToJson(IngredientMapping instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'canonical_name': instance.canonicalName,
|
||||
'canonical_name_ru': instance.canonicalNameRu,
|
||||
'category': instance.category,
|
||||
'default_unit': instance.defaultUnit,
|
||||
'storage_days': instance.storageDays,
|
||||
};
|
||||
46
client/lib/shared/models/product.dart
Normal file
46
client/lib/shared/models/product.dart
Normal file
@@ -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<String, dynamic> json) =>
|
||||
_$ProductFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ProductToJson(this);
|
||||
}
|
||||
37
client/lib/shared/models/product.g.dart
Normal file
37
client/lib/shared/models/product.g.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'product.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Product _$ProductFromJson(Map<String, dynamic> 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<String, dynamic> _$ProductToJson(Product instance) => <String, dynamic>{
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user