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:
dbastrikin
2026-02-21 23:22:30 +02:00
parent 0dbda0cd57
commit b9b9e9fe11
20 changed files with 1585 additions and 32 deletions

View File

@@ -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,
)

View File

@@ -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) {

View 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)
}

View File

@@ -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

View 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)
}

View 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"`
}

View 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
}

View File

@@ -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)

View File

@@ -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

View 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;

View 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;

View File

@@ -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: 'Профиль',
),
],
),
);

View 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;
}
}
}

View 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;
}
}
}

View 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();
}
}

View File

@@ -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('Повторить'),
),
],
),
);
}
}

View 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);
}

View 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,
};

View 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);
}

View 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,
};