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>
198 lines
5.6 KiB
Dart
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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|