Files
food-ai/client/lib/features/diary/food_search_service.dart
dbastrikin bf8dce36c5 fix: show dish calories in search and fix portion sheet layout crash
- DishSearchResult now carries calories_per_serving (backend entity + repo
  LEFT JOIN recipes / MIN / GROUP BY; Flutter model + fromJson)
- _FoodTile.fromDish shows kcal/serving subtitle when available
- _DishPortionSheet quick-select buttons: Row → Wrap to avoid
  BoxConstraints infinite-width crash inside DraggableScrollableSheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:08:20 +02:00

113 lines
3.3 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;
final double? caloriesPerServing;
const DishSearchResult({
required this.id,
required this.name,
this.imageUrl,
required this.avgRating,
this.caloriesPerServing,
});
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,
caloriesPerServing:
(json['calories_per_serving'] as num?)?.toDouble(),
);
}
}
/// 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();
}
}