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,211 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/auth/auth_provider.dart';
import 'recognition_service.dart';
// Provider wired to the shared ApiClient.
final _recognitionServiceProvider = Provider<RecognitionService>((ref) {
return RecognitionService(ref.read(apiClientProvider));
});
/// Entry screen — lets the user choose how to add products.
class ScanScreen extends ConsumerWidget {
const ScanScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Добавить продукты')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
const SizedBox(height: 16),
Text(
'Выберите способ',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
_ModeCard(
emoji: '🧾',
title: 'Сфотографировать чек',
subtitle: 'Распознаем все продукты из чека',
onTap: () => _pickAndRecognize(context, ref, _Mode.receipt),
),
const SizedBox(height: 16),
_ModeCard(
emoji: '🥦',
title: 'Сфотографировать продукты',
subtitle: 'Холодильник, стол, полка — до 3 фото',
onTap: () => _pickAndRecognize(context, ref, _Mode.products),
),
const SizedBox(height: 16),
_ModeCard(
emoji: '🍽️',
title: 'Определить блюдо',
subtitle: 'КБЖУ≈ по фото готового блюда',
onTap: () => _pickAndRecognize(context, ref, _Mode.dish),
),
const SizedBox(height: 16),
_ModeCard(
emoji: '✏️',
title: 'Добавить вручную',
subtitle: 'Ввести название, количество и срок',
onTap: () => context.push('/products/add'),
),
],
),
);
}
Future<void> _pickAndRecognize(
BuildContext context,
WidgetRef ref,
_Mode mode,
) async {
final picker = ImagePicker();
List<File> files = [];
if (mode == _Mode.products) {
// Allow up to 3 images.
final picked = await picker.pickMultiImage(imageQuality: 70);
if (picked.isEmpty) return;
files = picked.take(3).map((x) => File(x.path)).toList();
} else {
final source = await _chooseSource(context);
if (source == null) return;
final picked = await picker.pickImage(source: source, imageQuality: 70);
if (picked == null) return;
files = [File(picked.path)];
}
if (!context.mounted) return;
final service = ref.read(_recognitionServiceProvider);
// Show loading overlay while the AI processes.
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const _LoadingDialog(),
);
try {
switch (mode) {
case _Mode.receipt:
final result = await service.recognizeReceipt(files.first);
if (context.mounted) {
Navigator.pop(context); // close loading
context.push('/scan/confirm', extra: result.items);
}
case _Mode.products:
final items = await service.recognizeProducts(files);
if (context.mounted) {
Navigator.pop(context);
context.push('/scan/confirm', extra: items);
}
case _Mode.dish:
final dish = await service.recognizeDish(files.first);
if (context.mounted) {
Navigator.pop(context);
context.push('/scan/dish', extra: dish);
}
}
} catch (e) {
if (context.mounted) {
Navigator.pop(context); // close loading
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось распознать. Попробуйте ещё раз.'),
),
);
}
}
}
Future<ImageSource?> _chooseSource(BuildContext context) async {
return showModalBottomSheet<ImageSource>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Камера'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Галерея'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Mode enum
// ---------------------------------------------------------------------------
enum _Mode { receipt, products, dish }
// ---------------------------------------------------------------------------
// Widgets
// ---------------------------------------------------------------------------
class _ModeCard extends StatelessWidget {
const _ModeCard({
required this.emoji,
required this.title,
required this.subtitle,
required this.onTap,
});
final String emoji;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
leading: Text(emoji, style: const TextStyle(fontSize: 32)),
title: Text(title, style: theme.textTheme.titleMedium),
subtitle: Text(subtitle, style: theme.textTheme.bodySmall),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _LoadingDialog extends StatelessWidget {
const _LoadingDialog();
@override
Widget build(BuildContext context) {
return const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Распознаём...'),
],
),
);
}
}