- 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>
307 lines
9.6 KiB
Dart
307 lines
9.6 KiB
Dart
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('Назад')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|