Remove denormalized columns (name, calories, protein_g, fat_g, carbs_g) from meal_diary. Name is now resolved via JOIN with dishes/dish_translations; macros are computed as recipe.*_per_serving * portions at query time. - Add dish.Repository.FindOrCreateRecipe: finds or creates a minimal recipe stub seeded with AI-estimated macros - recognition/handler: resolve recipe_id synchronously per candidate; simplify enrichDishInBackground to translations-only - diary/handler: accept dish_id OR name; always resolve recipe_id via FindOrCreateRecipe before INSERT - diary/entity: DishID is now non-nullable string; CreateRequest drops macros - diary/repository: ListByDate and Create use JOIN to return computed macros - ai/types: add RecipeID field to DishCandidate - Update tests and wire_gen accordingly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
6.2 KiB
Dart
193 lines
6.2 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
|
||
import '../../core/api/api_client.dart';
|
||
import '../../core/auth/auth_provider.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Models
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class RecognizedItem {
|
||
final String name;
|
||
double quantity;
|
||
String unit;
|
||
final String category;
|
||
final double confidence;
|
||
final String? mappingId;
|
||
final int storageDays;
|
||
|
||
RecognizedItem({
|
||
required this.name,
|
||
required this.quantity,
|
||
required this.unit,
|
||
required this.category,
|
||
required this.confidence,
|
||
this.mappingId,
|
||
required this.storageDays,
|
||
});
|
||
|
||
factory RecognizedItem.fromJson(Map<String, dynamic> json) {
|
||
return RecognizedItem(
|
||
name: json['name'] as String? ?? '',
|
||
quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0,
|
||
unit: json['unit'] as String? ?? 'шт',
|
||
category: json['category'] as String? ?? 'other',
|
||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
|
||
mappingId: json['mapping_id'] as String?,
|
||
storageDays: json['storage_days'] as int? ?? 7,
|
||
);
|
||
}
|
||
}
|
||
|
||
class UnrecognizedItem {
|
||
final String rawText;
|
||
final double? price;
|
||
|
||
const UnrecognizedItem({required this.rawText, this.price});
|
||
|
||
factory UnrecognizedItem.fromJson(Map<String, dynamic> json) {
|
||
return UnrecognizedItem(
|
||
rawText: json['raw_text'] as String? ?? '',
|
||
price: (json['price'] as num?)?.toDouble(),
|
||
);
|
||
}
|
||
}
|
||
|
||
class ReceiptResult {
|
||
final List<RecognizedItem> items;
|
||
final List<UnrecognizedItem> unrecognized;
|
||
|
||
const ReceiptResult({required this.items, required this.unrecognized});
|
||
}
|
||
|
||
/// A single dish recognition candidate with estimated nutrition for the portion in the photo.
|
||
class DishCandidate {
|
||
final String? dishId;
|
||
final String dishName;
|
||
final int weightGrams;
|
||
final double calories;
|
||
final double proteinG;
|
||
final double fatG;
|
||
final double carbsG;
|
||
final double confidence;
|
||
|
||
const DishCandidate({
|
||
this.dishId,
|
||
required this.dishName,
|
||
required this.weightGrams,
|
||
required this.calories,
|
||
required this.proteinG,
|
||
required this.fatG,
|
||
required this.carbsG,
|
||
required this.confidence,
|
||
});
|
||
|
||
factory DishCandidate.fromJson(Map<String, dynamic> json) {
|
||
return DishCandidate(
|
||
dishId: json['dish_id'] as String?,
|
||
dishName: json['dish_name'] as String? ?? '',
|
||
weightGrams: json['weight_grams'] as int? ?? 0,
|
||
calories: (json['calories'] as num?)?.toDouble() ?? 0,
|
||
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,
|
||
fatG: (json['fat_g'] as num?)?.toDouble() ?? 0,
|
||
carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0,
|
||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Result of dish recognition: ordered list of candidates (best match first).
|
||
class DishResult {
|
||
final List<DishCandidate> candidates;
|
||
|
||
const DishResult({required this.candidates});
|
||
|
||
/// The best matching candidate.
|
||
DishCandidate get best => candidates.first;
|
||
|
||
// Convenience getters delegating to the best candidate.
|
||
String get dishName => best.dishName;
|
||
int get weightGrams => best.weightGrams;
|
||
double get calories => best.calories;
|
||
double get proteinG => best.proteinG;
|
||
double get fatG => best.fatG;
|
||
double get carbsG => best.carbsG;
|
||
double get confidence => best.confidence;
|
||
|
||
factory DishResult.fromJson(Map<String, dynamic> json) {
|
||
// New format: {"candidates": [...]}
|
||
if (json['candidates'] is List) {
|
||
final candidatesList = (json['candidates'] as List<dynamic>)
|
||
.map((element) => DishCandidate.fromJson(element as Map<String, dynamic>))
|
||
.toList();
|
||
return DishResult(candidates: candidatesList);
|
||
}
|
||
|
||
// Legacy flat format: {"dish_name": "...", "calories": ..., ...}
|
||
if (json['dish_name'] != null) {
|
||
return DishResult(candidates: [DishCandidate.fromJson(json)]);
|
||
}
|
||
|
||
return const DishResult(candidates: []);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Service
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class RecognitionService {
|
||
const RecognitionService(this._client);
|
||
|
||
final ApiClient _client;
|
||
|
||
/// Recognizes food items from a receipt photo.
|
||
Future<ReceiptResult> recognizeReceipt(XFile image) async {
|
||
final payload = await _buildImagePayload(image);
|
||
final data = await _client.post('/ai/recognize-receipt', data: payload);
|
||
return ReceiptResult(
|
||
items: (data['items'] as List<dynamic>? ?? [])
|
||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||
.toList(),
|
||
unrecognized: (data['unrecognized'] as List<dynamic>? ?? [])
|
||
.map((e) => UnrecognizedItem.fromJson(e as Map<String, dynamic>))
|
||
.toList(),
|
||
);
|
||
}
|
||
|
||
/// Recognizes food items from 1–3 product photos.
|
||
Future<List<RecognizedItem>> recognizeProducts(List<XFile> images) async {
|
||
final imageList = await Future.wait(images.map(_buildImagePayload));
|
||
final data = await _client.post(
|
||
'/ai/recognize-products',
|
||
data: {'images': imageList},
|
||
);
|
||
return (data['items'] as List<dynamic>? ?? [])
|
||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||
.toList();
|
||
}
|
||
|
||
/// Recognizes a dish and estimates its nutritional content.
|
||
Future<DishResult> recognizeDish(XFile image) async {
|
||
final payload = await _buildImagePayload(image);
|
||
final data = await _client.post('/ai/recognize-dish', data: payload);
|
||
return DishResult.fromJson(data);
|
||
}
|
||
|
||
Future<Map<String, String>> _buildImagePayload(XFile image) async {
|
||
final bytes = await image.readAsBytes();
|
||
final base64Data = base64Encode(bytes);
|
||
// XFile.mimeType may be null on some platforms; fall back to path extension.
|
||
final mimeType = image.mimeType ??
|
||
(image.path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg');
|
||
return {'image_base64': base64Data, 'mime_type': mimeType};
|
||
}
|
||
}
|
||
|
||
final recognitionServiceProvider = Provider<RecognitionService>((ref) {
|
||
return RecognitionService(ref.read(apiClientProvider));
|
||
});
|