import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; import '../../core/api/api_client.dart'; import '../../core/auth/auth_provider.dart'; import '../../core/auth/secure_storage.dart'; import '../../core/config/app_config.dart'; import '../../core/locale/language_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 json) { return RecognizedItem( name: json['name'] as String? ?? '', quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0, unit: json['unit'] as String? ?? 'pcs', 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 json) { return UnrecognizedItem( rawText: json['raw_text'] as String? ?? '', price: (json['price'] as num?)?.toDouble(), ); } } class ReceiptResult { final List items; final List 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 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 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 json) { // New format: {"candidates": [...]} if (json['candidates'] is List) { final candidatesList = (json['candidates'] as List) .map((element) => DishCandidate.fromJson(element as Map)) .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: []); } } // --------------------------------------------------------------------------- // Async job models // --------------------------------------------------------------------------- /// A lightweight summary of a dish recognition job (no image payload). class DishJobSummary { final String id; final String status; final String? targetDate; final String? targetMealType; final DishResult? result; final String? error; final DateTime createdAt; const DishJobSummary({ required this.id, required this.status, this.targetDate, this.targetMealType, this.result, this.error, required this.createdAt, }); factory DishJobSummary.fromJson(Map json) { return DishJobSummary( id: json['id'] as String, status: json['status'] as String? ?? '', targetDate: json['target_date'] as String?, targetMealType: json['target_meal_type'] as String?, result: json['result'] != null ? DishResult.fromJson(json['result'] as Map) : null, error: json['error'] as String?, createdAt: DateTime.parse(json['created_at'] as String), ); } } /// The 202 response from POST /ai/recognize-dish. class DishJobCreated { final String jobId; final int queuePosition; final int estimatedSeconds; const DishJobCreated({ required this.jobId, required this.queuePosition, required this.estimatedSeconds, }); factory DishJobCreated.fromJson(Map json) { return DishJobCreated( jobId: json['job_id'] as String, queuePosition: json['queue_position'] as int? ?? 0, estimatedSeconds: json['estimated_seconds'] as int? ?? 0, ); } } /// Events emitted by the SSE stream for a dish recognition job. sealed class DishJobEvent {} class DishJobQueued extends DishJobEvent { final int position; final int estimatedSeconds; DishJobQueued({required this.position, required this.estimatedSeconds}); } class DishJobProcessing extends DishJobEvent {} class DishJobDone extends DishJobEvent { final DishResult result; DishJobDone(this.result); } class DishJobFailed extends DishJobEvent { final String error; DishJobFailed(this.error); } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- class RecognitionService { const RecognitionService( this._client, this._storage, this._appConfig, this._languageGetter, ); final ApiClient _client; final SecureStorageService _storage; final AppConfig _appConfig; final String Function() _languageGetter; /// Recognizes food items from a receipt photo. Future 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? ?? []) .map((element) => RecognizedItem.fromJson(element as Map)) .toList(), unrecognized: (data['unrecognized'] as List? ?? []) .map((element) => UnrecognizedItem.fromJson(element as Map)) .toList(), ); } /// Recognizes food items from 1–3 product photos. Future> recognizeProducts(List 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? ?? []) .map((element) => RecognizedItem.fromJson(element as Map)) .toList(); } /// Submits a dish image for async recognition. /// Returns a [DishJobCreated] with the job ID and queue position. Future submitDishRecognition( XFile image, { String? targetDate, String? targetMealType, }) async { final imagePayload = await _buildImagePayload(image); final payload = {...imagePayload}; if (targetDate != null) payload['target_date'] = targetDate; if (targetMealType != null) payload['target_meal_type'] = targetMealType; final data = await _client.post('/ai/recognize-dish', data: payload); return DishJobCreated.fromJson(data); } /// Returns today's recognition jobs that have not yet been linked to a diary entry. Future> listTodayUnlinkedJobs() async { final data = await _client.getList('/ai/jobs'); return data .map((element) => DishJobSummary.fromJson(element as Map)) .toList(); } /// Returns all recognition jobs for the current user, newest first. Future> listAllJobs() async { final data = await _client.getList('/ai/jobs/history'); return data .map((element) => DishJobSummary.fromJson(element as Map)) .toList(); } /// Opens an SSE stream for job [jobId] and emits [DishJobEvent]s until the /// job reaches a terminal state (done or failed) or the stream is cancelled. /// /// Uses [http.Client] instead of Dio because on Flutter Web Dio relies on /// XHR which does not support SSE streaming. [http.BrowserClient] reads the /// response via XHR onProgress events and delivers chunks before the /// connection is closed. Stream streamJobEvents(String jobId) async* { final token = await _storage.getAccessToken(); final language = _languageGetter(); final uri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream'); final httpClient = http.Client(); try { final request = http.Request('GET', uri) ..headers['Authorization'] = token != null ? 'Bearer $token' : '' ..headers['Accept'] = 'text/event-stream' ..headers['Accept-Language'] = language ..headers['Cache-Control'] = 'no-cache'; final response = await httpClient.send(request).timeout( const Duration(seconds: 30), ); final buffer = StringBuffer(); String? currentEventName; await for (final chunk in response.stream.map(utf8.decode)) { buffer.write(chunk); final text = buffer.toString(); // Process complete SSE messages (terminated by \n\n). int doubleNewlineIndex; var remaining = text; while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) { final message = remaining.substring(0, doubleNewlineIndex); remaining = remaining.substring(doubleNewlineIndex + 2); for (final line in message.split('\n')) { if (line.startsWith('event:')) { currentEventName = line.substring(6).trim(); } else if (line.startsWith('data:')) { final dataPayload = line.substring(5).trim(); final event = _parseSseEvent(currentEventName, dataPayload); if (event != null) { yield event; if (event is DishJobDone || event is DishJobFailed) return; } currentEventName = null; } } } buffer ..clear() ..write(remaining); } } finally { httpClient.close(); } } DishJobEvent? _parseSseEvent(String? eventName, String dataPayload) { try { final json = jsonDecode(dataPayload) as Map; switch (eventName) { case 'queued': return DishJobQueued( position: json['position'] as int? ?? 0, estimatedSeconds: json['estimated_seconds'] as int? ?? 0, ); case 'processing': return DishJobProcessing(); case 'done': return DishJobDone(DishResult.fromJson(json)); case 'failed': return DishJobFailed(json['error'] as String? ?? 'Recognition failed'); default: return null; } } catch (_) { return null; } } Future> _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((ref) { final config = ref.read(appConfigProvider); final storage = ref.read(secureStorageProvider); return RecognitionService( ref.read(apiClientProvider), storage, config, () => ref.read(languageProvider), ); });