Files
food-ai/client/lib/features/scan/recognition_confirm_screen.dart
dbastrikin deceedd4a7 feat: implement Iteration 3 — product/receipt/dish recognition
Backend:
- gemini/client.go: refactor to shared callGroq transport; add
  generateVisionContent using llama-3.2-11b-vision-preview model
- gemini/recognition.go: RecognizeReceipt, RecognizeProducts,
  RecognizeDish (vision), ClassifyIngredient (text); shared parseJSON helper
- ingredient/repository.go: add FuzzyMatch (wraps Search, returns best hit)
- recognition/handler.go: POST /ai/recognize-receipt, /ai/recognize-products,
  /ai/recognize-dish; enrichItems with fuzzy match + AI classify fallback;
  parallel multi-image processing with deduplication
- server.go + main.go: wire recognition handler under /ai routes

Flutter:
- pubspec.yaml: add image_picker ^1.1.0
- AndroidManifest.xml: add CAMERA and READ_EXTERNAL_STORAGE permissions
- Info.plist: add NSCameraUsageDescription and NSPhotoLibraryUsageDescription
- recognition_service.dart: RecognitionService wrapping /ai/* endpoints;
  RecognizedItem, ReceiptResult, DishResult models
- scan_screen.dart: mode selector (receipt / products / dish / manual);
  image source picker; loading overlay; navigates to confirm or dish screen
- recognition_confirm_screen.dart: editable list of recognized items;
  inline qty/unit editing; swipe-to-delete; batch-add to pantry
- dish_result_screen.dart: dish name, KBZHU breakdown, similar dishes chips
- app_router.dart: /scan, /scan/confirm, /scan/dish routes (no bottom nav)
- products_screen.dart: FAB now shows bottom sheet with Manual / Scan options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:54:03 +02:00

322 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 '../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;
static const _units = ['г', 'кг', 'мл', 'л', 'шт', 'уп'];
@override
void initState() {
super.initState();
_items = widget.items
.map((item) => _EditableItem(
name: item.name,
quantity: item.quantity,
unit: _mapUnit(item.unit),
category: item.category,
mappingId: item.mappingId,
storageDays: item.storageDays,
confidence: item.confidence,
))
.toList();
}
String _mapUnit(String unit) {
// Backend may return 'pcs', 'g', 'kg', etc. — normalise to display units.
switch (unit.toLowerCase()) {
case 'g':
return 'г';
case 'kg':
return 'кг';
case 'ml':
return 'мл';
case 'l':
return 'л';
case 'pcs':
case 'шт':
return 'шт';
case 'уп':
return 'уп';
default:
return unit;
}
}
@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: _units,
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 List<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),
DropdownButton<String>(
value: widget.units.contains(widget.item.unit)
? widget.item.unit
: widget.units.last,
underline: const SizedBox(),
items: widget.units
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
.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('Назад')),
],
),
);
}
}