feat: food search sheet with FTS+trgm, dish/recent endpoints, multilingual aliases
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 <noreply@anthropic.com>
This commit is contained in:
@@ -184,12 +184,14 @@ go run ./cmd/importoff \
|
|||||||
| `GET/POST/DELETE` | `/user-products`, `/user-products/{id}` | Продукты пользователя (холодильник) |
|
| `GET/POST/DELETE` | `/user-products`, `/user-products/{id}` | Продукты пользователя (холодильник) |
|
||||||
| `POST` | `/user-products/batch` | Массовое добавление продуктов |
|
| `POST` | `/user-products/batch` | Массовое добавление продуктов |
|
||||||
| `GET` | `/diary?date=YYYY-MM-DD` | Записи дневника питания за дату |
|
| `GET` | `/diary?date=YYYY-MM-DD` | Записи дневника питания за дату |
|
||||||
|
| `GET` | `/diary/recent?limit=<n>` | Последние записи дневника (блюда и продукты) |
|
||||||
| `POST/DELETE` | `/diary`, `/diary/{id}` | Добавить / удалить запись дневника |
|
| `POST/DELETE` | `/diary`, `/diary/{id}` | Добавить / удалить запись дневника |
|
||||||
| `GET` | `/home/summary` | Сводка для главного экрана |
|
| `GET` | `/home/summary` | Сводка для главного экрана |
|
||||||
| `GET` | `/recommendations` | AI-рекомендации блюд |
|
| `GET` | `/recommendations` | AI-рекомендации блюд |
|
||||||
| `GET/POST/DELETE` | `/saved-recipes`, `/saved-recipes/{id}` | Сохранённые рецепты |
|
| `GET/POST/DELETE` | `/saved-recipes`, `/saved-recipes/{id}` | Сохранённые рецепты |
|
||||||
| `GET/PUT/DELETE` | `/menu/items/{id}` | Меню на неделю |
|
| `GET/PUT/DELETE` | `/menu/items/{id}` | Меню на неделю |
|
||||||
| `GET/POST/PATCH` | `/shopping-list`, `/shopping-list/{i}/check` | Список покупок |
|
| `GET/POST/PATCH` | `/shopping-list`, `/shopping-list/{i}/check` | Список покупок |
|
||||||
|
| `GET` | `/dishes/search?q=<query>&limit=<n>` | Поиск блюд (FTS + trgm) |
|
||||||
| `GET` | `/dishes/{id}` | Блюдо по ID |
|
| `GET` | `/dishes/{id}` | Блюдо по ID |
|
||||||
| `GET` | `/recipes/{id}` | Рецепт по ID |
|
| `GET` | `/recipes/{id}` | Рецепт по ID |
|
||||||
| `POST` | `/ai/recognize-receipt` | Распознать чек (синхронно) |
|
| `POST` | `/ai/recognize-receipt` | Распознать чек (синхронно) |
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ type offRecord struct {
|
|||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
ProductName string `json:"product_name"`
|
ProductName string `json:"product_name"`
|
||||||
ProductNameEN string `json:"product_name_en"`
|
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"`
|
CategoriesTags []string `json:"categories_tags"`
|
||||||
Nutriments offNutriments `json:"nutriments"`
|
Nutriments offNutriments `json:"nutriments"`
|
||||||
UniqueScansN int `json:"unique_scans_n"`
|
UniqueScansN int `json:"unique_scans_n"`
|
||||||
@@ -34,6 +45,12 @@ type offNutriments struct {
|
|||||||
Fiber100g *float64 `json:"fiber_100g"`
|
Fiber100g *float64 `json:"fiber_100g"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aliasRow holds one multilingual alias for a product.
|
||||||
|
type aliasRow struct {
|
||||||
|
lang string
|
||||||
|
alias string
|
||||||
|
}
|
||||||
|
|
||||||
type productImportRow struct {
|
type productImportRow struct {
|
||||||
canonicalName string
|
canonicalName string
|
||||||
barcode string
|
barcode string
|
||||||
@@ -43,6 +60,7 @@ type productImportRow struct {
|
|||||||
fat *float64
|
fat *float64
|
||||||
carbs *float64
|
carbs *float64
|
||||||
fiber *float64
|
fiber *float64
|
||||||
|
aliases []aliasRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// categoryPrefixes maps OpenFoodFacts category tag prefixes to our product_categories slugs.
|
// categoryPrefixes maps OpenFoodFacts category tag prefixes to our product_categories slugs.
|
||||||
@@ -217,6 +235,28 @@ func run() error {
|
|||||||
}
|
}
|
||||||
seenInBatch[canonicalName] = true
|
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++
|
totalAccepted++
|
||||||
productBatch = append(productBatch, productImportRow{
|
productBatch = append(productBatch, productImportRow{
|
||||||
canonicalName: canonicalName,
|
canonicalName: canonicalName,
|
||||||
@@ -227,6 +267,7 @@ func run() error {
|
|||||||
fat: record.Nutriments.Fat100g,
|
fat: record.Nutriments.Fat100g,
|
||||||
carbs: record.Nutriments.Carbohydrates100g,
|
carbs: record.Nutriments.Carbohydrates100g,
|
||||||
fiber: record.Nutriments.Fiber100g,
|
fiber: record.Nutriments.Fiber100g,
|
||||||
|
aliases: productAliases,
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(productBatch) >= *batchSizeFlag {
|
if len(productBatch) >= *batchSizeFlag {
|
||||||
@@ -336,18 +377,21 @@ func flushBatch(
|
|||||||
carbs_per_100g = COALESCE(EXCLUDED.carbs_per_100g, products.carbs_per_100g),
|
carbs_per_100g = COALESCE(EXCLUDED.carbs_per_100g, products.carbs_per_100g),
|
||||||
fiber_per_100g = COALESCE(EXCLUDED.fiber_per_100g, products.fiber_per_100g),
|
fiber_per_100g = COALESCE(EXCLUDED.fiber_per_100g, products.fiber_per_100g),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id`)
|
RETURNING id, canonical_name`)
|
||||||
if upsertError != nil {
|
if upsertError != nil {
|
||||||
return 0, fmt.Errorf("upsert products: %w", upsertError)
|
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
|
var insertedCount int64
|
||||||
for upsertRows.Next() {
|
for upsertRows.Next() {
|
||||||
var productID string
|
var productID, canonicalName string
|
||||||
if scanError := upsertRows.Scan(&productID); scanError != nil {
|
if scanError := upsertRows.Scan(&productID, &canonicalName); scanError != nil {
|
||||||
upsertRows.Close()
|
upsertRows.Close()
|
||||||
return 0, fmt.Errorf("scan upserted product id: %w", scanError)
|
return 0, fmt.Errorf("scan upserted product id: %w", scanError)
|
||||||
}
|
}
|
||||||
|
productIDByName[canonicalName] = productID
|
||||||
insertedCount++
|
insertedCount++
|
||||||
}
|
}
|
||||||
upsertRows.Close()
|
upsertRows.Close()
|
||||||
@@ -355,5 +399,35 @@ func flushBatch(
|
|||||||
return 0, fmt.Errorf("iterate upsert results: %w", rowsError)
|
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
|
return insertedCount, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ type Entry struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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.
|
// CreateRequest is the body for POST /diary.
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/infra/middleware"
|
"github.com/food-ai/backend/internal/infra/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -15,6 +16,7 @@ type DiaryRepository interface {
|
|||||||
ListByDate(ctx context.Context, userID, date string) ([]*Entry, error)
|
ListByDate(ctx context.Context, userID, date string) ([]*Entry, error)
|
||||||
Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error)
|
Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error)
|
||||||
Delete(ctx context.Context, id, userID string) 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.
|
// 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)
|
writeJSON(w, http.StatusCreated, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRecent handles GET /diary/recent?limit=<n>
|
||||||
|
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}
|
// Delete handles DELETE /diary/{id}
|
||||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.UserIDFromCtx(r.Context())
|
userID := middleware.UserIDFromCtx(r.Context())
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type MockDiaryRepository struct {
|
|||||||
ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error)
|
ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error)
|
||||||
CreateFn func(ctx context.Context, userID string, req diary.CreateRequest) (*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
|
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) {
|
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 {
|
func (m *MockDiaryRepository) Delete(ctx context.Context, id, userID string) error {
|
||||||
return m.DeleteFn(ctx, id, userID)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,78 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
|
|||||||
return scanEntry(row)
|
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.
|
// Delete removes a diary entry for the given user.
|
||||||
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
|
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
|
||||||
tag, deleteError := r.pool.Exec(ctx,
|
tag, deleteError := r.pool.Exec(ctx,
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ type RecipeStep struct {
|
|||||||
ImageURL *string `json:"image_url"`
|
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.
|
// CreateRequest is the body used to create a new dish + recipe at once.
|
||||||
// Used when saving a Gemini-generated recommendation.
|
// Used when saving a Gemini-generated recommendation.
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -18,6 +19,31 @@ func NewHandler(repo *Repository) *Handler {
|
|||||||
return &Handler{repo: repo}
|
return &Handler{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search handles GET /dishes/search?q=<query>&limit=<n>
|
||||||
|
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).
|
// List handles GET /dishes — returns all dishes (no recipe variants).
|
||||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
dishes, err := h.repo.List(r.Context())
|
dishes, err := h.repo.List(r.Context())
|
||||||
|
|||||||
@@ -98,6 +98,57 @@ func (r *Repository) List(ctx context.Context) ([]*Dish, error) {
|
|||||||
return dishes, nil
|
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.
|
// 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.
|
// 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) {
|
func (r *Repository) FindOrCreate(ctx context.Context, name string) (id string, created bool, err error) {
|
||||||
|
|||||||
@@ -202,15 +202,33 @@ func (r *Repository) Search(requestContext context.Context, query string, limit
|
|||||||
FROM product_aliases pa
|
FROM product_aliases pa
|
||||||
WHERE pa.product_id = p.id AND pa.lang = $3
|
WHERE pa.product_id = p.id AND pa.lang = $3
|
||||||
) al ON true
|
) al ON true
|
||||||
WHERE EXISTS (
|
WHERE (
|
||||||
SELECT 1 FROM product_aliases pa
|
to_tsvector('english', p.canonical_name) @@ plainto_tsquery('english', $1)
|
||||||
WHERE pa.product_id = p.id
|
OR EXISTS (
|
||||||
AND (pa.lang = $3 OR pa.lang = 'en')
|
SELECT 1 FROM product_aliases pa
|
||||||
AND pa.alias ILIKE '%' || $1 || '%'
|
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 || '%'
|
ORDER BY
|
||||||
OR similarity(p.canonical_name, $1) > 0.3
|
GREATEST(
|
||||||
ORDER BY similarity(p.canonical_name, $1) DESC
|
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`
|
LIMIT $2`
|
||||||
|
|
||||||
rows, queryError := r.pool.Query(requestContext, searchQuery, query, limit, lang)
|
rows, queryError := r.pool.Query(requestContext, searchQuery, query, limit, lang)
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func NewRouter(
|
|||||||
|
|
||||||
r.Route("/dishes", func(r chi.Router) {
|
r.Route("/dishes", func(r chi.Router) {
|
||||||
r.Get("/", dishHandler.List)
|
r.Get("/", dishHandler.List)
|
||||||
|
r.Get("/search", dishHandler.Search)
|
||||||
r.Get("/{id}", dishHandler.GetByID)
|
r.Get("/{id}", dishHandler.GetByID)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ func NewRouter(
|
|||||||
|
|
||||||
r.Route("/diary", func(r chi.Router) {
|
r.Route("/diary", func(r chi.Router) {
|
||||||
r.Get("/", diaryHandler.GetByDate)
|
r.Get("/", diaryHandler.GetByDate)
|
||||||
|
r.Get("/recent", diaryHandler.GetRecent)
|
||||||
r.Post("/", diaryHandler.Create)
|
r.Post("/", diaryHandler.Create)
|
||||||
r.Delete("/{id}", diaryHandler.Delete)
|
r.Delete("/{id}", diaryHandler.Delete)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ CREATE TABLE products (
|
|||||||
CREATE INDEX idx_products_canonical_name ON products (canonical_name);
|
CREATE INDEX idx_products_canonical_name ON products (canonical_name);
|
||||||
CREATE INDEX idx_products_category ON products (category);
|
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_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
|
-- 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_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_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
|
-- cuisines + cuisine_translations
|
||||||
@@ -217,9 +219,11 @@ CREATE TABLE dishes (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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_cuisine ON dishes (cuisine_slug);
|
||||||
CREATE INDEX idx_dishes_category ON dishes (category_slug);
|
CREATE INDEX idx_dishes_category ON dishes (category_slug);
|
||||||
CREATE INDEX idx_dishes_rating ON dishes (avg_rating DESC);
|
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 (
|
CREATE TABLE dish_translations (
|
||||||
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE,
|
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE,
|
||||||
@@ -228,6 +232,8 @@ CREATE TABLE dish_translations (
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
PRIMARY KEY (dish_id, lang)
|
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 (
|
CREATE TABLE dish_tags (
|
||||||
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE,
|
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE,
|
||||||
|
|||||||
27
client/lib/features/diary/food_search_provider.dart
Normal file
27
client/lib/features/diary/food_search_provider.dart
Normal file
@@ -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<FoodSearchService>(
|
||||||
|
(ref) => FoodSearchService(ref.read(apiClientProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Recently used diary items (dishes + products).
|
||||||
|
final recentDiaryItemsProvider =
|
||||||
|
FutureProvider.autoDispose<List<RecentDiaryItem>>((ref) {
|
||||||
|
return ref.read(foodSearchServiceProvider).getRecent(limit: 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Product search results for the given query string.
|
||||||
|
final productSearchProvider = FutureProvider.autoDispose
|
||||||
|
.family<List<CatalogProduct>, String>((ref, query) {
|
||||||
|
return ref.read(foodSearchServiceProvider).searchProducts(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Dish search results for the given query string.
|
||||||
|
final dishSearchProvider = FutureProvider.autoDispose
|
||||||
|
.family<List<DishSearchResult>, String>((ref, query) {
|
||||||
|
return ref.read(foodSearchServiceProvider).searchDishes(query);
|
||||||
|
});
|
||||||
108
client/lib/features/diary/food_search_service.dart
Normal file
108
client/lib/features/diary/food_search_service.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<List<CatalogProduct>> 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<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches dishes by name.
|
||||||
|
Future<List<DishSearchResult>> 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<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns recent diary items (dishes and products) for the current user.
|
||||||
|
Future<List<RecentDiaryItem>> getRecent({int limit = 10}) async {
|
||||||
|
final list = await _client.getList(
|
||||||
|
'/diary/recent',
|
||||||
|
params: {'limit': '$limit'},
|
||||||
|
);
|
||||||
|
return list
|
||||||
|
.map((item) => RecentDiaryItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
689
client/lib/features/diary/food_search_sheet.dart
Normal file
689
client/lib/features/diary/food_search_sheet.dart
Normal file
@@ -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<FoodSearchSheet> createState() => _FoodSearchSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FoodSearchSheetState extends ConsumerState<FoodSearchSheet> {
|
||||||
|
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<void> _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<void>(
|
||||||
|
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<void>(
|
||||||
|
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<void>(
|
||||||
|
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<void>(
|
||||||
|
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 = <String>[
|
||||||
|
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 = <String>[
|
||||||
|
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<void> _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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import '../../core/theme/app_colors.dart';
|
|||||||
import '../../shared/models/diary_entry.dart';
|
import '../../shared/models/diary_entry.dart';
|
||||||
import '../../shared/models/home_summary.dart';
|
import '../../shared/models/home_summary.dart';
|
||||||
import '../../shared/models/meal_type.dart';
|
import '../../shared/models/meal_type.dart';
|
||||||
|
import '../diary/food_search_sheet.dart';
|
||||||
import '../menu/menu_provider.dart';
|
import '../menu/menu_provider.dart';
|
||||||
import '../profile/profile_provider.dart';
|
import '../profile/profile_provider.dart';
|
||||||
import '../scan/dish_result_screen.dart';
|
import '../scan/dish_result_screen.dart';
|
||||||
@@ -966,8 +967,21 @@ class _MealCard extends ConsumerWidget {
|
|||||||
icon: const Icon(Icons.add, size: 20),
|
icon: const Icon(Icons.add, size: 20),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
tooltip: l10n.addDish,
|
tooltip: l10n.addDish,
|
||||||
onPressed: () => _pickAndShowDishResult(
|
onPressed: () {
|
||||||
context, ref, mealTypeOption.id),
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (_) => FoodSearchSheet(
|
||||||
|
mealType: mealTypeOption.id,
|
||||||
|
date: dateString,
|
||||||
|
onAdded: () => ref
|
||||||
|
.invalidate(diaryProvider(dateString)),
|
||||||
|
onScanDish: () => _pickAndShowDishResult(
|
||||||
|
context, ref, mealTypeOption.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "وزن الحصة (جم)",
|
"portionWeightG": "وزن الحصة (جم)",
|
||||||
"productNotFound": "المنتج غير موجود",
|
"productNotFound": "المنتج غير موجود",
|
||||||
"enterManually": "أدخل يدوياً",
|
"enterManually": "أدخل يدوياً",
|
||||||
"perHundredG": "لكل 100 جم"
|
"perHundredG": "لكل 100 جم",
|
||||||
|
"searchFoodHint": "البحث عن المنتجات والأطباق...",
|
||||||
|
"recentlyUsedLabel": "المستخدمة مؤخراً",
|
||||||
|
"productsSection": "المنتجات",
|
||||||
|
"dishesSection": "الأطباق",
|
||||||
|
"noResultsForQuery": "لم يتم العثور على نتائج لـ \"{query}\"",
|
||||||
|
"@noResultsForQuery": {
|
||||||
|
"placeholders": {
|
||||||
|
"query": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servingsLabel": "حصص",
|
||||||
|
"addToDiary": "إضافة إلى اليومية",
|
||||||
|
"scanDishPhoto": "مسح الصورة"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "Portionsgewicht (g)",
|
"portionWeightG": "Portionsgewicht (g)",
|
||||||
"productNotFound": "Produkt nicht gefunden",
|
"productNotFound": "Produkt nicht gefunden",
|
||||||
"enterManually": "Manuell eingeben",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"portionWeightG": "Portion weight (g)",
|
"portionWeightG": "Portion weight (g)",
|
||||||
"productNotFound": "Product not found",
|
"productNotFound": "Product not found",
|
||||||
"enterManually": "Enter manually",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "Peso de la porción (g)",
|
"portionWeightG": "Peso de la porción (g)",
|
||||||
"productNotFound": "Producto no encontrado",
|
"productNotFound": "Producto no encontrado",
|
||||||
"enterManually": "Ingresar manualmente",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "Poids de la portion (g)",
|
"portionWeightG": "Poids de la portion (g)",
|
||||||
"productNotFound": "Produit introuvable",
|
"productNotFound": "Produit introuvable",
|
||||||
"enterManually": "Saisir manuellement",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "हिस्से का वजन (ग्राम)",
|
"portionWeightG": "हिस्से का वजन (ग्राम)",
|
||||||
"productNotFound": "उत्पाद नहीं मिला",
|
"productNotFound": "उत्पाद नहीं मिला",
|
||||||
"enterManually": "मैन्युअल दर्ज करें",
|
"enterManually": "मैन्युअल दर्ज करें",
|
||||||
"perHundredG": "प्रति 100 ग्राम"
|
"perHundredG": "प्रति 100 ग्राम",
|
||||||
|
"searchFoodHint": "उत्पाद और व्यंजन खोजें...",
|
||||||
|
"recentlyUsedLabel": "हाल ही में उपयोग किए गए",
|
||||||
|
"productsSection": "उत्पाद",
|
||||||
|
"dishesSection": "व्यंजन",
|
||||||
|
"noResultsForQuery": "\"{query}\" के लिए कुछ नहीं मिला",
|
||||||
|
"@noResultsForQuery": {
|
||||||
|
"placeholders": {
|
||||||
|
"query": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servingsLabel": "सर्विंग",
|
||||||
|
"addToDiary": "डायरी में जोड़ें",
|
||||||
|
"scanDishPhoto": "फ़ोटो स्कैन करें"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "Peso della porzione (g)",
|
"portionWeightG": "Peso della porzione (g)",
|
||||||
"productNotFound": "Prodotto non trovato",
|
"productNotFound": "Prodotto non trovato",
|
||||||
"enterManually": "Inserisci manualmente",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "1食分の重さ(g)",
|
"portionWeightG": "1食分の重さ(g)",
|
||||||
"productNotFound": "商品が見つかりません",
|
"productNotFound": "商品が見つかりません",
|
||||||
"enterManually": "手動で入力",
|
"enterManually": "手動で入力",
|
||||||
"perHundredG": "100gあたり"
|
"perHundredG": "100gあたり",
|
||||||
|
"searchFoodHint": "食品と料理を検索...",
|
||||||
|
"recentlyUsedLabel": "最近使用",
|
||||||
|
"productsSection": "食品",
|
||||||
|
"dishesSection": "料理",
|
||||||
|
"noResultsForQuery": "「{query}」の検索結果はありません",
|
||||||
|
"@noResultsForQuery": {
|
||||||
|
"placeholders": {
|
||||||
|
"query": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servingsLabel": "人前",
|
||||||
|
"addToDiary": "日記に追加",
|
||||||
|
"scanDishPhoto": "写真をスキャン"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "1회 제공량 (g)",
|
"portionWeightG": "1회 제공량 (g)",
|
||||||
"productNotFound": "제품을 찾을 수 없습니다",
|
"productNotFound": "제품을 찾을 수 없습니다",
|
||||||
"enterManually": "직접 입력",
|
"enterManually": "직접 입력",
|
||||||
"perHundredG": "100g당"
|
"perHundredG": "100g당",
|
||||||
|
"searchFoodHint": "식품 및 요리 검색...",
|
||||||
|
"recentlyUsedLabel": "최근 사용",
|
||||||
|
"productsSection": "식품",
|
||||||
|
"dishesSection": "요리",
|
||||||
|
"noResultsForQuery": "\"{query}\"에 대한 결과 없음",
|
||||||
|
"@noResultsForQuery": {
|
||||||
|
"placeholders": {
|
||||||
|
"query": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servingsLabel": "인분",
|
||||||
|
"addToDiary": "일기에 추가",
|
||||||
|
"scanDishPhoto": "사진 스캔"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,6 +741,54 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'per 100 g'**
|
/// **'per 100 g'**
|
||||||
String get perHundredG;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -322,4 +322,30 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'لكل 100 جم';
|
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 => 'مسح الصورة';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,4 +324,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'pro 100 g';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,4 +322,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'per 100 g';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,4 +324,30 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'por 100 g';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,4 +325,30 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'pour 100 g';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,4 +323,30 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'प्रति 100 ग्राम';
|
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 => 'फ़ोटो स्कैन करें';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,4 +324,30 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'per 100 g';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,4 +321,30 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => '100gあたり';
|
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 => '写真をスキャン';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,4 +321,30 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => '100g당';
|
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 => '사진 스캔';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,4 +324,30 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'por 100 g';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,4 +322,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => 'на 100 г';
|
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 => 'Сканировать фото';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,4 +321,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get perHundredG => '每100克';
|
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 => '扫描照片';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "Peso da porção (g)",
|
"portionWeightG": "Peso da porção (g)",
|
||||||
"productNotFound": "Produto não encontrado",
|
"productNotFound": "Produto não encontrado",
|
||||||
"enterManually": "Inserir manualmente",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,18 @@
|
|||||||
"portionWeightG": "Вес порции (г)",
|
"portionWeightG": "Вес порции (г)",
|
||||||
"productNotFound": "Продукт не найден",
|
"productNotFound": "Продукт не найден",
|
||||||
"enterManually": "Ввести вручную",
|
"enterManually": "Ввести вручную",
|
||||||
"perHundredG": "на 100 г"
|
"perHundredG": "на 100 г",
|
||||||
|
"searchFoodHint": "Поиск продуктов и блюд...",
|
||||||
|
"recentlyUsedLabel": "Недавно использованные",
|
||||||
|
"productsSection": "Продукты",
|
||||||
|
"dishesSection": "Блюда",
|
||||||
|
"noResultsForQuery": "По запросу \"{query}\" ничего не найдено",
|
||||||
|
"@noResultsForQuery": {
|
||||||
|
"placeholders": {
|
||||||
|
"query": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servingsLabel": "Порций",
|
||||||
|
"addToDiary": "Добавить в дневник",
|
||||||
|
"scanDishPhoto": "Сканировать фото"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,18 @@
|
|||||||
"portionWeightG": "份量(克)",
|
"portionWeightG": "份量(克)",
|
||||||
"productNotFound": "未找到产品",
|
"productNotFound": "未找到产品",
|
||||||
"enterManually": "手动输入",
|
"enterManually": "手动输入",
|
||||||
"perHundredG": "每100克"
|
"perHundredG": "每100克",
|
||||||
|
"searchFoodHint": "搜索产品和菜肴...",
|
||||||
|
"recentlyUsedLabel": "最近使用",
|
||||||
|
"productsSection": "产品",
|
||||||
|
"dishesSection": "菜肴",
|
||||||
|
"noResultsForQuery": "未找到 \"{query}\" 的结果",
|
||||||
|
"@noResultsForQuery": {
|
||||||
|
"placeholders": {
|
||||||
|
"query": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servingsLabel": "份数",
|
||||||
|
"addToDiary": "添加到日记",
|
||||||
|
"scanDishPhoto": "扫描照片"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user