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>
This commit is contained in:
321
client/lib/features/scan/recognition_confirm_screen.dart
Normal file
321
client/lib/features/scan/recognition_confirm_screen.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
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('Назад')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user