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:
dbastrikin
2026-03-21 15:28:29 +02:00
parent 81185bb7ff
commit 78f1c8bf76
41 changed files with 1688 additions and 28 deletions

View File

@@ -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": "مسح الصورة"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": "फ़ोटो स्कैन करें"
}

View File

@@ -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"
}

View File

@@ -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": "写真をスキャン"
}

View File

@@ -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": "사진 스캔"
}

View File

@@ -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

View File

@@ -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 => 'مسح الصورة';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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 => 'फ़ोटो स्कैन करें';
}

View File

@@ -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';
}

View File

@@ -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 => '写真をスキャン';
}

View File

@@ -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 => '사진 스캔';
}

View File

@@ -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';
}

View File

@@ -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 => 'Сканировать фото';
}

View File

@@ -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 => '扫描照片';
}

View File

@@ -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"
}

View File

@@ -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": "Сканировать фото"
}

View File

@@ -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": "扫描照片"
}