glp-companion/lib/features/recipes/presentation/screens/recipes_screen.dart

599 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../meal_planner/domain/entities/recipe.dart';
import '../../../meal_planner/presentation/controllers/recipe_import_controller.dart';
import '../../../meal_planner/presentation/widgets/recipe_preview_dialog.dart';
/// One selectable macro filter chip applied to the recipe vault grid.
enum MacroFilter {
all('All'),
protein30('Protein > 30g'),
protein20('Protein > 20g'),
under500('Under 500 kcal');
const MacroFilter(this.label);
/// Human-readable chip label.
final String label;
/// Whether [recipe] satisfies this filter.
bool accepts(Recipe recipe) {
final macros = recipe.macrosPerServing;
switch (this) {
case MacroFilter.all:
return true;
case MacroFilter.protein30:
return macros.proteinGrams > 30;
case MacroFilter.protein20:
return macros.proteinGrams > 20;
case MacroFilter.under500:
return macros.calories < 500;
}
}
}
/// Dedicated Recipe Vault: URL import bar, search + macro filter chips, and
/// a responsive virtualized grid of recipe cards with chunked infinite
/// scroll (cards are built lazily by [GridView.builder]; the visible window
/// extends as the user approaches the end of the loaded chunk).
class RecipesScreen extends ConsumerStatefulWidget {
const RecipesScreen({super.key});
@override
ConsumerState<RecipesScreen> createState() => _RecipesScreenState();
}
class _RecipesScreenState extends ConsumerState<RecipesScreen> {
/// Recipes materialized per scroll chunk.
static const int _pageSize = 24;
/// How close (in items) the viewport must get to the end of the built
/// window before the next chunk is scheduled.
static const int _prefetchMargin = 6;
final TextEditingController _urlController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
String _query = '';
MacroFilter _activeFilter = MacroFilter.all;
/// Number of filtered recipes currently materialized in the grid; grows
/// in [_pageSize] chunks to emulate infinite scroll over the catalog.
int _visibleCount = _pageSize;
/// Guards the preview modal against double-opens while animating.
bool _previewDialogVisible = false;
@override
void dispose() {
_urlController.dispose();
_searchController.dispose();
super.dispose();
}
List<Recipe> _applyFilters(List<Recipe> recipes) {
final query = _query.trim().toLowerCase();
return recipes.where((recipe) {
if (!_activeFilter.accepts(recipe)) return false;
if (query.isEmpty) return true;
return recipe.title.toLowerCase().contains(query) ||
(recipe.description ?? '').toLowerCase().contains(query) ||
recipe.sourceUrl.toLowerCase().contains(query);
}).toList(growable: false);
}
void _extendWindow(int filteredLength) {
if (_visibleCount >= filteredLength) return;
// Defer past the current build phase: itemBuilder may run mid-build.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_visibleCount = (_visibleCount + _pageSize).clamp(0, filteredLength);
});
});
}
@override
Widget build(BuildContext context) {
final importState = ref.watch(recipeImportControllerProvider);
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!);
}
});
final filtered = _applyFilters(importState.savedRecipes);
return Scaffold(
appBar: AppBar(title: const Text('Recipe Vault')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildImportBar(importState.isImporting),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: SearchBar(
controller: _searchController,
hintText: 'Search recipes…',
leading: const Icon(Icons.search),
elevation: const WidgetStatePropertyAll(0),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
onChanged: (value) => setState(() {
_query = value;
_visibleCount = _pageSize;
}),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final filter in MacroFilter.values)
ChoiceChip(
label: Text(filter.label),
selected: _activeFilter == filter,
onSelected: (_) => setState(() {
_activeFilter = filter;
_visibleCount = _pageSize;
}),
),
],
),
),
Expanded(
child: filtered.isEmpty
? _EmptyVaultHint(
hasAnyRecipes: importState.savedRecipes.isNotEmpty,
)
: _buildGrid(filtered),
),
],
),
);
}
/// URL import bar pinned above the searchable grid.
Widget _buildImportBar(bool isImporting) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _urlController,
enabled: !isImporting,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Import from URL',
hintText: 'https://example.com/best-lasagna',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.link),
),
onSubmitted: (_) => _submitUrl(),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: isImporting ? null : _submitUrl,
icon: isImporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined),
label: const Text('Import'),
),
],
),
);
}
/// Responsive virtualized grid: column count adapts to the available
/// width and only the first [_visibleCount] filtered recipes are
/// materialized, extending in chunks as the user scrolls.
Widget _buildGrid(List<Recipe> filtered) {
final materialized =
filtered.length < _visibleCount ? filtered.length : _visibleCount;
return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 240,
mainAxisExtent: 232,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: materialized,
itemBuilder: (context, index) {
// Infinite scroll: extend the materialized window when the viewport
// nears the end of the current chunk.
if (index >= materialized - _prefetchMargin) {
_extendWindow(filtered.length);
}
return _RecipeVaultCard(
recipe: filtered[index],
onTap: () => _showDetails(filtered[index]),
onDelete: () => _deleteRecipe(filtered[index]),
);
},
);
}
Future<void> _submitUrl() async {
final url = _urlController.text;
await ref.read(recipeImportControllerProvider.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();
}
Future<void> _deleteRecipe(Recipe recipe) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete "${recipe.title}"?'),
content: const Text(
'Assigned meal-plan slots will fall back to empty. This cannot '
'be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
await ref
.read(recipeImportControllerProvider.notifier)
.deleteRecipe(recipe.id);
}
void _showDetails(Recipe recipe) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => _RecipeDetailsSheet(recipe: recipe),
);
}
void _showErrorSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
/// Recipe card used inside the vault grid: thumbnail, title, macro badges,
/// and a delete affordance.
class _RecipeVaultCard extends StatelessWidget {
const _RecipeVaultCard({
required this.recipe,
required this.onTap,
required this.onDelete,
});
final Recipe recipe;
final VoidCallback onTap;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final macros = recipe.macrosPerServing;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: recipe.imageUrl == null
? _VaultThumbnailPlaceholder(theme: theme)
: Image.network(
recipe.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_VaultThumbnailPlaceholder(theme: theme),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 4, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Row(
children: [
_VaultMacroBadge(
label: '${_fmt(macros.proteinGrams)}g P',
color: Colors.green.shade100,
textColor: Colors.green.shade800,
),
const SizedBox(width: 4),
_VaultMacroBadge(
label: '${macros.calories} kcal',
color: theme.colorScheme.secondaryContainer,
textColor: theme.colorScheme.onSecondaryContainer,
),
const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Delete recipe',
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: onDelete,
),
],
),
],
),
),
],
),
),
);
}
static String _fmt(double value) {
final rounded = value.roundToDouble();
return rounded == value
? rounded.toStringAsFixed(0)
: value.toStringAsFixed(1);
}
}
/// Fallback tile when a recipe has no (loadable) thumbnail.
class _VaultThumbnailPlaceholder extends StatelessWidget {
const _VaultThumbnailPlaceholder({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Container(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.menu_book_outlined,
size: 32,
color: theme.colorScheme.outline,
),
),
);
}
}
/// Small colored badge used for per-serving macro chips.
class _VaultMacroBadge extends StatelessWidget {
const _VaultMacroBadge({
required this.label,
required this.color,
required this.textColor,
});
final String label;
final Color color;
final Color textColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: textColor,
),
),
);
}
}
/// Placeholder shown while the (filtered) vault is empty.
class _EmptyVaultHint extends StatelessWidget {
const _EmptyVaultHint({required this.hasAnyRecipes});
/// `true` when recipes exist but the active search/filter hides them.
final bool hasAnyRecipes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
hasAnyRecipes ? Icons.search_off : Icons.menu_book_outlined,
size: 40,
color: theme.colorScheme.outline,
),
const SizedBox(height: 8),
Text(
hasAnyRecipes
? 'No recipes match the current search or filter.'
: 'Your vault is empty.\nImport a recipe URL above to get '
'started.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
],
),
),
);
}
}
/// Read-only details sheet for a saved recipe: macros, ingredients, and
/// instructions.
class _RecipeDetailsSheet extends StatelessWidget {
const _RecipeDetailsSheet({required this.recipe});
final Recipe recipe;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final macros = recipe.macrosPerServing;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
recipe.title,
style: theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.restaurant, size: 16),
label: Text('${recipe.servings} servings'),
),
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.timer_outlined, size: 16),
label: Text('${recipe.totalMinutes} min'),
),
Chip(
visualDensity: VisualDensity.compact,
backgroundColor: Colors.green.shade100,
label: Text(
'${_fmt(macros.proteinGrams)}g protein',
style: TextStyle(
color: Colors.green.shade800,
fontWeight: FontWeight.bold,
),
),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text('${macros.calories} kcal'),
),
],
),
if (recipe.description != null) ...[
const SizedBox(height: 12),
Text(recipe.description!, style: theme.textTheme.bodyMedium),
],
if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Ingredients',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
for (final ingredient in recipe.ingredients)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('• ${ingredient.rawText}',
style: theme.textTheme.bodySmall),
),
],
if (recipe.instructions.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Instructions',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
for (var i = 0; i < recipe.instructions.length; i++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
'${i + 1}. ${recipe.instructions[i]}',
style: theme.textTheme.bodySmall,
),
),
],
const SizedBox(height: 16),
Text(
'Source: ${recipe.sourceUrl}',
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.outline),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
static String _fmt(double value) {
final rounded = value.roundToDouble();
return rounded == value
? rounded.toStringAsFixed(0)
: value.toStringAsFixed(1);
}
}