Files
food-ai/client/lib/features/scan/recognition_service.dart
dbastrikin a32d2960c4 feat: show dish recognition result as bottom sheet on home screen
Remove "Определить блюдо" from ScanScreen and the /scan/dish route.
The + button on each meal card now triggers dish recognition inline —
picks image, shows loading dialog, then presents DishResultSheet as a
modal bottom sheet. After adding to diary the sheet closes and the user
stays on home.

Also fix Navigator.pop crash: showDialog uses the root navigator by
default, so capture Navigator.of(context, rootNavigator: true) before
the async gap and use it to close the loading dialog.

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

190 lines
6.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 dishName;
final int weightGrams;
final double calories;
final double proteinG;
final double fatG;
final double carbsG;
final double confidence;
const DishCandidate({
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(
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 13 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));
});