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

@@ -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