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

240 lines
8.0 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 searchable "Assign Recipe" modal sheet for a day/meal slot.
///
/// Populated from the Recipe Vault catalog; resolves to `true` when a slot
/// was written, otherwise `null`/`false`.
Future<bool?> showAssignRecipeSheet(
BuildContext context, {
required DateTime day,
required MealType mealType,
required List<Recipe> savedRecipes,
MealPlanSlot? existingSlot,
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => AssignRecipeSheet(
day: day,
mealType: mealType,
savedRecipes: savedRecipes,
existingSlot: existingSlot,
),
);
}
/// Modal bottom sheet: search field over the Recipe Vault plus a free-text
/// custom entry fallback for meals without a saved recipe.
class AssignRecipeSheet extends ConsumerStatefulWidget {
const AssignRecipeSheet({
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<AssignRecipeSheet> createState() => _AssignRecipeSheetState();
}
class _AssignRecipeSheetState extends ConsumerState<AssignRecipeSheet> {
final TextEditingController _searchController = TextEditingController();
final TextEditingController _customController = TextEditingController();
String _query = '';
bool _isSaving = false;
@override
void initState() {
super.initState();
_customController.text = widget.existingSlot?.customDescription ?? '';
}
@override
void dispose() {
_searchController.dispose();
_customController.dispose();
super.dispose();
}
List<Recipe> get _filtered {
final query = _query.trim().toLowerCase();
if (query.isEmpty) return widget.savedRecipes;
return widget.savedRecipes
.where((recipe) => recipe.title.toLowerCase().contains(query))
.toList(growable: false);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dayLabel =
MaterialLocalizations.of(context).formatCompactDate(widget.day);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final filtered = _filtered;
return Padding(
padding: EdgeInsets.only(left: 16, right: 16, bottom: bottomInset + 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Assign ${widget.mealType.displayName} — $dayLabel',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: 'Search Recipe Vault',
hintText: 'e.g. chicken',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.search),
),
onChanged: (value) => setState(() => _query = value),
),
const SizedBox(height: 12),
if (widget.savedRecipes.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'No saved recipes yet — import one from the Recipe Vault.',
style: theme.textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
)
else if (filtered.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'No recipes match "$_query".',
style: theme.textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.separated(
shrinkWrap: true,
itemCount: filtered.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final recipe = filtered[index];
final protein = recipe.macrosPerServing.proteinGrams.round();
return ListTile(
dense: true,
enabled: !_isSaving,
leading: const Icon(Icons.menu_book_outlined),
title: Text(
recipe.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${protein}g P',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.green.shade800,
),
),
),
onTap: () => _assignRecipe(recipe),
);
},
),
),
const SizedBox(height: 12),
const Divider(),
TextField(
controller: _customController,
enabled: !_isSaving,
decoration: const InputDecoration(
labelText: 'Custom entry (no recipe)',
hintText: 'e.g. Leftover chicken & greens',
border: OutlineInputBorder(),
isDense: true,
),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isSaving ? null : _saveCustom,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit_outlined),
label: const Text('Save Custom'),
),
],
),
],
),
);
}
Future<void> _assignRecipe(Recipe recipe) async {
setState(() => _isSaving = true);
final saved = await ref
.read(mealPlanCalendarControllerProvider.notifier)
.assignRecipe(
day: widget.day,
mealType: widget.mealType,
recipeId: recipe.id,
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
Future<void> _saveCustom() async {
final description = _customController.text.trim();
if (description.isEmpty) return;
setState(() => _isSaving = true);
final saved = await ref
.read(mealPlanCalendarControllerProvider.notifier)
.saveCustomEntry(
day: widget.day,
mealType: widget.mealType,
description: description,
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
}