Files
food-ai/client/lib/features/scan/scan_screen.dart
dbastrikin ea8e207a45 feat: implement Iteration 4 — menu planning, shopping list, diary
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>
2026-02-22 12:00:25 +02:00

220 lines
6.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('Распознаём...'),
],
),
);
}
}