282 lines
8.8 KiB
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),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|