glp-companion/lib/features/meal_planner/presentation/widgets/weekly_calendar_view.dart

429 lines
14 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 offers a "+ Assign Recipe" action that opens
/// the searchable Recipe Vault sheet.
class WeeklyCalendarView extends StatelessWidget {
const WeeklyCalendarView({
super.key,
required this.state,
required this.onSlotTapped,
required this.onTogglePrepared,
required this.onClearSlot,
});
final MealPlanCalendarState state;
/// The slot was tapped — open the searchable assign sheet.
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,
onSlotTapped: onSlotTapped,
onTogglePrepared: onTogglePrepared,
onClearSlot: onClearSlot,
),
),
),
],
),
);
}
return _CompactWeekPager(
days: days,
state: state,
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.onSlotTapped,
required this.onTogglePrepared,
required this.onClearSlot,
});
final List<DateTime> days;
final MealPlanCalendarState state;
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,
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.onSlotTapped,
required this.onTogglePrepared,
required this.onClearSlot,
});
final DateTime day;
final MealPlanCalendarState state;
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;
final roundedProtein = protein.roundToDouble();
final proteinLabel = roundedProtein == protein
? roundedProtein.toStringAsFixed(0)
: protein.toStringAsFixed(1);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_weekdayLabels[day.weekday - 1],
style: theme.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
Text(
reachedGoal ? '✓ $proteinLabel g' : '$proteinLabel g',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: reachedGoal
? Colors.green.shade700
: theme.colorScheme.outline,
),
),
],
),
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(
mealType: mealType,
slot: state.slotFor(day, mealType),
recipe: _recipeForSlot(state.slotFor(day, mealType)),
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: tap target for the searchable assign sheet; 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.mealType,
required this.slot,
required this.recipe,
required this.onTap,
required this.onTogglePrepared,
required this.onClearSlot,
});
final MealType mealType;
final MealPlanSlot? slot;
final Recipe? recipe;
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 Material(
color: 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
Row(
children: [
Icon(Icons.add_circle_outline,
size: 14, color: theme.colorScheme.primary),
const SizedBox(width: 4),
Expanded(
child: Text(
'Assign Recipe',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
),
],
),
],
);
}
}
/// 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,
),
),
);
}
}