From 78f1c8bf761304f6ee75b1a1e1dfb8ba438d899d Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sat, 21 Mar 2026 15:28:29 +0200 Subject: [PATCH] feat: food search sheet with FTS+trgm, dish/recent endpoints, multilingual aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /dishes/search — hybrid FTS (english + simple) + trgm + ILIKE search - GET /diary/recent — recently used dishes and products for the current user - product search upgraded: FTS on canonical_name and product_aliases, ranked by GREATEST(ts_rank, similarity) - importoff: collect product_name_ru/de/fr/... as product_aliases for multilingual search (e.g. "сникерс" → "Snickers") - migrations: FTS + trgm indexes merged into 001_initial_schema.sql (002 removed) Flutter: - FoodSearchSheet: debounced search field, recently-used section, product/dish results, scan-photo and barcode chips - DishPortionSheet: quick ½/1/1½/2 buttons + custom input - + button in meal card now opens FoodSearchSheet instead of going directly to AI scan - 7 new l10n keys across all 12 languages Co-Authored-By: Claude Sonnet 4.6 --- backend/README.md | 2 + backend/cmd/importoff/main.go | 80 +- backend/internal/domain/diary/entity.go | 12 + backend/internal/domain/diary/handler.go | 27 + .../internal/domain/diary/mocks/repository.go | 8 + backend/internal/domain/diary/repository.go | 72 ++ backend/internal/domain/dish/entity.go | 8 + backend/internal/domain/dish/handler.go | 26 + backend/internal/domain/dish/repository.go | 51 ++ backend/internal/domain/product/repository.go | 34 +- backend/internal/infra/server/server.go | 2 + backend/migrations/001_initial_schema.sql | 12 +- .../features/diary/food_search_provider.dart | 27 + .../features/diary/food_search_service.dart | 108 +++ .../lib/features/diary/food_search_sheet.dart | 689 ++++++++++++++++++ client/lib/features/home/home_screen.dart | 18 +- client/lib/l10n/app_ar.arb | 15 +- client/lib/l10n/app_de.arb | 15 +- client/lib/l10n/app_en.arb | 15 +- client/lib/l10n/app_es.arb | 15 +- client/lib/l10n/app_fr.arb | 15 +- client/lib/l10n/app_hi.arb | 15 +- client/lib/l10n/app_it.arb | 15 +- client/lib/l10n/app_ja.arb | 15 +- client/lib/l10n/app_ko.arb | 15 +- client/lib/l10n/app_localizations.dart | 48 ++ client/lib/l10n/app_localizations_ar.dart | 26 + client/lib/l10n/app_localizations_de.dart | 26 + client/lib/l10n/app_localizations_en.dart | 26 + client/lib/l10n/app_localizations_es.dart | 26 + client/lib/l10n/app_localizations_fr.dart | 26 + client/lib/l10n/app_localizations_hi.dart | 26 + client/lib/l10n/app_localizations_it.dart | 26 + client/lib/l10n/app_localizations_ja.dart | 26 + client/lib/l10n/app_localizations_ko.dart | 26 + client/lib/l10n/app_localizations_pt.dart | 26 + client/lib/l10n/app_localizations_ru.dart | 26 + client/lib/l10n/app_localizations_zh.dart | 26 + client/lib/l10n/app_pt.arb | 15 +- client/lib/l10n/app_ru.arb | 15 +- client/lib/l10n/app_zh.arb | 15 +- 41 files changed, 1688 insertions(+), 28 deletions(-) create mode 100644 client/lib/features/diary/food_search_provider.dart create mode 100644 client/lib/features/diary/food_search_service.dart create mode 100644 client/lib/features/diary/food_search_sheet.dart diff --git a/backend/README.md b/backend/README.md index c000e7a..c87d4df 100644 --- a/backend/README.md +++ b/backend/README.md @@ -184,12 +184,14 @@ go run ./cmd/importoff \ | `GET/POST/DELETE` | `/user-products`, `/user-products/{id}` | Продукты пользователя (холодильник) | | `POST` | `/user-products/batch` | Массовое добавление продуктов | | `GET` | `/diary?date=YYYY-MM-DD` | Записи дневника питания за дату | +| `GET` | `/diary/recent?limit=` | Последние записи дневника (блюда и продукты) | | `POST/DELETE` | `/diary`, `/diary/{id}` | Добавить / удалить запись дневника | | `GET` | `/home/summary` | Сводка для главного экрана | | `GET` | `/recommendations` | AI-рекомендации блюд | | `GET/POST/DELETE` | `/saved-recipes`, `/saved-recipes/{id}` | Сохранённые рецепты | | `GET/PUT/DELETE` | `/menu/items/{id}` | Меню на неделю | | `GET/POST/PATCH` | `/shopping-list`, `/shopping-list/{i}/check` | Список покупок | +| `GET` | `/dishes/search?q=&limit=` | Поиск блюд (FTS + trgm) | | `GET` | `/dishes/{id}` | Блюдо по ID | | `GET` | `/recipes/{id}` | Рецепт по ID | | `POST` | `/ai/recognize-receipt` | Распознать чек (синхронно) | diff --git a/backend/cmd/importoff/main.go b/backend/cmd/importoff/main.go index f6a3dd6..6009489 100644 --- a/backend/cmd/importoff/main.go +++ b/backend/cmd/importoff/main.go @@ -21,6 +21,17 @@ type offRecord struct { Code string `json:"code"` ProductName string `json:"product_name"` ProductNameEN string `json:"product_name_en"` + ProductNameRu string `json:"product_name_ru"` + ProductNameDe string `json:"product_name_de"` + ProductNameFr string `json:"product_name_fr"` + ProductNameEs string `json:"product_name_es"` + ProductNameIt string `json:"product_name_it"` + ProductNamePt string `json:"product_name_pt"` + ProductNameZh string `json:"product_name_zh"` + ProductNameJa string `json:"product_name_ja"` + ProductNameKo string `json:"product_name_ko"` + ProductNameAr string `json:"product_name_ar"` + ProductNameHi string `json:"product_name_hi"` CategoriesTags []string `json:"categories_tags"` Nutriments offNutriments `json:"nutriments"` UniqueScansN int `json:"unique_scans_n"` @@ -34,6 +45,12 @@ type offNutriments struct { Fiber100g *float64 `json:"fiber_100g"` } +// aliasRow holds one multilingual alias for a product. +type aliasRow struct { + lang string + alias string +} + type productImportRow struct { canonicalName string barcode string @@ -43,6 +60,7 @@ type productImportRow struct { fat *float64 carbs *float64 fiber *float64 + aliases []aliasRow } // categoryPrefixes maps OpenFoodFacts category tag prefixes to our product_categories slugs. @@ -217,6 +235,28 @@ func run() error { } seenInBatch[canonicalName] = true + // Collect non-English localised names as aliases so that searches in + // other languages (e.g. "сникерс" → "Snickers") can find the product. + langNames := map[string]string{ + "ru": strings.TrimSpace(record.ProductNameRu), + "de": strings.TrimSpace(record.ProductNameDe), + "fr": strings.TrimSpace(record.ProductNameFr), + "es": strings.TrimSpace(record.ProductNameEs), + "it": strings.TrimSpace(record.ProductNameIt), + "pt": strings.TrimSpace(record.ProductNamePt), + "zh": strings.TrimSpace(record.ProductNameZh), + "ja": strings.TrimSpace(record.ProductNameJa), + "ko": strings.TrimSpace(record.ProductNameKo), + "ar": strings.TrimSpace(record.ProductNameAr), + "hi": strings.TrimSpace(record.ProductNameHi), + } + var productAliases []aliasRow + for lang, name := range langNames { + if name != "" && name != canonicalName { + productAliases = append(productAliases, aliasRow{lang: lang, alias: name}) + } + } + totalAccepted++ productBatch = append(productBatch, productImportRow{ canonicalName: canonicalName, @@ -227,6 +267,7 @@ func run() error { fat: record.Nutriments.Fat100g, carbs: record.Nutriments.Carbohydrates100g, fiber: record.Nutriments.Fiber100g, + aliases: productAliases, }) if len(productBatch) >= *batchSizeFlag { @@ -336,18 +377,21 @@ func flushBatch( carbs_per_100g = COALESCE(EXCLUDED.carbs_per_100g, products.carbs_per_100g), fiber_per_100g = COALESCE(EXCLUDED.fiber_per_100g, products.fiber_per_100g), updated_at = now() - RETURNING id`) + RETURNING id, canonical_name`) if upsertError != nil { return 0, fmt.Errorf("upsert products: %w", upsertError) } + // Build a canonical_name → product_id map so we can insert aliases below. + productIDByName := make(map[string]string, len(productBatch)) var insertedCount int64 for upsertRows.Next() { - var productID string - if scanError := upsertRows.Scan(&productID); scanError != nil { + var productID, canonicalName string + if scanError := upsertRows.Scan(&productID, &canonicalName); scanError != nil { upsertRows.Close() return 0, fmt.Errorf("scan upserted product id: %w", scanError) } + productIDByName[canonicalName] = productID insertedCount++ } upsertRows.Close() @@ -355,5 +399,35 @@ func flushBatch( return 0, fmt.Errorf("iterate upsert results: %w", rowsError) } + // Insert multilingual aliases collected during parsing. + // ON CONFLICT DO NOTHING so re-imports are safe. + aliasBatch := &pgx.Batch{} + for _, row := range productBatch { + productID, exists := productIDByName[row.canonicalName] + if !exists || len(row.aliases) == 0 { + continue + } + for _, aliasEntry := range row.aliases { + aliasBatch.Queue( + `INSERT INTO product_aliases (product_id, lang, alias) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING`, + productID, aliasEntry.lang, aliasEntry.alias, + ) + } + } + if aliasBatch.Len() > 0 { + batchResults := pgxConn.SendBatch(requestContext, aliasBatch) + for range aliasBatch.Len() { + if _, execError := batchResults.Exec(); execError != nil { + batchResults.Close() + return 0, fmt.Errorf("insert product alias: %w", execError) + } + } + if closeError := batchResults.Close(); closeError != nil { + return 0, fmt.Errorf("close alias batch: %w", closeError) + } + } + return insertedCount, nil } diff --git a/backend/internal/domain/diary/entity.go b/backend/internal/domain/diary/entity.go index aa7f970..a71f877 100644 --- a/backend/internal/domain/diary/entity.go +++ b/backend/internal/domain/diary/entity.go @@ -22,6 +22,18 @@ type Entry struct { CreatedAt time.Time `json:"created_at"` } +// RecentDiaryItem is a lightweight summary of a recently logged product or dish. +type RecentDiaryItem struct { + ItemType string `json:"item_type"` // "dish" | "product" + DishID *string `json:"dish_id,omitempty"` + ProductID *string `json:"product_id,omitempty"` + Name string `json:"name"` + ImageURL *string `json:"image_url,omitempty"` + CategoryName *string `json:"category_name,omitempty"` + CaloriesPer100g *float64 `json:"calories_per_100g,omitempty"` + CaloriesPerServing *float64 `json:"calories_per_serving,omitempty"` +} + // CreateRequest is the body for POST /diary. type CreateRequest struct { Date string `json:"date"` diff --git a/backend/internal/domain/diary/handler.go b/backend/internal/domain/diary/handler.go index cbe2939..9a17d64 100644 --- a/backend/internal/domain/diary/handler.go +++ b/backend/internal/domain/diary/handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "github.com/food-ai/backend/internal/infra/middleware" "github.com/go-chi/chi/v5" @@ -15,6 +16,7 @@ type DiaryRepository interface { ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) Delete(ctx context.Context, id, userID string) error + GetRecent(ctx context.Context, userID string, limit int) ([]*RecentDiaryItem, error) } // DishRepository is the subset of dish.Repository used by Handler to resolve dish IDs. @@ -119,6 +121,31 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, entry) } +// GetRecent handles GET /diary/recent?limit= +func (h *Handler) GetRecent(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + limit := 10 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if parsed, parseError := strconv.Atoi(limitStr); parseError == nil && parsed > 0 && parsed <= 20 { + limit = parsed + } + } + items, queryError := h.repo.GetRecent(r.Context(), userID, limit) + if queryError != nil { + slog.Error("get recent diary items", "err", queryError) + writeError(w, http.StatusInternalServerError, "failed to get recent items") + return + } + if items == nil { + items = []*RecentDiaryItem{} + } + writeJSON(w, http.StatusOK, items) +} + // Delete handles DELETE /diary/{id} func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) diff --git a/backend/internal/domain/diary/mocks/repository.go b/backend/internal/domain/diary/mocks/repository.go index 19c4867..c5f461e 100644 --- a/backend/internal/domain/diary/mocks/repository.go +++ b/backend/internal/domain/diary/mocks/repository.go @@ -35,6 +35,7 @@ type MockDiaryRepository struct { ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error) CreateFn func(ctx context.Context, userID string, req diary.CreateRequest) (*diary.Entry, error) DeleteFn func(ctx context.Context, id, userID string) error + GetRecentFn func(ctx context.Context, userID string, limit int) ([]*diary.RecentDiaryItem, error) } func (m *MockDiaryRepository) ListByDate(ctx context.Context, userID, date string) ([]*diary.Entry, error) { @@ -48,3 +49,10 @@ func (m *MockDiaryRepository) Create(ctx context.Context, userID string, req dia func (m *MockDiaryRepository) Delete(ctx context.Context, id, userID string) error { return m.DeleteFn(ctx, id, userID) } + +func (m *MockDiaryRepository) GetRecent(ctx context.Context, userID string, limit int) ([]*diary.RecentDiaryItem, error) { + if m.GetRecentFn != nil { + return m.GetRecentFn(ctx, userID, limit) + } + return []*diary.RecentDiaryItem{}, nil +} diff --git a/backend/internal/domain/diary/repository.go b/backend/internal/domain/diary/repository.go index 3d96ac2..863083f 100644 --- a/backend/internal/domain/diary/repository.go +++ b/backend/internal/domain/diary/repository.go @@ -124,6 +124,78 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques return scanEntry(row) } +// GetRecent returns the most recently logged distinct dishes and products for a user. +func (r *Repository) GetRecent(ctx context.Context, userID string, limit int) ([]*RecentDiaryItem, error) { + if limit <= 0 || limit > 20 { + limit = 10 + } + lang := locale.FromContext(ctx) + + const q = ` + WITH recent AS ( + SELECT + dish_id, product_id, + MAX(created_at) AS last_used + FROM meal_diary + WHERE user_id = $1 + GROUP BY dish_id, product_id + ORDER BY last_used DESC + LIMIT $2 + ) + SELECT + r.dish_id::text, + r.product_id::text, + COALESCE(dt.name, d.name, p.canonical_name) AS name, + d.image_url, + COALESCE(pct.name, pc.name) AS category_name, + p.calories_per_100g, + (SELECT MIN(rec.calories_per_serving) + FROM recipes rec WHERE rec.dish_id = r.dish_id) AS calories_per_serving + FROM recent r + LEFT JOIN dishes d + ON d.id = r.dish_id + LEFT JOIN dish_translations dt + ON dt.dish_id = d.id AND dt.lang = $3 + LEFT JOIN products p + ON p.id = r.product_id + LEFT JOIN product_categories pc + ON pc.slug = p.category + LEFT JOIN product_category_translations pct + ON pct.product_category_slug = p.category AND pct.lang = $3 + ORDER BY r.last_used DESC` + + rows, queryError := r.pool.Query(ctx, q, userID, limit, lang) + if queryError != nil { + return nil, fmt.Errorf("get recent diary items: %w", queryError) + } + defer rows.Close() + + var results []*RecentDiaryItem + for rows.Next() { + var item RecentDiaryItem + var dishID, productID *string + if scanError := rows.Scan( + &dishID, &productID, + &item.Name, &item.ImageURL, &item.CategoryName, + &item.CaloriesPer100g, &item.CaloriesPerServing, + ); scanError != nil { + return nil, fmt.Errorf("scan recent diary item: %w", scanError) + } + item.DishID = dishID + item.ProductID = productID + if dishID != nil { + item.ItemType = "dish" + } else { + item.ItemType = "product" + } + results = append(results, &item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + // Delete removes a diary entry for the given user. func (r *Repository) Delete(ctx context.Context, id, userID string) error { tag, deleteError := r.pool.Exec(ctx, diff --git a/backend/internal/domain/dish/entity.go b/backend/internal/domain/dish/entity.go index 455b537..b7380ee 100644 --- a/backend/internal/domain/dish/entity.go +++ b/backend/internal/domain/dish/entity.go @@ -59,6 +59,14 @@ type RecipeStep struct { ImageURL *string `json:"image_url"` } +// DishSearchResult is a lightweight dish returned by the search endpoint. +type DishSearchResult struct { + ID string `json:"id"` + Name string `json:"name"` + ImageURL *string `json:"image_url,omitempty"` + AvgRating float64 `json:"avg_rating"` +} + // CreateRequest is the body used to create a new dish + recipe at once. // Used when saving a Gemini-generated recommendation. type CreateRequest struct { diff --git a/backend/internal/domain/dish/handler.go b/backend/internal/domain/dish/handler.go index 582f0ee..55be158 100644 --- a/backend/internal/domain/dish/handler.go +++ b/backend/internal/domain/dish/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "github.com/go-chi/chi/v5" ) @@ -18,6 +19,31 @@ func NewHandler(repo *Repository) *Handler { return &Handler{repo: repo} } +// Search handles GET /dishes/search?q=&limit= +func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + writeJSON(w, http.StatusOK, []*DishSearchResult{}) + return + } + limit := 10 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if parsed, parseError := strconv.Atoi(limitStr); parseError == nil && parsed > 0 && parsed <= 50 { + limit = parsed + } + } + results, searchError := h.repo.Search(r.Context(), query, limit) + if searchError != nil { + slog.Error("search dishes", "err", searchError) + writeError(w, http.StatusInternalServerError, "failed to search dishes") + return + } + if results == nil { + results = []*DishSearchResult{} + } + writeJSON(w, http.StatusOK, results) +} + // List handles GET /dishes — returns all dishes (no recipe variants). func (h *Handler) List(w http.ResponseWriter, r *http.Request) { dishes, err := h.repo.List(r.Context()) diff --git a/backend/internal/domain/dish/repository.go b/backend/internal/domain/dish/repository.go index 3049bd4..a796d9e 100644 --- a/backend/internal/domain/dish/repository.go +++ b/backend/internal/domain/dish/repository.go @@ -98,6 +98,57 @@ func (r *Repository) List(ctx context.Context) ([]*Dish, error) { return dishes, nil } +// Search finds dishes matching the query string using FTS + trigram similarity. +// Text is resolved for the language in ctx (English fallback). +func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*DishSearchResult, error) { + if limit <= 0 || limit > 50 { + limit = 10 + } + lang := locale.FromContext(ctx) + + const searchQuery = ` + SELECT d.id, + COALESCE(dt.name, d.name) AS name, + d.image_url, + d.avg_rating, + GREATEST( + ts_rank(to_tsvector('english', d.name), plainto_tsquery('english', $1)), + ts_rank(to_tsvector('simple', COALESCE(dt.name, '')), plainto_tsquery('simple', $1)), + similarity(COALESCE(dt.name, d.name), $1) + ) AS rank + FROM dishes d + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 + WHERE ( + to_tsvector('english', d.name) @@ plainto_tsquery('english', $1) + OR to_tsvector('simple', COALESCE(dt.name, '')) @@ plainto_tsquery('simple', $1) + OR d.name ILIKE '%' || $1 || '%' + OR dt.name ILIKE '%' || $1 || '%' + OR similarity(COALESCE(dt.name, d.name), $1) > 0.3 + ) + ORDER BY rank DESC + LIMIT $2` + + rows, queryError := r.pool.Query(ctx, searchQuery, query, limit, lang) + if queryError != nil { + return nil, fmt.Errorf("search dishes: %w", queryError) + } + defer rows.Close() + + var results []*DishSearchResult + for rows.Next() { + var result DishSearchResult + var rank float64 + if scanError := rows.Scan(&result.ID, &result.Name, &result.ImageURL, &result.AvgRating, &rank); scanError != nil { + return nil, fmt.Errorf("scan dish search result: %w", scanError) + } + results = append(results, &result) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + // FindOrCreate returns the dish ID and whether it was newly created. // Looks up by case-insensitive name match; creates a minimal dish row if not found. func (r *Repository) FindOrCreate(ctx context.Context, name string) (id string, created bool, err error) { diff --git a/backend/internal/domain/product/repository.go b/backend/internal/domain/product/repository.go index 4e9ca87..42058fb 100644 --- a/backend/internal/domain/product/repository.go +++ b/backend/internal/domain/product/repository.go @@ -202,15 +202,33 @@ func (r *Repository) Search(requestContext context.Context, query string, limit FROM product_aliases pa WHERE pa.product_id = p.id AND pa.lang = $3 ) al ON true - WHERE EXISTS ( - SELECT 1 FROM product_aliases pa - WHERE pa.product_id = p.id - AND (pa.lang = $3 OR pa.lang = 'en') - AND pa.alias ILIKE '%' || $1 || '%' + WHERE ( + to_tsvector('english', p.canonical_name) @@ plainto_tsquery('english', $1) + OR EXISTS ( + SELECT 1 FROM product_aliases pa + WHERE pa.product_id = p.id + AND (pa.lang = $3 OR pa.lang = 'en') + AND to_tsvector('simple', pa.alias) @@ plainto_tsquery('simple', $1) + ) + OR p.canonical_name ILIKE '%' || $1 || '%' + OR EXISTS ( + SELECT 1 FROM product_aliases pa + WHERE pa.product_id = p.id + AND (pa.lang = $3 OR pa.lang = 'en') + AND pa.alias ILIKE '%' || $1 || '%' + ) + OR similarity(p.canonical_name, $1) > 0.3 ) - OR p.canonical_name ILIKE '%' || $1 || '%' - OR similarity(p.canonical_name, $1) > 0.3 - ORDER BY similarity(p.canonical_name, $1) DESC + ORDER BY + GREATEST( + ts_rank(to_tsvector('english', p.canonical_name), plainto_tsquery('english', $1)), + COALESCE(( + SELECT MAX(ts_rank(to_tsvector('simple', pa.alias), plainto_tsquery('simple', $1))) + FROM product_aliases pa + WHERE pa.product_id = p.id AND (pa.lang = $3 OR pa.lang = 'en') + ), 0), + similarity(p.canonical_name, $1) + ) DESC LIMIT $2` rows, queryError := r.pool.Query(requestContext, searchQuery, query, limit, lang) diff --git a/backend/internal/infra/server/server.go b/backend/internal/infra/server/server.go index 6107df9..61a2099 100644 --- a/backend/internal/infra/server/server.go +++ b/backend/internal/infra/server/server.go @@ -92,6 +92,7 @@ func NewRouter( r.Route("/dishes", func(r chi.Router) { r.Get("/", dishHandler.List) + r.Get("/search", dishHandler.Search) r.Get("/{id}", dishHandler.GetByID) }) @@ -111,6 +112,7 @@ func NewRouter( r.Route("/diary", func(r chi.Router) { r.Get("/", diaryHandler.GetByDate) + r.Get("/recent", diaryHandler.GetRecent) r.Post("/", diaryHandler.Create) r.Delete("/{id}", diaryHandler.Delete) }) diff --git a/backend/migrations/001_initial_schema.sql b/backend/migrations/001_initial_schema.sql index 7b4a598..48344e6 100644 --- a/backend/migrations/001_initial_schema.sql +++ b/backend/migrations/001_initial_schema.sql @@ -141,6 +141,7 @@ CREATE TABLE products ( CREATE INDEX idx_products_canonical_name ON products (canonical_name); CREATE INDEX idx_products_category ON products (category); CREATE INDEX idx_products_barcode ON products (barcode) WHERE barcode IS NOT NULL; +CREATE INDEX idx_products_fts ON products USING GIN (to_tsvector('english', canonical_name)); -- --------------------------------------------------------------------------- -- product_aliases @@ -153,6 +154,7 @@ CREATE TABLE product_aliases ( ); CREATE INDEX idx_product_aliases_lookup ON product_aliases (product_id, lang); CREATE INDEX idx_product_aliases_trgm ON product_aliases USING GIN (alias gin_trgm_ops); +CREATE INDEX idx_product_aliases_fts ON product_aliases USING GIN (to_tsvector('simple', alias)); -- --------------------------------------------------------------------------- -- cuisines + cuisine_translations @@ -217,9 +219,11 @@ CREATE TABLE dishes ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX idx_dishes_cuisine ON dishes (cuisine_slug); -CREATE INDEX idx_dishes_category ON dishes (category_slug); -CREATE INDEX idx_dishes_rating ON dishes (avg_rating DESC); +CREATE INDEX idx_dishes_cuisine ON dishes (cuisine_slug); +CREATE INDEX idx_dishes_category ON dishes (category_slug); +CREATE INDEX idx_dishes_rating ON dishes (avg_rating DESC); +CREATE INDEX idx_dishes_name_fts ON dishes USING GIN (to_tsvector('english', name)); +CREATE INDEX idx_dishes_name_trgm ON dishes USING GIN (name gin_trgm_ops); CREATE TABLE dish_translations ( dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE, @@ -228,6 +232,8 @@ CREATE TABLE dish_translations ( description TEXT, PRIMARY KEY (dish_id, lang) ); +CREATE INDEX idx_dish_translations_name_fts ON dish_translations USING GIN (to_tsvector('simple', name)); +CREATE INDEX idx_dish_translations_name_trgm ON dish_translations USING GIN (name gin_trgm_ops); CREATE TABLE dish_tags ( dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE, diff --git a/client/lib/features/diary/food_search_provider.dart b/client/lib/features/diary/food_search_provider.dart new file mode 100644 index 0000000..da5e4be --- /dev/null +++ b/client/lib/features/diary/food_search_provider.dart @@ -0,0 +1,27 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; +import '../../shared/models/product.dart'; +import 'food_search_service.dart'; + +final foodSearchServiceProvider = Provider( + (ref) => FoodSearchService(ref.read(apiClientProvider)), +); + +/// Recently used diary items (dishes + products). +final recentDiaryItemsProvider = + FutureProvider.autoDispose>((ref) { + return ref.read(foodSearchServiceProvider).getRecent(limit: 15); +}); + +/// Product search results for the given query string. +final productSearchProvider = FutureProvider.autoDispose + .family, String>((ref, query) { + return ref.read(foodSearchServiceProvider).searchProducts(query); +}); + +/// Dish search results for the given query string. +final dishSearchProvider = FutureProvider.autoDispose + .family, String>((ref, query) { + return ref.read(foodSearchServiceProvider).searchDishes(query); +}); diff --git a/client/lib/features/diary/food_search_service.dart b/client/lib/features/diary/food_search_service.dart new file mode 100644 index 0000000..76532f8 --- /dev/null +++ b/client/lib/features/diary/food_search_service.dart @@ -0,0 +1,108 @@ +import '../../core/api/api_client.dart'; +import '../../shared/models/product.dart'; + +/// Lightweight dish result returned by GET /dishes/search. +class DishSearchResult { + final String id; + final String name; + final String? imageUrl; + final double avgRating; + + const DishSearchResult({ + required this.id, + required this.name, + this.imageUrl, + required this.avgRating, + }); + + factory DishSearchResult.fromJson(Map json) { + return DishSearchResult( + id: json['id'] as String, + name: json['name'] as String, + imageUrl: json['image_url'] as String?, + avgRating: (json['avg_rating'] as num?)?.toDouble() ?? 0, + ); + } +} + +/// One item from GET /diary/recent. +class RecentDiaryItem { + final String itemType; // "dish" | "product" + final String? dishId; + final String? productId; + final String name; + final String? imageUrl; + final String? categoryName; + final double? caloriesPer100g; + final double? caloriesPerServing; + + const RecentDiaryItem({ + required this.itemType, + this.dishId, + this.productId, + required this.name, + this.imageUrl, + this.categoryName, + this.caloriesPer100g, + this.caloriesPerServing, + }); + + factory RecentDiaryItem.fromJson(Map json) { + return RecentDiaryItem( + itemType: json['item_type'] as String? ?? 'dish', + dishId: json['dish_id'] as String?, + productId: json['product_id'] as String?, + name: json['name'] as String? ?? '', + imageUrl: json['image_url'] as String?, + categoryName: json['category_name'] as String?, + caloriesPer100g: (json['calories_per_100g'] as num?)?.toDouble(), + caloriesPerServing: (json['calories_per_serving'] as num?)?.toDouble(), + ); + } + + /// For products: calories per 100 g; for dishes: calories per serving. + double? get displayCalories => + itemType == 'product' ? caloriesPer100g : caloriesPerServing; +} + +/// Service for searching products/dishes and loading recently used diary items. +class FoodSearchService { + const FoodSearchService(this._client); + + final ApiClient _client; + + /// Searches catalog products by name. + Future> searchProducts(String query) async { + if (query.isEmpty) return []; + final list = await _client.getList( + '/products/search', + params: {'q': query, 'limit': '20'}, + ); + return list + .map((item) => CatalogProduct.fromJson(item as Map)) + .toList(); + } + + /// Searches dishes by name. + Future> searchDishes(String query) async { + if (query.isEmpty) return []; + final list = await _client.getList( + '/dishes/search', + params: {'q': query, 'limit': '10'}, + ); + return list + .map((item) => DishSearchResult.fromJson(item as Map)) + .toList(); + } + + /// Returns recent diary items (dishes and products) for the current user. + Future> getRecent({int limit = 10}) async { + final list = await _client.getList( + '/diary/recent', + params: {'limit': '$limit'}, + ); + return list + .map((item) => RecentDiaryItem.fromJson(item as Map)) + .toList(); + } +} diff --git a/client/lib/features/diary/food_search_sheet.dart b/client/lib/features/diary/food_search_sheet.dart new file mode 100644 index 0000000..7970542 --- /dev/null +++ b/client/lib/features/diary/food_search_sheet.dart @@ -0,0 +1,689 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; +import '../../l10n/app_localizations.dart'; +import '../../shared/models/product.dart'; +import 'barcode_scan_screen.dart'; +import 'food_search_provider.dart'; +import 'food_search_service.dart'; +import 'product_portion_sheet.dart'; + +/// Bottom sheet for searching and selecting food (product or dish) to add to diary. +/// +/// When the search query is empty the sheet shows recently used items. +/// When the search query is non-empty it shows product and dish search results. +class FoodSearchSheet extends ConsumerStatefulWidget { + const FoodSearchSheet({ + super.key, + required this.mealType, + required this.date, + required this.onAdded, + this.onScanDish, + }); + + final String mealType; + final String date; + + /// Called after any diary entry has been successfully added. + final VoidCallback onAdded; + + /// Optional callback to trigger AI dish-from-photo recognition. + /// When null the scan-photo chip is hidden. + final VoidCallback? onScanDish; + + @override + ConsumerState createState() => _FoodSearchSheetState(); +} + +class _FoodSearchSheetState extends ConsumerState { + final TextEditingController _queryController = TextEditingController(); + Timer? _debounce; + String _activeQuery = ''; + + @override + void initState() { + super.initState(); + _queryController.addListener(_onQueryChanged); + } + + @override + void dispose() { + _debounce?.cancel(); + _queryController.dispose(); + super.dispose(); + } + + void _onQueryChanged() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + final trimmed = _queryController.text.trim(); + if (trimmed != _activeQuery) { + setState(() => _activeQuery = trimmed); + } + }); + } + + Future _addProductToDiary( + CatalogProduct catalogProduct, double portionGrams) async { + await ref.read(apiClientProvider).post('/diary', data: { + 'product_id': catalogProduct.id, + 'portion_g': portionGrams, + 'meal_type': widget.mealType, + 'date': widget.date, + 'source': 'search', + }); + } + + void _openProductPortion(CatalogProduct catalogProduct) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(sheetContext).bottom, + ), + child: ProductPortionSheet( + catalogProduct: catalogProduct, + onConfirm: (portionGrams) async { + try { + await _addProductToDiary(catalogProduct, portionGrams); + widget.onAdded(); + if (mounted) Navigator.pop(context); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.addFailed), + ), + ); + } + } + }, + ), + ), + ); + } + + void _openDishPortion(DishSearchResult dish) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _DishPortionSheet( + dishId: dish.id, + dishName: dish.name, + mealType: widget.mealType, + date: widget.date, + onAdded: () { + widget.onAdded(); + Navigator.pop(context); + }, + ), + ); + } + + void _openRecentItem(RecentDiaryItem recentItem) { + if (recentItem.itemType == 'product' && recentItem.productId != null) { + final catalogProduct = CatalogProduct( + id: recentItem.productId!, + canonicalName: recentItem.name, + categoryName: recentItem.categoryName, + caloriesPer100g: recentItem.caloriesPer100g, + ); + _openProductPortion(catalogProduct); + } else if (recentItem.itemType == 'dish' && recentItem.dishId != null) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _DishPortionSheet( + dishId: recentItem.dishId!, + dishName: recentItem.name, + caloriesPerServing: recentItem.caloriesPerServing, + mealType: widget.mealType, + date: widget.date, + onAdded: () { + widget.onAdded(); + Navigator.pop(context); + }, + ), + ); + } + } + + void _openBarcodeScanner() { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BarcodeScanScreen( + mealType: widget.mealType, + date: widget.date, + onAdded: widget.onAdded, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.92, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (sheetContext, scrollController) { + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _queryController, + autofocus: true, + decoration: InputDecoration( + hintText: l10n.searchFoodHint, + prefixIcon: const Icon(Icons.search), + suffixIcon: _activeQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _queryController.clear(); + setState(() => _activeQuery = ''); + }, + ) + : null, + border: const OutlineInputBorder(), + contentPadding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + + // Quick action chips + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Wrap( + spacing: 8, + children: [ + if (widget.onScanDish != null) + ActionChip( + avatar: + const Icon(Icons.camera_alt_outlined, size: 18), + label: Text(l10n.scanDishPhoto), + onPressed: () { + Navigator.pop(context); + widget.onScanDish!(); + }, + ), + ActionChip( + avatar: const Icon(Icons.qr_code_scanner, size: 18), + label: Text(l10n.scanBarcode), + onPressed: _openBarcodeScanner, + ), + ], + ), + ), + + const SizedBox(height: 8), + + // Results area + Expanded( + child: _activeQuery.isEmpty + ? _RecentSection( + scrollController: scrollController, + onTap: _openRecentItem, + ) + : _SearchResults( + query: _activeQuery, + scrollController: scrollController, + onTapProduct: _openProductPortion, + onTapDish: _openDishPortion, + ), + ), + ], + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Recently used section +// --------------------------------------------------------------------------- + +class _RecentSection extends ConsumerWidget { + const _RecentSection({ + required this.scrollController, + required this.onTap, + }); + + final ScrollController scrollController; + final void Function(RecentDiaryItem) onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final recentState = ref.watch(recentDiaryItemsProvider); + + return recentState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const SizedBox.shrink(), + data: (recentItems) { + if (recentItems.isEmpty) return const SizedBox.shrink(); + return ListView.builder( + controller: scrollController, + itemCount: recentItems.length + 1, // +1 for header + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + l10n.recentlyUsedLabel, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + } + final recentItem = recentItems[index - 1]; + return _FoodTile.fromRecent( + recentItem: recentItem, + onTap: () => onTap(recentItem), + ); + }, + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Search results section +// --------------------------------------------------------------------------- + +class _SearchResults extends ConsumerWidget { + const _SearchResults({ + required this.query, + required this.scrollController, + required this.onTapProduct, + required this.onTapDish, + }); + + final String query; + final ScrollController scrollController; + final void Function(CatalogProduct) onTapProduct; + final void Function(DishSearchResult) onTapDish; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final productsState = ref.watch(productSearchProvider(query)); + final dishesState = ref.watch(dishSearchProvider(query)); + + if (productsState.isLoading && dishesState.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + final products = productsState.valueOrNull ?? []; + final dishes = dishesState.valueOrNull ?? []; + + if (products.isEmpty && dishes.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + l10n.noResultsForQuery(query), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + final items = <_ListItem>[]; + if (products.isNotEmpty) { + items.add(_SectionHeader(l10n.productsSection)); + items.addAll(products.map(_ProductItem.new)); + } + if (dishes.isNotEmpty) { + items.add(_SectionHeader(l10n.dishesSection)); + items.addAll(dishes.map(_DishItem.new)); + } + + return ListView.builder( + controller: scrollController, + itemCount: items.length, + itemBuilder: (context, index) { + final listItem = items[index]; + if (listItem is _SectionHeader) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + listItem.title, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + } else if (listItem is _ProductItem) { + return _FoodTile.fromProduct( + catalogProduct: listItem.catalogProduct, + onTap: () => onTapProduct(listItem.catalogProduct), + ); + } else if (listItem is _DishItem) { + return _FoodTile.fromDish( + dish: listItem.dish, + onTap: () => onTapDish(listItem.dish), + ); + } + return const SizedBox.shrink(); + }, + ); + } +} + +// ── Flat-list item types ─────────────────────────────────────── + +sealed class _ListItem {} + +final class _SectionHeader extends _ListItem { + final String title; + _SectionHeader(this.title); +} + +final class _ProductItem extends _ListItem { + final CatalogProduct catalogProduct; + _ProductItem(this.catalogProduct); +} + +final class _DishItem extends _ListItem { + final DishSearchResult dish; + _DishItem(this.dish); +} + +// --------------------------------------------------------------------------- +// Universal food tile +// --------------------------------------------------------------------------- + +class _FoodTile extends StatelessWidget { + const _FoodTile({ + required this.leading, + required this.title, + this.subtitle, + required this.onTap, + }); + + final Widget leading; + final String title; + final String? subtitle; + final VoidCallback onTap; + + factory _FoodTile.fromProduct({ + required CatalogProduct catalogProduct, + required VoidCallback onTap, + }) { + final calories = catalogProduct.caloriesPer100g; + final parts = [ + if (catalogProduct.categoryName != null) catalogProduct.categoryName!, + if (calories != null) '${calories.toInt()} kcal/100g', + ]; + return _FoodTile( + leading: CircleAvatar( + radius: 20, + backgroundColor: Colors.orange.shade50, + child: const Icon(Icons.fastfood_outlined, + size: 20, color: Colors.orange), + ), + title: catalogProduct.displayName, + subtitle: parts.isNotEmpty ? parts.join(' · ') : null, + onTap: onTap, + ); + } + + factory _FoodTile.fromDish({ + required DishSearchResult dish, + required VoidCallback onTap, + }) { + return _FoodTile( + leading: dish.imageUrl != null + ? CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(dish.imageUrl!), + ) + : CircleAvatar( + radius: 20, + backgroundColor: Colors.green.shade50, + child: const Icon(Icons.restaurant, + size: 20, color: Colors.green), + ), + title: dish.name, + subtitle: null, + onTap: onTap, + ); + } + + factory _FoodTile.fromRecent({ + required RecentDiaryItem recentItem, + required VoidCallback onTap, + }) { + final calories = recentItem.displayCalories; + final parts = [ + if (recentItem.categoryName != null) recentItem.categoryName!, + if (calories != null) + recentItem.itemType == 'product' + ? '${calories.toInt()} kcal/100g' + : '${calories.toInt()} kcal/serving', + ]; + return _FoodTile( + leading: recentItem.imageUrl != null + ? CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(recentItem.imageUrl!), + ) + : CircleAvatar( + radius: 20, + backgroundColor: recentItem.itemType == 'product' + ? Colors.orange.shade50 + : Colors.green.shade50, + child: Icon( + recentItem.itemType == 'product' + ? Icons.fastfood_outlined + : Icons.restaurant, + size: 20, + color: recentItem.itemType == 'product' + ? Colors.orange + : Colors.green, + ), + ), + title: recentItem.name, + subtitle: parts.isNotEmpty ? parts.join(' · ') : null, + onTap: onTap, + ); + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: leading, + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + onTap: onTap, + ); + } +} + +// --------------------------------------------------------------------------- +// Dish portion sheet +// --------------------------------------------------------------------------- + +class _DishPortionSheet extends ConsumerStatefulWidget { + const _DishPortionSheet({ + required this.dishId, + required this.dishName, + this.caloriesPerServing, + required this.mealType, + required this.date, + required this.onAdded, + }); + + final String dishId; + final String dishName; + final double? caloriesPerServing; + final String mealType; + final String date; + final VoidCallback onAdded; + + @override + ConsumerState<_DishPortionSheet> createState() => _DishPortionSheetState(); +} + +class _DishPortionSheetState extends ConsumerState<_DishPortionSheet> { + double _selectedPortions = 1.0; + bool _saving = false; + late final TextEditingController _portionsController = + TextEditingController(text: '1'); + + @override + void dispose() { + _portionsController.dispose(); + super.dispose(); + } + + void _setPortions(double value) { + setState(() { + _selectedPortions = value; + _portionsController.text = value % 1 == 0 + ? value.toInt().toString() + : value.toStringAsFixed(1); + }); + } + + Future _confirm() async { + final parsed = double.tryParse(_portionsController.text); + final portions = + (parsed != null && parsed > 0) ? parsed : _selectedPortions; + setState(() => _saving = true); + try { + await ref.read(apiClientProvider).post('/diary', data: { + 'dish_id': widget.dishId, + 'portions': portions, + 'meal_type': widget.mealType, + 'date': widget.date, + 'source': 'search', + }); + widget.onAdded(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.addFailed), + ), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final insets = MediaQuery.viewInsetsOf(context); + + return Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.dishName, style: theme.textTheme.titleMedium), + if (widget.caloriesPerServing != null) + Text( + '${widget.caloriesPerServing!.toInt()} kcal / serving', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + + // Quick-select portion buttons + Row( + children: [ + for (final quickValue in [0.5, 1.0, 1.5, 2.0]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton( + onPressed: () => _setPortions(quickValue), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + side: BorderSide( + color: _selectedPortions == quickValue + ? theme.colorScheme.primary + : theme.colorScheme.outline, + width: _selectedPortions == quickValue ? 2 : 1, + ), + ), + child: Text(quickValue % 1 == 0 + ? quickValue.toInt().toString() + : quickValue.toStringAsFixed(1)), + ), + ), + ], + ), + const SizedBox(height: 12), + + TextField( + controller: _portionsController, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: l10n.servingsLabel, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + FilledButton( + onPressed: _saving ? null : _confirm, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.addToDiary), + ), + ], + ), + ); + } +} diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index 30f20c3..a309d1f 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -13,6 +13,7 @@ import '../../core/theme/app_colors.dart'; import '../../shared/models/diary_entry.dart'; import '../../shared/models/home_summary.dart'; import '../../shared/models/meal_type.dart'; +import '../diary/food_search_sheet.dart'; import '../menu/menu_provider.dart'; import '../profile/profile_provider.dart'; import '../scan/dish_result_screen.dart'; @@ -966,8 +967,21 @@ class _MealCard extends ConsumerWidget { icon: const Icon(Icons.add, size: 20), visualDensity: VisualDensity.compact, tooltip: l10n.addDish, - onPressed: () => _pickAndShowDishResult( - context, ref, mealTypeOption.id), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => FoodSearchSheet( + mealType: mealTypeOption.id, + date: dateString, + onAdded: () => ref + .invalidate(diaryProvider(dateString)), + onScanDish: () => _pickAndShowDishResult( + context, ref, mealTypeOption.id), + ), + ); + }, ), ], ), diff --git a/client/lib/l10n/app_ar.arb b/client/lib/l10n/app_ar.arb index 400d911..6908d27 100644 --- a/client/lib/l10n/app_ar.arb +++ b/client/lib/l10n/app_ar.arb @@ -110,5 +110,18 @@ "portionWeightG": "وزن الحصة (جم)", "productNotFound": "المنتج غير موجود", "enterManually": "أدخل يدوياً", - "perHundredG": "لكل 100 جم" + "perHundredG": "لكل 100 جم", + "searchFoodHint": "البحث عن المنتجات والأطباق...", + "recentlyUsedLabel": "المستخدمة مؤخراً", + "productsSection": "المنتجات", + "dishesSection": "الأطباق", + "noResultsForQuery": "لم يتم العثور على نتائج لـ \"{query}\"", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "حصص", + "addToDiary": "إضافة إلى اليومية", + "scanDishPhoto": "مسح الصورة" } diff --git a/client/lib/l10n/app_de.arb b/client/lib/l10n/app_de.arb index d2d0f35..dd5812d 100644 --- a/client/lib/l10n/app_de.arb +++ b/client/lib/l10n/app_de.arb @@ -110,5 +110,18 @@ "portionWeightG": "Portionsgewicht (g)", "productNotFound": "Produkt nicht gefunden", "enterManually": "Manuell eingeben", - "perHundredG": "pro 100 g" + "perHundredG": "pro 100 g", + "searchFoodHint": "Produkte und Gerichte suchen...", + "recentlyUsedLabel": "Zuletzt verwendet", + "productsSection": "Produkte", + "dishesSection": "Gerichte", + "noResultsForQuery": "Keine Ergebnisse für \"{query}\"", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "Portionen", + "addToDiary": "Zum Tagebuch hinzufügen", + "scanDishPhoto": "Foto scannen" } diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index 01bbd51..9191bb1 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -108,5 +108,18 @@ "portionWeightG": "Portion weight (g)", "productNotFound": "Product not found", "enterManually": "Enter manually", - "perHundredG": "per 100 g" + "perHundredG": "per 100 g", + "searchFoodHint": "Search products and dishes...", + "recentlyUsedLabel": "Recently used", + "productsSection": "Products", + "dishesSection": "Dishes", + "noResultsForQuery": "Nothing found for \"{query}\"", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "Servings", + "addToDiary": "Add to diary", + "scanDishPhoto": "Scan photo" } diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index a44c94f..02f062f 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -110,5 +110,18 @@ "portionWeightG": "Peso de la porción (g)", "productNotFound": "Producto no encontrado", "enterManually": "Ingresar manualmente", - "perHundredG": "por 100 g" + "perHundredG": "por 100 g", + "searchFoodHint": "Buscar productos y platos...", + "recentlyUsedLabel": "Usados recientemente", + "productsSection": "Productos", + "dishesSection": "Platos", + "noResultsForQuery": "Nada encontrado para \"{query}\"", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "Porciones", + "addToDiary": "Añadir al diario", + "scanDishPhoto": "Escanear foto" } diff --git a/client/lib/l10n/app_fr.arb b/client/lib/l10n/app_fr.arb index 6c56742..646466e 100644 --- a/client/lib/l10n/app_fr.arb +++ b/client/lib/l10n/app_fr.arb @@ -110,5 +110,18 @@ "portionWeightG": "Poids de la portion (g)", "productNotFound": "Produit introuvable", "enterManually": "Saisir manuellement", - "perHundredG": "pour 100 g" + "perHundredG": "pour 100 g", + "searchFoodHint": "Rechercher produits et plats...", + "recentlyUsedLabel": "Récemment utilisés", + "productsSection": "Produits", + "dishesSection": "Plats", + "noResultsForQuery": "Rien trouvé pour \"{query}\"", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "Portions", + "addToDiary": "Ajouter au journal", + "scanDishPhoto": "Scanner une photo" } diff --git a/client/lib/l10n/app_hi.arb b/client/lib/l10n/app_hi.arb index 6020e0b..de6f1e7 100644 --- a/client/lib/l10n/app_hi.arb +++ b/client/lib/l10n/app_hi.arb @@ -110,5 +110,18 @@ "portionWeightG": "हिस्से का वजन (ग्राम)", "productNotFound": "उत्पाद नहीं मिला", "enterManually": "मैन्युअल दर्ज करें", - "perHundredG": "प्रति 100 ग्राम" + "perHundredG": "प्रति 100 ग्राम", + "searchFoodHint": "उत्पाद और व्यंजन खोजें...", + "recentlyUsedLabel": "हाल ही में उपयोग किए गए", + "productsSection": "उत्पाद", + "dishesSection": "व्यंजन", + "noResultsForQuery": "\"{query}\" के लिए कुछ नहीं मिला", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "सर्विंग", + "addToDiary": "डायरी में जोड़ें", + "scanDishPhoto": "फ़ोटो स्कैन करें" } diff --git a/client/lib/l10n/app_it.arb b/client/lib/l10n/app_it.arb index a6bd019..b2f1177 100644 --- a/client/lib/l10n/app_it.arb +++ b/client/lib/l10n/app_it.arb @@ -110,5 +110,18 @@ "portionWeightG": "Peso della porzione (g)", "productNotFound": "Prodotto non trovato", "enterManually": "Inserisci manualmente", - "perHundredG": "per 100 g" + "perHundredG": "per 100 g", + "searchFoodHint": "Cerca prodotti e piatti...", + "recentlyUsedLabel": "Usati di recente", + "productsSection": "Prodotti", + "dishesSection": "Piatti", + "noResultsForQuery": "Nessun risultato per \"{query}\"", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "Porzioni", + "addToDiary": "Aggiungi al diario", + "scanDishPhoto": "Scansiona foto" } diff --git a/client/lib/l10n/app_ja.arb b/client/lib/l10n/app_ja.arb index fb0b429..b0ca73d 100644 --- a/client/lib/l10n/app_ja.arb +++ b/client/lib/l10n/app_ja.arb @@ -110,5 +110,18 @@ "portionWeightG": "1食分の重さ(g)", "productNotFound": "商品が見つかりません", "enterManually": "手動で入力", - "perHundredG": "100gあたり" + "perHundredG": "100gあたり", + "searchFoodHint": "食品と料理を検索...", + "recentlyUsedLabel": "最近使用", + "productsSection": "食品", + "dishesSection": "料理", + "noResultsForQuery": "「{query}」の検索結果はありません", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "人前", + "addToDiary": "日記に追加", + "scanDishPhoto": "写真をスキャン" } diff --git a/client/lib/l10n/app_ko.arb b/client/lib/l10n/app_ko.arb index daccb51..dbd00f5 100644 --- a/client/lib/l10n/app_ko.arb +++ b/client/lib/l10n/app_ko.arb @@ -110,5 +110,18 @@ "portionWeightG": "1회 제공량 (g)", "productNotFound": "제품을 찾을 수 없습니다", "enterManually": "직접 입력", - "perHundredG": "100g당" + "perHundredG": "100g당", + "searchFoodHint": "식품 및 요리 검색...", + "recentlyUsedLabel": "최근 사용", + "productsSection": "식품", + "dishesSection": "요리", + "noResultsForQuery": "\"{query}\"에 대한 결과 없음", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "인분", + "addToDiary": "일기에 추가", + "scanDishPhoto": "사진 스캔" } diff --git a/client/lib/l10n/app_localizations.dart b/client/lib/l10n/app_localizations.dart index 7a7f7f0..a65ad8f 100644 --- a/client/lib/l10n/app_localizations.dart +++ b/client/lib/l10n/app_localizations.dart @@ -741,6 +741,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'per 100 g'** String get perHundredG; + + /// No description provided for @searchFoodHint. + /// + /// In en, this message translates to: + /// **'Search products and dishes...'** + String get searchFoodHint; + + /// No description provided for @recentlyUsedLabel. + /// + /// In en, this message translates to: + /// **'Recently used'** + String get recentlyUsedLabel; + + /// No description provided for @productsSection. + /// + /// In en, this message translates to: + /// **'Products'** + String get productsSection; + + /// No description provided for @dishesSection. + /// + /// In en, this message translates to: + /// **'Dishes'** + String get dishesSection; + + /// No description provided for @noResultsForQuery. + /// + /// In en, this message translates to: + /// **'Nothing found for \"{query}\"'** + String noResultsForQuery(String query); + + /// No description provided for @servingsLabel. + /// + /// In en, this message translates to: + /// **'Servings'** + String get servingsLabel; + + /// No description provided for @addToDiary. + /// + /// In en, this message translates to: + /// **'Add to diary'** + String get addToDiary; + + /// No description provided for @scanDishPhoto. + /// + /// In en, this message translates to: + /// **'Scan photo'** + String get scanDishPhoto; } class _AppLocalizationsDelegate diff --git a/client/lib/l10n/app_localizations_ar.dart b/client/lib/l10n/app_localizations_ar.dart index 8427aaa..4755b87 100644 --- a/client/lib/l10n/app_localizations_ar.dart +++ b/client/lib/l10n/app_localizations_ar.dart @@ -322,4 +322,30 @@ class AppLocalizationsAr extends AppLocalizations { @override String get perHundredG => 'لكل 100 جم'; + + @override + String get searchFoodHint => 'البحث عن المنتجات والأطباق...'; + + @override + String get recentlyUsedLabel => 'المستخدمة مؤخراً'; + + @override + String get productsSection => 'المنتجات'; + + @override + String get dishesSection => 'الأطباق'; + + @override + String noResultsForQuery(String query) { + return 'لم يتم العثور على نتائج لـ \"$query\"'; + } + + @override + String get servingsLabel => 'حصص'; + + @override + String get addToDiary => 'إضافة إلى اليومية'; + + @override + String get scanDishPhoto => 'مسح الصورة'; } diff --git a/client/lib/l10n/app_localizations_de.dart b/client/lib/l10n/app_localizations_de.dart index 1d63c23..a6c587f 100644 --- a/client/lib/l10n/app_localizations_de.dart +++ b/client/lib/l10n/app_localizations_de.dart @@ -324,4 +324,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get perHundredG => 'pro 100 g'; + + @override + String get searchFoodHint => 'Produkte und Gerichte suchen...'; + + @override + String get recentlyUsedLabel => 'Zuletzt verwendet'; + + @override + String get productsSection => 'Produkte'; + + @override + String get dishesSection => 'Gerichte'; + + @override + String noResultsForQuery(String query) { + return 'Keine Ergebnisse für \"$query\"'; + } + + @override + String get servingsLabel => 'Portionen'; + + @override + String get addToDiary => 'Zum Tagebuch hinzufügen'; + + @override + String get scanDishPhoto => 'Foto scannen'; } diff --git a/client/lib/l10n/app_localizations_en.dart b/client/lib/l10n/app_localizations_en.dart index 02b25df..f9c50f4 100644 --- a/client/lib/l10n/app_localizations_en.dart +++ b/client/lib/l10n/app_localizations_en.dart @@ -322,4 +322,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get perHundredG => 'per 100 g'; + + @override + String get searchFoodHint => 'Search products and dishes...'; + + @override + String get recentlyUsedLabel => 'Recently used'; + + @override + String get productsSection => 'Products'; + + @override + String get dishesSection => 'Dishes'; + + @override + String noResultsForQuery(String query) { + return 'Nothing found for \"$query\"'; + } + + @override + String get servingsLabel => 'Servings'; + + @override + String get addToDiary => 'Add to diary'; + + @override + String get scanDishPhoto => 'Scan photo'; } diff --git a/client/lib/l10n/app_localizations_es.dart b/client/lib/l10n/app_localizations_es.dart index ff5dc77..ed04397 100644 --- a/client/lib/l10n/app_localizations_es.dart +++ b/client/lib/l10n/app_localizations_es.dart @@ -324,4 +324,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get perHundredG => 'por 100 g'; + + @override + String get searchFoodHint => 'Buscar productos y platos...'; + + @override + String get recentlyUsedLabel => 'Usados recientemente'; + + @override + String get productsSection => 'Productos'; + + @override + String get dishesSection => 'Platos'; + + @override + String noResultsForQuery(String query) { + return 'Nada encontrado para \"$query\"'; + } + + @override + String get servingsLabel => 'Porciones'; + + @override + String get addToDiary => 'Añadir al diario'; + + @override + String get scanDishPhoto => 'Escanear foto'; } diff --git a/client/lib/l10n/app_localizations_fr.dart b/client/lib/l10n/app_localizations_fr.dart index a207700..3406a8b 100644 --- a/client/lib/l10n/app_localizations_fr.dart +++ b/client/lib/l10n/app_localizations_fr.dart @@ -325,4 +325,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String get perHundredG => 'pour 100 g'; + + @override + String get searchFoodHint => 'Rechercher produits et plats...'; + + @override + String get recentlyUsedLabel => 'Récemment utilisés'; + + @override + String get productsSection => 'Produits'; + + @override + String get dishesSection => 'Plats'; + + @override + String noResultsForQuery(String query) { + return 'Rien trouvé pour \"$query\"'; + } + + @override + String get servingsLabel => 'Portions'; + + @override + String get addToDiary => 'Ajouter au journal'; + + @override + String get scanDishPhoto => 'Scanner une photo'; } diff --git a/client/lib/l10n/app_localizations_hi.dart b/client/lib/l10n/app_localizations_hi.dart index cf378fb..7a0edef 100644 --- a/client/lib/l10n/app_localizations_hi.dart +++ b/client/lib/l10n/app_localizations_hi.dart @@ -323,4 +323,30 @@ class AppLocalizationsHi extends AppLocalizations { @override String get perHundredG => 'प्रति 100 ग्राम'; + + @override + String get searchFoodHint => 'उत्पाद और व्यंजन खोजें...'; + + @override + String get recentlyUsedLabel => 'हाल ही में उपयोग किए गए'; + + @override + String get productsSection => 'उत्पाद'; + + @override + String get dishesSection => 'व्यंजन'; + + @override + String noResultsForQuery(String query) { + return '\"$query\" के लिए कुछ नहीं मिला'; + } + + @override + String get servingsLabel => 'सर्विंग'; + + @override + String get addToDiary => 'डायरी में जोड़ें'; + + @override + String get scanDishPhoto => 'फ़ोटो स्कैन करें'; } diff --git a/client/lib/l10n/app_localizations_it.dart b/client/lib/l10n/app_localizations_it.dart index d3c2f5f..b0d3275 100644 --- a/client/lib/l10n/app_localizations_it.dart +++ b/client/lib/l10n/app_localizations_it.dart @@ -324,4 +324,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String get perHundredG => 'per 100 g'; + + @override + String get searchFoodHint => 'Cerca prodotti e piatti...'; + + @override + String get recentlyUsedLabel => 'Usati di recente'; + + @override + String get productsSection => 'Prodotti'; + + @override + String get dishesSection => 'Piatti'; + + @override + String noResultsForQuery(String query) { + return 'Nessun risultato per \"$query\"'; + } + + @override + String get servingsLabel => 'Porzioni'; + + @override + String get addToDiary => 'Aggiungi al diario'; + + @override + String get scanDishPhoto => 'Scansiona foto'; } diff --git a/client/lib/l10n/app_localizations_ja.dart b/client/lib/l10n/app_localizations_ja.dart index 6c2a382..3ab2d3d 100644 --- a/client/lib/l10n/app_localizations_ja.dart +++ b/client/lib/l10n/app_localizations_ja.dart @@ -321,4 +321,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get perHundredG => '100gあたり'; + + @override + String get searchFoodHint => '食品と料理を検索...'; + + @override + String get recentlyUsedLabel => '最近使用'; + + @override + String get productsSection => '食品'; + + @override + String get dishesSection => '料理'; + + @override + String noResultsForQuery(String query) { + return '「$query」の検索結果はありません'; + } + + @override + String get servingsLabel => '人前'; + + @override + String get addToDiary => '日記に追加'; + + @override + String get scanDishPhoto => '写真をスキャン'; } diff --git a/client/lib/l10n/app_localizations_ko.dart b/client/lib/l10n/app_localizations_ko.dart index e325378..99db8e6 100644 --- a/client/lib/l10n/app_localizations_ko.dart +++ b/client/lib/l10n/app_localizations_ko.dart @@ -321,4 +321,30 @@ class AppLocalizationsKo extends AppLocalizations { @override String get perHundredG => '100g당'; + + @override + String get searchFoodHint => '식품 및 요리 검색...'; + + @override + String get recentlyUsedLabel => '최근 사용'; + + @override + String get productsSection => '식품'; + + @override + String get dishesSection => '요리'; + + @override + String noResultsForQuery(String query) { + return '\"$query\"에 대한 결과 없음'; + } + + @override + String get servingsLabel => '인분'; + + @override + String get addToDiary => '일기에 추가'; + + @override + String get scanDishPhoto => '사진 스캔'; } diff --git a/client/lib/l10n/app_localizations_pt.dart b/client/lib/l10n/app_localizations_pt.dart index d22b8db..a165acd 100644 --- a/client/lib/l10n/app_localizations_pt.dart +++ b/client/lib/l10n/app_localizations_pt.dart @@ -324,4 +324,30 @@ class AppLocalizationsPt extends AppLocalizations { @override String get perHundredG => 'por 100 g'; + + @override + String get searchFoodHint => 'Pesquisar produtos e pratos...'; + + @override + String get recentlyUsedLabel => 'Usados recentemente'; + + @override + String get productsSection => 'Produtos'; + + @override + String get dishesSection => 'Pratos'; + + @override + String noResultsForQuery(String query) { + return 'Nada encontrado para \"$query\"'; + } + + @override + String get servingsLabel => 'Porções'; + + @override + String get addToDiary => 'Adicionar ao diário'; + + @override + String get scanDishPhoto => 'Escanear foto'; } diff --git a/client/lib/l10n/app_localizations_ru.dart b/client/lib/l10n/app_localizations_ru.dart index d98ad57..4d80c4e 100644 --- a/client/lib/l10n/app_localizations_ru.dart +++ b/client/lib/l10n/app_localizations_ru.dart @@ -322,4 +322,30 @@ class AppLocalizationsRu extends AppLocalizations { @override String get perHundredG => 'на 100 г'; + + @override + String get searchFoodHint => 'Поиск продуктов и блюд...'; + + @override + String get recentlyUsedLabel => 'Недавно использованные'; + + @override + String get productsSection => 'Продукты'; + + @override + String get dishesSection => 'Блюда'; + + @override + String noResultsForQuery(String query) { + return 'По запросу \"$query\" ничего не найдено'; + } + + @override + String get servingsLabel => 'Порций'; + + @override + String get addToDiary => 'Добавить в дневник'; + + @override + String get scanDishPhoto => 'Сканировать фото'; } diff --git a/client/lib/l10n/app_localizations_zh.dart b/client/lib/l10n/app_localizations_zh.dart index 063f445..72953f7 100644 --- a/client/lib/l10n/app_localizations_zh.dart +++ b/client/lib/l10n/app_localizations_zh.dart @@ -321,4 +321,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get perHundredG => '每100克'; + + @override + String get searchFoodHint => '搜索产品和菜肴...'; + + @override + String get recentlyUsedLabel => '最近使用'; + + @override + String get productsSection => '产品'; + + @override + String get dishesSection => '菜肴'; + + @override + String noResultsForQuery(String query) { + return '未找到 \"$query\" 的结果'; + } + + @override + String get servingsLabel => '份数'; + + @override + String get addToDiary => '添加到日记'; + + @override + String get scanDishPhoto => '扫描照片'; } diff --git a/client/lib/l10n/app_pt.arb b/client/lib/l10n/app_pt.arb index 6f33559..0b33cb2 100644 --- a/client/lib/l10n/app_pt.arb +++ b/client/lib/l10n/app_pt.arb @@ -110,5 +110,18 @@ "portionWeightG": "Peso da porção (g)", "productNotFound": "Produto não encontrado", "enterManually": "Inserir manualmente", - "perHundredG": "por 100 g" + "perHundredG": "por 100 g", + "searchFoodHint": "Pesquisar produtos e pratos...", + "recentlyUsedLabel": "Usados recentemente", + "productsSection": "Produtos", + "dishesSection": "Pratos", + "noResultsForQuery": "Nada encontrado para \"{query}\"", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "Porções", + "addToDiary": "Adicionar ao diário", + "scanDishPhoto": "Escanear foto" } diff --git a/client/lib/l10n/app_ru.arb b/client/lib/l10n/app_ru.arb index 0c013dd..8987fbf 100644 --- a/client/lib/l10n/app_ru.arb +++ b/client/lib/l10n/app_ru.arb @@ -108,5 +108,18 @@ "portionWeightG": "Вес порции (г)", "productNotFound": "Продукт не найден", "enterManually": "Ввести вручную", - "perHundredG": "на 100 г" + "perHundredG": "на 100 г", + "searchFoodHint": "Поиск продуктов и блюд...", + "recentlyUsedLabel": "Недавно использованные", + "productsSection": "Продукты", + "dishesSection": "Блюда", + "noResultsForQuery": "По запросу \"{query}\" ничего не найдено", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "Порций", + "addToDiary": "Добавить в дневник", + "scanDishPhoto": "Сканировать фото" } diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index 56aa0dc..03d385e 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -110,5 +110,18 @@ "portionWeightG": "份量(克)", "productNotFound": "未找到产品", "enterManually": "手动输入", - "perHundredG": "每100克" + "perHundredG": "每100克", + "searchFoodHint": "搜索产品和菜肴...", + "recentlyUsedLabel": "最近使用", + "productsSection": "产品", + "dishesSection": "菜肴", + "noResultsForQuery": "未找到 \"{query}\" 的结果", + "@noResultsForQuery": { + "placeholders": { + "query": { "type": "String" } + } + }, + "servingsLabel": "份数", + "addToDiary": "添加到日记", + "scanDishPhoto": "扫描照片" }