feat: async product/receipt recognition via Kafka

Backend:
- Migration 002: product_recognition_jobs table with JSONB images column
  and job_type CHECK ('receipt' | 'products')
- New Kafka topics: ai.products.paid / ai.products.free
- ProductJob model, ProductJobRepository (mirrors dish job pattern)
- itemEnricher extracted from Handler — shared by HTTP handler and worker
- ProductSSEBroker: PG LISTEN on product_job_update channel
- ProductWorkerPool: 5 workers, branches on job_type to call
  RecognizeReceipt or RecognizeProducts per image in parallel
- Handler: RecognizeReceipt and RecognizeProducts now return 202 Accepted
  instead of blocking; 4 new endpoints: GET /ai/product-jobs,
  /product-jobs/history, /product-jobs/{id}, /product-jobs/{id}/stream
- cmd/worker: extended to run ProductWorkerPool alongside dish WorkerPool
- cmd/server: wires productJobRepository + productSSEBroker; both SSE
  brokers started in App.Start()

Flutter client:
- ProductJobCreated, ProductJobResult, ProductJobSummary, ProductJobEvent
  models + submitReceiptRecognition/submitProductsRecognition/stream methods
- Shared _openSseStream helper eliminates duplicate SSE parsing loop
- ScanScreen: replace blocking AI calls with async submit + navigate to
  ProductJobWatchScreen
- ProductJobWatchScreen: watches SSE stream, navigates to /scan/confirm
  when done, shows error on failure
- ProductsScreen: prepends _RecentScansSection (hidden when empty); compact
  horizontal list of recent scans with "See all" → history
