glp-companion/lib/features/meal_planner/presentation/screens/meal_planner_screen.dart

342 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../nutrition/domain/entities/meal_type.dart';
import '../../domain/entities/recipe.dart';
import '../controllers/meal_plan_calendar_controller.dart';
import '../controllers/recipe_import_controller.dart';
import '../widgets/assign_slot_dialog.dart';
import '../widgets/recipe_preview_dialog.dart';
import '../widgets/saved_recipes_tray.dart';
import '../widgets/weekly_calendar_view.dart';
/// GLP-1 meal planner: recipe URL import bar, saved-recipes tray, and the
/// responsive weekly calendar with drag-and-drop assignment and per-day
/// protein rollups.
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: fixed top sections (~340 dp) plus a minimal usable calendar
/// area. Below this, a plain `Column` would overflow and paint the
/// yellow/black hatched stripes.
static const double _kFullScrollThreshold = 520;
/// Calendar height used in the fully-scrollable fallback layout; tall
/// enough for the day header plus four meal slots.
static const double _kCalendarScrollHeight = 560;
final TextEditingController _urlController = TextEditingController();
bool _previewDialogVisible = false;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final importState = ref.watch(recipeImportControllerProvider);
final calendarState = ref.watch(mealPlanCalendarControllerProvider);
// Open the preview modal when a scrape completes; surface messages as
// snackbars.
ref.listen(recipeImportControllerProvider, (previous, next) {
if (next.previewRecipe != null &&
previous?.previewRecipe == null &&
!_previewDialogVisible) {
_showPreview(next.previewRecipe!);
}
if (next.statusMessage != null &&
next.statusMessage != previous?.statusMessage) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(next.statusMessage!)));
}
if (next.errorMessage != null &&
next.errorMessage != previous?.errorMessage &&
next.previewRecipe == null) {
_showErrorSnack(next.errorMessage!);
}
});
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 & Import')),
body: LayoutBuilder(
builder: (context, constraints) {
// Short windows (e.g. resized desktop windows) can no longer fit
// the fixed top sections plus 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: [
_buildTopSections(importState, calendarState),
SizedBox(
height: _kCalendarScrollHeight,
child: _buildCalendar(calendarState),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTopSections(importState, calendarState),
Expanded(child: _buildCalendar(calendarState)),
],
);
},
),
);
}
/// Import bar + saved recipes tray + week navigation header.
Widget _buildTopSections(
RecipeImportState importState,
MealPlanCalendarState calendarState,
) =>
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_RecipeImportBar(
controller: _urlController,
isLoading: importState.isImporting,
onSubmit: _submitUrl,
),
const SizedBox(height: 12),
SavedRecipesTray(
recipes: importState.savedRecipes,
onAssignPressed: _openTrayAssign,
),
const SizedBox(height: 12),
_WeekNavigationHeader(state: calendarState),
const Divider(height: 1),
],
);
/// Weekly calendar grid (or the initial loading spinner).
Widget _buildCalendar(MealPlanCalendarState calendarState) =>
!calendarState.hasLoaded
? const Center(child: CircularProgressIndicator())
: WeeklyCalendarView(
state: calendarState,
onRecipeDropped: _onRecipeDropped,
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,
),
);
}
Future<void> _submitUrl() async {
final url = _urlController.text;
final notifier = ref.read(recipeImportControllerProvider.notifier);
await notifier.submitUrl(url);
if (mounted) _urlController.clear();
}
Future<void> _showPreview(Recipe recipe) async {
_previewDialogVisible = true;
final notifier = ref.read(recipeImportControllerProvider.notifier);
final saved = await showRecipePreviewDialog(context, recipe: recipe);
_previewDialogVisible = false;
// Cancelling (or abandoning a failed save) clears the parked preview so
// the modal never re-opens on the next rebuild.
if (saved != true) notifier.dismissPreview();
}
void _onRecipeDropped(Recipe recipe, DateTime day, MealType mealType) {
ref.read(mealPlanCalendarControllerProvider.notifier).assignRecipe(
day: day,
mealType: mealType,
recipeId: recipe.id,
);
}
void _onSlotTapped(DateTime day, MealType mealType) {
final calendarState = ref.read(mealPlanCalendarControllerProvider);
final importState = ref.read(recipeImportControllerProvider);
showAssignSlotDialog(
context,
day: day,
mealType: mealType,
savedRecipes: importState.savedRecipes,
existingSlot: calendarState.slotFor(day, mealType),
);
}
void _openTrayAssign(Recipe recipe) {
final calendarState = ref.read(mealPlanCalendarControllerProvider);
final importState = ref.read(recipeImportControllerProvider);
// Default target: today when inside the window, else the first day.
final now = DateTime.now();
final day = calendarState.days.firstWhere(
(day) =>
day.year == now.year &&
day.month == now.month &&
day.day == now.day,
orElse: () => calendarState.days.first,
);
showAssignSlotDialog(
context,
day: day,
mealType: MealType.breakfast,
savedRecipes: importState.savedRecipes,
existingSlot: calendarState.slotFor(day, MealType.breakfast),
);
}
}
/// URL import bar: text field + Import button with an inline loading
/// spinner.
class _RecipeImportBar extends StatelessWidget {
const _RecipeImportBar({
required this.controller,
required this.isLoading,
required this.onSubmit,
});
final TextEditingController controller;
final bool isLoading;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
enabled: !isLoading,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Recipe URL',
hintText: 'https://example.com/best-lasagna',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.link),
),
onSubmitted: (_) => onSubmit(),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: isLoading ? null : onSubmit,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined),
label: const Text('Import'),
),
],
),
);
}
}
/// 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);
}