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:
dbastrikin
2026-02-22 10:54:03 +02:00
parent 288bb1c375
commit deceedd4a7
16 changed files with 1623 additions and 8 deletions

View 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('Назад')),
],
),
);
}
}