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:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user