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('Добавить в журнал'),
),
),
),
],
);
}
}