feat: implement Iteration 3 — product/receipt/dish recognition
Backend: - gemini/client.go: refactor to shared callGroq transport; add generateVisionContent using llama-3.2-11b-vision-preview model - gemini/recognition.go: RecognizeReceipt, RecognizeProducts, RecognizeDish (vision), ClassifyIngredient (text); shared parseJSON helper - ingredient/repository.go: add FuzzyMatch (wraps Search, returns best hit) - recognition/handler.go: POST /ai/recognize-receipt, /ai/recognize-products, /ai/recognize-dish; enrichItems with fuzzy match + AI classify fallback; parallel multi-image processing with deduplication - server.go + main.go: wire recognition handler under /ai routes Flutter: - pubspec.yaml: add image_picker ^1.1.0 - AndroidManifest.xml: add CAMERA and READ_EXTERNAL_STORAGE permissions - Info.plist: add NSCameraUsageDescription and NSPhotoLibraryUsageDescription - recognition_service.dart: RecognitionService wrapping /ai/* endpoints; RecognizedItem, ReceiptResult, DishResult models - scan_screen.dart: mode selector (receipt / products / dish / manual); image source picker; loading overlay; navigates to confirm or dish screen - recognition_confirm_screen.dart: editable list of recognized items; inline qty/unit editing; swipe-to-delete; batch-add to pantry - dish_result_screen.dart: dish name, KBZHU breakdown, similar dishes chips - app_router.dart: /scan, /scan/confirm, /scan/dish routes (no bottom nav) - products_screen.dart: FAB now shows bottom sheet with Manual / Scan options Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/middleware"
|
"github.com/food-ai/backend/internal/middleware"
|
||||||
"github.com/food-ai/backend/internal/pexels"
|
"github.com/food-ai/backend/internal/pexels"
|
||||||
"github.com/food-ai/backend/internal/product"
|
"github.com/food-ai/backend/internal/product"
|
||||||
|
"github.com/food-ai/backend/internal/recognition"
|
||||||
"github.com/food-ai/backend/internal/recommendation"
|
"github.com/food-ai/backend/internal/recommendation"
|
||||||
"github.com/food-ai/backend/internal/savedrecipe"
|
"github.com/food-ai/backend/internal/savedrecipe"
|
||||||
"github.com/food-ai/backend/internal/server"
|
"github.com/food-ai/backend/internal/server"
|
||||||
@@ -101,6 +102,9 @@ func run() error {
|
|||||||
productRepo := product.NewRepository(pool)
|
productRepo := product.NewRepository(pool)
|
||||||
productHandler := product.NewHandler(productRepo)
|
productHandler := product.NewHandler(productRepo)
|
||||||
|
|
||||||
|
// Recognition domain
|
||||||
|
recognitionHandler := recognition.NewHandler(geminiClient, ingredientRepo)
|
||||||
|
|
||||||
// Recommendation domain
|
// Recommendation domain
|
||||||
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
|
recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo)
|
||||||
|
|
||||||
@@ -117,6 +121,7 @@ func run() error {
|
|||||||
savedRecipeHandler,
|
savedRecipeHandler,
|
||||||
ingredientHandler,
|
ingredientHandler,
|
||||||
productHandler,
|
productHandler,
|
||||||
|
recognitionHandler,
|
||||||
authMW,
|
authMW,
|
||||||
cfg.AllowedOrigins,
|
cfg.AllowedOrigins,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Groq — OpenAI-compatible API, free tier, no billing required.
|
// groqAPIURL is the Groq OpenAI-compatible endpoint (free tier, no billing required).
|
||||||
groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
|
groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
|
||||||
|
|
||||||
|
// groqModel is the default text generation model.
|
||||||
groqModel = "llama-3.3-70b-versatile"
|
groqModel = "llama-3.3-70b-versatile"
|
||||||
|
|
||||||
|
// groqVisionModel supports image inputs in OpenAI vision format.
|
||||||
|
groqVisionModel = "llama-3.2-11b-vision-preview"
|
||||||
|
|
||||||
maxRetries = 3
|
maxRetries = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,16 +34,49 @@ func NewClient(apiKey string) *Client {
|
|||||||
return &Client{
|
return &Client{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 90 * time.Second,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateContent sends a user prompt to Groq and returns the assistant text.
|
// generateContent sends text messages to the text-only model.
|
||||||
func (c *Client) generateContent(ctx context.Context, messages []map[string]string) (string, error) {
|
func (c *Client) generateContent(ctx context.Context, messages []map[string]string) (string, error) {
|
||||||
|
return c.callGroq(ctx, groqModel, 0.7, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateVisionContent sends an image + text prompt to the vision model.
|
||||||
|
// imageBase64 must be the raw base64-encoded image data (no data URI prefix).
|
||||||
|
// mimeType defaults to "image/jpeg" if empty.
|
||||||
|
func (c *Client) generateVisionContent(ctx context.Context, prompt, imageBase64, mimeType string) (string, error) {
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, imageBase64)
|
||||||
|
|
||||||
|
messages := []any{
|
||||||
|
map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": map[string]string{"url": dataURL},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": prompt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return c.callGroq(ctx, groqVisionModel, 0.1, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callGroq is the shared HTTP transport for all Groq requests.
|
||||||
|
// messages can be []map[string]string (text) or []any (vision with image content).
|
||||||
|
func (c *Client) callGroq(ctx context.Context, model string, temperature float64, messages any) (string, error) {
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"model": groqModel,
|
"model": model,
|
||||||
"temperature": 0.7,
|
"temperature": temperature,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
221
backend/internal/gemini/recognition.go
Normal file
221
backend/internal/gemini/recognition.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecognizedItem is a food item identified in an image.
|
||||||
|
type RecognizedItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnrecognizedItem is text from a receipt that could not be identified as food.
|
||||||
|
type UnrecognizedItem struct {
|
||||||
|
RawText string `json:"raw_text"`
|
||||||
|
Price float64 `json:"price,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiptResult is the full result of receipt OCR.
|
||||||
|
type ReceiptResult struct {
|
||||||
|
Items []RecognizedItem `json:"items"`
|
||||||
|
Unrecognized []UnrecognizedItem `json:"unrecognized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DishResult is the result of dish recognition.
|
||||||
|
type DishResult struct {
|
||||||
|
DishName string `json:"dish_name"`
|
||||||
|
WeightGrams int `json:"weight_grams"`
|
||||||
|
Calories float64 `json:"calories"`
|
||||||
|
ProteinG float64 `json:"protein_g"`
|
||||||
|
FatG float64 `json:"fat_g"`
|
||||||
|
CarbsG float64 `json:"carbs_g"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
SimilarDishes []string `json:"similar_dishes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngredientClassification is the AI-produced classification of an unknown food item.
|
||||||
|
type IngredientClassification struct {
|
||||||
|
CanonicalName string `json:"canonical_name"`
|
||||||
|
CanonicalNameRu string `json:"canonical_name_ru"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
DefaultUnit string `json:"default_unit"`
|
||||||
|
CaloriesPer100g *float64 `json:"calories_per_100g"`
|
||||||
|
ProteinPer100g *float64 `json:"protein_per_100g"`
|
||||||
|
FatPer100g *float64 `json:"fat_per_100g"`
|
||||||
|
CarbsPer100g *float64 `json:"carbs_per_100g"`
|
||||||
|
StorageDays int `json:"storage_days"`
|
||||||
|
Aliases []string `json:"aliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecognizeReceipt uses the vision model to extract food items from a receipt photo.
|
||||||
|
func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ReceiptResult, error) {
|
||||||
|
prompt := `Ты — OCR-система для чеков из продуктовых магазинов.
|
||||||
|
|
||||||
|
Проанализируй фото чека и извлеки список продуктов питания.
|
||||||
|
Для каждого продукта определи:
|
||||||
|
- name: название на русском языке (убери артикулы, коды, лишние символы)
|
||||||
|
- quantity: количество (число)
|
||||||
|
- unit: единица (г, кг, мл, л, шт, уп)
|
||||||
|
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||||||
|
- confidence: 0.0–1.0
|
||||||
|
|
||||||
|
Позиции, которые не являются едой (бытовая химия, табак, алкоголь) — пропусти.
|
||||||
|
Позиции с нечитаемым текстом — добавь в unrecognized.
|
||||||
|
|
||||||
|
Верни ТОЛЬКО валидный JSON без markdown:
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"name": "Молоко 2.5%", "quantity": 1, "unit": "л", "category": "dairy", "confidence": 0.95}
|
||||||
|
],
|
||||||
|
"unrecognized": [
|
||||||
|
{"raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recognize receipt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ReceiptResult
|
||||||
|
if err := parseJSON(text, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse receipt result: %w", err)
|
||||||
|
}
|
||||||
|
if result.Items == nil {
|
||||||
|
result.Items = []RecognizedItem{}
|
||||||
|
}
|
||||||
|
if result.Unrecognized == nil {
|
||||||
|
result.Unrecognized = []UnrecognizedItem{}
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecognizeProducts uses the vision model to identify food items in a photo (fridge, shelf, etc.).
|
||||||
|
func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]RecognizedItem, error) {
|
||||||
|
prompt := `Ты — система распознавания продуктов питания.
|
||||||
|
|
||||||
|
Посмотри на фото и определи все видимые продукты питания.
|
||||||
|
Для каждого продукта оцени:
|
||||||
|
- name: название на русском языке
|
||||||
|
- quantity: приблизительное количество (число)
|
||||||
|
- unit: единица (г, кг, мл, л, шт)
|
||||||
|
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||||||
|
- confidence: 0.0–1.0
|
||||||
|
|
||||||
|
Только продукты питания. Пустые упаковки и несъедобные предметы — пропусти.
|
||||||
|
|
||||||
|
Верни ТОЛЬКО валидный JSON без markdown:
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"name": "Яйца", "quantity": 10, "unit": "шт", "category": "dairy", "confidence": 0.9}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recognize products: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Items []RecognizedItem `json:"items"`
|
||||||
|
}
|
||||||
|
if err := parseJSON(text, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse products result: %w", err)
|
||||||
|
}
|
||||||
|
if result.Items == nil {
|
||||||
|
return []RecognizedItem{}, nil
|
||||||
|
}
|
||||||
|
return result.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecognizeDish uses the vision model to identify a dish and estimate its nutritional content.
|
||||||
|
func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*DishResult, error) {
|
||||||
|
prompt := `Ты — диетолог и кулинарный эксперт.
|
||||||
|
|
||||||
|
Посмотри на фото блюда и определи:
|
||||||
|
- dish_name: название блюда на русском языке
|
||||||
|
- weight_grams: приблизительный вес порции в граммах
|
||||||
|
- calories: калорийность порции (приблизительно)
|
||||||
|
- protein_g, fat_g, carbs_g: БЖУ на порцию
|
||||||
|
- confidence: 0.0–1.0
|
||||||
|
- similar_dishes: до 3 похожих блюд (для поиска рецептов)
|
||||||
|
|
||||||
|
Верни ТОЛЬКО валидный JSON без markdown:
|
||||||
|
{
|
||||||
|
"dish_name": "Паста Карбонара",
|
||||||
|
"weight_grams": 350,
|
||||||
|
"calories": 520,
|
||||||
|
"protein_g": 22,
|
||||||
|
"fat_g": 26,
|
||||||
|
"carbs_g": 48,
|
||||||
|
"confidence": 0.85,
|
||||||
|
"similar_dishes": ["Паста с беконом", "Спагетти"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recognize dish: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result DishResult
|
||||||
|
if err := parseJSON(text, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse dish result: %w", err)
|
||||||
|
}
|
||||||
|
if result.SimilarDishes == nil {
|
||||||
|
result.SimilarDishes = []string{}
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyIngredient uses the text model to classify an unknown food item
|
||||||
|
// and build an ingredient_mappings record for it.
|
||||||
|
func (c *Client) ClassifyIngredient(ctx context.Context, name string) (*IngredientClassification, error) {
|
||||||
|
prompt := fmt.Sprintf(`Классифицируй продукт питания: "%s".
|
||||||
|
|
||||||
|
Ответь ТОЛЬКО валидным JSON без markdown:
|
||||||
|
{
|
||||||
|
"canonical_name": "turkey_breast",
|
||||||
|
"canonical_name_ru": "грудка индейки",
|
||||||
|
"category": "meat",
|
||||||
|
"default_unit": "g",
|
||||||
|
"calories_per_100g": 135,
|
||||||
|
"protein_per_100g": 29,
|
||||||
|
"fat_per_100g": 1,
|
||||||
|
"carbs_per_100g": 0,
|
||||||
|
"storage_days": 3,
|
||||||
|
"aliases": ["грудка индейки", "филе индейки", "turkey breast"]
|
||||||
|
}`, name)
|
||||||
|
|
||||||
|
messages := []map[string]string{
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
}
|
||||||
|
text, err := c.generateContent(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("classify ingredient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result IngredientClassification
|
||||||
|
if err := parseJSON(text, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse classification: %w", err)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJSON strips optional markdown fences and unmarshals JSON.
|
||||||
|
func parseJSON(text string, dst any) error {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if strings.HasPrefix(text, "```") {
|
||||||
|
text = strings.TrimPrefix(text, "```json")
|
||||||
|
text = strings.TrimPrefix(text, "```")
|
||||||
|
text = strings.TrimSuffix(text, "```")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
return json.Unmarshal([]byte(text), dst)
|
||||||
|
}
|
||||||
@@ -94,6 +94,19 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
|
|||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FuzzyMatch finds the single best matching ingredient mapping for a given name.
|
||||||
|
// Returns nil, nil when no match is found.
|
||||||
|
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
||||||
|
results, err := r.Search(ctx, name, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
// Search finds ingredient mappings matching the query string.
|
// Search finds ingredient mappings matching the query string.
|
||||||
// Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity.
|
// 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) {
|
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
|
||||||
|
|||||||
303
backend/internal/recognition/handler.go
Normal file
303
backend/internal/recognition/handler.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package recognition
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/gemini"
|
||||||
|
"github.com/food-ai/backend/internal/ingredient"
|
||||||
|
"github.com/food-ai/backend/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ingredientRepo is the subset of ingredient.Repository used by this handler.
|
||||||
|
type ingredientRepo interface {
|
||||||
|
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
||||||
|
Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler handles POST /ai/* recognition endpoints.
|
||||||
|
type Handler struct {
|
||||||
|
gemini *gemini.Client
|
||||||
|
ingredientRepo ingredientRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new Handler.
|
||||||
|
func NewHandler(geminiClient *gemini.Client, repo ingredientRepo) *Handler {
|
||||||
|
return &Handler{gemini: geminiClient, ingredientRepo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request / Response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// imageRequest is the common request body containing a single base64-encoded image.
|
||||||
|
type imageRequest struct {
|
||||||
|
ImageBase64 string `json:"image_base64"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagesRequest is the request body for multi-image endpoints.
|
||||||
|
type imagesRequest struct {
|
||||||
|
Images []imageRequest `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichedItem is a recognized food item enriched with ingredient_mappings data.
|
||||||
|
type EnrichedItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
MappingID *string `json:"mapping_id"`
|
||||||
|
StorageDays int `json:"storage_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiptResponse is the response for POST /ai/recognize-receipt.
|
||||||
|
type ReceiptResponse struct {
|
||||||
|
Items []EnrichedItem `json:"items"`
|
||||||
|
Unrecognized []gemini.UnrecognizedItem `json:"unrecognized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DishResponse is the response for POST /ai/recognize-dish.
|
||||||
|
type DishResponse = gemini.DishResult
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RecognizeReceipt handles POST /ai/recognize-receipt.
|
||||||
|
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
|
||||||
|
func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
|
_ = userID // logged for tracing
|
||||||
|
|
||||||
|
var req imageRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.gemini.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("recognize receipt", "err", err)
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched := h.enrichItems(r.Context(), result.Items)
|
||||||
|
writeJSON(w, http.StatusOK, ReceiptResponse{
|
||||||
|
Items: enriched,
|
||||||
|
Unrecognized: result.Unrecognized,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecognizeProducts handles POST /ai/recognize-products.
|
||||||
|
// Body: {"images": [{"image_base64": "...", "mime_type": "image/jpeg"}, ...]}
|
||||||
|
func (h *Handler) RecognizeProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req imagesRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Images) == 0 {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "at least one image is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Images) > 3 {
|
||||||
|
req.Images = req.Images[:3] // cap at 3 photos as per spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each image in parallel.
|
||||||
|
allItems := make([][]gemini.RecognizedItem, len(req.Images))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, img := range req.Images {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, img imageRequest) {
|
||||||
|
defer wg.Done()
|
||||||
|
items, err := h.gemini.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("recognize products from image", "index", i, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allItems[i] = items
|
||||||
|
}(i, img)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
merged := mergeAndDeduplicate(allItems)
|
||||||
|
enriched := h.enrichItems(r.Context(), merged)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": enriched})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecognizeDish handles POST /ai/recognize-dish.
|
||||||
|
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
|
||||||
|
func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req imageRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.gemini.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("recognize dish", "err", err)
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// enrichItems matches each recognized item against ingredient_mappings.
|
||||||
|
// Items without a match trigger a Gemini classification call and upsert into the DB.
|
||||||
|
func (h *Handler) enrichItems(ctx context.Context, items []gemini.RecognizedItem) []EnrichedItem {
|
||||||
|
result := make([]EnrichedItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
enriched := EnrichedItem{
|
||||||
|
Name: item.Name,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Unit: item.Unit,
|
||||||
|
Category: item.Category,
|
||||||
|
Confidence: item.Confidence,
|
||||||
|
StorageDays: 7, // sensible default
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping, err := h.ingredientRepo.FuzzyMatch(ctx, item.Name)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("fuzzy match ingredient", "name", item.Name, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mapping != nil {
|
||||||
|
// Found existing mapping — use its canonical data.
|
||||||
|
id := mapping.ID
|
||||||
|
enriched.MappingID = &id
|
||||||
|
if mapping.DefaultUnit != nil {
|
||||||
|
enriched.Unit = *mapping.DefaultUnit
|
||||||
|
}
|
||||||
|
if mapping.StorageDays != nil {
|
||||||
|
enriched.StorageDays = *mapping.StorageDays
|
||||||
|
}
|
||||||
|
if mapping.Category != nil {
|
||||||
|
enriched.Category = *mapping.Category
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No mapping — ask AI to classify and save for future reuse.
|
||||||
|
classification, err := h.gemini.ClassifyIngredient(ctx, item.Name)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("classify unknown ingredient", "name", item.Name, "err", err)
|
||||||
|
} else {
|
||||||
|
saved := h.saveClassification(ctx, classification)
|
||||||
|
if saved != nil {
|
||||||
|
id := saved.ID
|
||||||
|
enriched.MappingID = &id
|
||||||
|
}
|
||||||
|
enriched.Category = classification.Category
|
||||||
|
enriched.Unit = classification.DefaultUnit
|
||||||
|
enriched.StorageDays = classification.StorageDays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, enriched)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveClassification upserts an AI-produced ingredient classification into the DB.
|
||||||
|
func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientClassification) *ingredient.IngredientMapping {
|
||||||
|
if c == nil || c.CanonicalName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasesJSON, err := json.Marshal(c.Aliases)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &ingredient.IngredientMapping{
|
||||||
|
CanonicalName: c.CanonicalName,
|
||||||
|
CanonicalNameRu: &c.CanonicalNameRu,
|
||||||
|
Category: strPtr(c.Category),
|
||||||
|
DefaultUnit: strPtr(c.DefaultUnit),
|
||||||
|
CaloriesPer100g: c.CaloriesPer100g,
|
||||||
|
ProteinPer100g: c.ProteinPer100g,
|
||||||
|
FatPer100g: c.FatPer100g,
|
||||||
|
CarbsPer100g: c.CarbsPer100g,
|
||||||
|
StorageDays: intPtr(c.StorageDays),
|
||||||
|
Aliases: aliasesJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := h.ingredientRepo.Upsert(ctx, m)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAndDeduplicate combines results from multiple images.
|
||||||
|
// Items sharing the same name (case-insensitive) have their quantities summed.
|
||||||
|
func mergeAndDeduplicate(batches [][]gemini.RecognizedItem) []gemini.RecognizedItem {
|
||||||
|
seen := make(map[string]*gemini.RecognizedItem)
|
||||||
|
var order []string
|
||||||
|
|
||||||
|
for _, batch := range batches {
|
||||||
|
for i := range batch {
|
||||||
|
item := &batch[i]
|
||||||
|
key := normalizeName(item.Name)
|
||||||
|
if existing, ok := seen[key]; ok {
|
||||||
|
existing.Quantity += item.Quantity
|
||||||
|
// Keep the higher confidence estimate.
|
||||||
|
if item.Confidence > existing.Confidence {
|
||||||
|
existing.Confidence = item.Confidence
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seen[key] = item
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]gemini.RecognizedItem, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
result = append(result, *seen[key])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeName(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(n int) *int {
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/ingredient"
|
"github.com/food-ai/backend/internal/ingredient"
|
||||||
"github.com/food-ai/backend/internal/middleware"
|
"github.com/food-ai/backend/internal/middleware"
|
||||||
"github.com/food-ai/backend/internal/product"
|
"github.com/food-ai/backend/internal/product"
|
||||||
|
"github.com/food-ai/backend/internal/recognition"
|
||||||
"github.com/food-ai/backend/internal/recommendation"
|
"github.com/food-ai/backend/internal/recommendation"
|
||||||
"github.com/food-ai/backend/internal/savedrecipe"
|
"github.com/food-ai/backend/internal/savedrecipe"
|
||||||
"github.com/food-ai/backend/internal/user"
|
"github.com/food-ai/backend/internal/user"
|
||||||
@@ -23,6 +24,7 @@ func NewRouter(
|
|||||||
savedRecipeHandler *savedrecipe.Handler,
|
savedRecipeHandler *savedrecipe.Handler,
|
||||||
ingredientHandler *ingredient.Handler,
|
ingredientHandler *ingredient.Handler,
|
||||||
productHandler *product.Handler,
|
productHandler *product.Handler,
|
||||||
|
recognitionHandler *recognition.Handler,
|
||||||
authMiddleware func(http.Handler) http.Handler,
|
authMiddleware func(http.Handler) http.Handler,
|
||||||
allowedOrigins []string,
|
allowedOrigins []string,
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
@@ -71,6 +73,12 @@ func NewRouter(
|
|||||||
r.Put("/{id}", productHandler.Update)
|
r.Put("/{id}", productHandler.Update)
|
||||||
r.Delete("/{id}", productHandler.Delete)
|
r.Delete("/{id}", productHandler.Delete)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/ai", func(r chi.Router) {
|
||||||
|
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
|
||||||
|
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
|
||||||
|
r.Post("/recognize-dish", recognitionHandler.RecognizeDish)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Camera and photo library access required for product/receipt scanning -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<!-- Required on Android < 13 to read photos from gallery -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<application
|
<application
|
||||||
android:label="food_ai"
|
android:label="food_ai"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- Required for image_picker: camera and photo library access -->
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Для сканирования продуктов и чеков</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Для выбора фото продуктов и чеков из галереи</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import '../../features/auth/register_screen.dart';
|
|||||||
import '../../features/home/home_screen.dart';
|
import '../../features/home/home_screen.dart';
|
||||||
import '../../features/products/products_screen.dart';
|
import '../../features/products/products_screen.dart';
|
||||||
import '../../features/products/add_product_screen.dart';
|
import '../../features/products/add_product_screen.dart';
|
||||||
|
import '../../features/scan/scan_screen.dart';
|
||||||
|
import '../../features/scan/recognition_confirm_screen.dart';
|
||||||
|
import '../../features/scan/dish_result_screen.dart';
|
||||||
|
import '../../features/scan/recognition_service.dart';
|
||||||
import '../../features/menu/menu_screen.dart';
|
import '../../features/menu/menu_screen.dart';
|
||||||
import '../../features/recipes/recipe_detail_screen.dart';
|
import '../../features/recipes/recipe_detail_screen.dart';
|
||||||
import '../../features/recipes/recipes_screen.dart';
|
import '../../features/recipes/recipes_screen.dart';
|
||||||
@@ -58,6 +62,26 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/products/add',
|
path: '/products/add',
|
||||||
builder: (_, __) => const AddProductScreen(),
|
builder: (_, __) => const AddProductScreen(),
|
||||||
),
|
),
|
||||||
|
// Scan / recognition flow — all without bottom nav.
|
||||||
|
GoRoute(
|
||||||
|
path: '/scan',
|
||||||
|
builder: (_, __) => const ScanScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/scan/confirm',
|
||||||
|
builder: (context, state) {
|
||||||
|
final items = state.extra as List<RecognizedItem>? ?? [];
|
||||||
|
return RecognitionConfirmScreen(items: items);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/scan/dish',
|
||||||
|
builder: (context, state) {
|
||||||
|
final dish = state.extra as DishResult?;
|
||||||
|
if (dish == null) return const _InvalidRoute();
|
||||||
|
return DishResultScreen(dish: dish);
|
||||||
|
},
|
||||||
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) => MainShell(child: child),
|
builder: (context, state, child) => MainShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
|
|||||||
@@ -5,6 +5,35 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../shared/models/product.dart';
|
import '../../shared/models/product.dart';
|
||||||
import 'product_provider.dart';
|
import 'product_provider.dart';
|
||||||
|
|
||||||
|
void _showAddMenu(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit_outlined),
|
||||||
|
title: const Text('Добавить вручную'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/products/add');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.document_scanner_outlined),
|
||||||
|
title: const Text('Сканировать чек или фото'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/scan');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class ProductsScreen extends ConsumerWidget {
|
class ProductsScreen extends ConsumerWidget {
|
||||||
const ProductsScreen({super.key});
|
const ProductsScreen({super.key});
|
||||||
|
|
||||||
@@ -23,7 +52,7 @@ class ProductsScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => context.push('/products/add'),
|
onPressed: () => _showAddMenu(context),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Добавить'),
|
label: const Text('Добавить'),
|
||||||
),
|
),
|
||||||
@@ -34,7 +63,7 @@ class ProductsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
data: (products) => products.isEmpty
|
data: (products) => products.isEmpty
|
||||||
? _EmptyState(
|
? _EmptyState(
|
||||||
onAdd: () => context.push('/products/add'),
|
onAdd: () => _showAddMenu(context),
|
||||||
)
|
)
|
||||||
: _ProductList(products: products),
|
: _ProductList(products: products),
|
||||||
),
|
),
|
||||||
|
|||||||
167
client/lib/features/scan/dish_result_screen.dart
Normal file
167
client/lib/features/scan/dish_result_screen.dart
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'recognition_service.dart';
|
||||||
|
|
||||||
|
/// Shows the nutritional breakdown of a recognized dish.
|
||||||
|
class DishResultScreen extends StatelessWidget {
|
||||||
|
const DishResultScreen({super.key, required this.dish});
|
||||||
|
|
||||||
|
final DishResult dish;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final confPct = (dish.confidence * 100).toInt();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Распознано блюдо')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
// Dish name + confidence
|
||||||
|
Text(
|
||||||
|
dish.dishName,
|
||||||
|
style: theme.textTheme.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Уверенность: $confPct%',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Nutrition card
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'≈ ${dish.calories.toInt()} ккал',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Tooltip(
|
||||||
|
message: 'Приблизительные значения на основе фото',
|
||||||
|
child: Text(
|
||||||
|
'≈',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_MacroChip(
|
||||||
|
label: 'Белки',
|
||||||
|
value: '${dish.proteinG.toStringAsFixed(1)} г',
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
_MacroChip(
|
||||||
|
label: 'Жиры',
|
||||||
|
value: '${dish.fatG.toStringAsFixed(1)} г',
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
_MacroChip(
|
||||||
|
label: 'Углеводы',
|
||||||
|
value: '${dish.carbsG.toStringAsFixed(1)} г',
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Вес порции: ~${dish.weightGrams} г',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Similar dishes
|
||||||
|
if (dish.similarDishes.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text('Похожие блюда', style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: dish.similarDishes
|
||||||
|
.map((name) => Chip(label: Text(name)))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'КБЖУ приблизительные — определены по фото.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MacroChip extends StatelessWidget {
|
||||||
|
const _MacroChip({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
321
client/lib/features/scan/recognition_confirm_screen.dart
Normal file
321
client/lib/features/scan/recognition_confirm_screen.dart
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../products/product_provider.dart';
|
||||||
|
import 'recognition_service.dart';
|
||||||
|
|
||||||
|
/// Editable confirmation screen shown after receipt/products recognition.
|
||||||
|
/// The user can adjust quantities, units, remove items, then batch-add to pantry.
|
||||||
|
class RecognitionConfirmScreen extends ConsumerStatefulWidget {
|
||||||
|
const RecognitionConfirmScreen({super.key, required this.items});
|
||||||
|
|
||||||
|
final List<RecognizedItem> items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RecognitionConfirmScreen> createState() =>
|
||||||
|
_RecognitionConfirmScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecognitionConfirmScreenState
|
||||||
|
extends ConsumerState<RecognitionConfirmScreen> {
|
||||||
|
late final List<_EditableItem> _items;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
static const _units = ['г', 'кг', 'мл', 'л', 'шт', 'уп'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_items = widget.items
|
||||||
|
.map((item) => _EditableItem(
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: _mapUnit(item.unit),
|
||||||
|
category: item.category,
|
||||||
|
mappingId: item.mappingId,
|
||||||
|
storageDays: item.storageDays,
|
||||||
|
confidence: item.confidence,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mapUnit(String unit) {
|
||||||
|
// Backend may return 'pcs', 'g', 'kg', etc. — normalise to display units.
|
||||||
|
switch (unit.toLowerCase()) {
|
||||||
|
case 'g':
|
||||||
|
return 'г';
|
||||||
|
case 'kg':
|
||||||
|
return 'кг';
|
||||||
|
case 'ml':
|
||||||
|
return 'мл';
|
||||||
|
case 'l':
|
||||||
|
return 'л';
|
||||||
|
case 'pcs':
|
||||||
|
case 'шт':
|
||||||
|
return 'шт';
|
||||||
|
case 'уп':
|
||||||
|
return 'уп';
|
||||||
|
default:
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Найдено ${_items.length} продуктов'),
|
||||||
|
actions: [
|
||||||
|
if (_items.isNotEmpty)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving ? null : _addAll,
|
||||||
|
child: const Text('Добавить всё'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _items.isEmpty
|
||||||
|
? _EmptyState(onBack: () => Navigator.pop(context))
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 80),
|
||||||
|
itemCount: _items.length,
|
||||||
|
itemBuilder: (_, i) => _ItemTile(
|
||||||
|
item: _items[i],
|
||||||
|
units: _units,
|
||||||
|
onDelete: () => setState(() => _items.removeAt(i)),
|
||||||
|
onChanged: () => setState(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: _items.isEmpty
|
||||||
|
? null
|
||||||
|
: FloatingActionButton.extended(
|
||||||
|
onPressed: _saving ? null : _addAll,
|
||||||
|
icon: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.add_shopping_cart),
|
||||||
|
label: const Text('В запасы'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addAll() async {
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
for (final item in _items) {
|
||||||
|
await ref.read(productsProvider.notifier).create(
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
category: item.category,
|
||||||
|
storageDays: item.storageDays,
|
||||||
|
mappingId: item.mappingId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Добавлено ${_items.length} продуктов'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Pop back to products screen.
|
||||||
|
int count = 0;
|
||||||
|
Navigator.popUntil(context, (_) => count++ >= 2);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Не удалось добавить продукты')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Editable item model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _EditableItem {
|
||||||
|
String name;
|
||||||
|
double quantity;
|
||||||
|
String unit;
|
||||||
|
final String category;
|
||||||
|
final String? mappingId;
|
||||||
|
final int storageDays;
|
||||||
|
final double confidence;
|
||||||
|
|
||||||
|
_EditableItem({
|
||||||
|
required this.name,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unit,
|
||||||
|
required this.category,
|
||||||
|
this.mappingId,
|
||||||
|
required this.storageDays,
|
||||||
|
required this.confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Item tile with inline editing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _ItemTile extends StatefulWidget {
|
||||||
|
const _ItemTile({
|
||||||
|
required this.item,
|
||||||
|
required this.units,
|
||||||
|
required this.onDelete,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _EditableItem item;
|
||||||
|
final List<String> units;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
final VoidCallback onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ItemTile> createState() => _ItemTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ItemTileState extends State<_ItemTile> {
|
||||||
|
late final _qtyController =
|
||||||
|
TextEditingController(text: _formatQty(widget.item.quantity));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_qtyController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatQty(double v) =>
|
||||||
|
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final conf = widget.item.confidence;
|
||||||
|
final confColor = conf >= 0.8
|
||||||
|
? Colors.green
|
||||||
|
: conf >= 0.5
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.red;
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(widget.item.name),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
onDismissed: (_) => widget.onDelete(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.item.name,
|
||||||
|
style: theme.textTheme.bodyLarge),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: confColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${(conf * 100).toInt()}% уверенность',
|
||||||
|
style: theme.textTheme.labelSmall
|
||||||
|
?.copyWith(color: confColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: TextField(
|
||||||
|
controller: _qtyController,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (v) {
|
||||||
|
final parsed = double.tryParse(v);
|
||||||
|
if (parsed != null) {
|
||||||
|
widget.item.quantity = parsed;
|
||||||
|
widget.onChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: widget.units.contains(widget.item.unit)
|
||||||
|
? widget.item.unit
|
||||||
|
: widget.units.last,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: widget.units
|
||||||
|
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) {
|
||||||
|
setState(() => widget.item.unit = v);
|
||||||
|
widget.onChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: widget.onDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState({required this.onBack});
|
||||||
|
|
||||||
|
final VoidCallback onBack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.search_off, size: 64),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('Продукты не найдены'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: onBack, child: const Text('Назад')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
client/lib/features/scan/recognition_service.dart
Normal file
150
client/lib/features/scan/recognition_service.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../../core/api/api_client.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Models
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RecognizedItem {
|
||||||
|
final String name;
|
||||||
|
double quantity;
|
||||||
|
String unit;
|
||||||
|
final String category;
|
||||||
|
final double confidence;
|
||||||
|
final String? mappingId;
|
||||||
|
final int storageDays;
|
||||||
|
|
||||||
|
RecognizedItem({
|
||||||
|
required this.name,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unit,
|
||||||
|
required this.category,
|
||||||
|
required this.confidence,
|
||||||
|
this.mappingId,
|
||||||
|
required this.storageDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RecognizedItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RecognizedItem(
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
unit: json['unit'] as String? ?? 'шт',
|
||||||
|
category: json['category'] as String? ?? 'other',
|
||||||
|
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
mappingId: json['mapping_id'] as String?,
|
||||||
|
storageDays: json['storage_days'] as int? ?? 7,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnrecognizedItem {
|
||||||
|
final String rawText;
|
||||||
|
final double? price;
|
||||||
|
|
||||||
|
const UnrecognizedItem({required this.rawText, this.price});
|
||||||
|
|
||||||
|
factory UnrecognizedItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UnrecognizedItem(
|
||||||
|
rawText: json['raw_text'] as String? ?? '',
|
||||||
|
price: (json['price'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReceiptResult {
|
||||||
|
final List<RecognizedItem> items;
|
||||||
|
final List<UnrecognizedItem> unrecognized;
|
||||||
|
|
||||||
|
const ReceiptResult({required this.items, required this.unrecognized});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DishResult {
|
||||||
|
final String dishName;
|
||||||
|
final int weightGrams;
|
||||||
|
final double calories;
|
||||||
|
final double proteinG;
|
||||||
|
final double fatG;
|
||||||
|
final double carbsG;
|
||||||
|
final double confidence;
|
||||||
|
final List<String> similarDishes;
|
||||||
|
|
||||||
|
const DishResult({
|
||||||
|
required this.dishName,
|
||||||
|
required this.weightGrams,
|
||||||
|
required this.calories,
|
||||||
|
required this.proteinG,
|
||||||
|
required this.fatG,
|
||||||
|
required this.carbsG,
|
||||||
|
required this.confidence,
|
||||||
|
required this.similarDishes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DishResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DishResult(
|
||||||
|
dishName: json['dish_name'] as String? ?? '',
|
||||||
|
weightGrams: json['weight_grams'] as int? ?? 0,
|
||||||
|
calories: (json['calories'] as num?)?.toDouble() ?? 0,
|
||||||
|
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,
|
||||||
|
fatG: (json['fat_g'] as num?)?.toDouble() ?? 0,
|
||||||
|
carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0,
|
||||||
|
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
|
||||||
|
similarDishes: (json['similar_dishes'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RecognitionService {
|
||||||
|
const RecognitionService(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
/// Recognizes food items from a receipt photo.
|
||||||
|
Future<ReceiptResult> recognizeReceipt(File image) async {
|
||||||
|
final payload = await _buildImagePayload(image);
|
||||||
|
final data = await _client.post('/ai/recognize-receipt', data: payload);
|
||||||
|
return ReceiptResult(
|
||||||
|
items: (data['items'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
unrecognized: (data['unrecognized'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => UnrecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes food items from 1–3 product photos.
|
||||||
|
Future<List<RecognizedItem>> recognizeProducts(List<File> images) async {
|
||||||
|
final imageList = await Future.wait(images.map(_buildImagePayload));
|
||||||
|
final data = await _client.post(
|
||||||
|
'/ai/recognize-products',
|
||||||
|
data: {'images': imageList},
|
||||||
|
);
|
||||||
|
return (data['items'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes a dish and estimates its nutritional content.
|
||||||
|
Future<DishResult> recognizeDish(File image) async {
|
||||||
|
final payload = await _buildImagePayload(image);
|
||||||
|
final data = await _client.post('/ai/recognize-dish', data: payload);
|
||||||
|
return DishResult.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> _buildImagePayload(File image) async {
|
||||||
|
final bytes = await image.readAsBytes();
|
||||||
|
final base64Data = base64Encode(bytes);
|
||||||
|
final ext = image.path.split('.').last.toLowerCase();
|
||||||
|
final mimeType = ext == 'png' ? 'image/png' : 'image/jpeg';
|
||||||
|
return {'image_base64': base64Data, 'mime_type': mimeType};
|
||||||
|
}
|
||||||
|
}
|
||||||
211
client/lib/features/scan/scan_screen.dart
Normal file
211
client/lib/features/scan/scan_screen.dart
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import '../../core/auth/auth_provider.dart';
|
||||||
|
import 'recognition_service.dart';
|
||||||
|
|
||||||
|
// Provider wired to the shared ApiClient.
|
||||||
|
final _recognitionServiceProvider = Provider<RecognitionService>((ref) {
|
||||||
|
return RecognitionService(ref.read(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Entry screen — lets the user choose how to add products.
|
||||||
|
class ScanScreen extends ConsumerWidget {
|
||||||
|
const ScanScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Добавить продукты')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Выберите способ',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_ModeCard(
|
||||||
|
emoji: '🧾',
|
||||||
|
title: 'Сфотографировать чек',
|
||||||
|
subtitle: 'Распознаем все продукты из чека',
|
||||||
|
onTap: () => _pickAndRecognize(context, ref, _Mode.receipt),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_ModeCard(
|
||||||
|
emoji: '🥦',
|
||||||
|
title: 'Сфотографировать продукты',
|
||||||
|
subtitle: 'Холодильник, стол, полка — до 3 фото',
|
||||||
|
onTap: () => _pickAndRecognize(context, ref, _Mode.products),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_ModeCard(
|
||||||
|
emoji: '🍽️',
|
||||||
|
title: 'Определить блюдо',
|
||||||
|
subtitle: 'КБЖУ≈ по фото готового блюда',
|
||||||
|
onTap: () => _pickAndRecognize(context, ref, _Mode.dish),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_ModeCard(
|
||||||
|
emoji: '✏️',
|
||||||
|
title: 'Добавить вручную',
|
||||||
|
subtitle: 'Ввести название, количество и срок',
|
||||||
|
onTap: () => context.push('/products/add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAndRecognize(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
_Mode mode,
|
||||||
|
) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
|
||||||
|
List<File> files = [];
|
||||||
|
|
||||||
|
if (mode == _Mode.products) {
|
||||||
|
// Allow up to 3 images.
|
||||||
|
final picked = await picker.pickMultiImage(imageQuality: 70);
|
||||||
|
if (picked.isEmpty) return;
|
||||||
|
files = picked.take(3).map((x) => File(x.path)).toList();
|
||||||
|
} else {
|
||||||
|
final source = await _chooseSource(context);
|
||||||
|
if (source == null) return;
|
||||||
|
final picked = await picker.pickImage(source: source, imageQuality: 70);
|
||||||
|
if (picked == null) return;
|
||||||
|
files = [File(picked.path)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final service = ref.read(_recognitionServiceProvider);
|
||||||
|
|
||||||
|
// Show loading overlay while the AI processes.
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const _LoadingDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (mode) {
|
||||||
|
case _Mode.receipt:
|
||||||
|
final result = await service.recognizeReceipt(files.first);
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context); // close loading
|
||||||
|
context.push('/scan/confirm', extra: result.items);
|
||||||
|
}
|
||||||
|
case _Mode.products:
|
||||||
|
final items = await service.recognizeProducts(files);
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/scan/confirm', extra: items);
|
||||||
|
}
|
||||||
|
case _Mode.dish:
|
||||||
|
final dish = await service.recognizeDish(files.first);
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/scan/dish', extra: dish);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context); // close loading
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Не удалось распознать. Попробуйте ещё раз.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImageSource?> _chooseSource(BuildContext context) async {
|
||||||
|
return showModalBottomSheet<ImageSource>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.camera_alt),
|
||||||
|
title: const Text('Камера'),
|
||||||
|
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library),
|
||||||
|
title: const Text('Галерея'),
|
||||||
|
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mode enum
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum _Mode { receipt, products, dish }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Widgets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _ModeCard extends StatelessWidget {
|
||||||
|
const _ModeCard({
|
||||||
|
required this.emoji,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String emoji;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
leading: Text(emoji, style: const TextStyle(fontSize: 32)),
|
||||||
|
title: Text(title, style: theme.textTheme.titleMedium),
|
||||||
|
subtitle: Text(subtitle, style: theme.textTheme.bodySmall),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoadingDialog extends StatelessWidget {
|
||||||
|
const _LoadingDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const AlertDialog(
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Распознаём...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -233,6 +241,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+5"
|
||||||
firebase_auth:
|
firebase_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -310,6 +350,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "5.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.33"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -472,6 +520,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+14"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+3"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.1"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ dependencies:
|
|||||||
# UI
|
# UI
|
||||||
cached_network_image: ^3.3.0
|
cached_network_image: ^3.3.0
|
||||||
|
|
||||||
|
# Camera / gallery
|
||||||
|
image_picker: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Reference in New Issue
Block a user