import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/entities/recipe.dart'; import '../controllers/recipe_import_controller.dart'; /// Opens the recipe preview dialog for the currently previewed recipe. /// /// Resolves to `true` when the user saves the recipe and `false` when the /// dialog is cancelled; both outcomes are reconciled with the import /// controller state by the caller (the meal planner screen). Future showRecipePreviewDialog( BuildContext context, { required Recipe recipe, }) { return showDialog( context: context, barrierDismissible: false, builder: (context) => RecipePreviewDialog(recipe: recipe), ); } /// Confirmation modal shown after a successful scrape: extracted macros, /// ingredients, instructions, timing, and yield — reviewed before the /// recipe is persisted via the import controller. class RecipePreviewDialog extends ConsumerStatefulWidget { const RecipePreviewDialog({super.key, required this.recipe}); /// Snapshot of the parsed recipe; the dialog never re-reads it from state /// so controller updates cannot collapse the modal mid-animation. final Recipe recipe; @override ConsumerState createState() => _RecipePreviewDialogState(); } class _RecipePreviewDialogState extends ConsumerState { bool _isSaving = false; String? _saveError; Recipe get recipe => widget.recipe; @override Widget build(BuildContext context) { final theme = Theme.of(context); final macros = recipe.macrosPerServing; return AlertDialog( title: Text(recipe.title, maxLines: 2, overflow: TextOverflow.ellipsis), content: SizedBox( width: 480, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (recipe.imageUrl != null) ...[ ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network( recipe.imageUrl!, height: 140, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Container( height: 140, color: theme.colorScheme.surfaceContainerHighest, child: const Icon(Icons.broken_image_outlined, size: 40), ), ), ), const SizedBox(height: 12), ], Wrap( spacing: 8, runSpacing: 4, children: [ _MetaChip( icon: Icons.restaurant, label: '${recipe.servings} servings', ), _MetaChip( icon: Icons.timer_outlined, label: 'Prep ${recipe.prepTimeMinutes} min', ), _MetaChip( icon: Icons.local_fire_department_outlined, label: 'Cook ${recipe.cookTimeMinutes} min', ), ], ), if (recipe.description != null) ...[ const SizedBox(height: 8), Text(recipe.description!, style: theme.textTheme.bodySmall), ], const SizedBox(height: 16), const _SectionText('Macros per serving'), const SizedBox(height: 8), Row( children: [ _MacroTile(label: 'kcal', value: '${macros.calories}'), _MacroTile( label: 'Protein', value: '${_fmt(macros.proteinGrams)} g', ), _MacroTile( label: 'Carbs', value: '${_fmt(macros.carbsGrams)} g', ), _MacroTile(label: 'Fat', value: '${_fmt(macros.fatGrams)} g'), ], ), const SizedBox(height: 16), _SectionText('Ingredients (${recipe.ingredients.length})'), const SizedBox(height: 4), if (recipe.ingredients.isEmpty) const _EmptyHint('No ingredient list found.') else for (final ingredient in recipe.ingredients) Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Text( '• ${ingredient.rawText}', style: theme.textTheme.bodySmall, ), ), const SizedBox(height: 12), _SectionText('Steps (${recipe.instructions.length})'), const SizedBox(height: 4), if (recipe.instructions.isEmpty) const _EmptyHint('No instructions found.') else for (var index = 0; index < recipe.instructions.length; index++) Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Text( '${index + 1}. ${recipe.instructions[index]}', style: theme.textTheme.bodySmall, ), ), if (_saveError != null) ...[ const SizedBox(height: 12), Text( _saveError!, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.error), ), ], ], ), ), ), actions: [ TextButton( onPressed: _isSaving ? null : () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton.icon( onPressed: _isSaving ? null : _save, icon: _isSaving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.bookmark_add_outlined), label: const Text('Save Recipe'), ), ], ); } static String _fmt(double value) { final rounded = value.roundToDouble(); return rounded == value ? rounded.toString() : value.toStringAsFixed(1); } Future _save() async { setState(() { _isSaving = true; _saveError = null; }); final saved = await ref .read(recipeImportControllerProvider.notifier) .confirmSavePreview(); if (!mounted) return; if (saved) { Navigator.of(context).pop(true); } else { setState(() { _isSaving = false; _saveError = ref.read(recipeImportControllerProvider).errorMessage ?? 'Could not save the recipe.'; }); } } } /// Bold sub-heading used for dialog sections. class _SectionText extends StatelessWidget { const _SectionText(this.text); final String text; @override Widget build(BuildContext context) { return Text( text, style: Theme.of(context) .textTheme .titleSmall ?.copyWith(fontWeight: FontWeight.bold), ); } } /// Italic hint shown when a recipe section could not be extracted. class _EmptyHint extends StatelessWidget { const _EmptyHint(this.text); final String text; @override Widget build(BuildContext context) { return Text( text, style: const TextStyle(fontStyle: FontStyle.italic), ); } } /// Compact chip used for yield / timing metadata. class _MetaChip extends StatelessWidget { const _MetaChip({required this.icon, required this.label}); final IconData icon; final String label; @override Widget build(BuildContext context) { return Chip( visualDensity: VisualDensity.compact, avatar: Icon(icon, size: 16), label: Text(label, style: const TextStyle(fontSize: 12)), ); } } /// Single macro tile (kcal / protein / carbs / fat). class _MacroTile extends StatelessWidget { const _MacroTile({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Expanded( child: Container( margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(10), ), child: Column( children: [ Text( value, style: theme.textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), Text(label, style: theme.textTheme.labelSmall), ], ), ), ); } }