Files
food-ai/client/lib/features/scan/recognition_confirm_screen.dart
dbastrikin 205edbdade feat: rename ingredients→products, products→user_products; add barcode/OFF import
- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:45:48 +02:00

307 lines
9.6 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/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,
))
.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(userProductsProvider.notifier).create(
name: item.name,
quantity: item.quantity,
unit: item.unit,
category: item.category,
storageDays: item.storageDays,
primaryProductId: item.primaryProductId,
);
}
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? primaryProductId;
final int storageDays;
final double confidence;
_EditableItem({
required this.name,
required this.quantity,
required this.unit,
required this.category,
this.primaryProductId,
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('Назад')),
],
),
);
}
}