- 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>
113 lines
3.3 KiB
Dart
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();
|
|
}
|
|
}
|