- ProductJobHistoryScreen: full list of all product recognition jobs
- New routes: /scan/product-job-watch, /products/job-history
- L10n: 7 new keys in all 12 ARB files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-23 23:01:30 +02:00
parent bffeb05a43
commit c7317c4335
43 changed files with 2073 additions and 239 deletions

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../l10n/app_localizations.dart';
import '../scan/recognition_service.dart';
import 'product_job_provider.dart';
/// Shows the complete history of product/receipt recognition scans.
class ProductJobHistoryScreen extends ConsumerWidget {
const ProductJobHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final state = ref.watch(allProductJobsProvider);
return Scaffold(
appBar: AppBar(
title: Text(l10n.productJobHistoryTitle),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () =>
ref.read(allProductJobsProvider.notifier).refresh(),
),
],
),
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
FilledButton(
onPressed: () =>
ref.read(allProductJobsProvider.notifier).refresh(),
child: const Text('Retry'),
),
],
),
),
data: (jobs) => jobs.isEmpty
? Center(
child: Text(
l10n.recentScans,
style: Theme.of(context).textTheme.bodyLarge,
),
)
: RefreshIndicator(
onRefresh: () =>
ref.read(allProductJobsProvider.notifier).refresh(),
child: ListView.builder(
itemCount: jobs.length,
itemBuilder: (context, index) =>
_JobTile(job: jobs[index]),
),
),
),
);
}
}
class _JobTile extends StatelessWidget {
const _JobTile({required this.job});
final ProductJobSummary job;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isDone = job.status == 'done';
final isFailed = job.status == 'failed';
final dateLabel =
DateFormat.yMd().add_jm().format(job.createdAt.toLocal());
final itemCount = job.result?.items.length ?? 0;
return ListTile(
leading: Icon(
job.jobType == 'receipt'
? Icons.receipt_long_outlined
: Icons.camera_alt_outlined,
color: theme.colorScheme.primary,
),
title: Text(
job.jobType == 'receipt' ? l10n.jobTypeReceipt : l10n.jobTypeProducts,
),
subtitle: Text(dateLabel, style: theme.textTheme.bodySmall),
trailing: isDone
? Chip(
label: Text(
'$itemCount',
style: theme.textTheme.labelSmall,
),
avatar: const Icon(Icons.check_circle_outline,
size: 16, color: Colors.green),
)
: isFailed
? const Icon(Icons.error_outline, color: Colors.red)
: const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
onTap: isDone && job.result != null
? () => context.push('/scan/confirm', extra: job.result!.items)
: null,
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../scan/recognition_service.dart';
// ---------------------------------------------------------------------------
// Recent product jobs (last 7 days) — shown on the products screen
// ---------------------------------------------------------------------------
class _RecentProductJobsNotifier
extends StateNotifier<AsyncValue<List<ProductJobSummary>>> {
_RecentProductJobsNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
final RecognitionService _service;
Future<void> load() async {
state = const AsyncValue.loading();
try {
final jobs = await _service.listRecentProductJobs();
state = AsyncValue.data(jobs);
} catch (error, stack) {
state = AsyncValue.error(error, stack);
}
}
Future<void> refresh() => load();
}
final recentProductJobsProvider = StateNotifierProvider<
_RecentProductJobsNotifier, AsyncValue<List<ProductJobSummary>>>((ref) {
final service = ref.read(recognitionServiceProvider);
return _RecentProductJobsNotifier(service);
});
// ---------------------------------------------------------------------------
// All product jobs — shown on the history screen
// ---------------------------------------------------------------------------
class _AllProductJobsNotifier
extends StateNotifier<AsyncValue<List<ProductJobSummary>>> {
_AllProductJobsNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
final RecognitionService _service;
Future<void> load() async {
state = const AsyncValue.loading();
try {
final jobs = await _service.listAllProductJobs();
state = AsyncValue.data(jobs);
} catch (error, stack) {
state = AsyncValue.error(error, stack);
}
}
Future<void> refresh() => load();
}
final allProductJobsProvider = StateNotifierProvider<_AllProductJobsNotifier,
AsyncValue<List<ProductJobSummary>>>((ref) {
final service = ref.read(recognitionServiceProvider);
return _AllProductJobsNotifier(service);
});
// ---------------------------------------------------------------------------
// SSE stream for a single product job
// ---------------------------------------------------------------------------
final productJobStreamProvider =
StreamProvider.family<ProductJobEvent, String>((ref, jobId) {
final service = ref.read(recognitionServiceProvider);
return service.streamProductJobEvents(jobId);
});

View File

@@ -3,7 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/locale/unit_provider.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/user_product.dart';
import '../scan/recognition_service.dart';
import 'product_job_provider.dart';
import 'user_product_provider.dart';
void _showAddMenu(BuildContext context) {
@@ -57,21 +60,181 @@ class ProductsScreen extends ConsumerWidget {
icon: const Icon(Icons.add),
label: const Text('Добавить'),
),
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorView(
onRetry: () => ref.read(userProductsProvider.notifier).refresh(),
),
data: (products) => products.isEmpty
? _EmptyState(
onAdd: () => _showAddMenu(context),
)
: _ProductList(products: products),
body: Column(
children: [
const _RecentScansSection(),
Expanded(
child: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorView(
onRetry: () => ref.read(userProductsProvider.notifier).refresh(),
),
data: (products) => products.isEmpty
? _EmptyState(onAdd: () => _showAddMenu(context))
: _ProductList(products: products),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Recent product recognition scans section
// ---------------------------------------------------------------------------
class _RecentScansSection extends ConsumerWidget {
const _RecentScansSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final jobsState = ref.watch(recentProductJobsProvider);
final jobs = jobsState.valueOrNull;
if (jobs == null || jobs.isEmpty) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
child: Row(
children: [
Icon(Icons.document_scanner_outlined,
size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 6),
Text(
l10n.recentScans,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton(
onPressed: () => context.push('/products/job-history'),
child: Text(l10n.seeAllScans),
),
],
),
),
SizedBox(
height: 72,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: jobs.length > 5 ? 5 : jobs.length,
itemBuilder: (context, index) =>
_ScanJobChip(job: jobs[index]),
),
),
const Divider(height: 1),
],
);
}
}
class _ScanJobChip extends ConsumerWidget {
const _ScanJobChip({required this.job});
final ProductJobSummary job;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isDone = job.status == 'done';
final isFailed = job.status == 'failed';
final isActive = !isDone && !isFailed;
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: isDone && job.result != null
? () => context.push('/scan/confirm', extra: job.result!.items)
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surfaceContainerHighest,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
job.jobType == 'receipt'
? Icons.receipt_long_outlined
: Icons.camera_alt_outlined,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.jobType == 'receipt'
? l10n.jobTypeReceipt
: l10n.jobTypeProducts,
style: theme.textTheme.labelMedium,
),
_StatusBadge(
status: job.status,
isFailed: isFailed,
isActive: isActive,
),
],
),
],
),
),
),
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({
required this.status,
required this.isFailed,
required this.isActive,
});
final String status;
final bool isFailed;
final bool isActive;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (isActive) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
const SizedBox(width: 4),
Text(status, style: theme.textTheme.labelSmall),
],
);
}
return Icon(
isFailed ? Icons.error_outline : Icons.check_circle_outline,
size: 14,
color: isFailed ? Colors.red : Colors.green,
);
}
}
// ---------------------------------------------------------------------------
// Product list split into expiring / normal sections
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../l10n/app_localizations.dart';
import '../products/product_job_provider.dart';
import 'recognition_service.dart';
/// Watches a product recognition job via SSE and navigates to the confirmation
/// screen when the job finishes.
class ProductJobWatchScreen extends ConsumerStatefulWidget {
const ProductJobWatchScreen({super.key, required this.jobCreated});
final ProductJobCreated jobCreated;
@override
ConsumerState<ProductJobWatchScreen> createState() =>
_ProductJobWatchScreenState();
}
class _ProductJobWatchScreenState
extends ConsumerState<ProductJobWatchScreen> {
String? _errorMessage;
bool _navigated = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
ref.listen(
productJobStreamProvider(widget.jobCreated.jobId),
(previous, next) {
next.whenData((event) {
if (_navigated) return;
if (event is ProductJobDone) {
_navigated = true;
ref.invalidate(recentProductJobsProvider);
context.pushReplacement('/scan/confirm', extra: event.result.items);
} else if (event is ProductJobFailed) {
setState(() => _errorMessage = event.error);
}
});
},
);
final streamState =
ref.watch(productJobStreamProvider(widget.jobCreated.jobId));
return Scaffold(
appBar: AppBar(title: Text(l10n.processingProducts)),
body: _errorMessage != null
? _ErrorBody(message: _errorMessage!)
: streamState.when(
loading: () => _ProgressBody(jobCreated: widget.jobCreated),
data: (event) => switch (event) {
ProductJobQueued(
position: final position,
estimatedSeconds: final estimated,
) =>
_QueuedBody(position: position, estimatedSeconds: estimated),
ProductJobProcessing() =>
_ProcessingBody(label: l10n.processingProducts),
ProductJobDone() => _ProcessingBody(label: l10n.processingProducts),
ProductJobFailed(error: final err) => _ErrorBody(message: err),
},
error: (err, _) => _ErrorBody(message: err.toString()),
),
);
}
}
class _ProgressBody extends StatelessWidget {
const _ProgressBody({required this.jobCreated});
final ProductJobCreated jobCreated;
@override
Widget build(BuildContext context) {
return _QueuedBody(
position: jobCreated.queuePosition,
estimatedSeconds: jobCreated.estimatedSeconds,
);
}
}
class _QueuedBody extends StatelessWidget {
const _QueuedBody(
{required this.position, required this.estimatedSeconds});
final int position;
final int estimatedSeconds;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
l10n.processingProducts,
style: Theme.of(context).textTheme.titleMedium,
),
if (position > 0) ...[
const SizedBox(height: 8),
Text(
'~${estimatedSeconds}s',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
);
}
}
class _ProcessingBody extends StatelessWidget {
const _ProcessingBody({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(label, style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
}
class _ErrorBody extends StatelessWidget {
const _ErrorBody({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => context.pop(),
child: const Text('Back'),
),
],
),
),
);
}
}

