From deceedd4a73f63fbde15d3eb50dd86cc84904911 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sun, 22 Feb 2026 10:54:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Iteration=203=20=E2=80=94?= =?UTF-8?q?=20product/receipt/dish=20recognition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/cmd/server/main.go | 5 + backend/internal/gemini/client.go | 51 ++- backend/internal/gemini/recognition.go | 221 ++++++++++++ backend/internal/ingredient/repository.go | 13 + backend/internal/recognition/handler.go | 303 +++++++++++++++++ backend/internal/server/server.go | 8 + .../android/app/src/main/AndroidManifest.xml | 4 + client/ios/Runner/Info.plist | 5 + client/lib/core/router/app_router.dart | 24 ++ .../features/products/products_screen.dart | 33 +- .../lib/features/scan/dish_result_screen.dart | 167 +++++++++ .../scan/recognition_confirm_screen.dart | 321 ++++++++++++++++++ .../features/scan/recognition_service.dart | 150 ++++++++ client/lib/features/scan/scan_screen.dart | 211 ++++++++++++ client/pubspec.lock | 112 ++++++ client/pubspec.yaml | 3 + 16 files changed, 1623 insertions(+), 8 deletions(-) create mode 100644 backend/internal/gemini/recognition.go create mode 100644 backend/internal/recognition/handler.go create mode 100644 client/lib/features/scan/dish_result_screen.dart create mode 100644 client/lib/features/scan/recognition_confirm_screen.dart create mode 100644 client/lib/features/scan/recognition_service.dart create mode 100644 client/lib/features/scan/scan_screen.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4d31fd7..90f504d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "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/recognition" "github.com/food-ai/backend/internal/recommendation" "github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/server" @@ -101,6 +102,9 @@ func run() error { productRepo := product.NewRepository(pool) productHandler := product.NewHandler(productRepo) + // Recognition domain + recognitionHandler := recognition.NewHandler(geminiClient, ingredientRepo) + // Recommendation domain recommendationHandler := recommendation.NewHandler(geminiClient, pexelsClient, userRepo, productRepo) @@ -117,6 +121,7 @@ func run() error { savedRecipeHandler, ingredientHandler, productHandler, + recognitionHandler, authMW, cfg.AllowedOrigins, ) diff --git a/backend/internal/gemini/client.go b/backend/internal/gemini/client.go index 0062e3b..95044e6 100644 --- a/backend/internal/gemini/client.go +++ b/backend/internal/gemini/client.go @@ -11,9 +11,15 @@ import ( ) 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" - groqModel = "llama-3.3-70b-versatile" + + // groqModel is the default text generation model. + groqModel = "llama-3.3-70b-versatile" + + // groqVisionModel supports image inputs in OpenAI vision format. + groqVisionModel = "llama-3.2-11b-vision-preview" + maxRetries = 3 ) @@ -28,16 +34,49 @@ func NewClient(apiKey string) *Client { return &Client{ apiKey: apiKey, 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) { + 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{ - "model": groqModel, - "temperature": 0.7, + "model": model, + "temperature": temperature, "messages": messages, } diff --git a/backend/internal/gemini/recognition.go b/backend/internal/gemini/recognition.go new file mode 100644 index 0000000..887ccc8 --- /dev/null +++ b/backend/internal/gemini/recognition.go @@ -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) +} diff --git a/backend/internal/ingredient/repository.go b/backend/internal/ingredient/repository.go index 617f34b..3ed768d 100644 --- a/backend/internal/ingredient/repository.go +++ b/backend/internal/ingredient/repository.go @@ -94,6 +94,19 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping 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. // 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) { diff --git a/backend/internal/recognition/handler.go b/backend/internal/recognition/handler.go new file mode 100644 index 0000000..0349f02 --- /dev/null +++ b/backend/internal/recognition/handler.go @@ -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) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index ea2840d..a7abe68 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -8,6 +8,7 @@ import ( "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/recognition" "github.com/food-ai/backend/internal/recommendation" "github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/user" @@ -23,6 +24,7 @@ func NewRouter( savedRecipeHandler *savedrecipe.Handler, ingredientHandler *ingredient.Handler, productHandler *product.Handler, + recognitionHandler *recognition.Handler, authMiddleware func(http.Handler) http.Handler, allowedOrigins []string, ) *chi.Mux { @@ -71,6 +73,12 @@ func NewRouter( r.Put("/{id}", productHandler.Update) 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 diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index e1698c9..f9a85dc 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + NSCameraUsageDescription + Для сканирования продуктов и чеков + NSPhotoLibraryUsageDescription + Для выбора фото продуктов и чеков из галереи CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index d8d6fe1..b16b044 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -8,6 +8,10 @@ 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/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/recipes/recipe_detail_screen.dart'; import '../../features/recipes/recipes_screen.dart'; @@ -58,6 +62,26 @@ final routerProvider = Provider((ref) { path: '/products/add', 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? ?? []; + 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( builder: (context, state, child) => MainShell(child: child), routes: [ diff --git a/client/lib/features/products/products_screen.dart b/client/lib/features/products/products_screen.dart index 429465e..39e2107 100644 --- a/client/lib/features/products/products_screen.dart +++ b/client/lib/features/products/products_screen.dart @@ -5,6 +5,35 @@ import 'package:go_router/go_router.dart'; import '../../shared/models/product.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 { const ProductsScreen({super.key}); @@ -23,7 +52,7 @@ class ProductsScreen extends ConsumerWidget { ], ), floatingActionButton: FloatingActionButton.extended( - onPressed: () => context.push('/products/add'), + onPressed: () => _showAddMenu(context), icon: const Icon(Icons.add), label: const Text('Добавить'), ), @@ -34,7 +63,7 @@ class ProductsScreen extends ConsumerWidget { ), data: (products) => products.isEmpty ? _EmptyState( - onAdd: () => context.push('/products/add'), + onAdd: () => _showAddMenu(context), ) : _ProductList(products: products), ), diff --git a/client/lib/features/scan/dish_result_screen.dart b/client/lib/features/scan/dish_result_screen.dart new file mode 100644 index 0000000..9d05a2d --- /dev/null +++ b/client/lib/features/scan/dish_result_screen.dart @@ -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, + ), + ], + ); + } +} diff --git a/client/lib/features/scan/recognition_confirm_screen.dart b/client/lib/features/scan/recognition_confirm_screen.dart new file mode 100644 index 0000000..cd115ed --- /dev/null +++ b/client/lib/features/scan/recognition_confirm_screen.dart @@ -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 items; + + @override + ConsumerState createState() => + _RecognitionConfirmScreenState(); +} + +class _RecognitionConfirmScreenState + extends ConsumerState { + 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 _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 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( + 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('Назад')), + ], + ), + ); + } +} diff --git a/client/lib/features/scan/recognition_service.dart b/client/lib/features/scan/recognition_service.dart new file mode 100644 index 0000000..b57487c --- /dev/null +++ b/client/lib/features/scan/recognition_service.dart @@ -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 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 json) { + return UnrecognizedItem( + rawText: json['raw_text'] as String? ?? '', + price: (json['price'] as num?)?.toDouble(), + ); + } +} + +class ReceiptResult { + final List items; + final List 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 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 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?) + ?.map((e) => e as String) + .toList() ?? + [], + ); + } +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +class RecognitionService { + const RecognitionService(this._client); + + final ApiClient _client; + + /// Recognizes food items from a receipt photo. + Future 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? ?? []) + .map((e) => RecognizedItem.fromJson(e as Map)) + .toList(), + unrecognized: (data['unrecognized'] as List? ?? []) + .map((e) => UnrecognizedItem.fromJson(e as Map)) + .toList(), + ); + } + + /// Recognizes food items from 1–3 product photos. + Future> recognizeProducts(List 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? ?? []) + .map((e) => RecognizedItem.fromJson(e as Map)) + .toList(); + } + + /// Recognizes a dish and estimates its nutritional content. + Future recognizeDish(File image) async { + final payload = await _buildImagePayload(image); + final data = await _client.post('/ai/recognize-dish', data: payload); + return DishResult.fromJson(data); + } + + Future> _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}; + } +} diff --git a/client/lib/features/scan/scan_screen.dart b/client/lib/features/scan/scan_screen.dart new file mode 100644 index 0000000..8efe38d --- /dev/null +++ b/client/lib/features/scan/scan_screen.dart @@ -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((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 _pickAndRecognize( + BuildContext context, + WidgetRef ref, + _Mode mode, + ) async { + final picker = ImagePicker(); + + List 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 _chooseSource(BuildContext context) async { + return showModalBottomSheet( + 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('Распознаём...'), + ], + ), + ); + } +} diff --git a/client/pubspec.lock b/client/pubspec.lock index 6dd3e6d..df7fdde 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -233,6 +241,38 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -310,6 +350,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -472,6 +520,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index d36078c..5e8e9ad 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -35,6 +35,9 @@ dependencies: # UI cached_network_image: ^3.3.0 + # Camera / gallery + image_picker: ^1.1.0 + dev_dependencies: flutter_test: sdk: flutter