glp-companion/lib/features/meal_planner/presentation/widgets/recipe_preview_dialog.dart

282 lines
8.8 KiB
Dart

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<bool?> showRecipePreviewDialog(
BuildContext context, {
required Recipe recipe,
}) {
return showDialog<bool>(
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<RecipePreviewDialog> createState() =>
_RecipePreviewDialogState();
}
class _RecipePreviewDialogState extends ConsumerState<RecipePreviewDialog> {
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<void> _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),
],
),
),
);
}
}