Files
food-ai/client/lib/features/scan/scan_screen.dart
dbastrikin c7317c4335 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>
2026-03-23 23:01:30 +02:00

198 lines
5.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../l10n/app_localizations.dart';
import 'recognition_service.dart';
/// Entry screen — lets the user choose how to add products.
class ScanScreen extends ConsumerStatefulWidget {
const ScanScreen({super.key});
@override
ConsumerState<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends ConsumerState<ScanScreen> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.addFromReceiptOrPhoto)),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
const SizedBox(height: 16),
Text(
l10n.chooseMethod,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
_ModeCard(
emoji: '🧾',
title: l10n.photoReceipt,
subtitle: l10n.photoReceiptSubtitle,
onTap: () => _pickAndRecognize(context, _Mode.receipt),
),
const SizedBox(height: 16),
_ModeCard(
emoji: '🥦',
title: l10n.photoProducts,
subtitle: l10n.photoProductsSubtitle,
onTap: () => _pickAndRecognize(context, _Mode.products),
),
],
),
);
}
Future<void> _pickAndRecognize(
BuildContext context,
_Mode mode,
) async {
final picker = ImagePicker();
List<XFile> files = [];
if (mode == _Mode.products) {
// Allow up to 3 images.
final picked = await picker.pickMultiImage(
imageQuality: 70,
maxWidth: 1024,
maxHeight: 1024,
);
if (picked.isEmpty) return;
files = picked.take(3).toList();
} else {
final source = await _chooseSource(context);
if (source == null) return;
final picked = await picker.pickImage(
source: source,
imageQuality: 70,
maxWidth: 1024,
maxHeight: 1024,
);
if (picked == null) return;
files = [picked];
}
if (!context.mounted) return;
final service = ref.read(recognitionServiceProvider);
final l10n = AppLocalizations.of(context)!;
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _LoadingDialog(label: l10n.scanSubmitting),
);
try {
ProductJobCreated jobCreated;
switch (mode) {
case _Mode.receipt:
jobCreated = await service.submitReceiptRecognition(files.first);
case _Mode.products:
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 submit error: $recognitionError');
if (context.mounted) {
Navigator.pop(context); // close loading
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recognitionFailed)),
);
}
}
}
Future<ImageSource?> _chooseSource(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return showModalBottomSheet<ImageSource>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: Text(l10n.camera),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: Text(l10n.gallery),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Mode enum
// ---------------------------------------------------------------------------
enum _Mode { receipt, products }
// ---------------------------------------------------------------------------
// Widgets
// ---------------------------------------------------------------------------
class _ModeCard extends StatelessWidget {
const _ModeCard({
required this.emoji,
required this.title,
required this.subtitle,
required this.onTap,
});
final String emoji;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
leading: Text(emoji, style: const TextStyle(fontSize: 32)),
title: Text(title, style: theme.textTheme.titleMedium),
subtitle: Text(subtitle, style: theme.textTheme.bodySmall),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _LoadingDialog extends StatelessWidget {
const _LoadingDialog({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(label),
],
),
);
}
}