Files
recipe-app/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart
T
Nils-Johan Gynther 2e117718a7 feat(localization): Implement Swedish localization and error messages
- Added localization support for Swedish and English languages.
- Integrated localized strings for user messages in the API error mapper.
- Updated UI components to use localized strings for labels and messages.
- Ensured all error messages are context-aware and utilize the localization framework.
- Created regression test to prevent common ASCII fallbacks in Swedish UI text.
2026-04-22 19:16:23 +02:00

191 lines
6.4 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';
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, 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<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: [
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),
],
),
);
}
}