feat: improved receipt recognition, batch product add, and scan UX

- Rewrite receipt OCR prompt: completes truncated names, preserves fat%
  and flavour attributes, extracts weight/volume from line, infers
  typical package sizes for solid goods with quantity_confidence field
- Add quantity_confidence to RecognizedItem, EnrichedItem, and
  ProductJobResultItem; propagate through item enricher and worker
- Replace per-item create loop with single POST /user-products/batch call
  from RecognitionConfirmScreen
- Rebuild RecognitionConfirmScreen: amber qty border for low
  quantity_confidence, tappable product name → catalog picker,
  sort items by confidence, full L10n (no hardcoded strings)
- Add timestamps (HH:mm / d MMM HH:mm) to recent scan chips
- Show close-app hint on ProductJobWatchScreen (queued + processing)
- Refresh recentProductJobsProvider on watch screen init so new job
  appears without a manual pull-to-refresh
- App-level WidgetsBindingObserver refreshes product and dish job lists
  on resume, fixing stale lists after background/foreground transitions
- Add 9 new L10n keys across all 12 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-26 23:09:57 +02:00
parent b2bdcbae6f
commit 5c5ed25e5b
38 changed files with 1221 additions and 115 deletions

View File

@@ -1,6 +1,7 @@
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 '../../core/locale/unit_provider.dart';
import '../../l10n/app_localizations.dart';
@@ -120,7 +121,7 @@ class _RecentScansSection extends ConsumerWidget {
),
),
SizedBox(
height: 72,
height: 84,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
@@ -187,6 +188,12 @@ class _ScanJobChip extends ConsumerWidget {
isFailed: isFailed,
isActive: isActive,
),
Text(
_formatChipDate(job.createdAt),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
@@ -197,6 +204,17 @@ class _ScanJobChip extends ConsumerWidget {
}
}
String _formatChipDate(DateTime dateTime) {
final local = dateTime.toLocal();
final now = DateTime.now();
final isToday = local.year == now.year &&
local.month == now.month &&
local.day == now.day;
return isToday
? DateFormat.Hm().format(local)
: DateFormat('d MMM HH:mm').format(local);
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({
required this.status,

View File

@@ -72,6 +72,17 @@ class UserProductsNotifier
});
}
/// Adds multiple products in a single request and appends them to the list.
Future<void> batchCreate(List<Map<String, dynamic>> payloads) async {
final created = await _service.batchCreateProducts(payloads);
state.whenData((products) {
final updated = [...products, ...created]
..sort((firstProduct, secondProduct) =>
firstProduct.expiresAt.compareTo(secondProduct.expiresAt));
state = AsyncValue.data(updated);
});
}
/// Updates a product in-place, keeping list sort order.
Future<void> update(
String id, {

View File

@@ -61,6 +61,14 @@ class UserProductService {
return UserProduct.fromJson(data);
}
Future<List<UserProduct>> batchCreateProducts(
List<Map<String, dynamic>> payloads) async {
final list = await _client.postList('/user-products/batch', data: payloads);
return list
.map((element) => UserProduct.fromJson(element as Map<String, dynamic>))
.toList();
}
Future<void> deleteProduct(String id) =>
_client.deleteVoid('/user-products/$id');

View File

@@ -23,6 +23,15 @@ class _ProductJobWatchScreenState
String? _errorMessage;
bool _navigated = false;
@override
void initState() {
super.initState();
// Refresh so the new job appears in recent scans immediately.
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(recentProductJobsProvider.notifier).refresh();
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -112,6 +121,14 @@ class _QueuedBody extends StatelessWidget {
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: 20),
Text(
l10n.scanJobCloseHint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
@@ -126,14 +143,26 @@ class _ProcessingBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(label, style: Theme.of(context).textTheme.titleMedium),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(label, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 20),
Text(
l10n.scanJobCloseHint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/locale/unit_provider.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
import '../products/user_product_provider.dart';
import 'recognition_service.dart';
@@ -34,20 +36,25 @@ class _RecognitionConfirmScreenState
primaryProductId: item.primaryProductId,
storageDays: item.storageDays,
confidence: item.confidence,
quantityConfidence: item.quantityConfidence,
))
.toList();
// Sort: low-confidence items first so the user notices them.
_items.sort((firstItem, secondItem) =>
firstItem.confidence.compareTo(secondItem.confidence));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text('Найдено ${_items.length} продуктов'),
title: Text(l10n.recognitionFoundProducts(_items.length)),
actions: [
if (_items.isNotEmpty)
TextButton(
onPressed: _saving ? null : _addAll,
child: const Text('Добавить всё'),
child: Text(l10n.recognitionAddAll),
),
],
),
@@ -56,10 +63,10 @@ class _RecognitionConfirmScreenState
: ListView.builder(
padding: const EdgeInsets.only(bottom: 80),
itemCount: _items.length,
itemBuilder: (_, i) => _ItemTile(
item: _items[i],
itemBuilder: (_, index) => _ItemTile(
item: _items[index],
units: ref.watch(unitsProvider).valueOrNull ?? {},
onDelete: () => setState(() => _items.removeAt(i)),
onDelete: () => setState(() => _items.removeAt(index)),
onChanged: () => setState(() {}),
),
),
@@ -74,29 +81,31 @@ class _RecognitionConfirmScreenState
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_shopping_cart),
label: const Text('В запасы'),
label: Text(l10n.recognitionAddToStock),
),
);
}
Future<void> _addAll() async {
final l10n = AppLocalizations.of(context)!;
final messenger = ScaffoldMessenger.of(context);
setState(() => _saving = true);
try {
for (final item in _items) {
await ref.read(userProductsProvider.notifier).create(
name: item.name,
quantity: item.quantity,
unit: item.unit,
category: item.category,
storageDays: item.storageDays,
primaryProductId: item.primaryProductId,
);
}
final payloads = _items
.map((item) => <String, dynamic>{
'name': item.name,
'quantity': item.quantity,
'unit': item.unit,
'category': item.category,
'storage_days': item.storageDays,
if (item.primaryProductId != null)
'primary_product_id': item.primaryProductId,
})
.toList();
await ref.read(userProductsProvider.notifier).batchCreate(payloads);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Добавлено ${_items.length} продуктов'),
),
messenger.showSnackBar(
SnackBar(content: Text(l10n.recognitionAdded(_items.length))),
);
// Pop back to products screen.
int count = 0;
@@ -104,8 +113,8 @@ class _RecognitionConfirmScreenState
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось добавить продукты')),
messenger.showSnackBar(
SnackBar(content: Text(l10n.recognitionProductsFailed)),
);
}
} finally {
@@ -123,9 +132,10 @@ class _EditableItem {
double quantity;
String unit;
final String category;
final String? primaryProductId;
String? primaryProductId;
final int storageDays;
final double confidence;
final double quantityConfidence;
_EditableItem({
required this.name,
@@ -135,6 +145,7 @@ class _EditableItem {
this.primaryProductId,
required this.storageDays,
required this.confidence,
required this.quantityConfidence,
});
}
@@ -142,7 +153,7 @@ class _EditableItem {
// Item tile with inline editing
// ---------------------------------------------------------------------------
class _ItemTile extends StatefulWidget {
class _ItemTile extends ConsumerStatefulWidget {
const _ItemTile({
required this.item,
required this.units,
@@ -156,10 +167,10 @@ class _ItemTile extends StatefulWidget {
final VoidCallback onChanged;
@override
State<_ItemTile> createState() => _ItemTileState();
ConsumerState<_ItemTile> createState() => _ItemTileState();
}
class _ItemTileState extends State<_ItemTile> {
class _ItemTileState extends ConsumerState<_ItemTile> {
late final _qtyController =
TextEditingController(text: _formatQty(widget.item.quantity));
@@ -169,18 +180,43 @@ class _ItemTileState extends State<_ItemTile> {
super.dispose();
}
String _formatQty(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
String _formatQty(double value) =>
value == value.roundToDouble()
? value.toInt().toString()
: value.toStringAsFixed(1);
void _openProductPicker() async {
final picked = await showModalBottomSheet<CatalogProduct>(
context: context,
isScrollControlled: true,
builder: (_) => _ProductPickerSheet(currentName: widget.item.name),
);
if (picked != null && mounted) {
setState(() {
widget.item.name = picked.displayName;
widget.item.primaryProductId = picked.id;
if (picked.defaultUnit != null) {
widget.item.unit = picked.defaultUnit!;
}
});
widget.onChanged();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
final conf = widget.item.confidence;
final confColor = conf >= 0.8
? Colors.green
: conf >= 0.5
? Colors.orange
: Colors.red;
final qtyUncertain = widget.item.quantityConfidence < 0.7;
final tileColor = conf < 0.7
? theme.colorScheme.errorContainer.withValues(alpha: 0.15)
: null;
return Dismissible(
key: ValueKey(widget.item.name),
@@ -192,7 +228,8 @@ class _ItemTileState extends State<_ItemTile> {
child: Icon(Icons.delete_outline, color: theme.colorScheme.onError),
),
onDismissed: (_) => widget.onDelete(),
child: Padding(
child: Container(
color: tileColor,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
@@ -200,8 +237,22 @@ class _ItemTileState extends State<_ItemTile> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.item.name,
style: theme.textTheme.bodyLarge),
GestureDetector(
onTap: _openProductPicker,
child: Row(
children: [
Expanded(
child: Text(widget.item.name,
style: theme.textTheme.bodyLarge),
),
Icon(
Icons.edit_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
Row(
children: [
Container(
@@ -214,7 +265,7 @@ class _ItemTileState extends State<_ItemTile> {
),
const SizedBox(width: 4),
Text(
'${(conf * 100).toInt()}% уверенность',
l10n.recognitionConfidence((conf * 100).toInt()),
style: theme.textTheme.labelSmall
?.copyWith(color: confColor),
),
@@ -231,13 +282,18 @@ class _ItemTileState extends State<_ItemTile> {
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center,
decoration: const InputDecoration(
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
border: const OutlineInputBorder(),
enabledBorder: qtyUncertain
? const OutlineInputBorder(
borderSide: BorderSide(color: Colors.orange, width: 2),
)
: null,
),
onChanged: (v) {
final parsed = double.tryParse(v);
onChanged: (value) {
final parsed = double.tryParse(value);
if (parsed != null) {
widget.item.quantity = parsed;
widget.onChanged();
@@ -249,24 +305,23 @@ class _ItemTileState extends State<_ItemTile> {
widget.units.isEmpty
? const SizedBox(width: 48)
: Builder(builder: (builderContext) {
// Reconcile item.unit with valid server codes so that the
// submitted value matches what the dropdown displays.
if (!widget.units.containsKey(widget.item.unit)) {
widget.item.unit = widget.units.keys.first;
}
return DropdownButton<String>(
value: widget.item.unit,
underline: const SizedBox(),
items: widget.units.entries
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) {
if (v != null) {
setState(() => widget.item.unit = v);
widget.onChanged();
}
},
);
value: widget.item.unit,
underline: const SizedBox(),
items: widget.units.entries
.map((entry) => DropdownMenuItem(
value: entry.key, child: Text(entry.value)))
.toList(),
onChanged: (value) {
if (value != null) {
setState(() => widget.item.unit = value);
widget.onChanged();
}
},
);
}),
IconButton(
icon: const Icon(Icons.close),
@@ -279,6 +334,116 @@ class _ItemTileState extends State<_ItemTile> {
}
}
// ---------------------------------------------------------------------------
// Product picker bottom sheet
// ---------------------------------------------------------------------------
class _ProductPickerSheet extends ConsumerStatefulWidget {
const _ProductPickerSheet({required this.currentName});
final String currentName;
@override
ConsumerState<_ProductPickerSheet> createState() =>
_ProductPickerSheetState();
}
class _ProductPickerSheetState extends ConsumerState<_ProductPickerSheet> {
late final _searchController =
TextEditingController(text: widget.currentName);
List<CatalogProduct> _results = [];
bool _loading = false;
@override
void initState() {
super.initState();
_search(widget.currentName);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _search(String query) async {
if (query.isEmpty) {
setState(() => _results = []);
return;
}
setState(() => _loading = true);
try {
final service = ref.read(userProductServiceProvider);
final searchResults = await service.searchProducts(query);
if (mounted) setState(() => _results = searchResults);
} catch (_) {
// Silently ignore search errors.
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
l10n.recognitionReplaceProduct,
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: l10n.searchProductsHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: _search,
),
),
const SizedBox(height: 8),
if (_loading)
const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.builder(
shrinkWrap: true,
itemCount: _results.length,
itemBuilder: (_, index) {
final product = _results[index];
return ListTile(
title: Text(product.displayName),
onTap: () => Navigator.pop(context, product),
);
},
),
),
const SizedBox(height: 8),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
@@ -290,15 +455,16 @@ class _EmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.search_off, size: 64),
const SizedBox(height: 12),
const Text('Продукты не найдены'),
Text(l10n.recognitionEmpty),
const SizedBox(height: 16),
FilledButton(onPressed: onBack, child: const Text('Назад')),
FilledButton(onPressed: onBack, child: Text(l10n.cancel)),
],
),
);

View File

@@ -21,6 +21,7 @@ class RecognizedItem {
String unit;
final String category;
final double confidence;
final double quantityConfidence;
final String? primaryProductId;
final int storageDays;
@@ -30,6 +31,7 @@ class RecognizedItem {
required this.unit,
required this.category,
required this.confidence,
this.quantityConfidence = 1.0,
this.primaryProductId,
required this.storageDays,
});
@@ -41,6 +43,7 @@ class RecognizedItem {
unit: json['unit'] as String? ?? 'pcs',
category: json['category'] as String? ?? 'other',
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
quantityConfidence: (json['quantity_confidence'] as num?)?.toDouble() ?? 1.0,
primaryProductId: json['mapping_id'] as String?,
storageDays: json['storage_days'] as int? ?? 7,
);