feat: implement Iteration 3 — product/receipt/dish recognition
Backend: - gemini/client.go: refactor to shared callGroq transport; add generateVisionContent using llama-3.2-11b-vision-preview model - gemini/recognition.go: RecognizeReceipt, RecognizeProducts, RecognizeDish (vision), ClassifyIngredient (text); shared parseJSON helper - ingredient/repository.go: add FuzzyMatch (wraps Search, returns best hit) - recognition/handler.go: POST /ai/recognize-receipt, /ai/recognize-products, /ai/recognize-dish; enrichItems with fuzzy match + AI classify fallback; parallel multi-image processing with deduplication - server.go + main.go: wire recognition handler under /ai routes Flutter: - pubspec.yaml: add image_picker ^1.1.0 - AndroidManifest.xml: add CAMERA and READ_EXTERNAL_STORAGE permissions - Info.plist: add NSCameraUsageDescription and NSPhotoLibraryUsageDescription - recognition_service.dart: RecognitionService wrapping /ai/* endpoints; RecognizedItem, ReceiptResult, DishResult models - scan_screen.dart: mode selector (receipt / products / dish / manual); image source picker; loading overlay; navigates to confirm or dish screen - recognition_confirm_screen.dart: editable list of recognized items; inline qty/unit editing; swipe-to-delete; batch-add to pantry - dish_result_screen.dart: dish name, KBZHU breakdown, similar dishes chips - app_router.dart: /scan, /scan/confirm, /scan/dish routes (no bottom nav) - products_screen.dart: FAB now shows bottom sheet with Manual / Scan options Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
150
client/lib/features/scan/recognition_service.dart
Normal file
150
client/lib/features/scan/recognition_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../core/api/api_client.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});
|
||||
}
|
||||
|
||||
class DishResult {
|
||||
final String dishName;
|
||||
final int weightGrams;
|
||||
final double calories;
|
||||
final double proteinG;
|
||||
final double fatG;
|
||||
final double carbsG;
|
||||
final double confidence;
|
||||
final List<String> similarDishes;
|
||||
|
||||
const DishResult({
|
||||
required this.dishName,
|
||||
required this.weightGrams,
|
||||
required this.calories,
|
||||
required this.proteinG,
|
||||
required this.fatG,
|
||||
required this.carbsG,
|
||||
required this.confidence,
|
||||
required this.similarDishes,
|
||||
});
|
||||
|
||||
factory DishResult.fromJson(Map<String, dynamic> json) {
|
||||
return DishResult(
|
||||
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,
|
||||
similarDishes: (json['similar_dishes'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecognitionService {
|
||||
const RecognitionService(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// Recognizes food items from a receipt photo.
|
||||
Future<ReceiptResult> recognizeReceipt(File 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<File> 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(File 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(File image) async {
|
||||
final bytes = await image.readAsBytes();
|
||||
final base64Data = base64Encode(bytes);
|
||||
final ext = image.path.split('.').last.toLowerCase();
|
||||
final mimeType = ext == 'png' ? 'image/png' : 'image/jpeg';
|
||||
return {'image_base64': base64Data, 'mime_type': mimeType};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user