- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
13 KiB
Dart
406 lines
13 KiB
Dart
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? primaryProductId;
|
||
final int storageDays;
|
||
|
||
RecognizedItem({
|
||
required this.name,
|
||
required this.quantity,
|
||
required this.unit,
|
||
required this.category,
|
||
required this.confidence,
|
||
this.primaryProductId,
|
||
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? ?? 'pcs',
|
||
category: json['category'] as String? ?? 'other',
|
||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
|
||
primaryProductId: 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: []);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<String, dynamic> 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<String, dynamic>)
|
||
: 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<String, dynamic> 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<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((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||
.toList(),
|
||
unrecognized: (data['unrecognized'] as List<dynamic>? ?? [])
|
||
.map((element) => UnrecognizedItem.fromJson(element 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((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||
.toList();
|
||
}
|
||
|
||
/// Submits a dish image for async recognition.
|
||
/// Returns a [DishJobCreated] with the job ID and queue position.
|
||
Future<DishJobCreated> submitDishRecognition(
|
||
XFile image, {
|
||
String? targetDate,
|
||
String? targetMealType,
|
||
}) async {
|
||
final imagePayload = await _buildImagePayload(image);
|
||
final payload = <String, dynamic>{...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<List<DishJobSummary>> listTodayUnlinkedJobs() async {
|
||
final data = await _client.getList('/ai/jobs');
|
||
return data
|
||
.map((element) =>
|
||
DishJobSummary.fromJson(element as Map<String, dynamic>))
|
||
.toList();
|
||
}
|
||
|
||
/// Returns all recognition jobs for the current user, newest first.
|
||
Future<List<DishJobSummary>> listAllJobs() async {
|
||
final data = await _client.getList('/ai/jobs/history');
|
||
return data
|
||
.map((element) =>
|
||
DishJobSummary.fromJson(element as Map<String, dynamic>))
|
||
.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<DishJobEvent> 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<String, dynamic>;
|
||
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<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) {
|
||
final config = ref.read(appConfigProvider);
|
||
final storage = ref.read(secureStorageProvider);
|
||
return RecognitionService(
|
||
ref.read(apiClientProvider),
|
||
storage,
|
||
config,
|
||
() => ref.read(languageProvider),
|
||
);
|
||
});
|