Files
food-ai/client/lib/features/diary/food_search_service.dart
dbastrikin 78f1c8bf76 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>
2026-03-21 15:28:29 +02:00

109 lines
3.1 KiB
Dart

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();
}
}