599 lines
19 KiB
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);
|
|
}
|
|
}
|