feat: show dish recognition result as bottom sheet on home screen

Remove "Определить блюдо" from ScanScreen and the /scan/dish route.
The + button on each meal card now triggers dish recognition inline —
picks image, shows loading dialog, then presents DishResultSheet as a
modal bottom sheet. After adding to diary the sheet closes and the user
stays on home.

Also fix Navigator.pop crash: showDialog uses the root navigator by
default, so capture Navigator.of(context, rootNavigator: true) before
the async gap and use it to close the loading dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-17 16:37:00 +02:00
parent 227780e1a9
commit a32d2960c4
5 changed files with 212 additions and 136 deletions

View File

@@ -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<GoRouter>((ref) {
return RecognitionConfirmScreen(items: items);
},
),
GoRoute(
path: '/scan/dish',
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
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: [

View File

@@ -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<void> _pickAndShowDishResult(
BuildContext context,
WidgetRef ref,
String mealTypeId,
) async {
// 1. Choose image source
final source = await 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),
),
],
),
),
);
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<DiaryEntry> 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),
),
],
),

View File

@@ -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<DishResultScreen> createState() => _DishResultScreenState();
ConsumerState<DishResultSheet> createState() => _DishResultSheetState();
}
class _DishResultScreenState extends ConsumerState<DishResultScreen> {
class _DishResultSheetState extends ConsumerState<DishResultSheet> {
late int _selectedIndex;
late int _portionGrams;
late String _mealType;
@@ -106,7 +107,7 @@ class _DishResultScreenState extends ConsumerState<DishResultScreen> {
'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<DishResultScreen> {
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('Добавить в журнал'),
),
),
),
],
);
}
}

View File

@@ -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<RecognitionService>((ref) {
return RecognitionService(ref.read(apiClientProvider));
});

View File

@@ -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<RecognitionService>((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<ScanScreen> {
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<ScanScreen> {
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<ScanScreen> {
Future<void> _pickAndRecognize(
BuildContext context,
_Mode mode, {
String? mealType,
}) async {
_Mode mode,
) async {
final picker = ImagePicker();
List<XFile> files = [];
@@ -118,7 +84,7 @@ class _ScanScreenState extends ConsumerState<ScanScreen> {
}
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<ScanScreen> {
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<ScanScreen> {
// Mode enum
// ---------------------------------------------------------------------------
enum _Mode { receipt, products, dish }
enum _Mode { receipt, products }
// ---------------------------------------------------------------------------
// Widgets