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/utils/formatters.dart'; import '../../../core/auth/jwt_decoder.dart'; import '../../../core/l10n/l10n.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'; String _fmtQty(double v) => formatQuantity(v); enum _ShareAction { share, unshare } 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)); final token = ref.watch(authStateProvider).maybeWhen( data: (t) => t, orElse: () => null, ); final currentUserId = jwtUserId(token); final recipe = recipeAsync.asData?.value; final isOwner = recipe != null && currentUserId != null && recipe.ownerId == currentUserId; return Scaffold( appBar: AppBar( title: const SizedBox.shrink(), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/recipes'), tooltip: context.l10n.recipeDetailBackToList, ), actions: recipe == null ? [] : [ if (isOwner) IconButton( tooltip: recipe.isPublic ? context.l10n.recipeDetailMakePrivate : context.l10n.recipeDetailMakePublic, icon: Icon(recipe.isPublic ? Icons.public : Icons.lock_outline), onPressed: () => _toggleVisibility(context, ref, recipe), ), if (isOwner) IconButton( tooltip: context.l10n.recipeDetailShareWithUser, icon: const Icon(Icons.person_add_alt_1_outlined), onPressed: () => _shareRecipe(context, ref, recipe), ), IconButton( tooltip: context.l10n.editTooltip, icon: const Icon(Icons.edit_outlined), onPressed: () => context.push('/recipes/$recipeId/edit'), ), IconButton( tooltip: context.l10n.recipeDetailGoToInventory, icon: const Icon(Icons.inventory_2_outlined), onPressed: () => context.go('/inventory'), ), if (isOwner) _DeleteButton(recipe: recipe), ], ), body: recipeAsync.when( loading: () => LoadingStateView(label: context.l10n.recipeDetailLoading), error: (error, _) => ErrorStateView( message: mapErrorToUserMessage(error, context), onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)), ), data: (recipe) => CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ SliverAppBar( expandedHeight: MediaQuery.of(context).size.height * 0.42, flexibleSpace: FlexibleSpaceBar( background: Stack( fit: StackFit.expand, children: [ recipe.imageUrl != null ? Image.network( recipe.imageUrl!, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _ImagePlaceholder(), ) : _ImagePlaceholder(), // Gradient + title overlay Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: const EdgeInsets.fromLTRB(16, 40, 16, 16), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black87], ), ), child: Text( recipe.title, style: const TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold, shadows: [Shadow(blurRadius: 4, color: Colors.black54)], ), ), ), ), if (recipe.isPublic && recipe.ownerUsername != null && recipe.ownerUsername!.isNotEmpty) Positioned( right: 12, top: 12, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: Colors.black.withOpacity(0.45), borderRadius: BorderRadius.circular(14), ), child: Text( '@${recipe.ownerUsername}', style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600, ), ), ), ), ], ), ), pinned: true, ), SliverToBoxAdapter( child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), ), ), child: _RecipeBody(recipe: recipe), ), ), ], ), ), ); } Future _toggleVisibility( BuildContext context, WidgetRef ref, Recipe recipe, ) async { try { final token = ref.read(authStateProvider).maybeWhen( data: (t) => t, orElse: () => null, ) ?? await ref.read(authStateProvider.future); await ref.read(recipeRepositoryProvider).setRecipeVisibility( recipe.id, isPublic: !recipe.isPublic, token: token, ); ref.invalidate(recipeDetailProvider(recipe.id)); ref.invalidate(recipesProvider); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( !recipe.isPublic ? context.l10n.recipeDetailNowPublic : context.l10n.recipeDetailNowPrivate, ), ), ); } on ApiException catch (e) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(mapErrorToUserMessage(e, context))), ); } } Future _shareRecipe( BuildContext context, WidgetRef ref, Recipe recipe, ) async { final ctrl = TextEditingController(); final result = await showDialog<(_ShareAction, String)>( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.recipeDetailShareTitle), content: TextField( controller: ctrl, autofocus: true, decoration: InputDecoration( labelText: context.l10n.recipeDetailUsernameLabel, hintText: context.l10n.recipeDetailUsernameHint, ), onSubmitted: (_) => Navigator.pop( context, (_ShareAction.share, ctrl.text.trim()), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(context.l10n.cancelAction), ), TextButton( onPressed: () => Navigator.pop( context, (_ShareAction.unshare, ctrl.text.trim()), ), child: Text(context.l10n.recipeDetailRemoveShare), ), FilledButton( onPressed: () => Navigator.pop( context, (_ShareAction.share, ctrl.text.trim()), ), child: Text(context.l10n.recipeDetailShareAction), ), ], ), ); ctrl.dispose(); final action = result?.$1; final trimmed = result?.$2.trim() ?? ''; if (trimmed.isEmpty) return; try { final token = ref.read(authStateProvider).maybeWhen( data: (t) => t, orElse: () => null, ) ?? await ref.read(authStateProvider.future); if (action == _ShareAction.unshare) { await ref.read(recipeRepositoryProvider).unshareRecipeWithUsername( recipe.id, username: trimmed, token: token, ); } else { await ref.read(recipeRepositoryProvider).shareRecipeWithUsername( recipe.id, username: trimmed, token: token, ); } ref.invalidate(recipeDetailProvider(recipe.id)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( action == _ShareAction.unshare ? context.l10n.recipeDetailSharingRemoved(trimmed) : context.l10n.recipeDetailSharedWith(trimmed), ), ), ); } on ApiException catch (e) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(mapErrorToUserMessage(e, context))), ); } } } class _ImagePlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { return Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Center( child: Icon( Icons.restaurant, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.4), ), ), ); } } class _DeleteButton extends ConsumerWidget { final Recipe recipe; const _DeleteButton({required this.recipe}); @override Widget build(BuildContext context, WidgetRef ref) { return IconButton( tooltip: context.l10n.deleteTooltip, 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: Text(context.l10n.recipeDetailDeleteTitle), content: Text(context.l10n.recipeDetailDeleteContent(recipe.title)), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.cancelAction), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error), onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.deleteAction), ), ], ), ); if (confirmed != true || !context.mounted) return; try { final token = ref.read(authStateProvider).maybeWhen( data: (t) => t, orElse: () => null, ) ?? 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}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Titel visas som overlay på bilden — inte upprepas här 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} ${context.l10n.recipeDetailServings}', style: theme.textTheme.bodySmall), ], ), ], if (recipe.ingredients.isNotEmpty) ...[ const SizedBox(height: 24), Text(context.l10n.recipeDetailIngredients, style: theme.textTheme.titleMedium), const SizedBox(height: 12), ...recipe.ingredients.map((ing) { final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity); final measureParts = [ if (qtyStr.isNotEmpty) qtyStr, if (ing.unit.isNotEmpty) ing.unit, ]; final measure = measureParts.join(' '); return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (measure.isNotEmpty) ...[ Container( width: 72, padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(6), ), child: Text( measure, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 10), ] else const SizedBox(width: 82), Expanded( child: Text( ing.note != null ? '${ing.productName} (${ing.note})' : ing.productName, style: theme.textTheme.bodyMedium, ), ), ], ), ); }), ], if (recipe.instructions != null && recipe.instructions!.isNotEmpty) ...[ const SizedBox(height: 32), Text(context.l10n.recipeDetailInstructions, style: theme.textTheme.titleMedium), const SizedBox(height: 16), ..._buildSteps(recipe.instructions!, theme), ], _InventoryPreviewSection(recipeId: recipe.id), const SizedBox(height: 40), ], ), ); } List _buildSteps(String instructions, ThemeData theme) { final steps = instructions .split(RegExp(r'\n{2,}')) .map((s) => s.trim()) .where((s) => s.isNotEmpty) .toList(); return steps.asMap().entries.map((entry) { final index = entry.key + 1; final text = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 20), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 32, height: 32, alignment: Alignment.center, decoration: BoxDecoration( color: theme.colorScheme.primary, shape: BoxShape.circle, ), child: Text( '$index', style: TextStyle( color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold, fontSize: 14, ), ), ), const SizedBox(width: 12), Expanded( child: Padding( padding: const EdgeInsets.only(top: 6), child: Text( text, style: theme.textTheme.bodyMedium?.copyWith(height: 1.6), ), ), ), ], ), ); }).toList(); } } 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), }; final requiredStr = '${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim(); final availableStr = '${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim(); final subtitle = switch (ingredient.status) { IngredientStatus.enough => 'Tillgängligt: $availableStr', IngredientStatus.missing => ingredient.availableQuantity > 0 ? 'Saknar ${_fmtQty(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), ), ], ), ), ], ), ); } }