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
// ---------------------------------------------------------------------------