import 'package:flutter/material.dart'; import '../../../nutrition/domain/entities/meal_type.dart'; import '../../domain/entities/meal_plan_slot.dart'; import '../../domain/entities/recipe.dart'; import '../controllers/meal_plan_calendar_controller.dart'; /// Responsive 7-day meal-plan grid. /// /// On wide layouts (>= 900 dp) all seven day columns are visible at once; /// compact widths fall back to a tabbed pager with one day per tab. Each /// column header shows the day's scheduled-protein rollup against the daily /// target, and every meal slot is a drop target for the saved-recipe tray /// (`Draggable`/`DragTarget`) as well as tappable for the quick-assign /// modal. class WeeklyCalendarView extends StatelessWidget { const WeeklyCalendarView({ super.key, required this.state, required this.onRecipeDropped, required this.onSlotTapped, required this.onTogglePrepared, required this.onClearSlot, }); final MealPlanCalendarState state; /// A tray recipe was dropped onto [day]'s [mealType] slot. final void Function(Recipe recipe, DateTime day, MealType mealType) onRecipeDropped; /// The slot was tapped — open the quick-assign modal. final void Function(DateTime day, MealType mealType) onSlotTapped; final void Function(MealPlanSlot slot) onTogglePrepared; final void Function(MealPlanSlot slot) onClearSlot; @override Widget build(BuildContext context) { final days = state.days; return LayoutBuilder( builder: (context, constraints) { final wide = constraints.maxWidth >= 900; if (wide) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final day in days) SizedBox( width: constraints.maxWidth / 7, // Each column scrolls vertically so short windows never // trigger RenderFlex overflow stripes. child: SingleChildScrollView( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 8, ), child: _DayColumn( day: day, state: state, onRecipeDropped: onRecipeDropped, onSlotTapped: onSlotTapped, onTogglePrepared: onTogglePrepared, onClearSlot: onClearSlot, ), ), ), ], ), ); } return _CompactWeekPager( days: days, state: state, onRecipeDropped: onRecipeDropped, onSlotTapped: onSlotTapped, onTogglePrepared: onTogglePrepared, onClearSlot: onClearSlot, ); }, ); } } /// Tabbed pager used below the 900 dp breakpoint: one tab per day. class _CompactWeekPager extends StatefulWidget { const _CompactWeekPager({ required this.days, required this.state, required this.onRecipeDropped, required this.onSlotTapped, required this.onTogglePrepared, required this.onClearSlot, }); final List days; final MealPlanCalendarState state; final void Function(Recipe recipe, DateTime day, MealType mealType) onRecipeDropped; final void Function(DateTime day, MealType mealType) onSlotTapped; final void Function(MealPlanSlot slot) onTogglePrepared; final void Function(MealPlanSlot slot) onClearSlot; @override State<_CompactWeekPager> createState() => _CompactWeekPagerState(); } class _CompactWeekPagerState extends State<_CompactWeekPager> with SingleTickerProviderStateMixin { late final TabController _tabController = TabController(length: widget.days.length, vsync: this); @override void dispose() { _tabController.dispose(); super.dispose(); } static const List _weekdayLabels = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', ]; @override Widget build(BuildContext context) { return DefaultTabController( length: widget.days.length, child: Column( children: [ TabBar( controller: _tabController, isScrollable: true, tabAlignment: TabAlignment.start, tabs: [ for (final day in widget.days) Tab(text: '${_weekdayLabels[day.weekday - 1]} ${day.day}'), ], ), Expanded( child: TabBarView( controller: _tabController, children: [ for (final day in widget.days) SingleChildScrollView( padding: const EdgeInsets.all(8), child: _DayColumn( day: day, state: widget.state, onRecipeDropped: widget.onRecipeDropped, onSlotTapped: widget.onSlotTapped, onTogglePrepared: widget.onTogglePrepared, onClearSlot: widget.onClearSlot, ), ), ], ), ), ], ), ); } } /// One day of the weekly grid: header (date + protein rollup indicator) /// followed by the four meal slots. class _DayColumn extends StatelessWidget { const _DayColumn({ required this.day, required this.state, required this.onRecipeDropped, required this.onSlotTapped, required this.onTogglePrepared, required this.onClearSlot, }); final DateTime day; final MealPlanCalendarState state; final void Function(Recipe recipe, DateTime day, MealType mealType) onRecipeDropped; final void Function(DateTime day, MealType mealType) onSlotTapped; final void Function(MealPlanSlot slot) onTogglePrepared; final void Function(MealPlanSlot slot) onClearSlot; static const List _weekdayLabels = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', ]; @override Widget build(BuildContext context) { final theme = Theme.of(context); final protein = state.proteinForDay(day); final target = state.dailyProteinTargetGrams; final progress = state.proteinProgressForDay(day).clamp(0.0, 1.0); final reachedGoal = protein >= target; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${_weekdayLabels[day.weekday - 1]} ${day.day}', style: theme.textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Row( children: [ Icon( Icons.fitness_center, size: 14, color: reachedGoal ? Colors.green : theme.colorScheme.outline, ), const SizedBox(width: 4), Expanded( child: Text( '${protein.toStringAsFixed(0)}/' '${target.toStringAsFixed(0)} g', style: theme.textTheme.labelSmall?.copyWith( color: reachedGoal ? Colors.green.shade800 : theme.colorScheme.outline, fontWeight: reachedGoal ? FontWeight.bold : FontWeight.normal, ), ), ), ], ), const SizedBox(height: 4), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progress, minHeight: 6, backgroundColor: theme.colorScheme.surfaceContainerHighest, color: reachedGoal ? Colors.green : theme.colorScheme.primary, ), ), ], ), ), const Divider(height: 12), for (final mealType in MealType.values) ...[ _DaySlotCard( day: day, mealType: mealType, slot: state.slotFor(day, mealType), recipe: _recipeForSlot(state.slotFor(day, mealType)), onRecipeDropped: onRecipeDropped, onTap: () => onSlotTapped(day, mealType), onTogglePrepared: onTogglePrepared, onClearSlot: onClearSlot, ), const SizedBox(height: 8), ], ], ); } Recipe? _recipeForSlot(MealPlanSlot? slot) => slot == null ? null : state.recipeFor(slot); } /// A single meal slot: drop target for tray drags and tap target for the /// quick-assign modal; shows the assigned recipe (with protein badge) or a /// free-text note, a prepared checkbox, and a clear action. class _DaySlotCard extends StatelessWidget { const _DaySlotCard({ required this.day, required this.mealType, required this.slot, required this.recipe, required this.onRecipeDropped, required this.onTap, required this.onTogglePrepared, required this.onClearSlot, }); final DateTime day; final MealType mealType; final MealPlanSlot? slot; final Recipe? recipe; final void Function(Recipe recipe, DateTime day, MealType mealType) onRecipeDropped; final VoidCallback onTap; final void Function(MealPlanSlot slot) onTogglePrepared; final void Function(MealPlanSlot slot) onClearSlot; static IconData _iconFor(MealType type) { switch (type) { case MealType.breakfast: return Icons.free_breakfast_outlined; case MealType.lunch: return Icons.lunch_dining_outlined; case MealType.dinner: return Icons.dinner_dining_outlined; case MealType.snack: return Icons.cookie_outlined; } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final currentSlot = slot; final filled = currentSlot != null && !currentSlot.isEmpty; return DragTarget( onAcceptWithDetails: (details) => onRecipeDropped(details.data, day, mealType), builder: (context, candidateRecipes, rejectedRecipes) { final highlighted = candidateRecipes.isNotEmpty; return Material( color: highlighted ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: 64), padding: const EdgeInsets.all(8), child: _buildContent(theme, currentSlot, filled), ), ), ); }, ); } Widget _buildContent( ThemeData theme, MealPlanSlot? currentSlot, bool filled, ) { final assignedRecipe = recipe; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(_iconFor(mealType), size: 16), const SizedBox(width: 6), Expanded( child: Text( mealType.displayName, style: theme.textTheme.labelMedium ?.copyWith(fontWeight: FontWeight.w600), ), ), if (currentSlot != null && filled) ...[ Checkbox( value: currentSlot.isPrepared, onChanged: (_) => onTogglePrepared(currentSlot), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), PopupMenuButton( padding: EdgeInsets.zero, icon: const Icon(Icons.more_vert, size: 18), onSelected: (value) { if (value == 'clear') onClearSlot(currentSlot); }, itemBuilder: (context) => const [ PopupMenuItem(value: 'clear', child: Text('Clear slot')), ], ), ] else Icon(Icons.add, size: 18, color: theme.colorScheme.outline), ], ), if (currentSlot != null && filled) ...[ const SizedBox(height: 2), Text( assignedRecipe?.title ?? currentSlot.customDescription ?? '', maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, decoration: currentSlot.isPrepared ? TextDecoration.lineThrough : null, ), ), if (assignedRecipe != null) ...[ const SizedBox(height: 4), Row( children: [ _MiniProteinBadge( proteinGrams: assignedRecipe.macrosPerServing.proteinGrams, ), const SizedBox(width: 6), Expanded( child: Text( '${assignedRecipe.macrosPerServing.calories} kcal', style: theme.textTheme.labelSmall, overflow: TextOverflow.ellipsis, ), ), ], ), ], ] else Text( 'Tap or drop a recipe', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( fontStyle: FontStyle.italic, color: theme.colorScheme.outline, ), ), ], ); } } /// Tiny protein badge used inside calendar slots. class _MiniProteinBadge extends StatelessWidget { const _MiniProteinBadge({required this.proteinGrams}); final double proteinGrams; @override Widget build(BuildContext context) { final rounded = proteinGrams.roundToDouble(); final label = rounded == proteinGrams ? '${rounded.toStringAsFixed(0)}g P' : '${proteinGrams.toStringAsFixed(1)}g P'; return Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: Colors.green.shade100, borderRadius: BorderRadius.circular(6), ), child: Text( label, style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: Colors.green.shade800, ), ), ); } }