240 lines
8.0 KiB
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);
|
|
}
|
|
}
|