Add Swedish localization for various app actions and inventory management strings

This commit is contained in:
Nils-Johan Gynther
2026-05-02 15:42:00 +02:00
parent 4e81f56225
commit 2563738fcf
24 changed files with 4510 additions and 366 deletions
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/l10n/l10n.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
import '../domain/parsed_recipe.dart';
@@ -84,7 +85,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
Future<void> _parseMarkdown() async {
final markdown = _markdownCtrl.text.trim();
if (markdown.isEmpty) {
setState(() => _parseError = 'Klistra in eller skriv ett recept i Markdown-format.');
setState(() => _parseError = context.l10n.recipeCreateMarkdownHint);
return;
}
setState(() {
@@ -117,7 +118,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
Future<void> _save() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty) {
setState(() => _saveError = 'Receptnamnet får inte vara tomt.');
setState(() => _saveError = context.l10n.recipeCreateNameRequired);
return;
}
@@ -175,7 +176,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
return Scaffold(
appBar: AppBar(
title:
Text(_step == _Step.input ? 'Nytt recept' : 'Granska ingredienser'),
Text(_step == _Step.input ? context.l10n.recipeCreateTitle : context.l10n.recipeCreateReviewIngredients),
leading: _step == _Step.review
? IconButton(
icon: const Icon(Icons.arrow_back),
@@ -202,9 +203,9 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
hintText: '# Receptnamn\n\n## Ingredienser\n- 500 g köttfärs\n- 1 st lök\n\n## Tillvägagångssätt\nStek löken...',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: context.l10n.recipeCreateMarkdownPlaceholder,
border: const OutlineInputBorder(),
alignLabelWithHint: true,
),
),
@@ -230,7 +231,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Granska ingredienser'),
: Text(context.l10n.recipeCreateReviewIngredients),
),
),
),
@@ -248,22 +249,22 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Receptnamn'),
decoration: InputDecoration(labelText: context.l10n.recipeEditNameLabel),
),
const SizedBox(height: 12),
TextField(
controller: _servingsCtrl,
decoration: const InputDecoration(
labelText: 'Antal portioner (valfritt)'),
decoration: InputDecoration(
labelText: context.l10n.recipeEditServingsLabel),
keyboardType: TextInputType.number,
),
if (parsed.ingredients.isNotEmpty) ...[
const SizedBox(height: 20),
Text('Ingredienser',
Text(context.l10n.recipeEditIngredientsLabel,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Bocka av ingredienser att inkludera och välj rätt produkt.',
context.l10n.recipeCreateIngredientsHint,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
@@ -295,7 +296,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara recept'),
: Text(context.l10n.recipeCreateSaveAction),
),
),
),
@@ -321,7 +322,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
title: Text(label),
subtitle: ing.suggestions.isEmpty
? Text(
'Ingen produkt hittades — ingrediensen hoppas över.',
context.l10n.recipeCreateNoProductFound,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12),
@@ -5,6 +5,7 @@ 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';
@@ -38,31 +39,31 @@ class RecipeDetailScreen extends ConsumerWidget {
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/recipes'),
tooltip: 'Tillbaka till receptlistan',
tooltip: context.l10n.recipeDetailBackToList,
),
actions: recipe == null
? []
: [
if (isOwner)
IconButton(
tooltip: recipe.isPublic ? 'Gör privat' : 'Gör publik',
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: 'Dela med användare',
tooltip: context.l10n.recipeDetailShareWithUser,
icon: const Icon(Icons.person_add_alt_1_outlined),
onPressed: () => _shareRecipe(context, ref, recipe),
),
IconButton(
tooltip: 'Redigera',
tooltip: context.l10n.editTooltip,
icon: const Icon(Icons.edit_outlined),
onPressed: () =>
context.push('/recipes/$recipeId/edit'),
),
IconButton(
tooltip: 'Gå till inventarie',
tooltip: context.l10n.recipeDetailGoToInventory,
icon: const Icon(Icons.inventory_2_outlined),
onPressed: () => context.go('/inventory'),
),
@@ -70,7 +71,7 @@ class RecipeDetailScreen extends ConsumerWidget {
],
),
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
loading: () => LoadingStateView(label: context.l10n.recipeDetailLoading),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)),
@@ -184,8 +185,8 @@ class RecipeDetailScreen extends ConsumerWidget {
SnackBar(
content: Text(
!recipe.isPublic
? 'Receptet är nu publikt.'
: 'Receptet är nu privat.',
? context.l10n.recipeDetailNowPublic
: context.l10n.recipeDetailNowPrivate,
),
),
);
@@ -206,13 +207,13 @@ class RecipeDetailScreen extends ConsumerWidget {
final result = await showDialog<(_ShareAction, String)>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Dela recept'),
title: Text(context.l10n.recipeDetailShareTitle),
content: TextField(
controller: ctrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Användarnamn',
hintText: 't.ex. anna',
decoration: InputDecoration(
labelText: context.l10n.recipeDetailUsernameLabel,
hintText: context.l10n.recipeDetailUsernameHint,
),
onSubmitted: (_) => Navigator.pop(
context,
@@ -222,21 +223,21 @@ class RecipeDetailScreen extends ConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
TextButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.unshare, ctrl.text.trim()),
),
child: const Text('Ta bort delning'),
child: Text(context.l10n.recipeDetailRemoveShare),
),
FilledButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.share, ctrl.text.trim()),
),
child: const Text('Dela'),
child: Text(context.l10n.recipeDetailShareAction),
),
],
),
@@ -272,8 +273,8 @@ class RecipeDetailScreen extends ConsumerWidget {
SnackBar(
content: Text(
action == _ShareAction.unshare
? 'Delning borttagen för $trimmed.'
: 'Receptet delades med $trimmed.',
? context.l10n.recipeDetailSharingRemoved(trimmed)
: context.l10n.recipeDetailSharedWith(trimmed),
),
),
);
@@ -310,7 +311,7 @@ class _DeleteButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: 'Ta bort',
tooltip: context.l10n.deleteTooltip,
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDelete(context, ref),
);
@@ -320,19 +321,18 @@ class _DeleteButton extends ConsumerWidget {
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.'),
title: Text(context.l10n.recipeDetailDeleteTitle),
content: Text(context.l10n.recipeDetailDeleteContent(recipe.title)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error),
onPressed: () => Navigator.pop(context, true),
child: const Text('Ta bort'),
child: Text(context.l10n.deleteAction),
),
],
),
@@ -385,14 +385,14 @@ class _RecipeBody extends StatelessWidget {
children: [
const Icon(Icons.people_outline, size: 16),
const SizedBox(width: 4),
Text('${recipe.servings} portioner',
Text('${recipe.servings} ${context.l10n.recipeDetailServings}',
style: theme.textTheme.bodySmall),
],
),
],
if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Ingredienser', style: theme.textTheme.titleMedium),
Text(context.l10n.recipeDetailIngredients, style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
...recipe.ingredients.map((ing) {
final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity);
@@ -443,7 +443,7 @@ class _RecipeBody extends StatelessWidget {
if (recipe.instructions != null &&
recipe.instructions!.isNotEmpty) ...[
const SizedBox(height: 32),
Text('Tillvägagångssätt', style: theme.textTheme.titleMedium),
Text(context.l10n.recipeDetailInstructions, style: theme.textTheme.titleMedium),
const SizedBox(height: 16),
..._buildSteps(recipe.instructions!, theme),
],
@@ -7,6 +7,7 @@ import '../../../core/api/api_exception.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
@@ -152,20 +153,20 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
String? _validateIngredients() {
if (_ingredients.isEmpty) {
return 'Minst en ingrediens krävs.';
return context.l10n.recipeEditMinIngredients;
}
for (final ingredient in _ingredients) {
if (ingredient.productId == null) {
return 'Välj produkt för alla ingredienser.';
return context.l10n.recipeEditSelectProduct;
}
final quantity = double.tryParse(
ingredient.quantityCtrl.text.trim().replaceAll(',', '.'),
);
if (quantity == null || quantity < 0) {
return 'Ange giltig mängd för alla ingredienser.';
return context.l10n.recipeEditValidQuantity;
}
if (ingredient.unit.trim().isEmpty) {
return 'Välj enhet för alla ingredienser.';
return context.l10n.recipeEditSelectUnit;
}
}
return null;
@@ -237,7 +238,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Redigera recept'),
title: Text(context.l10n.recipeEditTitle),
actions: [
if (_initialized)
TextButton(
@@ -248,12 +249,12 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara'),
: Text(context.l10n.saveAction),
),
],
),
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
loading: () => LoadingStateView(label: context.l10n.recipeDetailLoading),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)),
@@ -276,27 +277,27 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
children: [
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Receptnamn'),
decoration: InputDecoration(labelText: context.l10n.recipeEditNameLabel),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Ange ett receptnamn.' : null,
(v == null || v.trim().isEmpty) ? context.l10n.recipeEditNameRequired : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descCtrl,
decoration:
const InputDecoration(labelText: 'Beskrivning (valfritt)'),
InputDecoration(labelText: context.l10n.recipeEditDescriptionLabel),
maxLines: 3,
),
const SizedBox(height: 16),
TextFormField(
controller: _servingsCtrl,
decoration:
const InputDecoration(labelText: 'Antal portioner (valfritt)'),
InputDecoration(labelText: context.l10n.recipeEditServingsLabel),
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (int.tryParse(v.trim()) == null) {
return 'Ange ett heltal.';
return context.l10n.recipeEditServingsInvalid;
}
return null;
},
@@ -305,7 +306,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
TextFormField(
controller: _instructionsCtrl,
decoration:
const InputDecoration(labelText: 'Tillvägagångssätt (valfritt)'),
InputDecoration(labelText: context.l10n.recipeEditInstructionsLabel),
maxLines: 10,
textAlignVertical: TextAlignVertical.top,
),
@@ -314,20 +315,20 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
children: [
Expanded(
child: Text(
'Ingredienser',
context.l10n.recipeEditIngredientsLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
OutlinedButton.icon(
onPressed: _isSaving ? null : _addIngredient,
icon: const Icon(Icons.add),
label: const Text('Lägg till'),
label: Text(context.l10n.addAction),
),
],
),
const SizedBox(height: 8),
Text(
'Välj produkt, mängd och enhet för varje ingrediens.',
context.l10n.recipeEditIngredientsHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
@@ -338,10 +339,10 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
child: LinearProgressIndicator(),
),
if (_ingredients.isEmpty)
const Card(
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Inga ingredienser tillagda än.'),
padding: const EdgeInsets.all(16),
child: Text(context.l10n.recipeEditNoIngredients),
),
),
...List.generate(
@@ -366,7 +367,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara ändringar'),
: Text(context.l10n.recipeEditSaveChanges),
),
const SizedBox(height: 40),
],
@@ -387,14 +388,14 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
children: [
Expanded(
child: Text(
'Ingrediens ${index + 1}',
'${context.l10n.recipeEditIngredientPrefix}${index + 1}',
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
onPressed: _isSaving ? null : () => _removeIngredient(index),
icon: const Icon(Icons.delete_outline),
tooltip: 'Ta bort ingrediens',
tooltip: context.l10n.recipeEditRemoveIngredient,
),
],
),
@@ -440,19 +441,19 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
Expanded(
child: TextFormField(
controller: ingredient.quantityCtrl,
decoration: const InputDecoration(
labelText: 'Mängd *',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.quantityLabel,
border: const OutlineInputBorder(),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Ange mängd';
return context.l10n.quantityHint;
}
if (double.tryParse(value.trim().replaceAll(',', '.')) ==
null) {
return 'Ogiltigt tal';
return context.l10n.invalidNumber;
}
return null;
},
@@ -463,9 +464,9 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
child: DropdownButtonFormField<String>(
initialValue: ingredient.unit.trim().isEmpty ? null : ingredient.unit,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Enhet *',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.unitLabel,
border: const OutlineInputBorder(),
),
items: unitOptions
.map(
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../data/recipe_providers.dart';
import '../data/recipes_grid_provider.dart';
@@ -22,16 +23,16 @@ class RecipesScreen extends ConsumerWidget {
return Stack(
children: [
recipesAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
loading: () => LoadingStateView(label: context.l10n.recipesLoading),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipesProvider),
),
data: (recipes) {
if (recipes.isEmpty) {
return const EmptyStateView(
title: 'Inga recept hittades',
description: 'Lägg till ett recept för att komma igång.',
return EmptyStateView(
title: context.l10n.recipesEmpty,
description: context.l10n.recipesEmptyDescription,
);
}
@@ -95,7 +96,7 @@ class RecipesScreen extends ConsumerWidget {
right: 16,
bottom: 16,
child: FloatingActionButton(
tooltip: 'Nytt recept',
tooltip: context.l10n.recipesNewTooltip,
onPressed: () => context.push('/recipes/create'),
child: const Icon(Icons.add),
),