View File

@@ -1,7 +1,6 @@
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';
@@ -142,7 +141,110 @@ class DishResult {
}
// ---------------------------------------------------------------------------
// Async job models
// Product job models
// ---------------------------------------------------------------------------
/// Result of a completed product or receipt recognition job.
class ProductJobResult {
final String jobType;
final List<RecognizedItem> items;
final List<UnrecognizedItem> unrecognized;
const ProductJobResult({
required this.jobType,
required this.items,
required this.unrecognized,
});
factory ProductJobResult.fromJson(Map<String, dynamic> json) {
return ProductJobResult(
jobType: json['job_type'] as String? ?? '',
items: (json['items'] as List<dynamic>? ?? [])
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
.toList(),
unrecognized: (json['unrecognized'] as List<dynamic>? ?? [])
.map((element) => UnrecognizedItem.fromJson(element as Map<String, dynamic>))
.toList(),
);
}
}
/// The 202 response from POST /ai/recognize-receipt or /ai/recognize-products.
class ProductJobCreated {
final String jobId;
final int queuePosition;
final int estimatedSeconds;
const ProductJobCreated({
required this.jobId,
required this.queuePosition,
required this.estimatedSeconds,
});
factory ProductJobCreated.fromJson(Map<String, dynamic> json) {
return ProductJobCreated(
jobId: json['job_id'] as String,
queuePosition: json['queue_position'] as int? ?? 0,
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
);
}
}
/// A lightweight summary of a product recognition job for list endpoints.
class ProductJobSummary {
final String id;
final String jobType;
final String status;
final ProductJobResult? result;
final String? error;
final DateTime createdAt;
const ProductJobSummary({
required this.id,
required this.jobType,
required this.status,
this.result,
this.error,
required this.createdAt,
});
factory ProductJobSummary.fromJson(Map<String, dynamic> json) {
return ProductJobSummary(
id: json['id'] as String,
jobType: json['job_type'] as String? ?? '',
status: json['status'] as String? ?? '',
result: json['result'] != null
? ProductJobResult.fromJson(json['result'] as Map<String, dynamic>)
: null,
error: json['error'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
}
/// Events emitted by the SSE stream for a product recognition job.
sealed class ProductJobEvent {}
class ProductJobQueued extends ProductJobEvent {
final int position;
final int estimatedSeconds;
ProductJobQueued({required this.position, required this.estimatedSeconds});
}
class ProductJobProcessing extends ProductJobEvent {}
class ProductJobDone extends ProductJobEvent {
final ProductJobResult result;
ProductJobDone(this.result);
}
class ProductJobFailed extends ProductJobEvent {
final String error;
ProductJobFailed(this.error);
}
// ---------------------------------------------------------------------------
// Dish job models
// ---------------------------------------------------------------------------
/// A lightweight summary of a dish recognition job (no image payload).
@@ -239,32 +341,71 @@ class RecognitionService {
final AppConfig _appConfig;
final String Function() _languageGetter;
/// Recognizes food items from a receipt photo.
Future<ReceiptResult> recognizeReceipt(XFile image) async {
/// Submits a receipt image for async recognition.
/// Returns immediately with a [ProductJobCreated] containing the job ID.
Future<ProductJobCreated> submitReceiptRecognition(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(),
);
return ProductJobCreated.fromJson(data);
}
/// Recognizes food items from 13 product photos.
Future<List<RecognizedItem>> recognizeProducts(List<XFile> images) async {
/// Submits 13 product images for async recognition.
/// Returns immediately with a [ProductJobCreated] containing the job ID.
Future<ProductJobCreated> submitProductsRecognition(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>))
return ProductJobCreated.fromJson(data);
}
/// Returns product recognition jobs from the last 7 days.
Future<List<ProductJobSummary>> listRecentProductJobs() async {
final data = await _client.getList('/ai/product-jobs');
return data
.map((element) =>
ProductJobSummary.fromJson(element as Map<String, dynamic>))
.toList();
}
/// Returns all product recognition jobs for the current user, newest first.
Future<List<ProductJobSummary>> listAllProductJobs() async {
final data = await _client.getList('/ai/product-jobs/history');
return data
.map((element) =>
ProductJobSummary.fromJson(element as Map<String, dynamic>))
.toList();
}
/// Opens an SSE stream for product job [jobId] and emits [ProductJobEvent]s
/// until the job reaches a terminal state or the stream is cancelled.
Stream<ProductJobEvent> streamProductJobEvents(String jobId) async* {
final streamUri = Uri.parse('${_appConfig.apiBaseUrl}/ai/product-jobs/$jobId/stream');
await for (final parsed in _openSseStream(streamUri)) {
final eventName = parsed.$1;
final json = parsed.$2;
ProductJobEvent? event;
switch (eventName) {
case 'queued':
event = ProductJobQueued(
position: json['position'] as int? ?? 0,
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
);
case 'processing':
event = ProductJobProcessing();
case 'done':
event = ProductJobDone(ProductJobResult.fromJson(json));
case 'failed':
event = ProductJobFailed(json['error'] as String? ?? 'Recognition failed');
}
if (event != null) {
yield event;
if (event is ProductJobDone || event is ProductJobFailed) return;
}
}
}
/// Submits a dish image for async recognition.
/// Returns a [DishJobCreated] with the job ID and queue position.
Future<DishJobCreated> submitDishRecognition(
@@ -298,21 +439,45 @@ class RecognitionService {
.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.
/// Opens an SSE stream for dish job [jobId] and emits [DishJobEvent]s until
/// the job reaches a terminal state (done or failed) or the stream is cancelled.
Stream<DishJobEvent> streamJobEvents(String jobId) async* {
final streamUri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream');
await for (final parsed in _openSseStream(streamUri)) {
final eventName = parsed.$1;
final json = parsed.$2;
DishJobEvent? event;
switch (eventName) {
case 'queued':
event = DishJobQueued(
position: json['position'] as int? ?? 0,
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
);
case 'processing':
event = DishJobProcessing();
case 'done':
event = DishJobDone(DishResult.fromJson(json));
case 'failed':
event = DishJobFailed(json['error'] as String? ?? 'Recognition failed');
}
if (event != null) {
yield event;
if (event is DishJobDone || event is DishJobFailed) return;
}
}
}
/// Opens a raw SSE connection and emits (eventName, jsonData) pairs.
///
/// 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* {
/// XHR which does not support SSE streaming.
Stream<(String, Map<String, dynamic>)> _openSseStream(Uri streamUri) 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)
final request = http.Request('GET', streamUri)
..headers['Authorization'] = token != null ? 'Bearer $token' : ''
..headers['Accept'] = 'text/event-stream'
..headers['Accept-Language'] = language
@@ -329,7 +494,6 @@ class RecognitionService {
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) {
@@ -341,10 +505,13 @@ class RecognitionService {
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;
try {
final jsonData = jsonDecode(dataPayload) as Map<String, dynamic>;
if (currentEventName != null) {
yield (currentEventName, jsonData);
}
} catch (_) {
// Malformed JSON — skip this message.
}
currentEventName = null;
}
@@ -360,29 +527,6 @@ class RecognitionService {
}
}
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);

View File

@@ -82,30 +82,26 @@ class _ScanScreenState extends ConsumerState<ScanScreen> {
final service = ref.read(recognitionServiceProvider);
final l10n = AppLocalizations.of(context)!;
// Show loading overlay while the AI processes.
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _LoadingDialog(label: l10n.recognizing),
builder: (dialogContext) => _LoadingDialog(label: l10n.scanSubmitting),
);
try {
ProductJobCreated jobCreated;
switch (mode) {
case _Mode.receipt:
final result = await service.recognizeReceipt(files.first);
if (context.mounted) {
Navigator.pop(context); // close loading
context.push('/scan/confirm', extra: result.items);
}
jobCreated = await service.submitReceiptRecognition(files.first);
case _Mode.products:
final items = await service.recognizeProducts(files);
if (context.mounted) {
Navigator.pop(context);
context.push('/scan/confirm', extra: items);
}
jobCreated = await service.submitProductsRecognition(files);
}
if (context.mounted) {
Navigator.pop(context); // close loading dialog
context.push('/scan/product-job-watch', extra: jobCreated);
}
} catch (recognitionError) {
debugPrint('Recognition error: $recognitionError');
debugPrint('Recognition submit error: $recognitionError');
if (context.mounted) {
Navigator.pop(context); // close loading
ScaffoldMessenger.of(context).showSnackBar(