Files
food-ai/client/lib/features/scan/recognition_confirm_screen.dart
dbastrikin 6861e5e754 fix: POST /products 500 — invalid unit FK on unrecognized items
Two bugs caused a FK constraint violation on products.unit:
1. RecognizedItem.fromJson fell back to 'шт' (Cyrillic, not a valid
   units.code) when the AI returned a null unit — changed to 'pcs'.
2. The unit dropdown in RecognitionConfirmScreen displayed units.keys.first
   for invalid units but never updated item.unit, so the invalid value was
   still submitted. Added a reconcile step in build() that syncs item.unit
   to units.keys.first whenever the stored value is not in the valid set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:05:19 +02:00

307 lines
9.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/locale/unit_provider.dart';
import '../products/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,
mappingId: item.mappingId,
storageDays: item.storageDays,
confidence: item.confidence,
))
.toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Найдено ${_items.length} продуктов'),
actions: [
if (_items.isNotEmpty)
TextButton(
onPressed: _saving ? null : _addAll,
child: const Text('Добавить всё'),
),
],
),
body: _items.isEmpty
? _EmptyState(onBack: () => Navigator.pop(context))
: ListView.builder(
padding: const EdgeInsets.only(bottom: 80),
itemCount: _items.length,
itemBuilder: (_, i) => _ItemTile(
item: _items[i],
units: ref.watch(unitsProvider).valueOrNull ?? {},
onDelete: () => setState(() => _items.removeAt(i)),
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: const Text('В запасы'),
),
);
}
Future<void> _addAll() async {
setState(() => _saving = true);
try {
for (final item in _items) {
await ref.read(productsProvider.notifier).create(
name: item.name,
quantity: item.quantity,
unit: item.unit,
category: item.category,
storageDays: item.storageDays,
mappingId: item.mappingId,
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Добавлено ${_items.length} продуктов'),
),
);
// Pop back to products screen.
int count = 0;
Navigator.popUntil(context, (_) => count++ >= 2);
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось добавить продукты')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
// ---------------------------------------------------------------------------
// Editable item model
// ---------------------------------------------------------------------------
class _EditableItem {
String name;
double quantity;
String unit;
final String category;
final String? mappingId;
final int storageDays;
final double confidence;
_EditableItem({
required this.name,
required this.quantity,
required this.unit,
required this.category,
this.mappingId,
required this.storageDays,
required this.confidence,
});
}
// ---------------------------------------------------------------------------
// Item tile with inline editing
// ---------------------------------------------------------------------------
class _ItemTile extends StatefulWidget {
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
State<_ItemTile> createState() => _ItemTileState();
}
class _ItemTileState extends State<_ItemTile> {
late final _qtyController =
TextEditingController(text: _formatQty(widget.item.quantity));
@override
void dispose() {
_qtyController.dispose();
super.dispose();
}
String _formatQty(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final conf = widget.item.confidence;
final confColor = conf >= 0.8
? Colors.green
: conf >= 0.5
? Colors.orange
: Colors.red;
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: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.item.name,
style: theme.textTheme.bodyLarge),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: confColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
'${(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: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder(),
),
onChanged: (v) {
final parsed = double.tryParse(v);
if (parsed != null) {
widget.item.quantity = parsed;
widget.onChanged();
}
},
),
),
const SizedBox(width: 8),
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();
}
},
);
}),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onDelete,
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.onBack});
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.search_off, size: 64),
const SizedBox(height: 12),
const Text('Продукты не найдены'),
const SizedBox(height: 16),
FilledButton(onPressed: onBack, child: const Text('Назад')),
],
),
);
}
}