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'; import '../controllers/mealie_integration_controller.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; } } } /// One selectable recipe-source filter chip applied to the recipe vault /// grid (Phase 4B): separates public web scrapes from recipes synced from /// the private Mealie instance. enum RecipeSourceFilter { all('All'), webScraped('Web Scraped'), mealie('Mealie'); const RecipeSourceFilter(this.label); /// Human-readable chip label. final String label; } /// Dedicated Recipe Vault: URL import bar, search + source/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 createState() => _RecipesScreenState(); } class _RecipesScreenState extends ConsumerState { /// 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; RecipeSourceFilter _activeSource = RecipeSourceFilter.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 _applyFilters( List recipes, MealieIntegrationState mealie, ) { final query = _query.trim().toLowerCase(); return recipes.where((recipe) { if (!_activeFilter.accepts(recipe)) return false; if (!_sourceAccepts(recipe, mealie)) 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); } /// Applies the [RecipeSourceFilter] chip: Mealie recipes are recognized /// through their `{baseUrl}/recipe/{slug}` source URL (see /// [MealieApiClient.isMealieSourceUrl]); with no server configured every /// recipe counts as web-scraped. bool _sourceAccepts(Recipe recipe, MealieIntegrationState mealie) { switch (_activeSource) { case RecipeSourceFilter.all: return true; case RecipeSourceFilter.mealie: return mealie.isMealieRecipe(recipe); case RecipeSourceFilter.webScraped: return !mealie.isMealieRecipe(recipe); } } 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); final mealieState = ref.watch(mealieIntegrationControllerProvider); 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, mealieState); return Scaffold( appBar: AppBar( title: const Text('Recipe Vault'), actions: [ if (mealieState.isConfigured) mealieState.isSyncing ? const Padding( padding: EdgeInsets.all(14), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ) : IconButton( tooltip: 'Sync Private Recipes', icon: const Icon(Icons.cloud_sync_outlined), onPressed: _syncMealie, ), ], ), 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 source in RecipeSourceFilter.values) ChoiceChip( avatar: source == RecipeSourceFilter.mealie ? const Icon(Icons.restaurant_menu, size: 16) : null, label: Text(source.label), selected: _activeSource == source, onSelected: (_) => setState(() { _activeSource = source; _visibleCount = _pageSize; }), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 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, mealieState), ), ], ), ); } /// 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 filtered, MealieIntegrationState mealie) { 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); } final recipe = filtered[index]; return _RecipeVaultCard( recipe: recipe, isMealie: mealie.isMealieRecipe(recipe), onTap: () => _showDetails(recipe), onDelete: () => _deleteRecipe(recipe), ); }, ); } /// Manual "Sync Private Recipes" round trip; the outcome surfaces as a /// snackbar and the transient banners are cleared afterwards. Future _syncMealie() async { final notifier = ref.read(mealieIntegrationControllerProvider.notifier); await notifier.syncNow(); if (!mounted) return; final state = ref.read(mealieIntegrationControllerProvider); final message = state.errorMessage ?? state.statusMessage; if (message == null) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: state.errorMessage != null ? Theme.of(context).colorScheme.error : null, ), ); notifier.clearMessages(); } Future _submitUrl() async { final url = _urlController.text; await ref.read(recipeImportControllerProvider.notifier).submitUrl(url); if (mounted) _urlController.clear(); } Future _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 _deleteRecipe(Recipe recipe) async { final confirmed = await showDialog( 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( 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, this.isMealie = false, }); final Recipe recipe; final VoidCallback onTap; final VoidCallback onDelete; /// `true` when the recipe was synced from the private Mealie server — /// renders the subtle cloud badge next to the title. final bool isMealie; @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: [ Row( children: [ if (isMealie) ...[ Tooltip( message: 'Synced from your Mealie server', child: Icon( Icons.restaurant_menu, size: 14, color: theme.colorScheme.primary, ), ), const SizedBox(width: 4), ], Expanded( child: 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); } }