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:
@@ -13,6 +13,8 @@ import '../../features/profile/profile_provider.dart';
|
||||
import '../../shared/models/user.dart';
|
||||
import '../../features/products/products_screen.dart';
|
||||
import '../../features/products/add_product_screen.dart';
|
||||
import '../../features/products/product_job_history_screen.dart';
|
||||
import '../../features/scan/product_job_watch_screen.dart';
|
||||
import '../../features/scan/scan_screen.dart';
|
||||
import '../../features/scan/recognition_confirm_screen.dart';
|
||||
import '../../features/scan/recognition_service.dart';
|
||||
@@ -129,6 +131,17 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/scan/history',
|
||||
builder: (_, __) => const RecognitionHistoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/scan/product-job-watch',
|
||||
builder: (context, state) {
|
||||
final jobCreated = state.extra as ProductJobCreated;
|
||||
return ProductJobWatchScreen(jobCreated: jobCreated);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/products/job-history',
|
||||
builder: (_, __) => const ProductJobHistoryScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => MainShell(child: child),
|
||||
routes: [
|
||||
|
||||
114
client/lib/features/products/product_job_history_screen.dart
Normal file
114
client/lib/features/products/product_job_history_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
75
client/lib/features/products/product_job_provider.dart
Normal file
75
client/lib/features/products/product_job_provider.dart
Normal 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);
|
||||
});
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
172
client/lib/features/scan/product_job_watch_screen.dart
Normal file
172
client/lib/features/scan/product_job_watch_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 1–3 product photos.
|
||||
Future<List<RecognizedItem>> recognizeProducts(List<XFile> images) async {
|
||||
/// Submits 1–3 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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "تخطي اختيار المنتجات",
|
||||
"planProductsSkipNoProducts": "التخطيط بدون منتجات",
|
||||
"planProductsSelectAll": "تحديد الكل",
|
||||
"planProductsDeselectAll": "إلغاء تحديد الكل"
|
||||
"planProductsDeselectAll": "إلغاء تحديد الكل",
|
||||
"recentScans": "عمليات المسح الأخيرة",
|
||||
"seeAllScans": "عرض الكل",
|
||||
"productJobHistoryTitle": "سجل المسح",
|
||||
"jobTypeReceipt": "إيصال",
|
||||
"jobTypeProducts": "منتجات",
|
||||
"scanSubmitting": "جارٍ الإرسال...",
|
||||
"processingProducts": "جارٍ المعالجة..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "Produktauswahl überspringen",
|
||||
"planProductsSkipNoProducts": "Ohne Produkte planen",
|
||||
"planProductsSelectAll": "Alle auswählen",
|
||||
"planProductsDeselectAll": "Alle abwählen"
|
||||
"planProductsDeselectAll": "Alle abwählen",
|
||||
"recentScans": "Letzte Scans",
|
||||
"seeAllScans": "Alle",
|
||||
"productJobHistoryTitle": "Scan-Verlauf",
|
||||
"jobTypeReceipt": "Kassenbon",
|
||||
"jobTypeProducts": "Produkte",
|
||||
"scanSubmitting": "Wird gesendet...",
|
||||
"processingProducts": "Verarbeitung..."
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "Position {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Processing...",
|
||||
@@ -116,7 +118,9 @@
|
||||
"noResultsForQuery": "Nothing found for \"{query}\"",
|
||||
"@noResultsForQuery": {
|
||||
"placeholders": {
|
||||
"query": { "type": "String" }
|
||||
"query": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"servingsLabel": "Servings",
|
||||
@@ -125,7 +129,9 @@
|
||||
"planningForDate": "Planning for {date}",
|
||||
"@planningForDate": {
|
||||
"placeholders": {
|
||||
"date": { "type": "String" }
|
||||
"date": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"markAsEaten": "Mark as eaten",
|
||||
@@ -134,7 +140,6 @@
|
||||
"generateWeekSubtitle": "AI will create a menu with breakfast, lunch and dinner for the whole week",
|
||||
"generatingMenu": "Generating menu...",
|
||||
"dayPlannedLabel": "Day planned",
|
||||
|
||||
"planMenuButton": "Plan meals",
|
||||
"planMenuTitle": "What to plan?",
|
||||
"planOptionSingleMeal": "Single meal",
|
||||
@@ -149,16 +154,23 @@
|
||||
"planSelectMealType": "Meal type",
|
||||
"planSelectRange": "Select period",
|
||||
"planGenerateButton": "Plan",
|
||||
"planGenerating": "Generating plan\u2026",
|
||||
"planGenerating": "Generating plan…",
|
||||
"planSuccess": "Menu planned!",
|
||||
"planProductsTitle": "Products for the menu",
|
||||
"planProductsSubtitle": "AI will take the selected products into account when generating recipes",
|
||||
"planProductsEmpty": "No products added",
|
||||
"planProductsEmptyMessage": "Add products you have at home \u2014 AI will suggest recipes from what you already have",
|
||||
"planProductsEmptyMessage": "Add products you have at home — AI will suggest recipes from what you already have",
|
||||
"planProductsAddProducts": "Add products",
|
||||
"planProductsContinue": "Continue",
|
||||
"planProductsSkip": "Skip product selection",
|
||||
"planProductsSkipNoProducts": "Plan without products",
|
||||
"planProductsSelectAll": "Select all",
|
||||
"planProductsDeselectAll": "Deselect all"
|
||||
"planProductsDeselectAll": "Deselect all",
|
||||
"recentScans": "Recent scans",
|
||||
"seeAllScans": "See all",
|
||||
"productJobHistoryTitle": "Scan history",
|
||||
"jobTypeReceipt": "Receipt",
|
||||
"jobTypeProducts": "Products",
|
||||
"scanSubmitting": "Submitting...",
|
||||
"processingProducts": "Processing..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "Omitir selección de productos",
|
||||
"planProductsSkipNoProducts": "Planificar sin productos",
|
||||
"planProductsSelectAll": "Seleccionar todo",
|
||||
"planProductsDeselectAll": "Deseleccionar todo"
|
||||
"planProductsDeselectAll": "Deseleccionar todo",
|
||||
"recentScans": "Escaneos recientes",
|
||||
"seeAllScans": "Ver todos",
|
||||
"productJobHistoryTitle": "Historial de escaneos",
|
||||
"jobTypeReceipt": "Ticket",
|
||||
"jobTypeProducts": "Productos",
|
||||
"scanSubmitting": "Enviando...",
|
||||
"processingProducts": "Procesando..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "Ignorer la sélection des produits",
|
||||
"planProductsSkipNoProducts": "Planifier sans produits",
|
||||
"planProductsSelectAll": "Tout sélectionner",
|
||||
"planProductsDeselectAll": "Tout désélectionner"
|
||||
"planProductsDeselectAll": "Tout désélectionner",
|
||||
"recentScans": "Scans récents",
|
||||
"seeAllScans": "Tout voir",
|
||||
"productJobHistoryTitle": "Historique des scans",
|
||||
"jobTypeReceipt": "Reçu",
|
||||
"jobTypeProducts": "Produits",
|
||||
"scanSubmitting": "Envoi...",
|
||||
"processingProducts": "Traitement..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "उत्पाद चयन छोड़ें",
|
||||
"planProductsSkipNoProducts": "उत्पादों के बिना योजना बनाएं",
|
||||
"planProductsSelectAll": "सभी चुनें",
|
||||
"planProductsDeselectAll": "सभी हटाएं"
|
||||
"planProductsDeselectAll": "सभी हटाएं",
|
||||
"recentScans": "हाल के स्कैन",
|
||||
"seeAllScans": "सभी देखें",
|
||||
"productJobHistoryTitle": "स्कैन इतिहास",
|
||||
"jobTypeReceipt": "रसीद",
|
||||
"jobTypeProducts": "उत्पाद",
|
||||
"scanSubmitting": "सबमिट हो रहा है...",
|
||||
"processingProducts": "प्रोसेस हो रहा है..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "Salta la selezione dei prodotti",
|
||||
"planProductsSkipNoProducts": "Pianifica senza prodotti",
|
||||
"planProductsSelectAll": "Seleziona tutto",
|
||||
"planProductsDeselectAll": "Deseleziona tutto"
|
||||
"planProductsDeselectAll": "Deseleziona tutto",
|
||||
"recentScans": "Scansioni recenti",
|
||||
"seeAllScans": "Vedi tutto",
|
||||
"productJobHistoryTitle": "Cronologia scansioni",
|
||||
"jobTypeReceipt": "Scontrino",
|
||||
"jobTypeProducts": "Prodotti",
|
||||
"scanSubmitting": "Invio...",
|
||||
"processingProducts": "Elaborazione..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "食材選択をスキップ",
|
||||
"planProductsSkipNoProducts": "食材なしでプランニング",
|
||||
"planProductsSelectAll": "すべて選択",
|
||||
"planProductsDeselectAll": "すべて解除"
|
||||
"planProductsDeselectAll": "すべて解除",
|
||||
"recentScans": "最近のスキャン",
|
||||
"seeAllScans": "すべて表示",
|
||||
"productJobHistoryTitle": "スキャン履歴",
|
||||
"jobTypeReceipt": "レシート",
|
||||
"jobTypeProducts": "商品",
|
||||
"scanSubmitting": "送信中...",
|
||||
"processingProducts": "処理中..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "재료 선택 건너뛰기",
|
||||
"planProductsSkipNoProducts": "재료 없이 계획하기",
|
||||
"planProductsSelectAll": "모두 선택",
|
||||
"planProductsDeselectAll": "모두 해제"
|
||||
"planProductsDeselectAll": "모두 해제",
|
||||
"recentScans": "최근 스캔",
|
||||
"seeAllScans": "전체 보기",
|
||||
"productJobHistoryTitle": "스캔 기록",
|
||||
"jobTypeReceipt": "영수증",
|
||||
"jobTypeProducts": "제품",
|
||||
"scanSubmitting": "제출 중...",
|
||||
"processingProducts": "처리 중..."
|
||||
}
|
||||
|
||||
@@ -987,6 +987,48 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Deselect all'**
|
||||
String get planProductsDeselectAll;
|
||||
|
||||
/// No description provided for @recentScans.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recent scans'**
|
||||
String get recentScans;
|
||||
|
||||
/// No description provided for @seeAllScans.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'See all'**
|
||||
String get seeAllScans;
|
||||
|
||||
/// No description provided for @productJobHistoryTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan history'**
|
||||
String get productJobHistoryTitle;
|
||||
|
||||
/// No description provided for @jobTypeReceipt.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Receipt'**
|
||||
String get jobTypeReceipt;
|
||||
|
||||
/// No description provided for @jobTypeProducts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Products'**
|
||||
String get jobTypeProducts;
|
||||
|
||||
/// No description provided for @scanSubmitting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Submitting...'**
|
||||
String get scanSubmitting;
|
||||
|
||||
/// No description provided for @processingProducts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Processing...'**
|
||||
String get processingProducts;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -452,4 +452,25 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'إلغاء تحديد الكل';
|
||||
|
||||
@override
|
||||
String get recentScans => 'عمليات المسح الأخيرة';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'عرض الكل';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'سجل المسح';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'إيصال';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'منتجات';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'جارٍ الإرسال...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'جارٍ المعالجة...';
|
||||
}
|
||||
|
||||
@@ -454,4 +454,25 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'Alle abwählen';
|
||||
|
||||
@override
|
||||
String get recentScans => 'Letzte Scans';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'Alle';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'Scan-Verlauf';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'Kassenbon';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'Produkte';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'Wird gesendet...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'Verarbeitung...';
|
||||
}
|
||||
|
||||
@@ -452,4 +452,25 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'Deselect all';
|
||||
|
||||
@override
|
||||
String get recentScans => 'Recent scans';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'See all';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'Scan history';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'Receipt';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'Products';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'Submitting...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'Processing...';
|
||||
}
|
||||
|
||||
@@ -454,4 +454,25 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'Deseleccionar todo';
|
||||
|
||||
@override
|
||||
String get recentScans => 'Escaneos recientes';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'Ver todos';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'Historial de escaneos';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'Ticket';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'Productos';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'Enviando...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'Procesando...';
|
||||
}
|
||||
|
||||
@@ -455,4 +455,25 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'Tout désélectionner';
|
||||
|
||||
@override
|
||||
String get recentScans => 'Scans récents';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'Tout voir';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'Historique des scans';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'Reçu';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'Produits';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'Envoi...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'Traitement...';
|
||||
}
|
||||
|
||||
@@ -453,4 +453,25 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'सभी हटाएं';
|
||||
|
||||
@override
|
||||
String get recentScans => 'हाल के स्कैन';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'सभी देखें';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'स्कैन इतिहास';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'रसीद';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'उत्पाद';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'सबमिट हो रहा है...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'प्रोसेस हो रहा है...';
|
||||
}
|
||||
|
||||
@@ -454,4 +454,25 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'Deseleziona tutto';
|
||||
|
||||
@override
|
||||
String get recentScans => 'Scansioni recenti';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'Vedi tutto';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'Cronologia scansioni';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'Scontrino';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'Prodotti';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'Invio...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'Elaborazione...';
|
||||
}
|
||||
|
||||
@@ -449,4 +449,25 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'すべて解除';
|
||||
|
||||
@override
|
||||
String get recentScans => '最近のスキャン';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'すべて表示';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'スキャン履歴';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'レシート';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => '商品';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => '送信中...';
|
||||
|
||||
@override
|
||||
String get processingProducts => '処理中...';
|
||||
}
|
||||
|
||||
@@ -449,4 +449,25 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => '모두 해제';
|
||||
|
||||
@override
|
||||
String get recentScans => '최근 스캔';
|
||||
|
||||
@override
|
||||
String get seeAllScans => '전체 보기';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => '스캔 기록';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => '영수증';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => '제품';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => '제출 중...';
|
||||
|
||||
@override
|
||||
String get processingProducts => '처리 중...';
|
||||
}
|
||||
|
||||
@@ -454,4 +454,25 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'Desmarcar tudo';
|
||||
|
||||
@override
|
||||
String get recentScans => 'Scans recentes';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'Ver tudo';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'Histórico de scans';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'Recibo';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'Produtos';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'Enviando...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'Processando...';
|
||||
}
|
||||
|
||||
@@ -452,4 +452,25 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => 'Снять всё';
|
||||
|
||||
@override
|
||||
String get recentScans => 'Последние сканирования';
|
||||
|
||||
@override
|
||||
String get seeAllScans => 'Все';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => 'История сканирования';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => 'Чек';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => 'Продукты';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => 'Отправка...';
|
||||
|
||||
@override
|
||||
String get processingProducts => 'Обработка...';
|
||||
}
|
||||
|
||||
@@ -448,4 +448,25 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get planProductsDeselectAll => '取消全选';
|
||||
|
||||
@override
|
||||
String get recentScans => '最近扫描';
|
||||
|
||||
@override
|
||||
String get seeAllScans => '全部';
|
||||
|
||||
@override
|
||||
String get productJobHistoryTitle => '扫描历史';
|
||||
|
||||
@override
|
||||
String get jobTypeReceipt => '收据';
|
||||
|
||||
@override
|
||||
String get jobTypeProducts => '产品';
|
||||
|
||||
@override
|
||||
String get scanSubmitting => '提交中...';
|
||||
|
||||
@override
|
||||
String get processingProducts => '处理中...';
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "Pular seleção de produtos",
|
||||
"planProductsSkipNoProducts": "Planejar sem produtos",
|
||||
"planProductsSelectAll": "Selecionar tudo",
|
||||
"planProductsDeselectAll": "Desmarcar tudo"
|
||||
"planProductsDeselectAll": "Desmarcar tudo",
|
||||
"recentScans": "Scans recentes",
|
||||
"seeAllScans": "Ver tudo",
|
||||
"productJobHistoryTitle": "Histórico de scans",
|
||||
"jobTypeReceipt": "Recibo",
|
||||
"jobTypeProducts": "Produtos",
|
||||
"scanSubmitting": "Enviando...",
|
||||
"processingProducts": "Processando..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "Пропустить выбор продуктов",
|
||||
"planProductsSkipNoProducts": "Планировать без продуктов",
|
||||
"planProductsSelectAll": "Выбрать все",
|
||||
"planProductsDeselectAll": "Снять всё"
|
||||
"planProductsDeselectAll": "Снять всё",
|
||||
"recentScans": "Последние сканирования",
|
||||
"seeAllScans": "Все",
|
||||
"productJobHistoryTitle": "История сканирования",
|
||||
"jobTypeReceipt": "Чек",
|
||||
"jobTypeProducts": "Продукты",
|
||||
"scanSubmitting": "Отправка...",
|
||||
"processingProducts": "Обработка..."
|
||||
}
|
||||
|
||||
@@ -165,5 +165,12 @@
|
||||
"planProductsSkip": "跳过食材选择",
|
||||
"planProductsSkipNoProducts": "不选食材直接规划",
|
||||
"planProductsSelectAll": "全选",
|
||||
"planProductsDeselectAll": "取消全选"
|
||||
"planProductsDeselectAll": "取消全选",
|
||||
"recentScans": "最近扫描",
|
||||
"seeAllScans": "全部",
|
||||
"productJobHistoryTitle": "扫描历史",
|
||||
"jobTypeReceipt": "收据",
|
||||
"jobTypeProducts": "产品",
|
||||
"scanSubmitting": "提交中...",
|
||||
"processingProducts": "处理中..."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user