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:
211
client/lib/features/scan/scan_screen.dart
Normal file
211
client/lib/features/scan/scan_screen.dart
Normal 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('Распознаём...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user