feat: add recipe creation, editing, and detail screens; enhance recipe model with instructions and ingredients
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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';
|
||||
|
||||
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.valueOrNull?.title ?? 'Recept'),
|
||||
actions: recipeAsync.valueOrNull == 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),
|
||||
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<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))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user