Backend: - Migrations 007 (menu_plans, menu_items, shopping_lists) and 008 (meal_diary) - gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call - internal/menu: model, repository (GetByWeek, SaveMenuInTx, shopping list CRUD), handler (GET/PUT/DELETE /menu, POST /ai/generate-menu, shopping list endpoints) - internal/diary: model, repository, handler (GET/POST/DELETE /diary) - Increase server WriteTimeout to 120s for long AI calls - api_client.go: add patch() and postList() helpers Flutter: - shared/models: menu.dart, shopping_item.dart, diary_entry.dart - features/menu: menu_service.dart, menu_provider.dart (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family) - MenuScreen: 7-day view, week nav, skeleton on generation, generate FAB with confirmation dialog - ShoppingListScreen: items by category, optimistic checkbox toggle - DiaryScreen: daily entries with swipe-to-delete, add-entry sheet - Router: /menu/shopping-list and /menu/diary routes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
220 lines
6.6 KiB
Dart
220 lines
6.6 KiB
Dart
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<XFile> files = [];
|
||
|
||
if (mode == _Mode.products) {
|
||
// Allow up to 3 images.
|
||
final picked = await picker.pickMultiImage(
|
||
imageQuality: 70,
|
||
maxWidth: 1024,
|
||
maxHeight: 1024,
|
||
);
|
||
if (picked.isEmpty) return;
|
||
files = picked.take(3).toList();
|
||
} else {
|
||
final source = await _chooseSource(context);
|
||
if (source == null) return;
|
||
final picked = await picker.pickImage(
|
||
source: source,
|
||
imageQuality: 70,
|
||
maxWidth: 1024,
|
||
maxHeight: 1024,
|
||
);
|
||
if (picked == null) return;
|
||
files = [picked];
|
||
}
|
||
|
||
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, s) {
|
||
debugPrint('Recognition error: $e\n$s');
|
||
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('Распознаём...'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|