456 lines
15 KiB
Dart
456 lines
15 KiB
Dart
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<DateTime> 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<String> _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<String> _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<Recipe>(
|
|
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<String>(
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|