342 lines
11 KiB
Dart
342 lines
11 KiB
Dart
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);
|
||
}
|
||
|
||
|