Files
food-ai/client/lib/features/menu/menu_screen.dart
dbastrikin 31ae45dfdd fix: use dialog builder context for Navigator.pop in dialogs/sheets
Passing the outer widget context to Navigator.pop() inside a dialog or
bottom sheet builder caused GoRouter to pop a page route instead of the
modal, triggering the "no pages left to show" assertion.

Affects _showChangeDialog and _confirmGenerate in menu_screen.dart,
and _showAddMenu bottom sheet in products_screen.dart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 23:22:58 +02:00

469 lines
15 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: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 '../../shared/models/menu.dart';
import 'menu_provider.dart';
class MenuScreen extends ConsumerWidget {
const MenuScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final week = ref.watch(currentWeekProvider);
final state = ref.watch(menuProvider(week));
return Scaffold(
appBar: AppBar(
title: const Text('Меню'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(menuProvider(week).notifier).load(),
),
],
bottom: _WeekNavBar(week: week, ref: ref),
),
body: state.when(
loading: () => const _MenuSkeleton(),
error: (err, _) => _ErrorView(
onRetry: () => ref.read(menuProvider(week).notifier).load(),
),
data: (plan) => plan == null
? _EmptyState(
onGenerate: () =>
ref.read(menuProvider(week).notifier).generate(),
)
: _MenuContent(plan: plan, week: week),
),
floatingActionButton: state.maybeWhen(
data: (_) => _GenerateFab(week: week),
orElse: () => null,
),
);
}
}
// ── Week navigation app-bar bottom ────────────────────────────
class _WeekNavBar extends StatelessWidget implements PreferredSizeWidget {
final String week;
final WidgetRef ref;
const _WeekNavBar({required this.week, required this.ref});
@override
Size get preferredSize => const Size.fromHeight(40);
String _weekLabel(String week) {
try {
final parts = week.split('-W');
if (parts.length != 2) return week;
final year = int.parse(parts[0]);
final w = int.parse(parts[1]);
final monday = _mondayOfISOWeek(year, w);
final sunday = monday.add(const Duration(days: 6));
const months = [
'янв', 'фев', 'мар', 'апр', 'май', 'июн',
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
];
return '${monday.day}${sunday.day} ${months[sunday.month - 1]}';
} catch (_) {
return week;
}
}
DateTime _mondayOfISOWeek(int year, int w) {
final jan4 = DateTime.utc(year, 1, 4);
final monday1 = jan4.subtract(Duration(days: jan4.weekday - 1));
return monday1.add(Duration(days: (w - 1) * 7));
}
String _offsetWeek(String week, int offsetWeeks) {
try {
final parts = week.split('-W');
final year = int.parse(parts[0]);
final w = int.parse(parts[1]);
final monday = _mondayOfISOWeek(year, w);
final newMonday = monday.add(Duration(days: offsetWeeks * 7));
final (ny, nw) = _isoWeekOf(newMonday);
return '$ny-W${nw.toString().padLeft(2, '0')}';
} catch (_) {
return week;
}
}
(int, int) _isoWeekOf(DateTime dt) {
final thu = dt.add(Duration(days: 4 - (dt.weekday == 7 ? 0 : dt.weekday)));
final jan1 = DateTime.utc(thu.year, 1, 1);
final w = ((thu.difference(jan1).inDays) / 7).ceil();
return (thu.year, w);
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, -1);
},
),
Text(_weekLabel(week), style: Theme.of(context).textTheme.bodyMedium),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, 1);
},
),
],
);
}
}
// ── Menu content ──────────────────────────────────────────────
class _MenuContent extends StatelessWidget {
final MenuPlan plan;
final String week;
const _MenuContent({required this.plan, required this.week});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.only(bottom: 120),
children: [
...plan.days.map((day) => _DayCard(day: day, week: week)),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
onPressed: () =>
context.push('/menu/shopping-list', extra: week),
icon: const Icon(Icons.shopping_cart_outlined),
label: const Text('Список покупок'),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
onPressed: () {
final today = DateTime.now();
final dateStr =
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
context.push('/menu/diary', extra: dateStr);
},
icon: const Icon(Icons.book_outlined),
label: const Text('Дневник питания'),
),
),
],
);
}
}
class _DayCard extends StatelessWidget {
final MenuDay day;
final String week;
const _DayCard({required this.day, required this.week});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${day.dayName}, ${day.shortDate}',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
const Spacer(),
Text(
'${day.totalCalories.toInt()} ккал',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
),
const SizedBox(height: 8),
Card(
child: Column(
children: day.meals.asMap().entries.map((entry) {
final index = entry.key;
final slot = entry.value;
return Column(
children: [
_MealRow(slot: slot, week: week),
if (index < day.meals.length - 1)
const Divider(height: 1),
],
);
}).toList(),
),
),
],
),
);
}
}
class _MealRow extends ConsumerWidget {
final MealSlot slot;
final String week;
const _MealRow({required this.slot, required this.week});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final recipe = slot.recipe;
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: recipe?.imageUrl.isNotEmpty == true
? CachedNetworkImage(
imageUrl: recipe!.imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => _placeholder(),
)
: _placeholder(),
),
title: Row(
children: [
Text(slot.mealEmoji, style: const TextStyle(fontSize: 14)),
const SizedBox(width: 4),
Text(slot.mealLabel, style: theme.textTheme.labelMedium),
if (recipe?.nutrition?.calories != null) ...[
const Spacer(),
Text(
'${recipe!.nutrition!.calories.toInt()} ккал',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
],
),
subtitle: recipe != null
? Text(recipe.title, style: theme.textTheme.bodyMedium)
: Text(
'Не задано',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
trailing: TextButton(
onPressed: () => _showChangeDialog(context, ref),
child: const Text('Изменить'),
),
);
}
Widget _placeholder() => Container(
width: 56,
height: 56,
color: Colors.grey.shade200,
child: const Icon(Icons.restaurant, color: Colors.grey),
);
void _showChangeDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Изменить ${slot.mealLabel.toLowerCase()}?'),
content: const Text(
'Удалите текущий рецепт из слота — новый появится после следующей генерации.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
if (slot.recipe != null)
TextButton(
onPressed: () async {
Navigator.pop(ctx);
await ref
.read(menuProvider(week).notifier)
.deleteItem(slot.id);
},
child: const Text('Убрать рецепт'),
),
],
),
);
}
}
// ── Generate FAB ──────────────────────────────────────────────
class _GenerateFab extends ConsumerWidget {
final String week;
const _GenerateFab({required this.week});
@override
Widget build(BuildContext context, WidgetRef ref) {
return FloatingActionButton.extended(
onPressed: () => _confirmGenerate(context, ref),
icon: const Icon(Icons.auto_awesome),
label: const Text('Сгенерировать меню'),
);
}
void _confirmGenerate(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Сгенерировать меню?'),
content: const Text(
'Gemini составит меню на неделю с учётом ваших продуктов и целей. Текущее меню будет заменено.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
ref.read(menuProvider(week).notifier).generate();
},
child: const Text('Сгенерировать'),
),
],
),
);
}
}
// ── Skeleton ──────────────────────────────────────────────────
class _MenuSkeleton extends StatelessWidget {
const _MenuSkeleton();
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.surfaceContainerHighest;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Составляем меню на неделю...'),
SizedBox(height: 4),
Text(
'Учитываем ваши продукты и цели',
style: TextStyle(color: Colors.grey),
),
],
),
),
for (int i = 0; i < 3; i++) ...[
_shimmer(color, height: 16, width: 160),
const SizedBox(height: 8),
_shimmer(color, height: 90),
const SizedBox(height: 16),
],
],
);
}
Widget _shimmer(Color color, {double height = 60, double? width}) =>
Container(
height: height,
width: width,
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
);
}
// ── Empty state ───────────────────────────────────────────────
class _EmptyState extends StatelessWidget {
final VoidCallback onGenerate;
const _EmptyState({required this.onGenerate});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today_outlined,
size: 72, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('Меню не составлено',
style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Нажмите кнопку ниже, чтобы Gemini составил меню на неделю с учётом ваших продуктов',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onGenerate,
icon: const Icon(Icons.auto_awesome),
label: const Text('Сгенерировать меню'),
),
],
),
),
);
}
}
// ── Error view ────────────────────────────────────────────────
class _ErrorView extends StatelessWidget {
final VoidCallback onRetry;
const _ErrorView({required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
const Text('Не удалось загрузить меню'),
const SizedBox(height: 12),
FilledButton(
onPressed: onRetry, child: const Text('Повторить')),
],
),
);
}
}