187 lines
6.4 KiB
Dart
187 lines
6.4 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../nutrition/domain/entities/meal_type.dart';
|
||
import '../controllers/meal_plan_calendar_controller.dart';
|
||
import '../controllers/recipe_import_controller.dart';
|
||
import '../widgets/assign_recipe_sheet.dart';
|
||
import '../widgets/weekly_calendar_view.dart';
|
||
|
||
/// GLP-1 meal planner: the responsive 7-day weekly calendar (Monday–Sunday)
|
||
/// with per-day protein rollups. Recipe import and browsing live in the
|
||
/// Recipe Vault; slots are filled via the searchable "+ Assign Recipe"
|
||
/// sheet.
|
||
class MealPlannerScreen extends ConsumerStatefulWidget {
|
||
const MealPlannerScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<MealPlannerScreen> createState() => _MealPlannerScreenState();
|
||
}
|
||
|
||
class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
||
/// Body height below which the page collapses into a single scrollable
|
||
/// column: the week header plus a minimal usable calendar area. Below
|
||
/// this, a plain `Column` would overflow and paint the yellow/black
|
||
/// hatched stripes.
|
||
static const double _kFullScrollThreshold = 360;
|
||
|
||
/// Calendar height used in the fully-scrollable fallback layout; tall
|
||
/// enough for the day header plus four meal slots.
|
||
static const double _kCalendarScrollHeight = 560;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final calendarState = ref.watch(mealPlanCalendarControllerProvider);
|
||
|
||
ref.listen(mealPlanCalendarControllerProvider, (previous, next) {
|
||
if (next.errorMessage != null &&
|
||
next.errorMessage != previous?.errorMessage) {
|
||
_showErrorSnack(next.errorMessage!);
|
||
}
|
||
});
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Meal Planner')),
|
||
body: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// Short windows (e.g. resized desktop windows) can no longer fit
|
||
// a useful calendar area; collapse the whole page into a single
|
||
// scroll view instead of overflowing.
|
||
if (constraints.maxHeight < _kFullScrollThreshold) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_WeekNavigationHeader(state: calendarState),
|
||
SizedBox(
|
||
height: _kCalendarScrollHeight,
|
||
child: _buildCalendar(calendarState),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_WeekNavigationHeader(state: calendarState),
|
||
const Divider(height: 1),
|
||
Expanded(child: _buildCalendar(calendarState)),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Weekly calendar grid (or the initial loading spinner).
|
||
Widget _buildCalendar(MealPlanCalendarState calendarState) =>
|
||
!calendarState.hasLoaded
|
||
? const Center(child: CircularProgressIndicator())
|
||
: WeeklyCalendarView(
|
||
state: calendarState,
|
||
onSlotTapped: _onSlotTapped,
|
||
onTogglePrepared: (slot) => ref
|
||
.read(mealPlanCalendarControllerProvider.notifier)
|
||
.togglePrepared(slot),
|
||
onClearSlot: (slot) => ref
|
||
.read(mealPlanCalendarControllerProvider.notifier)
|
||
.clearSlot(slot),
|
||
);
|
||
|
||
void _showErrorSnack(String message) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(message),
|
||
backgroundColor: Theme.of(context).colorScheme.error,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Opens the searchable assign sheet, populated from the Recipe Vault.
|
||
Future<void> _onSlotTapped(DateTime day, MealType mealType) async {
|
||
final calendarState = ref.read(mealPlanCalendarControllerProvider);
|
||
final importState = ref.read(recipeImportControllerProvider);
|
||
await showAssignRecipeSheet(
|
||
context,
|
||
day: day,
|
||
mealType: mealType,
|
||
savedRecipes: importState.savedRecipes,
|
||
existingSlot: calendarState.slotFor(day, mealType),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Week navigation row: prev/next arrows, the visible week label, and a
|
||
/// "Today" shortcut.
|
||
class _WeekNavigationHeader extends StatelessWidget {
|
||
const _WeekNavigationHeader({required this.state});
|
||
|
||
final MealPlanCalendarState state;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
tooltip: 'Previous week',
|
||
icon: const Icon(Icons.chevron_left),
|
||
onPressed: state.isLoading
|
||
? null
|
||
: () => _readController(context).previousWeek(),
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
state.weekLabel,
|
||
style: theme.textTheme.titleSmall
|
||
?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
Text(
|
||
'Monday – Sunday',
|
||
style: theme.textTheme.labelSmall
|
||
?.copyWith(color: theme.colorScheme.outline),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: state.isLoading
|
||
? null
|
||
: () => _readController(context).goToToday(),
|
||
child: const Text('Today'),
|
||
),
|
||
IconButton(
|
||
tooltip: 'Next week',
|
||
icon: const Icon(Icons.chevron_right),
|
||
onPressed: state.isLoading
|
||
? null
|
||
: () => _readController(context).nextWeek(),
|
||
),
|
||
if (state.isLoading) ...[
|
||
const SizedBox(width: 4),
|
||
const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
MealPlanCalendarController _readController(BuildContext context) =>
|
||
ProviderScope.containerOf(context, listen: false)
|
||
.read(mealPlanCalendarControllerProvider.notifier);
|
||
}
|