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:
@@ -5,6 +5,35 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
import 'product_provider.dart';
|
||||
|
||||
void _showAddMenu(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: const Text('Добавить вручную'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/products/add');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.document_scanner_outlined),
|
||||
title: const Text('Сканировать чек или фото'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/scan');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ProductsScreen extends ConsumerWidget {
|
||||
const ProductsScreen({super.key});
|
||||
|
||||
@@ -23,7 +52,7 @@ class ProductsScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/products/add'),
|
||||
onPressed: () => _showAddMenu(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Добавить'),
|
||||
),
|
||||
@@ -34,7 +63,7 @@ class ProductsScreen extends ConsumerWidget {
|
||||
),
|
||||
data: (products) => products.isEmpty
|
||||
? _EmptyState(
|
||||
onAdd: () => context.push('/products/add'),
|
||||
onAdd: () => _showAddMenu(context),
|
||||
)
|
||||
: _ProductList(products: products),
|
||||
),
|
||||
|
||||
167
client/lib/features/scan/dish_result_screen.dart
Normal file
167
client/lib/features/scan/dish_result_screen.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'recognition_service.dart';
|
||||
|
||||
/// Shows the nutritional breakdown of a recognized dish.
|
||||
class DishResultScreen extends StatelessWidget {
|
||||
const DishResultScreen({super.key, required this.dish});
|
||||
|
||||
final DishResult dish;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final confPct = (dish.confidence * 100).toInt();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Распознано блюдо')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
// Dish name + confidence
|
||||
Text(
|
||||
dish.dishName,
|
||||
style: theme.textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Уверенность: $confPct%',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Nutrition card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'≈ ${dish.calories.toInt()} ккал',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Tooltip(
|
||||
message: 'Приблизительные значения на основе фото',
|
||||
child: Text(
|
||||
'≈',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_MacroChip(
|
||||
label: 'Белки',
|
||||
value: '${dish.proteinG.toStringAsFixed(1)} г',
|
||||
color: Colors.blue,
|
||||
),
|
||||
_MacroChip(
|
||||
label: 'Жиры',
|
||||
value: '${dish.fatG.toStringAsFixed(1)} г',
|
||||
color: Colors.orange,
|
||||
),
|
||||
_MacroChip(
|
||||
label: 'Углеводы',
|
||||
value: '${dish.carbsG.toStringAsFixed(1)} г',
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Вес порции: ~${dish.weightGrams} г',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Similar dishes
|
||||
if (dish.similarDishes.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text('Похожие блюда', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: dish.similarDishes
|
||||
.map((name) => Chip(label: Text(name)))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'КБЖУ приблизительные — определены по фото.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MacroChip extends StatelessWidget {
|
||||
const _MacroChip({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
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('Назад')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
150
client/lib/features/scan/recognition_service.dart
Normal file
150
client/lib/features/scan/recognition_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecognizedItem {
|
||||
final String name;
|
||||
double quantity;
|
||||
String unit;
|
||||
final String category;
|
||||
final double confidence;
|
||||
final String? mappingId;
|
||||
final int storageDays;
|
||||
|
||||
RecognizedItem({
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
required this.category,
|
||||
required this.confidence,
|
||||
this.mappingId,
|
||||
required this.storageDays,
|
||||
});
|
||||
|
||||
factory RecognizedItem.fromJson(Map<String, dynamic> json) {
|
||||
return RecognizedItem(
|
||||
name: json['name'] as String? ?? '',
|
||||
quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0,
|
||||
unit: json['unit'] as String? ?? 'шт',
|
||||
category: json['category'] as String? ?? 'other',
|
||||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
|
||||
mappingId: json['mapping_id'] as String?,
|
||||
storageDays: json['storage_days'] as int? ?? 7,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnrecognizedItem {
|
||||
final String rawText;
|
||||
final double? price;
|
||||
|
||||
const UnrecognizedItem({required this.rawText, this.price});
|
||||
|
||||
factory UnrecognizedItem.fromJson(Map<String, dynamic> json) {
|
||||
return UnrecognizedItem(
|
||||
rawText: json['raw_text'] as String? ?? '',
|
||||
price: (json['price'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReceiptResult {
|
||||
final List<RecognizedItem> items;
|
||||
final List<UnrecognizedItem> unrecognized;
|
||||
|
||||
const ReceiptResult({required this.items, required this.unrecognized});
|
||||
}
|
||||
|
||||
class DishResult {
|
||||
final String dishName;
|
||||
final int weightGrams;
|
||||
final double calories;
|
||||
final double proteinG;
|
||||
final double fatG;
|
||||
final double carbsG;
|
||||
final double confidence;
|
||||
final List<String> similarDishes;
|
||||
|
||||
const DishResult({
|
||||
required this.dishName,
|
||||
required this.weightGrams,
|
||||
required this.calories,
|
||||
required this.proteinG,
|
||||
required this.fatG,
|
||||
required this.carbsG,
|
||||
required this.confidence,
|
||||
required this.similarDishes,
|
||||
});
|
||||
|
||||
factory DishResult.fromJson(Map<String, dynamic> json) {
|
||||
return DishResult(
|
||||
dishName: json['dish_name'] as String? ?? '',
|
||||
weightGrams: json['weight_grams'] as int? ?? 0,
|
||||
calories: (json['calories'] as num?)?.toDouble() ?? 0,
|
||||
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,
|
||||
fatG: (json['fat_g'] as num?)?.toDouble() ?? 0,
|
||||
carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0,
|
||||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
|
||||
similarDishes: (json['similar_dishes'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecognitionService {
|
||||
const RecognitionService(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// Recognizes food items from a receipt photo.
|
||||
Future<ReceiptResult> recognizeReceipt(File image) async {
|
||||
final payload = await _buildImagePayload(image);
|
||||
final data = await _client.post('/ai/recognize-receipt', data: payload);
|
||||
return ReceiptResult(
|
||||
items: (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
unrecognized: (data['unrecognized'] as List<dynamic>? ?? [])
|
||||
.map((e) => UnrecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Recognizes food items from 1–3 product photos.
|
||||
Future<List<RecognizedItem>> recognizeProducts(List<File> images) async {
|
||||
final imageList = await Future.wait(images.map(_buildImagePayload));
|
||||
final data = await _client.post(
|
||||
'/ai/recognize-products',
|
||||
data: {'images': imageList},
|
||||
);
|
||||
return (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Recognizes a dish and estimates its nutritional content.
|
||||
Future<DishResult> recognizeDish(File image) async {
|
||||
final payload = await _buildImagePayload(image);
|
||||
final data = await _client.post('/ai/recognize-dish', data: payload);
|
||||
return DishResult.fromJson(data);
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _buildImagePayload(File image) async {
|
||||
final bytes = await image.readAsBytes();
|
||||
final base64Data = base64Encode(bytes);
|
||||
final ext = image.path.split('.').last.toLowerCase();
|
||||
final mimeType = ext == 'png' ? 'image/png' : 'image/jpeg';
|
||||
return {'image_base64': base64Data, 'mime_type': mimeType};
|
||||
}
|
||||
}
|
||||
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