Files
recipe-app/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart
T

700 lines
24 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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) =>
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
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<void> _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<void> _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<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
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<Widget> _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),
),
],
),
),
],
),
);
}
}