418 lines
14 KiB
Dart
418 lines
14 KiB
Dart
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'),
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: () => context.go('/recipes'),
|
||
tooltip: 'Tillbaka till receptlistan',
|
||
),
|
||
actions: recipeAsync.maybeWhen(data: (d) => d, orElse: () => null) == null
|
||
? []
|
||
: [
|
||
IconButton(
|
||
tooltip: 'Redigera',
|
||
icon: const Icon(Icons.edit_outlined),
|
||
onPressed: () =>
|
||
context.push('/recipes/$recipeId/edit'),
|
||
),
|
||
IconButton(
|
||
tooltip: 'Gå till inventarie',
|
||
icon: const Icon(Icons.inventory_2_outlined),
|
||
onPressed: () => context.go('/inventory'),
|
||
),
|
||
_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) => CustomScrollView(
|
||
physics: const BouncingScrollPhysics(),
|
||
slivers: [
|
||
SliverAppBar(
|
||
expandedHeight: MediaQuery.of(context).size.height * 2 / 3,
|
||
flexibleSpace: FlexibleSpaceBar(
|
||
background: recipe.imageUrl != null
|
||
? Image.network(
|
||
recipe.imageUrl!,
|
||
fit: BoxFit.cover,
|
||
)
|
||
: Container(color: Colors.grey[200]),
|
||
),
|
||
pinned: true,
|
||
floating: false,
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(20),
|
||
topRight: Radius.circular(20),
|
||
),
|
||
),
|
||
child: _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<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||
final confirmed = await showDialog<bool>(
|
||
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: [
|
||
// Bilden visas endast som bakgrund i detaljvyn
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|