- 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>
473 lines
15 KiB
Dart
473 lines
15 KiB
Dart
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';
|
|
|
|
/// Editable confirmation screen shown after receipt/products recognition.
|
|
/// The user can adjust quantities, units, remove items, then batch-add to pantry.
|
|
class RecognitionConfirmScreen extends ConsumerStatefulWidget {
|
|
const RecognitionConfirmScreen({super.key, required this.items});
|
|
|
|
final List<RecognizedItem> items;
|
|
|
|
@override
|
|
ConsumerState<RecognitionConfirmScreen> createState() =>
|
|
_RecognitionConfirmScreenState();
|
|
}
|
|
|
|
class _RecognitionConfirmScreenState
|
|
extends ConsumerState<RecognitionConfirmScreen> {
|
|
late final List<_EditableItem> _items;
|
|
bool _saving = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_items = widget.items
|
|
.map((item) => _EditableItem(
|
|
name: item.name,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
category: item.category,
|
|
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(l10n.recognitionFoundProducts(_items.length)),
|
|
actions: [
|
|
if (_items.isNotEmpty)
|
|
TextButton(
|
|
onPressed: _saving ? null : _addAll,
|
|
child: Text(l10n.recognitionAddAll),
|
|
),
|
|
],
|
|
),
|
|
body: _items.isEmpty
|
|
? _EmptyState(onBack: () => Navigator.pop(context))
|
|
: ListView.builder(
|
|
padding: const EdgeInsets.only(bottom: 80),
|
|
itemCount: _items.length,
|
|
itemBuilder: (_, index) => _ItemTile(
|
|
item: _items[index],
|
|
units: ref.watch(unitsProvider).valueOrNull ?? {},
|
|
onDelete: () => setState(() => _items.removeAt(index)),
|
|
onChanged: () => setState(() {}),
|
|
),
|
|
),
|
|
floatingActionButton: _items.isEmpty
|
|
? null
|
|
: FloatingActionButton.extended(
|
|
onPressed: _saving ? null : _addAll,
|
|
icon: _saving
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.add_shopping_cart),
|
|
label: Text(l10n.recognitionAddToStock),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _addAll() async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
setState(() => _saving = true);
|
|
try {
|
|
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) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recognitionAdded(_items.length))),
|
|
);
|
|
// Pop back to products screen.
|
|
int count = 0;
|
|
Navigator.popUntil(context, (_) => count++ >= 2);
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recognitionProductsFailed)),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Editable item model
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _EditableItem {
|
|
String name;
|
|
double quantity;
|
|
String unit;
|
|
final String category;
|
|
String? primaryProductId;
|
|
final int storageDays;
|
|
final double confidence;
|
|
final double quantityConfidence;
|
|
|
|
_EditableItem({
|
|
required this.name,
|
|
required this.quantity,
|
|
required this.unit,
|
|
required this.category,
|
|
this.primaryProductId,
|
|
required this.storageDays,
|
|
required this.confidence,
|
|
required this.quantityConfidence,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Item tile with inline editing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _ItemTile extends ConsumerStatefulWidget {
|
|
const _ItemTile({
|
|
required this.item,
|
|
required this.units,
|
|
required this.onDelete,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final _EditableItem item;
|
|
final Map<String, String> units;
|
|
final VoidCallback onDelete;
|
|
final VoidCallback onChanged;
|
|
|
|
@override
|
|
ConsumerState<_ItemTile> createState() => _ItemTileState();
|
|
}
|
|
|
|
class _ItemTileState extends ConsumerState<_ItemTile> {
|
|
late final _qtyController =
|
|
TextEditingController(text: _formatQty(widget.item.quantity));
|
|
|
|
@override
|
|
void dispose() {
|
|
_qtyController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
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),
|
|
direction: DismissDirection.endToStart,
|
|
background: Container(
|
|
color: theme.colorScheme.error,
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20),
|
|
child: Icon(Icons.delete_outline, color: theme.colorScheme.onError),
|
|
),
|
|
onDismissed: (_) => widget.onDelete(),
|
|
child: Container(
|
|
color: tileColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: confColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
l10n.recognitionConfidence((conf * 100).toInt()),
|
|
style: theme.textTheme.labelSmall
|
|
?.copyWith(color: confColor),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
width: 72,
|
|
child: TextField(
|
|
controller: _qtyController,
|
|
keyboardType:
|
|
const TextInputType.numberWithOptions(decimal: true),
|
|
textAlign: TextAlign.center,
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
|
border: const OutlineInputBorder(),
|
|
enabledBorder: qtyUncertain
|
|
? const OutlineInputBorder(
|
|
borderSide: BorderSide(color: Colors.orange, width: 2),
|
|
)
|
|
: null,
|
|
),
|
|
onChanged: (value) {
|
|
final parsed = double.tryParse(value);
|
|
if (parsed != null) {
|
|
widget.item.quantity = parsed;
|
|
widget.onChanged();
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
widget.units.isEmpty
|
|
? const SizedBox(width: 48)
|
|
: Builder(builder: (builderContext) {
|
|
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((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),
|
|
onPressed: widget.onDelete,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
const _EmptyState({required this.onBack});
|
|
|
|
final VoidCallback onBack;
|
|
|
|
@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),
|
|
Text(l10n.recognitionEmpty),
|
|
const SizedBox(height: 16),
|
|
FilledButton(onPressed: onBack, child: Text(l10n.cancel)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|