import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_exception.dart'; import '../../../core/ui/async_state_views.dart'; import '../../auth/data/auth_providers.dart'; import '../data/recipe_providers.dart'; import '../domain/recipe.dart'; import '../domain/inventory_preview.dart'; class RecipeDetailScreen extends ConsumerWidget { final int recipeId; const RecipeDetailScreen({super.key, required this.recipeId}); @override Widget build(BuildContext context, WidgetRef ref) { final recipeAsync = ref.watch(recipeDetailProvider(recipeId)); return Scaffold( appBar: AppBar( title: Text(recipeAsync.maybeWhen(data: (d) => d, orElse: () => null)?.title ?? 'Recept'), actions: recipeAsync.maybeWhen(data: (d) => d, orElse: () => null) == null ? [] : [ IconButton( tooltip: 'Redigera', icon: const Icon(Icons.edit_outlined), onPressed: () => context.push('/recipes/$recipeId/edit'), ), _DeleteButton(recipe: recipeAsync.value!), ], ), body: recipeAsync.when( loading: () => const LoadingStateView(label: 'Laddar recept...'), error: (error, _) => ErrorStateView( message: mapErrorToUserMessage(error, context), onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)), ), data: (recipe) => _RecipeBody(recipe: recipe), ), ); } } class _DeleteButton extends ConsumerWidget { final Recipe recipe; const _DeleteButton({required this.recipe}); @override Widget build(BuildContext context, WidgetRef ref) { return IconButton( tooltip: 'Ta bort', icon: const Icon(Icons.delete_outline), onPressed: () => _confirmDelete(context, ref), ); } Future _confirmDelete(BuildContext context, WidgetRef ref) async { final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Ta bort recept?'), content: Text( 'Vill du ta bort "${recipe.title}"? Åtgärden kan inte ångras.'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Avbryt'), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error), onPressed: () => Navigator.pop(context, true), child: const Text('Ta bort'), ), ], ), ); if (confirmed != true || !context.mounted) return; try { final token = await ref.read(authStateProvider.future); await ref.read(recipeRepositoryProvider).deleteRecipe(recipe.id, token: token); ref.invalidate(recipesProvider); if (context.mounted) context.go('/recipes'); } on ApiException catch (e) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(mapErrorToUserMessage(e, context))), ); } } } class _RecipeBody extends StatelessWidget { final Recipe recipe; const _RecipeBody({required this.recipe}); String _formatQty(double qty) { if (qty == 0) return ''; return qty == qty.truncateToDouble() ? qty.toInt().toString() : qty.toString(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (recipe.imageUrl != null) ClipRRect( borderRadius: BorderRadius.circular(12), child: AspectRatio( aspectRatio: 16 / 9, child: Image.network( recipe.imageUrl!, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), ), ), if (recipe.imageUrl != null) const SizedBox(height: 20), Text(recipe.title, style: theme.textTheme.headlineSmall), if (recipe.description != null) ...[ const SizedBox(height: 8), Text(recipe.description!, style: theme.textTheme.bodyMedium ?.copyWith(color: theme.colorScheme.onSurfaceVariant)), ], if (recipe.servings != null) ...[ const SizedBox(height: 8), Row( children: [ const Icon(Icons.people_outline, size: 16), const SizedBox(width: 4), Text('${recipe.servings} portioner', style: theme.textTheme.bodySmall), ], ), ], if (recipe.ingredients.isNotEmpty) ...[ const SizedBox(height: 24), Text('Ingredienser', style: theme.textTheme.titleMedium), const SizedBox(height: 8), ...recipe.ingredients.map((ing) { final qtyStr = _formatQty(ing.quantity); final parts = [ if (qtyStr.isNotEmpty) qtyStr, if (ing.unit.isNotEmpty) ing.unit, ing.productName, if (ing.note != null) '(${ing.note})', ]; return Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('• '), Expanded(child: Text(parts.join(' '))), ], ), ); }), ], if (recipe.instructions != null && recipe.instructions!.isNotEmpty) ...[ const SizedBox(height: 24), Text('Tillvägagångssätt', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Text(recipe.instructions!, style: theme.textTheme.bodyMedium ?.copyWith(height: 1.6)), ], _InventoryPreviewSection(recipeId: recipe.id), const SizedBox(height: 40), ], ), ); } } class _InventoryPreviewSection extends ConsumerStatefulWidget { final int recipeId; const _InventoryPreviewSection({required this.recipeId}); @override ConsumerState<_InventoryPreviewSection> createState() => _InventoryPreviewSectionState(); } class _InventoryPreviewSectionState extends ConsumerState<_InventoryPreviewSection> { bool _loaded = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 24), Row( children: [ Text('Vad saknas?', style: theme.textTheme.titleMedium), const Spacer(), if (!_loaded) FilledButton.tonalIcon( onPressed: () => setState(() => _loaded = true), icon: const Icon(Icons.search, size: 16), label: const Text('Kontrollera inventarie'), ), if (_loaded) IconButton( tooltip: 'Uppdatera', icon: const Icon(Icons.refresh), onPressed: () { ref.invalidate(inventoryPreviewProvider(widget.recipeId)); }, ), ], ), if (_loaded) _InventoryPreviewResults(recipeId: widget.recipeId), ], ); } } class _InventoryPreviewResults extends ConsumerWidget { final int recipeId; const _InventoryPreviewResults({required this.recipeId}); @override Widget build(BuildContext context, WidgetRef ref) { final previewAsync = ref.watch(inventoryPreviewProvider(recipeId)); final theme = Theme.of(context); return previewAsync.when( loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center(child: CircularProgressIndicator()), ), error: (error, _) => Padding( padding: const EdgeInsets.only(top: 8), child: Text( mapErrorToUserMessage(error, context), style: TextStyle(color: theme.colorScheme.error), ), ), data: (preview) { final summary = preview.summary; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), _SummaryChips(summary: summary), const SizedBox(height: 12), ...preview.ingredients.map( (ing) => _IngredientPreviewRow(ingredient: ing), ), ], ); }, ); } } class _SummaryChips extends StatelessWidget { final PreviewSummary summary; const _SummaryChips({required this.summary}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final cs = theme.colorScheme; return Wrap( spacing: 8, runSpacing: 4, children: [ if (summary.canCookExactly) Chip( avatar: Icon(Icons.check_circle, color: cs.onPrimary, size: 16), label: const Text('Kan lagas!'), backgroundColor: cs.primary, labelStyle: TextStyle(color: cs.onPrimary), ) else Chip( avatar: Icon(Icons.warning_amber, color: cs.onErrorContainer, size: 16), label: Text('Saknar ${summary.missingCount} ingrediens' '${summary.missingCount == 1 ? '' : 'er'}'), backgroundColor: cs.errorContainer, labelStyle: TextStyle(color: cs.onErrorContainer), ), if (summary.unitMismatchCount > 0) Chip( avatar: Icon(Icons.swap_horiz, color: cs.onTertiaryContainer, size: 16), label: Text('${summary.unitMismatchCount} enhetsmismatch'), backgroundColor: cs.tertiaryContainer, labelStyle: TextStyle(color: cs.onTertiaryContainer), ), ], ); } } class _IngredientPreviewRow extends StatelessWidget { final IngredientPreview ingredient; const _IngredientPreviewRow({required this.ingredient}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final cs = theme.colorScheme; final (icon, color) = switch (ingredient.status) { IngredientStatus.enough => (Icons.check_circle_outline, cs.primary), IngredientStatus.unitMismatch => ( Icons.swap_horiz, cs.tertiary, ), IngredientStatus.missing => (Icons.cancel_outlined, cs.error), }; String _fmt(double v) => v == v.truncateToDouble() ? v.toInt().toString() : v.toString(); final requiredStr = '${_fmt(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim(); final availableStr = '${_fmt(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim(); final subtitle = switch (ingredient.status) { IngredientStatus.enough => 'Tillgängligt: $availableStr', IngredientStatus.missing => ingredient.availableQuantity > 0 ? 'Saknar ${_fmt(ingredient.missingQuantity)} ${ingredient.requiredUnit} ' '(har $availableStr)' : 'Saknas helt', IngredientStatus.unitMismatch => 'Annan enhet i lager – kontrollera manuellt', }; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: color, size: 18), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${ingredient.productName}' '${ingredient.note != null ? ' (${ingredient.note})' : ''}' ' – $requiredStr', style: theme.textTheme.bodyMedium, ), Text( subtitle, style: theme.textTheme.bodySmall ?.copyWith(color: color), ), ], ), ), ], ), ); } }