From c7317c43356222a76ecca1ceff05b3f7dffdf9a0 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Mon, 23 Mar 2026 23:01:30 +0200 Subject: [PATCH] feat: async product/receipt recognition via Kafka MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Migration 002: product_recognition_jobs table with JSONB images column and job_type CHECK ('receipt' | 'products') - New Kafka topics: ai.products.paid / ai.products.free - ProductJob model, ProductJobRepository (mirrors dish job pattern) - itemEnricher extracted from Handler — shared by HTTP handler and worker - ProductSSEBroker: PG LISTEN on product_job_update channel - ProductWorkerPool: 5 workers, branches on job_type to call RecognizeReceipt or RecognizeProducts per image in parallel - Handler: RecognizeReceipt and RecognizeProducts now return 202 Accepted instead of blocking; 4 new endpoints: GET /ai/product-jobs, /product-jobs/history, /product-jobs/{id}, /product-jobs/{id}/stream - cmd/worker: extended to run ProductWorkerPool alongside dish WorkerPool - cmd/server: wires productJobRepository + productSSEBroker; both SSE brokers started in App.Start() Flutter client: - ProductJobCreated, ProductJobResult, ProductJobSummary, ProductJobEvent models + submitReceiptRecognition/submitProductsRecognition/stream methods - Shared _openSseStream helper eliminates duplicate SSE parsing loop - ScanScreen: replace blocking AI calls with async submit + navigate to ProductJobWatchScreen - ProductJobWatchScreen: watches SSE stream, navigates to /scan/confirm when done, shows error on failure - ProductsScreen: prepends _RecentScansSection (hidden when empty); compact horizontal list of recent scans with "See all" → history - ProductJobHistoryScreen: full list of all product recognition jobs - New routes: /scan/product-job-watch, /products/job-history - L10n: 7 new keys in all 12 ARB files Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/app.go | 8 +- backend/cmd/server/init.go | 9 +- backend/cmd/worker/init.go | 49 +++- .../internal/domain/recognition/handler.go | 260 +++++++++--------- .../domain/recognition/item_enricher.go | 98 +++++++ .../domain/recognition/product_job.go | 63 +++++ .../recognition/product_job_repository.go | 203 ++++++++++++++ .../domain/recognition/product_sse.go | 196 +++++++++++++ .../domain/recognition/product_worker.go | 152 ++++++++++ backend/internal/infra/server/server.go | 4 + .../002_product_recognition_jobs.sql | 24 ++ client/lib/core/router/app_router.dart | 13 + .../products/product_job_history_screen.dart | 114 ++++++++ .../products/product_job_provider.dart | 75 +++++ .../features/products/products_screen.dart | 183 +++++++++++- .../scan/product_job_watch_screen.dart | 172 ++++++++++++ .../features/scan/recognition_service.dart | 248 +++++++++++++---- client/lib/features/scan/scan_screen.dart | 22 +- client/lib/l10n/app_ar.arb | 9 +- client/lib/l10n/app_de.arb | 9 +- client/lib/l10n/app_en.arb | 26 +- client/lib/l10n/app_es.arb | 9 +- client/lib/l10n/app_fr.arb | 9 +- client/lib/l10n/app_hi.arb | 9 +- client/lib/l10n/app_it.arb | 9 +- client/lib/l10n/app_ja.arb | 9 +- client/lib/l10n/app_ko.arb | 9 +- client/lib/l10n/app_localizations.dart | 42 +++ client/lib/l10n/app_localizations_ar.dart | 21 ++ client/lib/l10n/app_localizations_de.dart | 21 ++ client/lib/l10n/app_localizations_en.dart | 21 ++ client/lib/l10n/app_localizations_es.dart | 21 ++ client/lib/l10n/app_localizations_fr.dart | 21 ++ client/lib/l10n/app_localizations_hi.dart | 21 ++ client/lib/l10n/app_localizations_it.dart | 21 ++ client/lib/l10n/app_localizations_ja.dart | 21 ++ client/lib/l10n/app_localizations_ko.dart | 21 ++ client/lib/l10n/app_localizations_pt.dart | 21 ++ client/lib/l10n/app_localizations_ru.dart | 21 ++ client/lib/l10n/app_localizations_zh.dart | 21 ++ client/lib/l10n/app_pt.arb | 9 +- client/lib/l10n/app_ru.arb | 9 +- client/lib/l10n/app_zh.arb | 9 +- 43 files changed, 2073 insertions(+), 239 deletions(-) create mode 100644 backend/internal/domain/recognition/item_enricher.go create mode 100644 backend/internal/domain/recognition/product_job.go create mode 100644 backend/internal/domain/recognition/product_job_repository.go create mode 100644 backend/internal/domain/recognition/product_sse.go create mode 100644 backend/internal/domain/recognition/product_worker.go create mode 100644 backend/migrations/002_product_recognition_jobs.sql create mode 100644 client/lib/features/products/product_job_history_screen.dart create mode 100644 client/lib/features/products/product_job_provider.dart create mode 100644 client/lib/features/scan/product_job_watch_screen.dart diff --git a/backend/cmd/server/app.go b/backend/cmd/server/app.go index df6918f..e5cf307 100644 --- a/backend/cmd/server/app.go +++ b/backend/cmd/server/app.go @@ -9,8 +9,9 @@ import ( // App bundles the HTTP handler with background services that need lifecycle management. type App struct { - handler http.Handler - sseBroker *recognition.SSEBroker + handler http.Handler + sseBroker *recognition.SSEBroker + productSSEBroker *recognition.ProductSSEBroker } // ServeHTTP implements http.Handler. @@ -18,8 +19,9 @@ func (application *App) ServeHTTP(responseWriter http.ResponseWriter, request *h application.handler.ServeHTTP(responseWriter, request) } -// Start launches the SSE broker's LISTEN loop. +// Start launches the SSE brokers' LISTEN loops. // Call this once before the HTTP server begins accepting connections. func (application *App) Start(applicationContext context.Context) { application.sseBroker.Start(applicationContext) + application.productSSEBroker.Start(applicationContext) } diff --git a/backend/cmd/server/init.go b/backend/cmd/server/init.go index daf9f85..2490e7a 100644 --- a/backend/cmd/server/init.go +++ b/backend/cmd/server/init.go @@ -56,7 +56,9 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { // Recognition pipeline jobRepository := recognition.NewJobRepository(pool) sseBroker := recognition.NewSSEBroker(pool, jobRepository) - recognitionHandler := recognition.NewHandler(openaiClient, productRepository, jobRepository, kafkaProducer, sseBroker) + productJobRepository := recognition.NewProductJobRepository(pool) + productSSEBroker := recognition.NewProductSSEBroker(pool, productJobRepository) + recognitionHandler := recognition.NewHandler(openaiClient, productRepository, jobRepository, productJobRepository, kafkaProducer, sseBroker, productSSEBroker) menuRepository := menu.NewRepository(pool) menuHandler := menu.NewHandler(menuRepository, openaiClient, openaiClient, dishRepository, pexelsClient, userRepository, userProductRepository, dishRepository) @@ -93,7 +95,8 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { mainTagListHandler, ) return &App{ - handler: httpHandler, - sseBroker: sseBroker, + handler: httpHandler, + sseBroker: sseBroker, + productSSEBroker: productSSEBroker, }, nil } diff --git a/backend/cmd/worker/init.go b/backend/cmd/worker/init.go index 2acff6d..dbc3b9b 100644 --- a/backend/cmd/worker/init.go +++ b/backend/cmd/worker/init.go @@ -6,6 +6,7 @@ import ( "github.com/food-ai/backend/internal/adapters/kafka" "github.com/food-ai/backend/internal/adapters/openai" "github.com/food-ai/backend/internal/domain/dish" + "github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/domain/recognition" "github.com/jackc/pgx/v5/pgxpool" "github.com/kelseyhightower/envconfig" @@ -28,31 +29,57 @@ func loadConfig() (*workerConfig, error) { // WorkerApp bundles background services that need lifecycle management. type WorkerApp struct { - workerPool *recognition.WorkerPool + workerPool *recognition.WorkerPool + productWorkerPool *recognition.ProductWorkerPool } -// Start launches the worker pool goroutines. +// Start launches the dish and product worker pool goroutines. func (workerApp *WorkerApp) Start(applicationContext context.Context) { workerApp.workerPool.Start(applicationContext) + workerApp.productWorkerPool.Start(applicationContext) } func initWorker(workerCfg *workerConfig, pool *pgxpool.Pool) (*WorkerApp, error) { openaiClient := openai.NewClient(workerCfg.OpenAIAPIKey) + + // Dish recognition worker. dishRepository := dish.NewRepository(pool) jobRepository := recognition.NewJobRepository(pool) - topic := recognition.TopicFree - groupID := "dish-recognition-free" + dishTopic := recognition.TopicFree + dishGroupID := "dish-recognition-free" if workerCfg.WorkerPlan == "paid" { - topic = recognition.TopicPaid - groupID = "dish-recognition-paid" + dishTopic = recognition.TopicPaid + dishGroupID = "dish-recognition-paid" } - consumer, consumerError := kafka.NewConsumer(workerCfg.KafkaBrokers, groupID, topic) - if consumerError != nil { - return nil, consumerError + dishConsumer, dishConsumerError := kafka.NewConsumer(workerCfg.KafkaBrokers, dishGroupID, dishTopic) + if dishConsumerError != nil { + return nil, dishConsumerError } - workerPool := recognition.NewWorkerPool(jobRepository, openaiClient, dishRepository, consumer) - return &WorkerApp{workerPool: workerPool}, nil + workerPool := recognition.NewWorkerPool(jobRepository, openaiClient, dishRepository, dishConsumer) + + // Product recognition worker. + productRepository := product.NewRepository(pool) + productJobRepository := recognition.NewProductJobRepository(pool) + + productTopic := recognition.ProductTopicFree + productGroupID := "product-recognition-free" + if workerCfg.WorkerPlan == "paid" { + productTopic = recognition.ProductTopicPaid + productGroupID = "product-recognition-paid" + } + + productConsumer, productConsumerError := kafka.NewConsumer(workerCfg.KafkaBrokers, productGroupID, productTopic) + if productConsumerError != nil { + return nil, productConsumerError + } + + productWorkerPool := recognition.NewProductWorkerPool(productJobRepository, openaiClient, productRepository, productConsumer) + + return &WorkerApp{ + workerPool: workerPool, + productWorkerPool: productWorkerPool, + }, nil } diff --git a/backend/internal/domain/recognition/handler.go b/backend/internal/domain/recognition/handler.go index 4ce8567..cca8959 100644 --- a/backend/internal/domain/recognition/handler.go +++ b/backend/internal/domain/recognition/handler.go @@ -6,7 +6,6 @@ import ( "log/slog" "net/http" "strings" - "sync" "github.com/go-chi/chi/v5" @@ -49,27 +48,33 @@ type KafkaPublisher interface { // Handler handles POST /ai/* recognition endpoints. type Handler struct { - recognizer Recognizer - productRepo ProductRepository - jobRepo JobRepository - kafkaProducer KafkaPublisher - sseBroker *SSEBroker + enricher *itemEnricher + recognizer Recognizer + jobRepo JobRepository + productJobRepo ProductJobRepository + kafkaProducer KafkaPublisher + sseBroker *SSEBroker + productSSEBroker *ProductSSEBroker } -// NewHandler creates a new Handler with async dish recognition support. +// NewHandler creates a new Handler with async dish and product recognition support. func NewHandler( recognizer Recognizer, productRepo ProductRepository, jobRepo JobRepository, + productJobRepo ProductJobRepository, kafkaProducer KafkaPublisher, sseBroker *SSEBroker, + productSSEBroker *ProductSSEBroker, ) *Handler { return &Handler{ - recognizer: recognizer, - productRepo: productRepo, - jobRepo: jobRepo, - kafkaProducer: kafkaProducer, - sseBroker: sseBroker, + enricher: newItemEnricher(recognizer, productRepo), + recognizer: recognizer, + jobRepo: jobRepo, + productJobRepo: productJobRepo, + kafkaProducer: kafkaProducer, + sseBroker: sseBroker, + productSSEBroker: productSSEBroker, } } @@ -117,34 +122,23 @@ type ReceiptResponse struct { // Handlers // --------------------------------------------------------------------------- -// RecognizeReceipt handles POST /ai/recognize-receipt. +// RecognizeReceipt handles POST /ai/recognize-receipt (async). +// Enqueues the receipt image for AI processing and returns 202 Accepted with a job_id. // Body: {"image_base64": "...", "mime_type": "image/jpeg"} func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, request *http.Request) { - userID := middleware.UserIDFromCtx(request.Context()) - _ = userID // logged for tracing - var req imageRequest if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" { writeErrorJSON(responseWriter, request, http.StatusBadRequest, "image_base64 is required") return } - lang := locale.FromContext(request.Context()) - result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang) - if recognizeError != nil { - slog.ErrorContext(request.Context(), "recognize receipt", "err", recognizeError) - writeErrorJSON(responseWriter, request, http.StatusServiceUnavailable, "recognition failed, please try again") - return - } - - enriched := handler.enrichItems(request.Context(), result.Items) - writeJSON(responseWriter, http.StatusOK, ReceiptResponse{ - Items: enriched, - Unrecognized: result.Unrecognized, + handler.submitProductJob(responseWriter, request, "receipt", []ProductImagePayload{ + {ImageBase64: req.ImageBase64, MimeType: req.MimeType}, }) } -// RecognizeProducts handles POST /ai/recognize-products. +// RecognizeProducts handles POST /ai/recognize-products (async). +// Enqueues up to 3 product images for AI processing and returns 202 Accepted with a job_id. // Body: {"images": [{"image_base64": "...", "mime_type": "image/jpeg"}, ...]} func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) { var req imagesRequest @@ -153,29 +147,118 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re return } if len(req.Images) > 3 { - req.Images = req.Images[:3] // cap at 3 photos as per spec + req.Images = req.Images[:3] } + images := make([]ProductImagePayload, len(req.Images)) + for index, img := range req.Images { + images[index] = ProductImagePayload{ImageBase64: img.ImageBase64, MimeType: img.MimeType} + } + handler.submitProductJob(responseWriter, request, "products", images) +} + +// submitProductJob is shared by RecognizeReceipt and RecognizeProducts. +// It inserts a product job, publishes to Kafka, and writes the 202 response. +func (handler *Handler) submitProductJob( + responseWriter http.ResponseWriter, + request *http.Request, + jobType string, + images []ProductImagePayload, +) { + userID := middleware.UserIDFromCtx(request.Context()) + userPlan := middleware.UserPlanFromCtx(request.Context()) lang := locale.FromContext(request.Context()) - allItems := make([][]ai.RecognizedItem, len(req.Images)) - var wg sync.WaitGroup - for i, img := range req.Images { - wg.Add(1) - go func(index int, imageReq imageRequest) { - defer wg.Done() - items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang) - if recognizeError != nil { - slog.WarnContext(request.Context(), "recognize products from image", "index", index, "err", recognizeError) - return - } - allItems[index] = items - }(i, img) - } - wg.Wait() - merged := MergeAndDeduplicate(allItems) - enriched := handler.enrichItems(request.Context(), merged) - writeJSON(responseWriter, http.StatusOK, map[string]any{"items": enriched}) + job := &ProductJob{ + UserID: userID, + UserPlan: userPlan, + JobType: jobType, + Images: images, + Lang: lang, + } + if insertError := handler.productJobRepo.InsertProductJob(request.Context(), job); insertError != nil { + slog.ErrorContext(request.Context(), "insert product recognition job", "err", insertError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create job") + return + } + + position, positionError := handler.productJobRepo.ProductQueuePosition(request.Context(), userPlan, job.CreatedAt) + if positionError != nil { + position = 0 + } + + topic := ProductTopicFree + if userPlan == "paid" { + topic = ProductTopicPaid + } + if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil { + slog.ErrorContext(request.Context(), "publish product recognition job", "job_id", job.ID, "err", publishError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to enqueue job") + return + } + + estimatedSeconds := (position + 1) * 6 + writeJSON(responseWriter, http.StatusAccepted, map[string]any{ + "job_id": job.ID, + "queue_position": position, + "estimated_seconds": estimatedSeconds, + }) +} + +// ListRecentProductJobs handles GET /ai/product-jobs — returns the last 7 days of product jobs. +func (handler *Handler) ListRecentProductJobs(responseWriter http.ResponseWriter, request *http.Request) { + userID := middleware.UserIDFromCtx(request.Context()) + + summaries, listError := handler.productJobRepo.ListRecentProductJobs(request.Context(), userID) + if listError != nil { + slog.ErrorContext(request.Context(), "list recent product jobs", "err", listError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs") + return + } + + if summaries == nil { + summaries = []*ProductJobSummary{} + } + writeJSON(responseWriter, http.StatusOK, summaries) +} + +// ListAllProductJobs handles GET /ai/product-jobs/history — returns all product jobs for the user. +func (handler *Handler) ListAllProductJobs(responseWriter http.ResponseWriter, request *http.Request) { + userID := middleware.UserIDFromCtx(request.Context()) + + summaries, listError := handler.productJobRepo.ListAllProductJobs(request.Context(), userID) + if listError != nil { + slog.ErrorContext(request.Context(), "list all product jobs", "err", listError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs") + return + } + + if summaries == nil { + summaries = []*ProductJobSummary{} + } + writeJSON(responseWriter, http.StatusOK, summaries) +} + +// GetProductJob handles GET /ai/product-jobs/{id}. +func (handler *Handler) GetProductJob(responseWriter http.ResponseWriter, request *http.Request) { + jobID := chi.URLParam(request, "id") + userID := middleware.UserIDFromCtx(request.Context()) + + job, fetchError := handler.productJobRepo.GetProductJobByID(request.Context(), jobID) + if fetchError != nil { + writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found") + return + } + if job.UserID != userID { + writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden") + return + } + writeJSON(responseWriter, http.StatusOK, job) +} + +// GetProductJobStream handles GET /ai/product-jobs/{id}/stream — SSE stream for product job updates. +func (handler *Handler) GetProductJobStream(responseWriter http.ResponseWriter, request *http.Request) { + handler.productSSEBroker.ServeSSE(responseWriter, request) } // RecognizeDish handles POST /ai/recognize-dish (async). @@ -287,87 +370,6 @@ func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http writeJSON(responseWriter, http.StatusOK, job) } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// enrichItems matches each recognized item against the product catalog. -// Items without a match trigger a classification call and upsert into the DB. -func (handler *Handler) enrichItems(ctx context.Context, items []ai.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 - } - - catalogProduct, matchError := handler.productRepo.FuzzyMatch(ctx, item.Name) - if matchError != nil { - slog.WarnContext(ctx, "fuzzy match product", "name", item.Name, "err", matchError) - } - - if catalogProduct != nil { - id := catalogProduct.ID - enriched.MappingID = &id - if catalogProduct.DefaultUnit != nil { - enriched.Unit = *catalogProduct.DefaultUnit - } - if catalogProduct.StorageDays != nil { - enriched.StorageDays = *catalogProduct.StorageDays - } - if catalogProduct.Category != nil { - enriched.Category = *catalogProduct.Category - } - } else { - classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name) - if classifyError != nil { - slog.WarnContext(ctx, "classify unknown product", "name", item.Name, "err", classifyError) - } else { - saved := handler.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 classification into the product catalog. -func (handler *Handler) saveClassification(ctx context.Context, classification *ai.IngredientClassification) *product.Product { - if classification == nil || classification.CanonicalName == "" { - return nil - } - - catalogProduct := &product.Product{ - CanonicalName: classification.CanonicalName, - Category: strPtr(classification.Category), - DefaultUnit: strPtr(classification.DefaultUnit), - CaloriesPer100g: classification.CaloriesPer100g, - ProteinPer100g: classification.ProteinPer100g, - FatPer100g: classification.FatPer100g, - CarbsPer100g: classification.CarbsPer100g, - StorageDays: intPtr(classification.StorageDays), - } - - saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct) - if upsertError != nil { - slog.WarnContext(ctx, "upsert classified product", "name", classification.CanonicalName, "err", upsertError) - return nil - } - - return saved -} - // MergeAndDeduplicate combines results from multiple images. // Items sharing the same name (case-insensitive) have their quantities summed. func MergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem { diff --git a/backend/internal/domain/recognition/item_enricher.go b/backend/internal/domain/recognition/item_enricher.go new file mode 100644 index 0000000..f4a8c51 --- /dev/null +++ b/backend/internal/domain/recognition/item_enricher.go @@ -0,0 +1,98 @@ +package recognition + +import ( + "context" + "log/slog" + + "github.com/food-ai/backend/internal/adapters/ai" + "github.com/food-ai/backend/internal/domain/product" +) + +// itemEnricher matches recognized items against the product catalog, +// triggering AI classification for unknown items. +// Extracted from Handler so both the HTTP handler and the product worker pool can use it. +type itemEnricher struct { + recognizer Recognizer + productRepo ProductRepository +} + +func newItemEnricher(recognizer Recognizer, productRepo ProductRepository) *itemEnricher { + return &itemEnricher{recognizer: recognizer, productRepo: productRepo} +} + +// enrich matches each recognized item against the product catalog. +// Items without a match trigger a classification call and upsert into the DB. +func (enricher *itemEnricher) enrich(enrichContext context.Context, items []ai.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 + } + + catalogProduct, matchError := enricher.productRepo.FuzzyMatch(enrichContext, item.Name) + if matchError != nil { + slog.WarnContext(enrichContext, "fuzzy match product", "name", item.Name, "err", matchError) + } + + if catalogProduct != nil { + productID := catalogProduct.ID + enriched.MappingID = &productID + if catalogProduct.DefaultUnit != nil { + enriched.Unit = *catalogProduct.DefaultUnit + } + if catalogProduct.StorageDays != nil { + enriched.StorageDays = *catalogProduct.StorageDays + } + if catalogProduct.Category != nil { + enriched.Category = *catalogProduct.Category + } + } else { + classification, classifyError := enricher.recognizer.ClassifyIngredient(enrichContext, item.Name) + if classifyError != nil { + slog.WarnContext(enrichContext, "classify unknown product", "name", item.Name, "err", classifyError) + } else { + saved := enricher.saveClassification(enrichContext, classification) + if saved != nil { + savedID := saved.ID + enriched.MappingID = &savedID + } + enriched.Category = classification.Category + enriched.Unit = classification.DefaultUnit + enriched.StorageDays = classification.StorageDays + } + } + result = append(result, enriched) + } + return result +} + +// saveClassification upserts an AI-produced classification into the product catalog. +func (enricher *itemEnricher) saveClassification(enrichContext context.Context, classification *ai.IngredientClassification) *product.Product { + if classification == nil || classification.CanonicalName == "" { + return nil + } + + catalogProduct := &product.Product{ + CanonicalName: classification.CanonicalName, + Category: strPtr(classification.Category), + DefaultUnit: strPtr(classification.DefaultUnit), + CaloriesPer100g: classification.CaloriesPer100g, + ProteinPer100g: classification.ProteinPer100g, + FatPer100g: classification.FatPer100g, + CarbsPer100g: classification.CarbsPer100g, + StorageDays: intPtr(classification.StorageDays), + } + + saved, upsertError := enricher.productRepo.Upsert(enrichContext, catalogProduct) + if upsertError != nil { + slog.WarnContext(enrichContext, "upsert classified product", "name", classification.CanonicalName, "err", upsertError) + return nil + } + + return saved +} diff --git a/backend/internal/domain/recognition/product_job.go b/backend/internal/domain/recognition/product_job.go new file mode 100644 index 0000000..b5e0ff1 --- /dev/null +++ b/backend/internal/domain/recognition/product_job.go @@ -0,0 +1,63 @@ +package recognition + +import ( + "time" + + "github.com/food-ai/backend/internal/adapters/ai" +) + +// Kafka topic names for product recognition. +const ( + ProductTopicPaid = "ai.products.paid" + ProductTopicFree = "ai.products.free" +) + +// ProductImagePayload is a single image stored in the product_recognition_jobs.images JSONB column. +type ProductImagePayload struct { + ImageBase64 string `json:"image_base64"` + MimeType string `json:"mime_type"` +} + +// ProductJobResultItem is an enriched product item stored in the result JSONB. +type ProductJobResultItem 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,omitempty"` + StorageDays int `json:"storage_days"` +} + +// ProductJobResult is the JSONB payload stored in product_recognition_jobs.result. +type ProductJobResult struct { + JobType string `json:"job_type"` + Items []ProductJobResultItem `json:"items"` + Unrecognized []ai.UnrecognizedItem `json:"unrecognized,omitempty"` +} + +// ProductJob represents an async product/receipt recognition task. +type ProductJob struct { + ID string + UserID string + UserPlan string + JobType string // "receipt" | "products" + Images []ProductImagePayload + Lang string + Status string + Result *ProductJobResult + Error *string + CreatedAt time.Time + StartedAt *time.Time + CompletedAt *time.Time +} + +// ProductJobSummary is a lightweight record for list endpoints (omits image payloads). +type ProductJobSummary struct { + ID string `json:"id"` + JobType string `json:"job_type"` + Status string `json:"status"` + Result *ProductJobResult `json:"result,omitempty"` + Error *string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/domain/recognition/product_job_repository.go b/backend/internal/domain/recognition/product_job_repository.go new file mode 100644 index 0000000..684b6c4 --- /dev/null +++ b/backend/internal/domain/recognition/product_job_repository.go @@ -0,0 +1,203 @@ +package recognition + +import ( + "context" + "encoding/json" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ProductJobRepository provides all DB operations on product_recognition_jobs. +type ProductJobRepository interface { + InsertProductJob(ctx context.Context, job *ProductJob) error + GetProductJobByID(ctx context.Context, jobID string) (*ProductJob, error) + UpdateProductJobStatus(ctx context.Context, jobID, status string, result *ProductJobResult, errMsg *string) error + ProductQueuePosition(ctx context.Context, userPlan string, createdAt time.Time) (int, error) + NotifyProductJobUpdate(ctx context.Context, jobID string) error + ListRecentProductJobs(ctx context.Context, userID string) ([]*ProductJobSummary, error) + ListAllProductJobs(ctx context.Context, userID string) ([]*ProductJobSummary, error) +} + +// PostgresProductJobRepository implements ProductJobRepository using a pgxpool. +type PostgresProductJobRepository struct { + pool *pgxpool.Pool +} + +// NewProductJobRepository creates a new PostgresProductJobRepository. +func NewProductJobRepository(pool *pgxpool.Pool) *PostgresProductJobRepository { + return &PostgresProductJobRepository{pool: pool} +} + +// InsertProductJob inserts a new product recognition job and populates the ID and CreatedAt fields. +func (repository *PostgresProductJobRepository) InsertProductJob(queryContext context.Context, job *ProductJob) error { + imagesJSON, marshalError := json.Marshal(job.Images) + if marshalError != nil { + return marshalError + } + return repository.pool.QueryRow(queryContext, + `INSERT INTO product_recognition_jobs (user_id, user_plan, job_type, images, lang) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, created_at`, + job.UserID, job.UserPlan, job.JobType, imagesJSON, job.Lang, + ).Scan(&job.ID, &job.CreatedAt) +} + +// GetProductJobByID fetches a single product job by primary key. +func (repository *PostgresProductJobRepository) GetProductJobByID(queryContext context.Context, jobID string) (*ProductJob, error) { + var job ProductJob + var imagesJSON []byte + var resultJSON []byte + + queryError := repository.pool.QueryRow(queryContext, + `SELECT id, user_id, user_plan, job_type, images, lang, + status, result, error, created_at, started_at, completed_at + FROM product_recognition_jobs WHERE id = $1`, + jobID, + ).Scan( + &job.ID, &job.UserID, &job.UserPlan, &job.JobType, &imagesJSON, &job.Lang, + &job.Status, &resultJSON, &job.Error, &job.CreatedAt, &job.StartedAt, &job.CompletedAt, + ) + if queryError != nil { + return nil, queryError + } + + if imagesJSON != nil { + if unmarshalError := json.Unmarshal(imagesJSON, &job.Images); unmarshalError != nil { + return nil, unmarshalError + } + } + + if resultJSON != nil { + var productResult ProductJobResult + if unmarshalError := json.Unmarshal(resultJSON, &productResult); unmarshalError == nil { + job.Result = &productResult + } + } + + return &job, nil +} + +// UpdateProductJobStatus transitions a job to a new status and records the result or error. +func (repository *PostgresProductJobRepository) UpdateProductJobStatus( + queryContext context.Context, + jobID, status string, + result *ProductJobResult, + errMsg *string, +) error { + var resultJSON []byte + if result != nil { + marshalledBytes, marshalError := json.Marshal(result) + if marshalError != nil { + return marshalError + } + resultJSON = marshalledBytes + } + + switch status { + case JobStatusProcessing: + _, updateError := repository.pool.Exec(queryContext, + `UPDATE product_recognition_jobs SET status = $1, started_at = now() WHERE id = $2`, + status, jobID, + ) + return updateError + default: + _, updateError := repository.pool.Exec(queryContext, + `UPDATE product_recognition_jobs + SET status = $1, result = $2, error = $3, completed_at = now() + WHERE id = $4`, + status, resultJSON, errMsg, jobID, + ) + return updateError + } +} + +// ProductQueuePosition counts product jobs ahead of createdAt in the same plan's queue. +func (repository *PostgresProductJobRepository) ProductQueuePosition( + queryContext context.Context, + userPlan string, + createdAt time.Time, +) (int, error) { + var position int + queryError := repository.pool.QueryRow(queryContext, + `SELECT COUNT(*) FROM product_recognition_jobs + WHERE status IN ('pending', 'processing') + AND user_plan = $1 + AND created_at < $2`, + userPlan, createdAt, + ).Scan(&position) + return position, queryError +} + +// NotifyProductJobUpdate sends a PostgreSQL NOTIFY on the product_job_update channel. +func (repository *PostgresProductJobRepository) NotifyProductJobUpdate(queryContext context.Context, jobID string) error { + _, notifyError := repository.pool.Exec(queryContext, `SELECT pg_notify('product_job_update', $1)`, jobID) + return notifyError +} + +// ListRecentProductJobs returns product recognition jobs from the last 7 days for the given user. +func (repository *PostgresProductJobRepository) ListRecentProductJobs(queryContext context.Context, userID string) ([]*ProductJobSummary, error) { + rows, queryError := repository.pool.Query(queryContext, + `SELECT id, job_type, status, result, error, created_at + FROM product_recognition_jobs + WHERE user_id = $1 + AND created_at >= now() - interval '7 days' + ORDER BY created_at DESC`, + userID, + ) + if queryError != nil { + return nil, queryError + } + defer rows.Close() + + return scanProductJobSummaries(rows) +} + +// ListAllProductJobs returns all product recognition jobs for the given user, newest first. +func (repository *PostgresProductJobRepository) ListAllProductJobs(queryContext context.Context, userID string) ([]*ProductJobSummary, error) { + rows, queryError := repository.pool.Query(queryContext, + `SELECT id, job_type, status, result, error, created_at + FROM product_recognition_jobs + WHERE user_id = $1 + ORDER BY created_at DESC`, + userID, + ) + if queryError != nil { + return nil, queryError + } + defer rows.Close() + + return scanProductJobSummaries(rows) +} + +type productSummaryScanner interface { + Next() bool + Scan(dest ...any) error + Err() error +} + +func scanProductJobSummaries(rows productSummaryScanner) ([]*ProductJobSummary, error) { + var summaries []*ProductJobSummary + for rows.Next() { + var summary ProductJobSummary + var resultJSON []byte + scanError := rows.Scan( + &summary.ID, &summary.JobType, &summary.Status, + &resultJSON, &summary.Error, &summary.CreatedAt, + ) + if scanError != nil { + return nil, scanError + } + if resultJSON != nil { + var productResult ProductJobResult + if unmarshalError := json.Unmarshal(resultJSON, &productResult); unmarshalError == nil { + summary.Result = &productResult + } + } + summaries = append(summaries, &summary) + } + if rowsError := rows.Err(); rowsError != nil { + return nil, rowsError + } + return summaries, nil +} diff --git a/backend/internal/domain/recognition/product_sse.go b/backend/internal/domain/recognition/product_sse.go new file mode 100644 index 0000000..1d61ebf --- /dev/null +++ b/backend/internal/domain/recognition/product_sse.go @@ -0,0 +1,196 @@ +package recognition + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/food-ai/backend/internal/infra/middleware" +) + +// ProductSSEBroker manages Server-Sent Events for product recognition job status updates. +// It listens on the PostgreSQL "product_job_update" NOTIFY channel and fans out events +// to all HTTP clients currently streaming a given job. +type ProductSSEBroker struct { + pool *pgxpool.Pool + productJobRepo ProductJobRepository + mu sync.RWMutex + clients map[string][]chan sseEvent +} + +// NewProductSSEBroker creates a new ProductSSEBroker. +func NewProductSSEBroker(pool *pgxpool.Pool, productJobRepo ProductJobRepository) *ProductSSEBroker { + return &ProductSSEBroker{ + pool: pool, + productJobRepo: productJobRepo, + clients: make(map[string][]chan sseEvent), + } +} + +// Start launches the PostgreSQL LISTEN loop in a background goroutine. +func (broker *ProductSSEBroker) Start(brokerContext context.Context) { + go broker.listenLoop(brokerContext) +} + +func (broker *ProductSSEBroker) listenLoop(brokerContext context.Context) { + conn, acquireError := broker.pool.Acquire(brokerContext) + if acquireError != nil { + slog.Error("ProductSSEBroker: acquire PG connection", "err", acquireError) + return + } + defer conn.Release() + + if _, listenError := conn.Exec(brokerContext, "LISTEN product_job_update"); listenError != nil { + slog.Error("ProductSSEBroker: LISTEN product_job_update", "err", listenError) + return + } + + for { + notification, waitError := conn.Conn().WaitForNotification(brokerContext) + if brokerContext.Err() != nil { + return + } + if waitError != nil { + slog.Error("ProductSSEBroker: wait for notification", "err", waitError) + return + } + broker.fanOut(brokerContext, notification.Payload) + } +} + +func (broker *ProductSSEBroker) subscribe(jobID string) chan sseEvent { + channel := make(chan sseEvent, 10) + broker.mu.Lock() + broker.clients[jobID] = append(broker.clients[jobID], channel) + broker.mu.Unlock() + return channel +} + +func (broker *ProductSSEBroker) unsubscribe(jobID string, channel chan sseEvent) { + broker.mu.Lock() + defer broker.mu.Unlock() + existing := broker.clients[jobID] + for index, existingChannel := range existing { + if existingChannel == channel { + broker.clients[jobID] = append(broker.clients[jobID][:index], broker.clients[jobID][index+1:]...) + break + } + } + if len(broker.clients[jobID]) == 0 { + delete(broker.clients, jobID) + } +} + +func (broker *ProductSSEBroker) fanOut(fanContext context.Context, jobID string) { + job, fetchError := broker.productJobRepo.GetProductJobByID(fanContext, jobID) + if fetchError != nil { + slog.Warn("ProductSSEBroker: get job for fan-out", "job_id", jobID, "err", fetchError) + return + } + + event, ok := productJobToSSEEvent(job) + if !ok { + return + } + + broker.mu.RLock() + channels := make([]chan sseEvent, len(broker.clients[jobID])) + copy(channels, broker.clients[jobID]) + broker.mu.RUnlock() + + for _, channel := range channels { + select { + case channel <- event: + default: + // channel full; skip this delivery + } + } +} + +func productJobToSSEEvent(job *ProductJob) (sseEvent, bool) { + switch job.Status { + case JobStatusProcessing: + return sseEvent{name: "processing", data: "{}"}, true + case JobStatusDone: + resultJSON, marshalError := json.Marshal(job.Result) + if marshalError != nil { + return sseEvent{}, false + } + return sseEvent{name: "done", data: string(resultJSON)}, true + case JobStatusFailed: + errMsg := "recognition failed, please try again" + if job.Error != nil { + errMsg = *job.Error + } + errorData, _ := json.Marshal(map[string]string{"error": errMsg}) + return sseEvent{name: "failed", data: string(errorData)}, true + default: + return sseEvent{}, false + } +} + +// ServeSSE handles GET /ai/product-jobs/{id}/stream — streams SSE events until the job completes. +func (broker *ProductSSEBroker) ServeSSE(responseWriter http.ResponseWriter, request *http.Request) { + jobID := chi.URLParam(request, "id") + userID := middleware.UserIDFromCtx(request.Context()) + + job, fetchError := broker.productJobRepo.GetProductJobByID(request.Context(), jobID) + if fetchError != nil { + writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found") + return + } + if job.UserID != userID { + writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden") + return + } + + flusher, supported := responseWriter.(http.Flusher) + if !supported { + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "streaming not supported") + return + } + + responseWriter.Header().Set("Content-Type", "text/event-stream") + responseWriter.Header().Set("Cache-Control", "no-cache") + responseWriter.Header().Set("Connection", "keep-alive") + responseWriter.Header().Set("X-Accel-Buffering", "no") + + if job.Status == JobStatusDone || job.Status == JobStatusFailed { + if event, ok := productJobToSSEEvent(job); ok { + fmt.Fprintf(responseWriter, "event: %s\ndata: %s\n\n", event.name, event.data) + flusher.Flush() + } + return + } + + eventChannel := broker.subscribe(jobID) + defer broker.unsubscribe(jobID, eventChannel) + + position, _ := broker.productJobRepo.ProductQueuePosition(request.Context(), job.UserPlan, job.CreatedAt) + estimatedSeconds := (position + 1) * 6 + queuedData, _ := json.Marshal(map[string]any{ + "position": position, + "estimated_seconds": estimatedSeconds, + }) + fmt.Fprintf(responseWriter, "event: queued\ndata: %s\n\n", queuedData) + flusher.Flush() + + for { + select { + case event := <-eventChannel: + fmt.Fprintf(responseWriter, "event: %s\ndata: %s\n\n", event.name, event.data) + flusher.Flush() + if event.name == "done" || event.name == "failed" { + return + } + case <-request.Context().Done(): + return + } + } +} diff --git a/backend/internal/domain/recognition/product_worker.go b/backend/internal/domain/recognition/product_worker.go new file mode 100644 index 0000000..68bef05 --- /dev/null +++ b/backend/internal/domain/recognition/product_worker.go @@ -0,0 +1,152 @@ +package recognition + +import ( + "context" + "log/slog" + "sync" + + "github.com/food-ai/backend/internal/adapters/ai" + "github.com/food-ai/backend/internal/adapters/kafka" +) + +// ProductWorkerPool processes product/receipt recognition jobs from a single Kafka topic. +type ProductWorkerPool struct { + productJobRepo ProductJobRepository + enricher *itemEnricher + recognizer Recognizer + consumer *kafka.Consumer + workerCount int + jobs chan string +} + +// NewProductWorkerPool creates a ProductWorkerPool with five workers consuming from a single consumer. +func NewProductWorkerPool( + productJobRepo ProductJobRepository, + recognizer Recognizer, + productRepo ProductRepository, + consumer *kafka.Consumer, +) *ProductWorkerPool { + return &ProductWorkerPool{ + productJobRepo: productJobRepo, + enricher: newItemEnricher(recognizer, productRepo), + recognizer: recognizer, + consumer: consumer, + workerCount: defaultWorkerCount, + jobs: make(chan string, 100), + } +} + +// Start launches the Kafka feeder goroutine and all worker goroutines. +func (pool *ProductWorkerPool) Start(workerContext context.Context) { + go pool.consumer.Run(workerContext, pool.jobs) + for i := 0; i < pool.workerCount; i++ { + go pool.runWorker(workerContext) + } +} + +func (pool *ProductWorkerPool) runWorker(workerContext context.Context) { + for { + select { + case jobID := <-pool.jobs: + pool.processJob(workerContext, jobID) + case <-workerContext.Done(): + return + } + } +} + +func (pool *ProductWorkerPool) processJob(workerContext context.Context, jobID string) { + job, fetchError := pool.productJobRepo.GetProductJobByID(workerContext, jobID) + if fetchError != nil { + slog.Error("product worker: fetch job", "job_id", jobID, "err", fetchError) + return + } + + if updateError := pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusProcessing, nil, nil); updateError != nil { + slog.Error("product worker: set processing status", "job_id", jobID, "err", updateError) + } + if notifyError := pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID); notifyError != nil { + slog.Warn("product worker: notify processing", "job_id", jobID, "err", notifyError) + } + + var recognizedItems []ai.RecognizedItem + var unrecognized []ai.UnrecognizedItem + var recognizeError error + + switch job.JobType { + case "receipt": + if len(job.Images) == 0 { + errMsg := "no images in job" + _ = pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusFailed, nil, &errMsg) + _ = pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID) + return + } + imagePayload := job.Images[0] + var receiptResult *ai.ReceiptResult + receiptResult, recognizeError = pool.recognizer.RecognizeReceipt(workerContext, imagePayload.ImageBase64, imagePayload.MimeType, job.Lang) + if recognizeError == nil && receiptResult != nil { + recognizedItems = receiptResult.Items + unrecognized = receiptResult.Unrecognized + } + + case "products": + allItems := make([][]ai.RecognizedItem, len(job.Images)) + var wg sync.WaitGroup + for index, imagePayload := range job.Images { + wg.Add(1) + go func(workerIndex int, payload ProductImagePayload) { + defer wg.Done() + items, itemsError := pool.recognizer.RecognizeProducts(workerContext, payload.ImageBase64, payload.MimeType, job.Lang) + if itemsError != nil { + slog.WarnContext(workerContext, "product worker: recognize products from image", "index", workerIndex, "err", itemsError) + return + } + allItems[workerIndex] = items + }(index, imagePayload) + } + wg.Wait() + recognizedItems = MergeAndDeduplicate(allItems) + + default: + errMsg := "unknown job type: " + job.JobType + slog.Error("product worker: unknown job type", "job_id", jobID, "job_type", job.JobType) + _ = pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusFailed, nil, &errMsg) + _ = pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID) + return + } + + if recognizeError != nil { + slog.Error("product worker: recognize", "job_id", jobID, "err", recognizeError) + errMsg := "recognition failed, please try again" + _ = pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusFailed, nil, &errMsg) + _ = pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID) + return + } + + enriched := pool.enricher.enrich(workerContext, recognizedItems) + resultItems := make([]ProductJobResultItem, len(enriched)) + for index, item := range enriched { + resultItems[index] = ProductJobResultItem{ + Name: item.Name, + Quantity: item.Quantity, + Unit: item.Unit, + Category: item.Category, + Confidence: item.Confidence, + MappingID: item.MappingID, + StorageDays: item.StorageDays, + } + } + + jobResult := &ProductJobResult{ + JobType: job.JobType, + Items: resultItems, + Unrecognized: unrecognized, + } + + if updateError := pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusDone, jobResult, nil); updateError != nil { + slog.Error("product worker: set done status", "job_id", jobID, "err", updateError) + } + if notifyError := pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID); notifyError != nil { + slog.Warn("product worker: notify done", "job_id", jobID, "err", notifyError) + } +} diff --git a/backend/internal/infra/server/server.go b/backend/internal/infra/server/server.go index 61a2099..7f7ce59 100644 --- a/backend/internal/infra/server/server.go +++ b/backend/internal/infra/server/server.go @@ -127,6 +127,10 @@ func NewRouter( r.Get("/jobs/history", recognitionHandler.ListAllJobs) r.Get("/jobs/{id}", recognitionHandler.GetJob) r.Get("/jobs/{id}/stream", recognitionHandler.GetJobStream) + r.Get("/product-jobs", recognitionHandler.ListRecentProductJobs) + r.Get("/product-jobs/history", recognitionHandler.ListAllProductJobs) + r.Get("/product-jobs/{id}", recognitionHandler.GetProductJob) + r.Get("/product-jobs/{id}/stream", recognitionHandler.GetProductJobStream) r.Post("/generate-menu", menuHandler.GenerateMenu) }) }) diff --git a/backend/migrations/002_product_recognition_jobs.sql b/backend/migrations/002_product_recognition_jobs.sql new file mode 100644 index 0000000..679c764 --- /dev/null +++ b/backend/migrations/002_product_recognition_jobs.sql @@ -0,0 +1,24 @@ +-- +goose Up +CREATE TABLE product_recognition_jobs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_plan TEXT NOT NULL, + job_type TEXT NOT NULL CHECK (job_type IN ('receipt', 'products')), + images JSONB NOT NULL, + lang TEXT NOT NULL DEFAULT 'en', + status TEXT NOT NULL DEFAULT 'pending', + result JSONB, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE INDEX idx_product_recognition_jobs_user + ON product_recognition_jobs (user_id, created_at DESC); + +CREATE INDEX idx_product_recognition_jobs_status + ON product_recognition_jobs (status, user_plan, created_at ASC); + +-- +goose Down +DROP TABLE IF EXISTS product_recognition_jobs; diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index a8a63cb..15440bb 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -13,6 +13,8 @@ import '../../features/profile/profile_provider.dart'; import '../../shared/models/user.dart'; import '../../features/products/products_screen.dart'; import '../../features/products/add_product_screen.dart'; +import '../../features/products/product_job_history_screen.dart'; +import '../../features/scan/product_job_watch_screen.dart'; import '../../features/scan/scan_screen.dart'; import '../../features/scan/recognition_confirm_screen.dart'; import '../../features/scan/recognition_service.dart'; @@ -129,6 +131,17 @@ final routerProvider = Provider((ref) { path: '/scan/history', builder: (_, __) => const RecognitionHistoryScreen(), ), + GoRoute( + path: '/scan/product-job-watch', + builder: (context, state) { + final jobCreated = state.extra as ProductJobCreated; + return ProductJobWatchScreen(jobCreated: jobCreated); + }, + ), + GoRoute( + path: '/products/job-history', + builder: (_, __) => const ProductJobHistoryScreen(), + ), ShellRoute( builder: (context, state, child) => MainShell(child: child), routes: [ diff --git a/client/lib/features/products/product_job_history_screen.dart b/client/lib/features/products/product_job_history_screen.dart new file mode 100644 index 0000000..d77f6ed --- /dev/null +++ b/client/lib/features/products/product_job_history_screen.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../l10n/app_localizations.dart'; +import '../scan/recognition_service.dart'; +import 'product_job_provider.dart'; + +/// Shows the complete history of product/receipt recognition scans. +class ProductJobHistoryScreen extends ConsumerWidget { + const ProductJobHistoryScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final state = ref.watch(allProductJobsProvider); + + return Scaffold( + appBar: AppBar( + title: Text(l10n.productJobHistoryTitle), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => + ref.read(allProductJobsProvider.notifier).refresh(), + ), + ], + ), + body: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 12), + FilledButton( + onPressed: () => + ref.read(allProductJobsProvider.notifier).refresh(), + child: const Text('Retry'), + ), + ], + ), + ), + data: (jobs) => jobs.isEmpty + ? Center( + child: Text( + l10n.recentScans, + style: Theme.of(context).textTheme.bodyLarge, + ), + ) + : RefreshIndicator( + onRefresh: () => + ref.read(allProductJobsProvider.notifier).refresh(), + child: ListView.builder( + itemCount: jobs.length, + itemBuilder: (context, index) => + _JobTile(job: jobs[index]), + ), + ), + ), + ); + } +} + +class _JobTile extends StatelessWidget { + const _JobTile({required this.job}); + + final ProductJobSummary job; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final isDone = job.status == 'done'; + final isFailed = job.status == 'failed'; + final dateLabel = + DateFormat.yMd().add_jm().format(job.createdAt.toLocal()); + final itemCount = job.result?.items.length ?? 0; + + return ListTile( + leading: Icon( + job.jobType == 'receipt' + ? Icons.receipt_long_outlined + : Icons.camera_alt_outlined, + color: theme.colorScheme.primary, + ), + title: Text( + job.jobType == 'receipt' ? l10n.jobTypeReceipt : l10n.jobTypeProducts, + ), + subtitle: Text(dateLabel, style: theme.textTheme.bodySmall), + trailing: isDone + ? Chip( + label: Text( + '$itemCount', + style: theme.textTheme.labelSmall, + ), + avatar: const Icon(Icons.check_circle_outline, + size: 16, color: Colors.green), + ) + : isFailed + ? const Icon(Icons.error_outline, color: Colors.red) + : const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + onTap: isDone && job.result != null + ? () => context.push('/scan/confirm', extra: job.result!.items) + : null, + ); + } +} diff --git a/client/lib/features/products/product_job_provider.dart b/client/lib/features/products/product_job_provider.dart new file mode 100644 index 0000000..78a3656 --- /dev/null +++ b/client/lib/features/products/product_job_provider.dart @@ -0,0 +1,75 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../scan/recognition_service.dart'; + +// --------------------------------------------------------------------------- +// Recent product jobs (last 7 days) — shown on the products screen +// --------------------------------------------------------------------------- + +class _RecentProductJobsNotifier + extends StateNotifier>> { + _RecentProductJobsNotifier(this._service) : super(const AsyncValue.loading()) { + load(); + } + + final RecognitionService _service; + + Future load() async { + state = const AsyncValue.loading(); + try { + final jobs = await _service.listRecentProductJobs(); + state = AsyncValue.data(jobs); + } catch (error, stack) { + state = AsyncValue.error(error, stack); + } + } + + Future refresh() => load(); +} + +final recentProductJobsProvider = StateNotifierProvider< + _RecentProductJobsNotifier, AsyncValue>>((ref) { + final service = ref.read(recognitionServiceProvider); + return _RecentProductJobsNotifier(service); +}); + +// --------------------------------------------------------------------------- +// All product jobs — shown on the history screen +// --------------------------------------------------------------------------- + +class _AllProductJobsNotifier + extends StateNotifier>> { + _AllProductJobsNotifier(this._service) : super(const AsyncValue.loading()) { + load(); + } + + final RecognitionService _service; + + Future load() async { + state = const AsyncValue.loading(); + try { + final jobs = await _service.listAllProductJobs(); + state = AsyncValue.data(jobs); + } catch (error, stack) { + state = AsyncValue.error(error, stack); + } + } + + Future refresh() => load(); +} + +final allProductJobsProvider = StateNotifierProvider<_AllProductJobsNotifier, + AsyncValue>>((ref) { + final service = ref.read(recognitionServiceProvider); + return _AllProductJobsNotifier(service); +}); + +// --------------------------------------------------------------------------- +// SSE stream for a single product job +// --------------------------------------------------------------------------- + +final productJobStreamProvider = + StreamProvider.family((ref, jobId) { + final service = ref.read(recognitionServiceProvider); + return service.streamProductJobEvents(jobId); +}); diff --git a/client/lib/features/products/products_screen.dart b/client/lib/features/products/products_screen.dart index 4d993ed..67ed900 100644 --- a/client/lib/features/products/products_screen.dart +++ b/client/lib/features/products/products_screen.dart @@ -3,7 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/locale/unit_provider.dart'; +import '../../l10n/app_localizations.dart'; import '../../shared/models/user_product.dart'; +import '../scan/recognition_service.dart'; +import 'product_job_provider.dart'; import 'user_product_provider.dart'; void _showAddMenu(BuildContext context) { @@ -57,21 +60,181 @@ class ProductsScreen extends ConsumerWidget { icon: const Icon(Icons.add), label: const Text('Добавить'), ), - body: state.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, _) => _ErrorView( - onRetry: () => ref.read(userProductsProvider.notifier).refresh(), - ), - data: (products) => products.isEmpty - ? _EmptyState( - onAdd: () => _showAddMenu(context), - ) - : _ProductList(products: products), + body: Column( + children: [ + const _RecentScansSection(), + Expanded( + child: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => _ErrorView( + onRetry: () => ref.read(userProductsProvider.notifier).refresh(), + ), + data: (products) => products.isEmpty + ? _EmptyState(onAdd: () => _showAddMenu(context)) + : _ProductList(products: products), + ), + ), + ], ), ); } } +// --------------------------------------------------------------------------- +// Recent product recognition scans section +// --------------------------------------------------------------------------- + +class _RecentScansSection extends ConsumerWidget { + const _RecentScansSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final jobsState = ref.watch(recentProductJobsProvider); + final jobs = jobsState.valueOrNull; + if (jobs == null || jobs.isEmpty) return const SizedBox.shrink(); + + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 4), + child: Row( + children: [ + Icon(Icons.document_scanner_outlined, + size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Text( + l10n.recentScans, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + TextButton( + onPressed: () => context.push('/products/job-history'), + child: Text(l10n.seeAllScans), + ), + ], + ), + ), + SizedBox( + height: 72, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: jobs.length > 5 ? 5 : jobs.length, + itemBuilder: (context, index) => + _ScanJobChip(job: jobs[index]), + ), + ), + const Divider(height: 1), + ], + ); + } +} + +class _ScanJobChip extends ConsumerWidget { + const _ScanJobChip({required this.job}); + + final ProductJobSummary job; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isDone = job.status == 'done'; + final isFailed = job.status == 'failed'; + final isActive = !isDone && !isFailed; + final l10n = AppLocalizations.of(context)!; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: isDone && job.result != null + ? () => context.push('/scan/confirm', extra: job.result!.items) + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surfaceContainerHighest, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + job.jobType == 'receipt' + ? Icons.receipt_long_outlined + : Icons.camera_alt_outlined, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + job.jobType == 'receipt' + ? l10n.jobTypeReceipt + : l10n.jobTypeProducts, + style: theme.textTheme.labelMedium, + ), + _StatusBadge( + status: job.status, + isFailed: isFailed, + isActive: isActive, + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({ + required this.status, + required this.isFailed, + required this.isActive, + }); + + final String status; + final bool isFailed; + final bool isActive; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (isActive) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + const SizedBox(width: 4), + Text(status, style: theme.textTheme.labelSmall), + ], + ); + } + return Icon( + isFailed ? Icons.error_outline : Icons.check_circle_outline, + size: 14, + color: isFailed ? Colors.red : Colors.green, + ); + } +} + // --------------------------------------------------------------------------- // Product list split into expiring / normal sections // --------------------------------------------------------------------------- diff --git a/client/lib/features/scan/product_job_watch_screen.dart b/client/lib/features/scan/product_job_watch_screen.dart new file mode 100644 index 0000000..ddb81a6 --- /dev/null +++ b/client/lib/features/scan/product_job_watch_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../l10n/app_localizations.dart'; +import '../products/product_job_provider.dart'; +import 'recognition_service.dart'; + +/// Watches a product recognition job via SSE and navigates to the confirmation +/// screen when the job finishes. +class ProductJobWatchScreen extends ConsumerStatefulWidget { + const ProductJobWatchScreen({super.key, required this.jobCreated}); + + final ProductJobCreated jobCreated; + + @override + ConsumerState createState() => + _ProductJobWatchScreenState(); +} + +class _ProductJobWatchScreenState + extends ConsumerState { + String? _errorMessage; + bool _navigated = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + ref.listen( + productJobStreamProvider(widget.jobCreated.jobId), + (previous, next) { + next.whenData((event) { + if (_navigated) return; + if (event is ProductJobDone) { + _navigated = true; + ref.invalidate(recentProductJobsProvider); + context.pushReplacement('/scan/confirm', extra: event.result.items); + } else if (event is ProductJobFailed) { + setState(() => _errorMessage = event.error); + } + }); + }, + ); + + final streamState = + ref.watch(productJobStreamProvider(widget.jobCreated.jobId)); + + return Scaffold( + appBar: AppBar(title: Text(l10n.processingProducts)), + body: _errorMessage != null + ? _ErrorBody(message: _errorMessage!) + : streamState.when( + loading: () => _ProgressBody(jobCreated: widget.jobCreated), + data: (event) => switch (event) { + ProductJobQueued( + position: final position, + estimatedSeconds: final estimated, + ) => + _QueuedBody(position: position, estimatedSeconds: estimated), + ProductJobProcessing() => + _ProcessingBody(label: l10n.processingProducts), + ProductJobDone() => _ProcessingBody(label: l10n.processingProducts), + ProductJobFailed(error: final err) => _ErrorBody(message: err), + }, + error: (err, _) => _ErrorBody(message: err.toString()), + ), + ); + } +} + +class _ProgressBody extends StatelessWidget { + const _ProgressBody({required this.jobCreated}); + + final ProductJobCreated jobCreated; + + @override + Widget build(BuildContext context) { + return _QueuedBody( + position: jobCreated.queuePosition, + estimatedSeconds: jobCreated.estimatedSeconds, + ); + } +} + +class _QueuedBody extends StatelessWidget { + const _QueuedBody( + {required this.position, required this.estimatedSeconds}); + + final int position; + final int estimatedSeconds; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text( + l10n.processingProducts, + style: Theme.of(context).textTheme.titleMedium, + ), + if (position > 0) ...[ + const SizedBox(height: 8), + Text( + '~${estimatedSeconds}s', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ); + } +} + +class _ProcessingBody extends StatelessWidget { + const _ProcessingBody({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text(label, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ErrorBody extends StatelessWidget { + const _ErrorBody({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => context.pop(), + child: const Text('Back'), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/features/scan/recognition_service.dart b/client/lib/features/scan/recognition_service.dart index 8031755..64c7697 100644 --- a/client/lib/features/scan/recognition_service.dart +++ b/client/lib/features/scan/recognition_service.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; @@ -142,7 +141,110 @@ class DishResult { } // --------------------------------------------------------------------------- -// Async job models +// Product job models +// --------------------------------------------------------------------------- + +/// Result of a completed product or receipt recognition job. +class ProductJobResult { + final String jobType; + final List items; + final List unrecognized; + + const ProductJobResult({ + required this.jobType, + required this.items, + required this.unrecognized, + }); + + factory ProductJobResult.fromJson(Map json) { + return ProductJobResult( + jobType: json['job_type'] as String? ?? '', + items: (json['items'] as List? ?? []) + .map((element) => RecognizedItem.fromJson(element as Map)) + .toList(), + unrecognized: (json['unrecognized'] as List? ?? []) + .map((element) => UnrecognizedItem.fromJson(element as Map)) + .toList(), + ); + } +} + +/// The 202 response from POST /ai/recognize-receipt or /ai/recognize-products. +class ProductJobCreated { + final String jobId; + final int queuePosition; + final int estimatedSeconds; + + const ProductJobCreated({ + required this.jobId, + required this.queuePosition, + required this.estimatedSeconds, + }); + + factory ProductJobCreated.fromJson(Map json) { + return ProductJobCreated( + jobId: json['job_id'] as String, + queuePosition: json['queue_position'] as int? ?? 0, + estimatedSeconds: json['estimated_seconds'] as int? ?? 0, + ); + } +} + +/// A lightweight summary of a product recognition job for list endpoints. +class ProductJobSummary { + final String id; + final String jobType; + final String status; + final ProductJobResult? result; + final String? error; + final DateTime createdAt; + + const ProductJobSummary({ + required this.id, + required this.jobType, + required this.status, + this.result, + this.error, + required this.createdAt, + }); + + factory ProductJobSummary.fromJson(Map json) { + return ProductJobSummary( + id: json['id'] as String, + jobType: json['job_type'] as String? ?? '', + status: json['status'] as String? ?? '', + result: json['result'] != null + ? ProductJobResult.fromJson(json['result'] as Map) + : null, + error: json['error'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + ); + } +} + +/// Events emitted by the SSE stream for a product recognition job. +sealed class ProductJobEvent {} + +class ProductJobQueued extends ProductJobEvent { + final int position; + final int estimatedSeconds; + ProductJobQueued({required this.position, required this.estimatedSeconds}); +} + +class ProductJobProcessing extends ProductJobEvent {} + +class ProductJobDone extends ProductJobEvent { + final ProductJobResult result; + ProductJobDone(this.result); +} + +class ProductJobFailed extends ProductJobEvent { + final String error; + ProductJobFailed(this.error); +} + +// --------------------------------------------------------------------------- +// Dish job models // --------------------------------------------------------------------------- /// A lightweight summary of a dish recognition job (no image payload). @@ -239,32 +341,71 @@ class RecognitionService { final AppConfig _appConfig; final String Function() _languageGetter; - /// Recognizes food items from a receipt photo. - Future recognizeReceipt(XFile image) async { + /// Submits a receipt image for async recognition. + /// Returns immediately with a [ProductJobCreated] containing the job ID. + Future submitReceiptRecognition(XFile 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((element) => RecognizedItem.fromJson(element as Map)) - .toList(), - unrecognized: (data['unrecognized'] as List? ?? []) - .map((element) => UnrecognizedItem.fromJson(element as Map)) - .toList(), - ); + return ProductJobCreated.fromJson(data); } - /// Recognizes food items from 1–3 product photos. - Future> recognizeProducts(List images) async { + /// Submits 1–3 product images for async recognition. + /// Returns immediately with a [ProductJobCreated] containing the job ID. + Future submitProductsRecognition(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((element) => RecognizedItem.fromJson(element as Map)) + return ProductJobCreated.fromJson(data); + } + + /// Returns product recognition jobs from the last 7 days. + Future> listRecentProductJobs() async { + final data = await _client.getList('/ai/product-jobs'); + return data + .map((element) => + ProductJobSummary.fromJson(element as Map)) .toList(); } + /// Returns all product recognition jobs for the current user, newest first. + Future> listAllProductJobs() async { + final data = await _client.getList('/ai/product-jobs/history'); + return data + .map((element) => + ProductJobSummary.fromJson(element as Map)) + .toList(); + } + + /// Opens an SSE stream for product job [jobId] and emits [ProductJobEvent]s + /// until the job reaches a terminal state or the stream is cancelled. + Stream streamProductJobEvents(String jobId) async* { + final streamUri = Uri.parse('${_appConfig.apiBaseUrl}/ai/product-jobs/$jobId/stream'); + await for (final parsed in _openSseStream(streamUri)) { + final eventName = parsed.$1; + final json = parsed.$2; + ProductJobEvent? event; + switch (eventName) { + case 'queued': + event = ProductJobQueued( + position: json['position'] as int? ?? 0, + estimatedSeconds: json['estimated_seconds'] as int? ?? 0, + ); + case 'processing': + event = ProductJobProcessing(); + case 'done': + event = ProductJobDone(ProductJobResult.fromJson(json)); + case 'failed': + event = ProductJobFailed(json['error'] as String? ?? 'Recognition failed'); + } + if (event != null) { + yield event; + if (event is ProductJobDone || event is ProductJobFailed) return; + } + } + } + /// Submits a dish image for async recognition. /// Returns a [DishJobCreated] with the job ID and queue position. Future submitDishRecognition( @@ -298,21 +439,45 @@ class RecognitionService { .toList(); } - /// Opens an SSE stream for job [jobId] and emits [DishJobEvent]s until the - /// job reaches a terminal state (done or failed) or the stream is cancelled. + /// Opens an SSE stream for dish job [jobId] and emits [DishJobEvent]s until + /// the job reaches a terminal state (done or failed) or the stream is cancelled. + Stream streamJobEvents(String jobId) async* { + final streamUri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream'); + await for (final parsed in _openSseStream(streamUri)) { + final eventName = parsed.$1; + final json = parsed.$2; + DishJobEvent? event; + switch (eventName) { + case 'queued': + event = DishJobQueued( + position: json['position'] as int? ?? 0, + estimatedSeconds: json['estimated_seconds'] as int? ?? 0, + ); + case 'processing': + event = DishJobProcessing(); + case 'done': + event = DishJobDone(DishResult.fromJson(json)); + case 'failed': + event = DishJobFailed(json['error'] as String? ?? 'Recognition failed'); + } + if (event != null) { + yield event; + if (event is DishJobDone || event is DishJobFailed) return; + } + } + } + + /// Opens a raw SSE connection and emits (eventName, jsonData) pairs. /// /// Uses [http.Client] instead of Dio because on Flutter Web Dio relies on - /// XHR which does not support SSE streaming. [http.BrowserClient] reads the - /// response via XHR onProgress events and delivers chunks before the - /// connection is closed. - Stream streamJobEvents(String jobId) async* { + /// XHR which does not support SSE streaming. + Stream<(String, Map)> _openSseStream(Uri streamUri) async* { final token = await _storage.getAccessToken(); final language = _languageGetter(); - final uri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream'); final httpClient = http.Client(); try { - final request = http.Request('GET', uri) + final request = http.Request('GET', streamUri) ..headers['Authorization'] = token != null ? 'Bearer $token' : '' ..headers['Accept'] = 'text/event-stream' ..headers['Accept-Language'] = language @@ -329,7 +494,6 @@ class RecognitionService { buffer.write(chunk); final text = buffer.toString(); - // Process complete SSE messages (terminated by \n\n). int doubleNewlineIndex; var remaining = text; while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) { @@ -341,10 +505,13 @@ class RecognitionService { currentEventName = line.substring(6).trim(); } else if (line.startsWith('data:')) { final dataPayload = line.substring(5).trim(); - final event = _parseSseEvent(currentEventName, dataPayload); - if (event != null) { - yield event; - if (event is DishJobDone || event is DishJobFailed) return; + try { + final jsonData = jsonDecode(dataPayload) as Map; + if (currentEventName != null) { + yield (currentEventName, jsonData); + } + } catch (_) { + // Malformed JSON — skip this message. } currentEventName = null; } @@ -360,29 +527,6 @@ class RecognitionService { } } - DishJobEvent? _parseSseEvent(String? eventName, String dataPayload) { - try { - final json = jsonDecode(dataPayload) as Map; - switch (eventName) { - case 'queued': - return DishJobQueued( - position: json['position'] as int? ?? 0, - estimatedSeconds: json['estimated_seconds'] as int? ?? 0, - ); - case 'processing': - return DishJobProcessing(); - case 'done': - return DishJobDone(DishResult.fromJson(json)); - case 'failed': - return DishJobFailed(json['error'] as String? ?? 'Recognition failed'); - default: - return null; - } - } catch (_) { - return null; - } - } - Future> _buildImagePayload(XFile image) async { final bytes = await image.readAsBytes(); final base64Data = base64Encode(bytes); diff --git a/client/lib/features/scan/scan_screen.dart b/client/lib/features/scan/scan_screen.dart index 819f152..abde743 100644 --- a/client/lib/features/scan/scan_screen.dart +++ b/client/lib/features/scan/scan_screen.dart @@ -82,30 +82,26 @@ class _ScanScreenState extends ConsumerState { final service = ref.read(recognitionServiceProvider); final l10n = AppLocalizations.of(context)!; - // Show loading overlay while the AI processes. showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) => _LoadingDialog(label: l10n.recognizing), + builder: (dialogContext) => _LoadingDialog(label: l10n.scanSubmitting), ); try { + ProductJobCreated jobCreated; 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); - } + jobCreated = await service.submitReceiptRecognition(files.first); case _Mode.products: - final items = await service.recognizeProducts(files); - if (context.mounted) { - Navigator.pop(context); - context.push('/scan/confirm', extra: items); - } + jobCreated = await service.submitProductsRecognition(files); + } + if (context.mounted) { + Navigator.pop(context); // close loading dialog + context.push('/scan/product-job-watch', extra: jobCreated); } } catch (recognitionError) { - debugPrint('Recognition error: $recognitionError'); + debugPrint('Recognition submit error: $recognitionError'); if (context.mounted) { Navigator.pop(context); // close loading ScaffoldMessenger.of(context).showSnackBar( diff --git a/client/lib/l10n/app_ar.arb b/client/lib/l10n/app_ar.arb index 142c97a..2f69443 100644 --- a/client/lib/l10n/app_ar.arb +++ b/client/lib/l10n/app_ar.arb @@ -165,5 +165,12 @@ "planProductsSkip": "تخطي اختيار المنتجات", "planProductsSkipNoProducts": "التخطيط بدون منتجات", "planProductsSelectAll": "تحديد الكل", - "planProductsDeselectAll": "إلغاء تحديد الكل" + "planProductsDeselectAll": "إلغاء تحديد الكل", + "recentScans": "عمليات المسح الأخيرة", + "seeAllScans": "عرض الكل", + "productJobHistoryTitle": "سجل المسح", + "jobTypeReceipt": "إيصال", + "jobTypeProducts": "منتجات", + "scanSubmitting": "جارٍ الإرسال...", + "processingProducts": "جارٍ المعالجة..." } diff --git a/client/lib/l10n/app_de.arb b/client/lib/l10n/app_de.arb index 9b258f1..cce5b8b 100644 --- a/client/lib/l10n/app_de.arb +++ b/client/lib/l10n/app_de.arb @@ -165,5 +165,12 @@ "planProductsSkip": "Produktauswahl überspringen", "planProductsSkipNoProducts": "Ohne Produkte planen", "planProductsSelectAll": "Alle auswählen", - "planProductsDeselectAll": "Alle abwählen" + "planProductsDeselectAll": "Alle abwählen", + "recentScans": "Letzte Scans", + "seeAllScans": "Alle", + "productJobHistoryTitle": "Scan-Verlauf", + "jobTypeReceipt": "Kassenbon", + "jobTypeProducts": "Produkte", + "scanSubmitting": "Wird gesendet...", + "processingProducts": "Verarbeitung..." } diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index 63e7490..cbb9bff 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -28,7 +28,9 @@ "queuePosition": "Position {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "Processing...", @@ -116,7 +118,9 @@ "noResultsForQuery": "Nothing found for \"{query}\"", "@noResultsForQuery": { "placeholders": { - "query": { "type": "String" } + "query": { + "type": "String" + } } }, "servingsLabel": "Servings", @@ -125,7 +129,9 @@ "planningForDate": "Planning for {date}", "@planningForDate": { "placeholders": { - "date": { "type": "String" } + "date": { + "type": "String" + } } }, "markAsEaten": "Mark as eaten", @@ -134,7 +140,6 @@ "generateWeekSubtitle": "AI will create a menu with breakfast, lunch and dinner for the whole week", "generatingMenu": "Generating menu...", "dayPlannedLabel": "Day planned", - "planMenuButton": "Plan meals", "planMenuTitle": "What to plan?", "planOptionSingleMeal": "Single meal", @@ -149,16 +154,23 @@ "planSelectMealType": "Meal type", "planSelectRange": "Select period", "planGenerateButton": "Plan", - "planGenerating": "Generating plan\u2026", + "planGenerating": "Generating plan…", "planSuccess": "Menu planned!", "planProductsTitle": "Products for the menu", "planProductsSubtitle": "AI will take the selected products into account when generating recipes", "planProductsEmpty": "No products added", - "planProductsEmptyMessage": "Add products you have at home \u2014 AI will suggest recipes from what you already have", + "planProductsEmptyMessage": "Add products you have at home — AI will suggest recipes from what you already have", "planProductsAddProducts": "Add products", "planProductsContinue": "Continue", "planProductsSkip": "Skip product selection", "planProductsSkipNoProducts": "Plan without products", "planProductsSelectAll": "Select all", - "planProductsDeselectAll": "Deselect all" + "planProductsDeselectAll": "Deselect all", + "recentScans": "Recent scans", + "seeAllScans": "See all", + "productJobHistoryTitle": "Scan history", + "jobTypeReceipt": "Receipt", + "jobTypeProducts": "Products", + "scanSubmitting": "Submitting...", + "processingProducts": "Processing..." } diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index c6aada7..930529b 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -165,5 +165,12 @@ "planProductsSkip": "Omitir selección de productos", "planProductsSkipNoProducts": "Planificar sin productos", "planProductsSelectAll": "Seleccionar todo", - "planProductsDeselectAll": "Deseleccionar todo" + "planProductsDeselectAll": "Deseleccionar todo", + "recentScans": "Escaneos recientes", + "seeAllScans": "Ver todos", + "productJobHistoryTitle": "Historial de escaneos", + "jobTypeReceipt": "Ticket", + "jobTypeProducts": "Productos", + "scanSubmitting": "Enviando...", + "processingProducts": "Procesando..." } diff --git a/client/lib/l10n/app_fr.arb b/client/lib/l10n/app_fr.arb index bf731fd..c896586 100644 --- a/client/lib/l10n/app_fr.arb +++ b/client/lib/l10n/app_fr.arb @@ -165,5 +165,12 @@ "planProductsSkip": "Ignorer la sélection des produits", "planProductsSkipNoProducts": "Planifier sans produits", "planProductsSelectAll": "Tout sélectionner", - "planProductsDeselectAll": "Tout désélectionner" + "planProductsDeselectAll": "Tout désélectionner", + "recentScans": "Scans récents", + "seeAllScans": "Tout voir", + "productJobHistoryTitle": "Historique des scans", + "jobTypeReceipt": "Reçu", + "jobTypeProducts": "Produits", + "scanSubmitting": "Envoi...", + "processingProducts": "Traitement..." } diff --git a/client/lib/l10n/app_hi.arb b/client/lib/l10n/app_hi.arb index c327d4e..d3e0e5f 100644 --- a/client/lib/l10n/app_hi.arb +++ b/client/lib/l10n/app_hi.arb @@ -165,5 +165,12 @@ "planProductsSkip": "उत्पाद चयन छोड़ें", "planProductsSkipNoProducts": "उत्पादों के बिना योजना बनाएं", "planProductsSelectAll": "सभी चुनें", - "planProductsDeselectAll": "सभी हटाएं" + "planProductsDeselectAll": "सभी हटाएं", + "recentScans": "हाल के स्कैन", + "seeAllScans": "सभी देखें", + "productJobHistoryTitle": "स्कैन इतिहास", + "jobTypeReceipt": "रसीद", + "jobTypeProducts": "उत्पाद", + "scanSubmitting": "सबमिट हो रहा है...", + "processingProducts": "प्रोसेस हो रहा है..." } diff --git a/client/lib/l10n/app_it.arb b/client/lib/l10n/app_it.arb index 3cfe716..5e9daf7 100644 --- a/client/lib/l10n/app_it.arb +++ b/client/lib/l10n/app_it.arb @@ -165,5 +165,12 @@ "planProductsSkip": "Salta la selezione dei prodotti", "planProductsSkipNoProducts": "Pianifica senza prodotti", "planProductsSelectAll": "Seleziona tutto", - "planProductsDeselectAll": "Deseleziona tutto" + "planProductsDeselectAll": "Deseleziona tutto", + "recentScans": "Scansioni recenti", + "seeAllScans": "Vedi tutto", + "productJobHistoryTitle": "Cronologia scansioni", + "jobTypeReceipt": "Scontrino", + "jobTypeProducts": "Prodotti", + "scanSubmitting": "Invio...", + "processingProducts": "Elaborazione..." } diff --git a/client/lib/l10n/app_ja.arb b/client/lib/l10n/app_ja.arb index bb0d0e5..3431708 100644 --- a/client/lib/l10n/app_ja.arb +++ b/client/lib/l10n/app_ja.arb @@ -165,5 +165,12 @@ "planProductsSkip": "食材選択をスキップ", "planProductsSkipNoProducts": "食材なしでプランニング", "planProductsSelectAll": "すべて選択", - "planProductsDeselectAll": "すべて解除" + "planProductsDeselectAll": "すべて解除", + "recentScans": "最近のスキャン", + "seeAllScans": "すべて表示", + "productJobHistoryTitle": "スキャン履歴", + "jobTypeReceipt": "レシート", + "jobTypeProducts": "商品", + "scanSubmitting": "送信中...", + "processingProducts": "処理中..." } diff --git a/client/lib/l10n/app_ko.arb b/client/lib/l10n/app_ko.arb index e2ee16e..894e549 100644 --- a/client/lib/l10n/app_ko.arb +++ b/client/lib/l10n/app_ko.arb @@ -165,5 +165,12 @@ "planProductsSkip": "재료 선택 건너뛰기", "planProductsSkipNoProducts": "재료 없이 계획하기", "planProductsSelectAll": "모두 선택", - "planProductsDeselectAll": "모두 해제" + "planProductsDeselectAll": "모두 해제", + "recentScans": "최근 스캔", + "seeAllScans": "전체 보기", + "productJobHistoryTitle": "스캔 기록", + "jobTypeReceipt": "영수증", + "jobTypeProducts": "제품", + "scanSubmitting": "제출 중...", + "processingProducts": "처리 중..." } diff --git a/client/lib/l10n/app_localizations.dart b/client/lib/l10n/app_localizations.dart index 45fe496..2b0dac0 100644 --- a/client/lib/l10n/app_localizations.dart +++ b/client/lib/l10n/app_localizations.dart @@ -987,6 +987,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Deselect all'** String get planProductsDeselectAll; + + /// No description provided for @recentScans. + /// + /// In en, this message translates to: + /// **'Recent scans'** + String get recentScans; + + /// No description provided for @seeAllScans. + /// + /// In en, this message translates to: + /// **'See all'** + String get seeAllScans; + + /// No description provided for @productJobHistoryTitle. + /// + /// In en, this message translates to: + /// **'Scan history'** + String get productJobHistoryTitle; + + /// No description provided for @jobTypeReceipt. + /// + /// In en, this message translates to: + /// **'Receipt'** + String get jobTypeReceipt; + + /// No description provided for @jobTypeProducts. + /// + /// In en, this message translates to: + /// **'Products'** + String get jobTypeProducts; + + /// No description provided for @scanSubmitting. + /// + /// In en, this message translates to: + /// **'Submitting...'** + String get scanSubmitting; + + /// No description provided for @processingProducts. + /// + /// In en, this message translates to: + /// **'Processing...'** + String get processingProducts; } class _AppLocalizationsDelegate diff --git a/client/lib/l10n/app_localizations_ar.dart b/client/lib/l10n/app_localizations_ar.dart index 943d35c..8de3d48 100644 --- a/client/lib/l10n/app_localizations_ar.dart +++ b/client/lib/l10n/app_localizations_ar.dart @@ -452,4 +452,25 @@ class AppLocalizationsAr extends AppLocalizations { @override String get planProductsDeselectAll => 'إلغاء تحديد الكل'; + + @override + String get recentScans => 'عمليات المسح الأخيرة'; + + @override + String get seeAllScans => 'عرض الكل'; + + @override + String get productJobHistoryTitle => 'سجل المسح'; + + @override + String get jobTypeReceipt => 'إيصال'; + + @override + String get jobTypeProducts => 'منتجات'; + + @override + String get scanSubmitting => 'جارٍ الإرسال...'; + + @override + String get processingProducts => 'جارٍ المعالجة...'; } diff --git a/client/lib/l10n/app_localizations_de.dart b/client/lib/l10n/app_localizations_de.dart index ac31373..dfccba7 100644 --- a/client/lib/l10n/app_localizations_de.dart +++ b/client/lib/l10n/app_localizations_de.dart @@ -454,4 +454,25 @@ class AppLocalizationsDe extends AppLocalizations { @override String get planProductsDeselectAll => 'Alle abwählen'; + + @override + String get recentScans => 'Letzte Scans'; + + @override + String get seeAllScans => 'Alle'; + + @override + String get productJobHistoryTitle => 'Scan-Verlauf'; + + @override + String get jobTypeReceipt => 'Kassenbon'; + + @override + String get jobTypeProducts => 'Produkte'; + + @override + String get scanSubmitting => 'Wird gesendet...'; + + @override + String get processingProducts => 'Verarbeitung...'; } diff --git a/client/lib/l10n/app_localizations_en.dart b/client/lib/l10n/app_localizations_en.dart index 8b1a777..0ff2413 100644 --- a/client/lib/l10n/app_localizations_en.dart +++ b/client/lib/l10n/app_localizations_en.dart @@ -452,4 +452,25 @@ class AppLocalizationsEn extends AppLocalizations { @override String get planProductsDeselectAll => 'Deselect all'; + + @override + String get recentScans => 'Recent scans'; + + @override + String get seeAllScans => 'See all'; + + @override + String get productJobHistoryTitle => 'Scan history'; + + @override + String get jobTypeReceipt => 'Receipt'; + + @override + String get jobTypeProducts => 'Products'; + + @override + String get scanSubmitting => 'Submitting...'; + + @override + String get processingProducts => 'Processing...'; } diff --git a/client/lib/l10n/app_localizations_es.dart b/client/lib/l10n/app_localizations_es.dart index 33963f9..1754df2 100644 --- a/client/lib/l10n/app_localizations_es.dart +++ b/client/lib/l10n/app_localizations_es.dart @@ -454,4 +454,25 @@ class AppLocalizationsEs extends AppLocalizations { @override String get planProductsDeselectAll => 'Deseleccionar todo'; + + @override + String get recentScans => 'Escaneos recientes'; + + @override + String get seeAllScans => 'Ver todos'; + + @override + String get productJobHistoryTitle => 'Historial de escaneos'; + + @override + String get jobTypeReceipt => 'Ticket'; + + @override + String get jobTypeProducts => 'Productos'; + + @override + String get scanSubmitting => 'Enviando...'; + + @override + String get processingProducts => 'Procesando...'; } diff --git a/client/lib/l10n/app_localizations_fr.dart b/client/lib/l10n/app_localizations_fr.dart index d8515c5..3e859cd 100644 --- a/client/lib/l10n/app_localizations_fr.dart +++ b/client/lib/l10n/app_localizations_fr.dart @@ -455,4 +455,25 @@ class AppLocalizationsFr extends AppLocalizations { @override String get planProductsDeselectAll => 'Tout désélectionner'; + + @override + String get recentScans => 'Scans récents'; + + @override + String get seeAllScans => 'Tout voir'; + + @override + String get productJobHistoryTitle => 'Historique des scans'; + + @override + String get jobTypeReceipt => 'Reçu'; + + @override + String get jobTypeProducts => 'Produits'; + + @override + String get scanSubmitting => 'Envoi...'; + + @override + String get processingProducts => 'Traitement...'; } diff --git a/client/lib/l10n/app_localizations_hi.dart b/client/lib/l10n/app_localizations_hi.dart index b8f7370..6bb5b50 100644 --- a/client/lib/l10n/app_localizations_hi.dart +++ b/client/lib/l10n/app_localizations_hi.dart @@ -453,4 +453,25 @@ class AppLocalizationsHi extends AppLocalizations { @override String get planProductsDeselectAll => 'सभी हटाएं'; + + @override + String get recentScans => 'हाल के स्कैन'; + + @override + String get seeAllScans => 'सभी देखें'; + + @override + String get productJobHistoryTitle => 'स्कैन इतिहास'; + + @override + String get jobTypeReceipt => 'रसीद'; + + @override + String get jobTypeProducts => 'उत्पाद'; + + @override + String get scanSubmitting => 'सबमिट हो रहा है...'; + + @override + String get processingProducts => 'प्रोसेस हो रहा है...'; } diff --git a/client/lib/l10n/app_localizations_it.dart b/client/lib/l10n/app_localizations_it.dart index b2506a6..bc9b092 100644 --- a/client/lib/l10n/app_localizations_it.dart +++ b/client/lib/l10n/app_localizations_it.dart @@ -454,4 +454,25 @@ class AppLocalizationsIt extends AppLocalizations { @override String get planProductsDeselectAll => 'Deseleziona tutto'; + + @override + String get recentScans => 'Scansioni recenti'; + + @override + String get seeAllScans => 'Vedi tutto'; + + @override + String get productJobHistoryTitle => 'Cronologia scansioni'; + + @override + String get jobTypeReceipt => 'Scontrino'; + + @override + String get jobTypeProducts => 'Prodotti'; + + @override + String get scanSubmitting => 'Invio...'; + + @override + String get processingProducts => 'Elaborazione...'; } diff --git a/client/lib/l10n/app_localizations_ja.dart b/client/lib/l10n/app_localizations_ja.dart index 90a6f9a..d72ff9c 100644 --- a/client/lib/l10n/app_localizations_ja.dart +++ b/client/lib/l10n/app_localizations_ja.dart @@ -449,4 +449,25 @@ class AppLocalizationsJa extends AppLocalizations { @override String get planProductsDeselectAll => 'すべて解除'; + + @override + String get recentScans => '最近のスキャン'; + + @override + String get seeAllScans => 'すべて表示'; + + @override + String get productJobHistoryTitle => 'スキャン履歴'; + + @override + String get jobTypeReceipt => 'レシート'; + + @override + String get jobTypeProducts => '商品'; + + @override + String get scanSubmitting => '送信中...'; + + @override + String get processingProducts => '処理中...'; } diff --git a/client/lib/l10n/app_localizations_ko.dart b/client/lib/l10n/app_localizations_ko.dart index b420421..6a11627 100644 --- a/client/lib/l10n/app_localizations_ko.dart +++ b/client/lib/l10n/app_localizations_ko.dart @@ -449,4 +449,25 @@ class AppLocalizationsKo extends AppLocalizations { @override String get planProductsDeselectAll => '모두 해제'; + + @override + String get recentScans => '최근 스캔'; + + @override + String get seeAllScans => '전체 보기'; + + @override + String get productJobHistoryTitle => '스캔 기록'; + + @override + String get jobTypeReceipt => '영수증'; + + @override + String get jobTypeProducts => '제품'; + + @override + String get scanSubmitting => '제출 중...'; + + @override + String get processingProducts => '처리 중...'; } diff --git a/client/lib/l10n/app_localizations_pt.dart b/client/lib/l10n/app_localizations_pt.dart index e44fae5..15d606d 100644 --- a/client/lib/l10n/app_localizations_pt.dart +++ b/client/lib/l10n/app_localizations_pt.dart @@ -454,4 +454,25 @@ class AppLocalizationsPt extends AppLocalizations { @override String get planProductsDeselectAll => 'Desmarcar tudo'; + + @override + String get recentScans => 'Scans recentes'; + + @override + String get seeAllScans => 'Ver tudo'; + + @override + String get productJobHistoryTitle => 'Histórico de scans'; + + @override + String get jobTypeReceipt => 'Recibo'; + + @override + String get jobTypeProducts => 'Produtos'; + + @override + String get scanSubmitting => 'Enviando...'; + + @override + String get processingProducts => 'Processando...'; } diff --git a/client/lib/l10n/app_localizations_ru.dart b/client/lib/l10n/app_localizations_ru.dart index 00d74f4..f227450 100644 --- a/client/lib/l10n/app_localizations_ru.dart +++ b/client/lib/l10n/app_localizations_ru.dart @@ -452,4 +452,25 @@ class AppLocalizationsRu extends AppLocalizations { @override String get planProductsDeselectAll => 'Снять всё'; + + @override + String get recentScans => 'Последние сканирования'; + + @override + String get seeAllScans => 'Все'; + + @override + String get productJobHistoryTitle => 'История сканирования'; + + @override + String get jobTypeReceipt => 'Чек'; + + @override + String get jobTypeProducts => 'Продукты'; + + @override + String get scanSubmitting => 'Отправка...'; + + @override + String get processingProducts => 'Обработка...'; } diff --git a/client/lib/l10n/app_localizations_zh.dart b/client/lib/l10n/app_localizations_zh.dart index 9f36aa9..0e52d52 100644 --- a/client/lib/l10n/app_localizations_zh.dart +++ b/client/lib/l10n/app_localizations_zh.dart @@ -448,4 +448,25 @@ class AppLocalizationsZh extends AppLocalizations { @override String get planProductsDeselectAll => '取消全选'; + + @override + String get recentScans => '最近扫描'; + + @override + String get seeAllScans => '全部'; + + @override + String get productJobHistoryTitle => '扫描历史'; + + @override + String get jobTypeReceipt => '收据'; + + @override + String get jobTypeProducts => '产品'; + + @override + String get scanSubmitting => '提交中...'; + + @override + String get processingProducts => '处理中...'; } diff --git a/client/lib/l10n/app_pt.arb b/client/lib/l10n/app_pt.arb index 3d2c53f..bff6805 100644 --- a/client/lib/l10n/app_pt.arb +++ b/client/lib/l10n/app_pt.arb @@ -165,5 +165,12 @@ "planProductsSkip": "Pular seleção de produtos", "planProductsSkipNoProducts": "Planejar sem produtos", "planProductsSelectAll": "Selecionar tudo", - "planProductsDeselectAll": "Desmarcar tudo" + "planProductsDeselectAll": "Desmarcar tudo", + "recentScans": "Scans recentes", + "seeAllScans": "Ver tudo", + "productJobHistoryTitle": "Histórico de scans", + "jobTypeReceipt": "Recibo", + "jobTypeProducts": "Produtos", + "scanSubmitting": "Enviando...", + "processingProducts": "Processando..." } diff --git a/client/lib/l10n/app_ru.arb b/client/lib/l10n/app_ru.arb index 6d5ba0a..6d3f50c 100644 --- a/client/lib/l10n/app_ru.arb +++ b/client/lib/l10n/app_ru.arb @@ -165,5 +165,12 @@ "planProductsSkip": "Пропустить выбор продуктов", "planProductsSkipNoProducts": "Планировать без продуктов", "planProductsSelectAll": "Выбрать все", - "planProductsDeselectAll": "Снять всё" + "planProductsDeselectAll": "Снять всё", + "recentScans": "Последние сканирования", + "seeAllScans": "Все", + "productJobHistoryTitle": "История сканирования", + "jobTypeReceipt": "Чек", + "jobTypeProducts": "Продукты", + "scanSubmitting": "Отправка...", + "processingProducts": "Обработка..." } diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index 8fb6c7f..32d9fe4 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -165,5 +165,12 @@ "planProductsSkip": "跳过食材选择", "planProductsSkipNoProducts": "不选食材直接规划", "planProductsSelectAll": "全选", - "planProductsDeselectAll": "取消全选" + "planProductsDeselectAll": "取消全选", + "recentScans": "最近扫描", + "seeAllScans": "全部", + "productJobHistoryTitle": "扫描历史", + "jobTypeReceipt": "收据", + "jobTypeProducts": "产品", + "scanSubmitting": "提交中...", + "processingProducts": "处理中..." }