diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 9f391d5..1475f94 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -13,7 +13,6 @@ import '../../features/products/products_screen.dart'; import '../../features/products/add_product_screen.dart'; import '../../features/scan/scan_screen.dart'; import '../../features/scan/recognition_confirm_screen.dart'; -import '../../features/scan/dish_result_screen.dart'; import '../../features/scan/recognition_service.dart'; import '../../features/menu/diary_screen.dart'; import '../../features/menu/menu_screen.dart'; @@ -150,16 +149,6 @@ final routerProvider = Provider((ref) { return RecognitionConfirmScreen(items: items); }, ), - GoRoute( - path: '/scan/dish', - builder: (context, state) { - final extra = state.extra as Map?; - final dish = extra?['dish'] as DishResult?; - final mealType = extra?['meal_type'] as String?; - if (dish == null) return const _InvalidRoute(); - return DishResultScreen(dish: dish, preselectedMealType: mealType); - }, - ), ShellRoute( builder: (context, state, child) => MainShell(child: child), routes: [ diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index a4c56d3..0368458 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.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/theme/app_colors.dart'; import '../../shared/models/diary_entry.dart'; @@ -11,6 +12,8 @@ import '../../shared/models/home_summary.dart'; import '../../shared/models/meal_type.dart'; import '../menu/menu_provider.dart'; import '../profile/profile_provider.dart'; +import '../scan/dish_result_screen.dart'; +import '../scan/recognition_service.dart'; import 'home_provider.dart'; // ── Root screen ─────────────────────────────────────────────── @@ -727,6 +730,93 @@ class _DailyMealsSection extends ConsumerWidget { } } +Future _pickAndShowDishResult( + BuildContext context, + WidgetRef ref, + String mealTypeId, +) async { + // 1. Choose image source + final source = await showModalBottomSheet( + 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), + ), + ], + ), + ), + ); + if (source == null || !context.mounted) return; + + // 2. Pick image + final image = await ImagePicker().pickImage( + source: source, + imageQuality: 70, + maxWidth: 1024, + maxHeight: 1024, + ); + if (image == null || !context.mounted) return; + + // 3. Show loading + // Capture root navigator now (before await) to avoid using the wrong one later. + // showDialog defaults to useRootNavigator: true; Navigator.pop(context) would resolve + // to GoRouter's inner navigator instead, which only has /home and would crash. + final rootNavigator = Navigator.of(context, rootNavigator: true); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Распознаём...'), + ], + ), + ), + ); + + // 4. Call API + try { + final dish = await ref.read(recognitionServiceProvider).recognizeDish(image); + if (!context.mounted) return; + rootNavigator.pop(); // close loading + + // 5. Show result as bottom sheet + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) => DishResultSheet( + dish: dish, + preselectedMealType: mealTypeId, + onAdded: () => Navigator.pop(sheetContext), + ), + ); + } catch (recognitionError) { + debugPrint('Dish recognition error: $recognitionError'); + if (context.mounted) { + rootNavigator.pop(); // close loading + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Не удалось распознать. Попробуйте ещё раз.'), + ), + ); + } + } +} + class _MealCard extends ConsumerWidget { final MealTypeOption mealTypeOption; final List entries; @@ -769,8 +859,8 @@ class _MealCard extends ConsumerWidget { icon: const Icon(Icons.add, size: 20), visualDensity: VisualDensity.compact, tooltip: 'Добавить блюдо', - onPressed: () => - context.push('/scan', extra: mealTypeOption.id), + onPressed: () => _pickAndShowDishResult( + context, ref, mealTypeOption.id), ), ], ), diff --git a/client/lib/features/scan/dish_result_screen.dart b/client/lib/features/scan/dish_result_screen.dart index 5aa44ba..d6f1ac1 100644 --- a/client/lib/features/scan/dish_result_screen.dart +++ b/client/lib/features/scan/dish_result_screen.dart @@ -1,30 +1,31 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../../features/menu/menu_provider.dart'; import '../../features/home/home_provider.dart'; import '../../shared/models/meal_type.dart'; import 'recognition_service.dart'; -/// Shows the recognition candidates and lets the user confirm a dish entry -/// before adding it to the diary. -class DishResultScreen extends ConsumerStatefulWidget { - const DishResultScreen({ +/// Bottom sheet that shows dish recognition candidates and lets the user +/// confirm a dish entry before adding it to the diary. +class DishResultSheet extends ConsumerStatefulWidget { + const DishResultSheet({ super.key, required this.dish, + required this.onAdded, this.preselectedMealType, }); final DishResult dish; + final VoidCallback onAdded; final String? preselectedMealType; @override - ConsumerState createState() => _DishResultScreenState(); + ConsumerState createState() => _DishResultSheetState(); } -class _DishResultScreenState extends ConsumerState { +class _DishResultSheetState extends ConsumerState { late int _selectedIndex; late int _portionGrams; late String _mealType; @@ -106,7 +107,7 @@ class _DishResultScreenState extends ConsumerState { 'portion_g': _portionGrams, 'source': 'recognition', }); - if (mounted) context.go('/home'); + if (mounted) widget.onAdded(); } catch (addError) { debugPrint('Add to diary error: $addError'); if (mounted) { @@ -123,82 +124,112 @@ class _DishResultScreenState extends ConsumerState { final theme = Theme.of(context); final hasCandidates = widget.dish.candidates.isNotEmpty; - return Scaffold( - appBar: AppBar(title: const Text('Распознано блюдо')), - bottomNavigationBar: hasCandidates - ? SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), - child: FilledButton( - onPressed: _saving ? null : _addToDiary, - child: _saving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Добавить в журнал'), - ), + return Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title row + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 8, 0), + child: Row( + children: [ + Text('Распознано блюдо', style: theme.textTheme.titleMedium), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), ), - ) - : null, - body: hasCandidates - ? ListView( - padding: const EdgeInsets.all(20), - children: [ - _CandidatesSection( - candidates: widget.dish.candidates, - selectedIndex: _selectedIndex, - onSelect: _selectCandidate, - ), - const SizedBox(height: 20), - _NutritionCard( - calories: _scale(_selected.calories), - proteinG: _scale(_selected.proteinG), - fatG: _scale(_selected.fatG), - carbsG: _scale(_selected.carbsG), - ), - const SizedBox(height: 8), - Text( - 'КБЖУ приблизительные — определены по фото.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + ], + ), + ), + // Scrollable content + Expanded( + child: hasCandidates + ? ListView( + padding: const EdgeInsets.all(20), + children: [ + _CandidatesSection( + candidates: widget.dish.candidates, + selectedIndex: _selectedIndex, + onSelect: _selectCandidate, + ), + const SizedBox(height: 20), + _NutritionCard( + calories: _scale(_selected.calories), + proteinG: _scale(_selected.proteinG), + fatG: _scale(_selected.fatG), + carbsG: _scale(_selected.carbsG), + ), + const SizedBox(height: 8), + Text( + 'КБЖУ приблизительные — определены по фото.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + _PortionRow( + controller: _portionController, + onMinus: () => _adjustPortion(-10), + onPlus: () => _adjustPortion(10), + onChanged: _onPortionEdited, + ), + const SizedBox(height: 20), + _MealTypeDropdown( + selected: _mealType, + onChanged: (value) { + if (value != null) setState(() => _mealType = value); + }, + ), + const SizedBox(height: 16), + ], + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Блюдо не распознано', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Попробовать снова'), + ), + ], ), - textAlign: TextAlign.center, ), - const SizedBox(height: 20), - _PortionRow( - controller: _portionController, - onMinus: () => _adjustPortion(-10), - onPlus: () => _adjustPortion(10), - onChanged: _onPortionEdited, - ), - const SizedBox(height: 20), - _MealTypeDropdown( - selected: _mealType, - onChanged: (value) { - if (value != null) setState(() => _mealType = value); - }, - ), - const SizedBox(height: 16), - ], - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Блюдо не распознано', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - FilledButton( - onPressed: () => context.pop(), - child: const Text('Попробовать снова'), - ), - ], + ), + // Bottom button + if (hasCandidates) + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: FilledButton( + onPressed: _saving ? null : _addToDiary, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Добавить в журнал'), ), ), + ), + ], ); } } diff --git a/client/lib/features/scan/recognition_service.dart b/client/lib/features/scan/recognition_service.dart index 5d56073..dba2698 100644 --- a/client/lib/features/scan/recognition_service.dart +++ b/client/lib/features/scan/recognition_service.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/api/api_client.dart'; +import '../../core/auth/auth_provider.dart'; // --------------------------------------------------------------------------- // Models @@ -181,3 +183,7 @@ class RecognitionService { return {'image_base64': base64Data, 'mime_type': mimeType}; } } + +final recognitionServiceProvider = Provider((ref) { + return RecognitionService(ref.read(apiClientProvider)); +}); diff --git a/client/lib/features/scan/scan_screen.dart b/client/lib/features/scan/scan_screen.dart index eddb48d..9c14483 100644 --- a/client/lib/features/scan/scan_screen.dart +++ b/client/lib/features/scan/scan_screen.dart @@ -3,17 +3,9 @@ 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((ref) { - return RecognitionService(ref.read(apiClientProvider)); -}); - /// Entry screen — lets the user choose how to add products. -/// If [GoRouterState.extra] is a non-null String, it is treated as a meal type ID -/// and the screen immediately opens the camera for dish recognition. class ScanScreen extends ConsumerStatefulWidget { const ScanScreen({super.key}); @@ -22,24 +14,6 @@ class ScanScreen extends ConsumerStatefulWidget { } class _ScanScreenState extends ConsumerState { - bool _autoStarted = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (_autoStarted) return; - final mealType = GoRouterState.of(context).extra as String?; - if (mealType != null && mealType.isNotEmpty) { - _autoStarted = true; - // Defer to avoid calling context navigation during build. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _pickAndRecognize(context, _Mode.dish, mealType: mealType); - } - }); - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -68,13 +42,6 @@ class _ScanScreenState extends ConsumerState { onTap: () => _pickAndRecognize(context, _Mode.products), ), const SizedBox(height: 16), - _ModeCard( - emoji: '🍽️', - title: 'Определить блюдо', - subtitle: 'КБЖУ≈ по фото готового блюда', - onTap: () => _pickAndRecognize(context, _Mode.dish), - ), - const SizedBox(height: 16), _ModeCard( emoji: '✏️', title: 'Добавить вручную', @@ -88,9 +55,8 @@ class _ScanScreenState extends ConsumerState { Future _pickAndRecognize( BuildContext context, - _Mode mode, { - String? mealType, - }) async { + _Mode mode, + ) async { final picker = ImagePicker(); List files = []; @@ -118,7 +84,7 @@ class _ScanScreenState extends ConsumerState { } if (!context.mounted) return; - final service = ref.read(_recognitionServiceProvider); + final service = ref.read(recognitionServiceProvider); // Show loading overlay while the AI processes. showDialog( @@ -141,12 +107,6 @@ class _ScanScreenState extends ConsumerState { 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': dish, 'meal_type': mealType}); - } } } catch (recognitionError) { debugPrint('Recognition error: $recognitionError'); @@ -189,7 +149,7 @@ class _ScanScreenState extends ConsumerState { // Mode enum // --------------------------------------------------------------------------- -enum _Mode { receipt, products, dish } +enum _Mode { receipt, products } // --------------------------------------------------------------------------- // Widgets