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.
This commit is contained in:
Nils-Johan Gynther
2026-04-22 19:16:23 +02:00
parent 37472f6c43
commit 2e117718a7
26 changed files with 315 additions and 96 deletions
@@ -14,7 +14,7 @@ class RecipeRepository {
final data = await _api.getJson(RecipeApiPaths.list, token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return data
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
@@ -23,7 +23,7 @@ class RecipeRepository {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte hamta recept.');
type: ApiErrorType.network, message: 'Kunde inte hämta recept.');
}
}
@@ -32,14 +32,14 @@ class RecipeRepository {
final data = await _api.getJson(RecipeApiPaths.detail(id), token: token);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return Recipe.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network, message: 'Kunde inte hamta recept.');
type: ApiErrorType.network, message: 'Kunde inte hämta recept.');
}
}
@@ -50,7 +50,7 @@ class RecipeRepository {
await _api.postJson(RecipeApiPaths.list, body: body, token: token);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return Recipe.fromJson(data);
} on ApiException {
@@ -71,7 +71,7 @@ class RecipeRepository {
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return Recipe.fromJson(data);
} on ApiException {
@@ -103,7 +103,7 @@ class RecipeRepository {
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return ParsedRecipe.fromJson(data);
} on ApiException {
@@ -92,7 +92,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
return;
}
setState(() {
_parseError = mapErrorToUserMessage(e);
_parseError = mapErrorToUserMessage(e, context);
_isParsing = false;
});
}
@@ -146,7 +146,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
return;
}
setState(() {
_saveError = mapErrorToUserMessage(e);
_saveError = mapErrorToUserMessage(e, context);
_isSaving = false;
});
}
@@ -36,7 +36,7 @@ class RecipeDetailScreen extends ConsumerWidget {
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error),
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)),
),
data: (recipe) => _RecipeBody(recipe: recipe),
@@ -92,7 +92,7 @@ class _DeleteButton extends ConsumerWidget {
} on ApiException catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e))),
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
);
}
}
@@ -152,20 +152,20 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
String? _validateIngredients() {
if (_ingredients.isEmpty) {
return 'Minst en ingrediens kravs.';
return 'Minst en ingrediens krävs.';
}
for (final ingredient in _ingredients) {
if (ingredient.productId == null) {
return 'Valj produkt for alla ingredienser.';
return 'Välj produkt för alla ingredienser.';
}
final quantity = double.tryParse(
ingredient.quantityCtrl.text.trim().replaceAll(',', '.'),
);
if (quantity == null || quantity < 0) {
return 'Ange giltig mangd for alla ingredienser.';
return 'Ange giltig mängd för alla ingredienser.';
}
if (ingredient.unit.trim().isEmpty) {
return 'Valj enhet for alla ingredienser.';
return 'Välj enhet för alla ingredienser.';
}
}
return null;
@@ -225,7 +225,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
return;
}
setState(() {
_saveError = mapErrorToUserMessage(e);
_saveError = mapErrorToUserMessage(e, context);
_isSaving = false;
});
}
@@ -255,7 +255,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error),
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)),
),
data: (recipe) {
@@ -321,13 +321,13 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
OutlinedButton.icon(
onPressed: _isSaving ? null : _addIngredient,
icon: const Icon(Icons.add),
label: const Text('Lagg till'),
label: const Text('Lägg till'),
),
],
),
const SizedBox(height: 8),
Text(
'Valj produkt, mangd och enhet for varje ingrediens.',
'Välj produkt, mängd och enhet för varje ingrediens.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
@@ -341,7 +341,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Inga ingredienser tillagda an.'),
child: Text('Inga ingredienser tillagda än.'),
),
),
...List.generate(
@@ -441,14 +441,14 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
child: TextFormField(
controller: ingredient.quantityCtrl,
decoration: const InputDecoration(
labelText: 'Mangd *',
labelText: 'Mängd *',
border: OutlineInputBorder(),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Ange mangd';
return 'Ange mängd';
}
if (double.tryParse(value.trim().replaceAll(',', '.')) ==
null) {
@@ -16,7 +16,7 @@ class RecipesScreen extends ConsumerWidget {
body: recipesAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error),
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipesProvider),
),
data: (recipes) {