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('Список покупок'), ), ), ], ); } } 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('Повторить')), ], ), ); } }