189 lines
5.8 KiB
Dart
189 lines
5.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.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';
|
|
|
|
/// Opens the quick-assign modal for a specific day/meal slot.
|
|
///
|
|
/// Shows the saved recipes to pick from, plus a free-text override for
|
|
/// meals without a saved recipe. Resolves to `true` when a slot was
|
|
/// written, otherwise `null`/`false`.
|
|
Future<bool?> showAssignSlotDialog(
|
|
BuildContext context, {
|
|
required DateTime day,
|
|
required MealType mealType,
|
|
required List<Recipe> savedRecipes,
|
|
MealPlanSlot? existingSlot,
|
|
}) {
|
|
return showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AssignSlotDialog(
|
|
day: day,
|
|
mealType: mealType,
|
|
savedRecipes: savedRecipes,
|
|
existingSlot: existingSlot,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Modal used by the weekly calendar's "+" affordance: assign a saved
|
|
/// recipe (via dropdown) or type a quick custom description for the slot.
|
|
class AssignSlotDialog extends ConsumerStatefulWidget {
|
|
const AssignSlotDialog({
|
|
super.key,
|
|
required this.day,
|
|
required this.mealType,
|
|
required this.savedRecipes,
|
|
this.existingSlot,
|
|
});
|
|
|
|
final DateTime day;
|
|
final MealType mealType;
|
|
final List<Recipe> savedRecipes;
|
|
final MealPlanSlot? existingSlot;
|
|
|
|
@override
|
|
ConsumerState<AssignSlotDialog> createState() => _AssignSlotDialogState();
|
|
}
|
|
|
|
class _AssignSlotDialogState extends ConsumerState<AssignSlotDialog> {
|
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
|
late final TextEditingController _descriptionController;
|
|
|
|
Recipe? _selectedRecipe;
|
|
bool _isSaving = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_descriptionController = TextEditingController(
|
|
text: widget.existingSlot?.customDescription ?? '',
|
|
);
|
|
// Pre-select the currently assigned recipe, if any.
|
|
final recipeId = widget.existingSlot?.recipeId;
|
|
if (recipeId != null) {
|
|
for (final recipe in widget.savedRecipes) {
|
|
if (recipe.id == recipeId) {
|
|
_selectedRecipe = recipe;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final dayLabel = MaterialLocalizations.of(context).formatCompactDate(widget.day);
|
|
|
|
return AlertDialog(
|
|
title: Text('Assign ${widget.mealType.displayName} — $dayLabel'),
|
|
content: SizedBox(
|
|
width: 420,
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DropdownButtonFormField<Recipe>(
|
|
initialValue: _selectedRecipe,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Saved recipe',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
for (final recipe in widget.savedRecipes)
|
|
DropdownMenuItem<Recipe>(
|
|
value: recipe,
|
|
child: Text(
|
|
recipe.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
onChanged: (recipe) => setState(() => _selectedRecipe = recipe),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _descriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description (optional)',
|
|
hintText: 'e.g. Leftover chicken & greens',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
textCapitalization: TextCapitalization.sentences,
|
|
validator: _validate,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton.icon(
|
|
onPressed: _isSaving ? null : _assign,
|
|
icon: _isSaving
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.event_available_outlined),
|
|
label: const Text('Assign'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String? _validate(String? value) {
|
|
// A description is only required when no saved recipe is selected.
|
|
if (_selectedRecipe == null && (value == null || value.trim().isEmpty)) {
|
|
return 'Select a recipe or enter a description';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<void> _assign() async {
|
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
|
|
|
setState(() => _isSaving = true);
|
|
final controller = ref.read(mealPlanCalendarControllerProvider.notifier);
|
|
final description = _descriptionController.text.trim();
|
|
|
|
final bool saved;
|
|
final recipe = _selectedRecipe;
|
|
if (recipe != null) {
|
|
saved = await controller.assignRecipe(
|
|
day: widget.day,
|
|
mealType: widget.mealType,
|
|
recipeId: recipe.id,
|
|
note: description.isEmpty ? null : description,
|
|
);
|
|
} else {
|
|
saved = await controller.saveCustomEntry(
|
|
day: widget.day,
|
|
mealType: widget.mealType,
|
|
description: description,
|
|
);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop(saved);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_descriptionController.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|