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:
@@ -0,0 +1,3 @@
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
@@ -1,21 +1,23 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
String mapErrorToUserMessage(Object error) {
|
||||
String mapErrorToUserMessage(Object error, BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
if (error is ApiException) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.unauthorized:
|
||||
return 'Din session har gatt ut. Logga in igen.';
|
||||
return l10n.sessionExpiredError;
|
||||
case ApiErrorType.forbidden:
|
||||
return 'Du saknar behorighet for denna funktion.';
|
||||
return l10n.forbiddenError;
|
||||
case ApiErrorType.server:
|
||||
return 'Serverfel uppstod. Forsok igen om en stund.';
|
||||
return l10n.serverError;
|
||||
case ApiErrorType.network:
|
||||
return 'Natverksfel. Kontrollera anslutningen och forsok igen.';
|
||||
return l10n.networkError;
|
||||
case ApiErrorType.unknown:
|
||||
return error.message.isNotEmpty
|
||||
? error.message
|
||||
: 'Ett ovantat fel uppstod.';
|
||||
return error.message.isNotEmpty ? error.message : l10n.unexpectedError;
|
||||
}
|
||||
}
|
||||
return 'Ett ovantat fel uppstod.';
|
||||
return l10n.unexpectedError;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
extension AppLocalizationsX on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this)!;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class LoadingStateView extends StatelessWidget {
|
||||
final String? label;
|
||||
|
||||
@@ -83,7 +85,7 @@ class ErrorStateView extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Forsok igen'),
|
||||
child: Text(context.l10n.retryAction),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ class AuthRepository {
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw const ApiException(
|
||||
type: ApiErrorType.unknown,
|
||||
message: 'Ogiltigt svar fran servern.',
|
||||
message: 'Ogiltigt svar från servern.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class AuthRepository {
|
||||
} catch (_) {
|
||||
throw const ApiException(
|
||||
type: ApiErrorType.network,
|
||||
message: 'Kunde inte na servern.',
|
||||
message: 'Kunde inte nå servern.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '../data/auth_providers.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
@@ -41,9 +42,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
final l10n = context.l10n;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Logga in')),
|
||||
appBar: AppBar(title: Text(l10n.loginTitle)),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -57,8 +59,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _usernameCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Användarnamn'),
|
||||
decoration: InputDecoration(labelText: l10n.usernameLabel),
|
||||
textInputAction: TextInputAction.next,
|
||||
autofocus: true,
|
||||
enabled: !isLoading,
|
||||
@@ -66,7 +67,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
FocusScope.of(context).requestFocus(_passwordFocus),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Ange ditt användarnamn.';
|
||||
return l10n.usernameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -75,14 +76,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
TextFormField(
|
||||
controller: _passwordCtrl,
|
||||
focusNode: _passwordFocus,
|
||||
decoration: const InputDecoration(labelText: 'Lösenord'),
|
||||
decoration: InputDecoration(labelText: l10n.passwordLabel),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
enabled: !isLoading,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Ange ditt lösenord.';
|
||||
return l10n.passwordRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -93,13 +94,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Logga in'),
|
||||
child: Text(l10n.loginAction),
|
||||
),
|
||||
if (authState is AsyncError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
mapErrorToUserMessage(authState.error!),
|
||||
mapErrorToUserMessage(authState.error!, context),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
|
||||
@@ -51,7 +51,7 @@ class _ConsumeInventoryScreenState
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e))));
|
||||
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e, context))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
@@ -78,7 +78,7 @@ class _ConsumeInventoryScreenState
|
||||
children: [
|
||||
if (itemAsync.hasValue) ...[
|
||||
Text(
|
||||
'Tillgangligt: ${itemAsync.value!.quantity} ${itemAsync.value!.unit}',
|
||||
'Tillgängligt: ${itemAsync.value!.quantity} ${itemAsync.value!.unit}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -86,7 +86,7 @@ class _ConsumeInventoryScreenState
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Mangd att konsumera *',
|
||||
labelText: 'Mängd att konsumera *',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: itemAsync.maybeWhen(
|
||||
data: (item) => item.unit,
|
||||
@@ -98,7 +98,7 @@ class _ConsumeInventoryScreenState
|
||||
autofocus: true,
|
||||
enabled: !_saving,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Ange mangd';
|
||||
if (v == null || v.trim().isEmpty) return 'Ange mängd';
|
||||
final parsed =
|
||||
double.tryParse(v.trim().replaceAll(',', '.'));
|
||||
if (parsed == null || parsed <= 0) {
|
||||
|
||||
@@ -26,7 +26,7 @@ class ConsumptionHistoryScreen extends ConsumerWidget {
|
||||
body: historyAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar historik...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(e),
|
||||
message: mapErrorToUserMessage(e, context),
|
||||
onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)),
|
||||
),
|
||||
data: (history) {
|
||||
|
||||
@@ -91,7 +91,7 @@ class _CreateInventoryScreenState
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_selectedProductId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Valj en produkt ur listan.')),
|
||||
const SnackBar(content: Text('Välj en produkt ur listan.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ class _CreateInventoryScreenState
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e))));
|
||||
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e, context))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
@@ -131,7 +131,7 @@ class _CreateInventoryScreenState
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? dt) {
|
||||
if (dt == null) return 'Valj datum';
|
||||
if (dt == null) return 'Välj datum';
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ class _CreateInventoryScreenState
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Lagg till inventariepost')),
|
||||
appBar: AppBar(title: const Text('Lägg till inventariepost')),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
@@ -183,7 +183,7 @@ class _CreateInventoryScreenState
|
||||
onChanged: (_loadingProducts || _saving)
|
||||
? null
|
||||
: (value) => setState(() => _selectedProductId = value),
|
||||
validator: (value) => value == null ? 'Valj produkt' : null,
|
||||
validator: (value) => value == null ? 'Välj produkt' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
@@ -194,14 +194,14 @@ class _CreateInventoryScreenState
|
||||
child: TextFormField(
|
||||
controller: _quantityController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mangd *',
|
||||
labelText: 'Mängd *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
enabled: !_saving,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Ange mangd';
|
||||
if (v == null || v.trim().isEmpty) return 'Ange mängd';
|
||||
if (double.tryParse(v.trim().replaceAll(',', '.')) ==
|
||||
null) {
|
||||
return 'Ogiltigt tal';
|
||||
@@ -267,7 +267,7 @@ class _CreateInventoryScreenState
|
||||
TextFormField(
|
||||
controller: _brandController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Marke (valfritt)',
|
||||
labelText: 'Märke (valfritt)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
enabled: !_saving,
|
||||
@@ -280,7 +280,7 @@ class _CreateInventoryScreenState
|
||||
onPressed: _saving ? null : () => _pickDate(false),
|
||||
icon: const Icon(Icons.calendar_today, size: 16),
|
||||
label: Text(
|
||||
'Inkop: ${_formatDate(_purchaseDate)}',
|
||||
'Inköp: ${_formatDate(_purchaseDate)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -291,7 +291,7 @@ class _CreateInventoryScreenState
|
||||
onPressed: _saving ? null : () => _pickDate(true),
|
||||
icon: const Icon(Icons.event_available, size: 16),
|
||||
label: Text(
|
||||
'Bast fore: ${_formatDate(_bestBeforeDate)}',
|
||||
'Bäst före: ${_formatDate(_bestBeforeDate)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -299,7 +299,7 @@ class _CreateInventoryScreenState
|
||||
],
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Oppnad'),
|
||||
title: const Text('Öppnad'),
|
||||
value: _opened,
|
||||
onChanged:
|
||||
_saving ? null : (v) => setState(() => _opened = v ?? false),
|
||||
|
||||
@@ -36,7 +36,7 @@ class InventoryDetailScreen extends ConsumerWidget {
|
||||
body: itemAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(e),
|
||||
message: mapErrorToUserMessage(e, context),
|
||||
onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)),
|
||||
),
|
||||
data: (item) => ListView(
|
||||
@@ -127,7 +127,7 @@ class _DeleteButton extends ConsumerWidget {
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(e))),
|
||||
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e))));
|
||||
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e, context))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
@@ -117,7 +117,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? dt) {
|
||||
if (dt == null) return 'Valj datum';
|
||||
if (dt == null) return 'Välj datum';
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
body: itemAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(e),
|
||||
message: mapErrorToUserMessage(e, context),
|
||||
onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)),
|
||||
),
|
||||
data: (item) {
|
||||
@@ -153,7 +153,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
child: TextFormField(
|
||||
controller: _quantityController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mangd *',
|
||||
labelText: 'Mängd *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
@@ -161,7 +161,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
enabled: !_saving,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'Ange mangd';
|
||||
return 'Ange mängd';
|
||||
}
|
||||
if (double.tryParse(
|
||||
v.trim().replaceAll(',', '.')) ==
|
||||
@@ -229,7 +229,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
TextFormField(
|
||||
controller: _brandController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Marke',
|
||||
labelText: 'Märke',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
enabled: !_saving,
|
||||
@@ -242,7 +242,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
onPressed: _saving ? null : () => _pickDate(false),
|
||||
icon: const Icon(Icons.calendar_today, size: 16),
|
||||
label: Text(
|
||||
'Inkop: ${_formatDate(_purchaseDate)}',
|
||||
'Inköp: ${_formatDate(_purchaseDate)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -253,7 +253,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
onPressed: _saving ? null : () => _pickDate(true),
|
||||
icon: const Icon(Icons.event_available, size: 16),
|
||||
label: Text(
|
||||
'Bast fore: ${_formatDate(_bestBeforeDate)}',
|
||||
'Bäst före: ${_formatDate(_bestBeforeDate)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -261,7 +261,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
],
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Oppnad'),
|
||||
title: const Text('Öppnad'),
|
||||
value: _opened,
|
||||
onChanged: _saving
|
||||
? null
|
||||
|
||||
@@ -14,9 +14,9 @@ class InventoryScreen extends ConsumerWidget {
|
||||
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
|
||||
static const _sortOptions = <({String value, String label})>[
|
||||
(value: '', label: 'Senast tillagda'),
|
||||
(value: 'nameAsc', label: 'Namn A-O'),
|
||||
(value: 'bestBeforeAsc', label: 'Bast fore stigande'),
|
||||
(value: 'bestBeforeDesc', label: 'Bast fore fallande'),
|
||||
(value: 'nameAsc', label: 'Namn A-Ö'),
|
||||
(value: 'bestBeforeAsc', label: 'Bäst före stigande'),
|
||||
(value: 'bestBeforeDesc', label: 'Bäst före fallande'),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -28,7 +28,7 @@ class InventoryScreen extends ConsumerWidget {
|
||||
return inventoryAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar inventarie...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: mapErrorToUserMessage(e),
|
||||
message: mapErrorToUserMessage(e, context),
|
||||
onRetry: () => ref.invalidate(inventoryProvider),
|
||||
),
|
||||
data: (items) {
|
||||
@@ -89,7 +89,7 @@ class InventoryScreen extends ConsumerWidget {
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
filterSection,
|
||||
const EmptyStateView(title: 'Inventariet ar tomt.'),
|
||||
const EmptyStateView(title: 'Inventariet är tomt.'),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
@@ -155,7 +155,7 @@ class _InventoryTile extends StatelessWidget {
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 4),
|
||||
child: Chip(
|
||||
label: Text('Oppnad'),
|
||||
label: Text('Öppnad'),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
@@ -232,7 +232,7 @@ class _DeleteInventoryButton extends ConsumerWidget {
|
||||
} catch (error) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error))),
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error, context))),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text('Lagg "${item.displayName}" i inventarie'),
|
||||
title: Text('Lägg "${item.displayName}" i inventarie'),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: Column(
|
||||
@@ -44,7 +44,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mangd',
|
||||
labelText: 'Mängd',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@@ -116,7 +116,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
double.tryParse(quantityController.text.trim().replaceAll(',', '.'));
|
||||
if (quantity == null || quantity <= 0) {
|
||||
setDialogState(() {
|
||||
formError = 'Ange en giltig mangd over 0.';
|
||||
formError = 'Ange en giltig mängd över 0.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
'location': selectedLocation,
|
||||
});
|
||||
},
|
||||
child: const Text('Lagg till'),
|
||||
child: const Text('Lägg till'),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -158,7 +158,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error))),
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error, context))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error))),
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error, context))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
@@ -215,7 +215,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error))),
|
||||
SnackBar(content: Text(mapErrorToUserMessage(error, context))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
return item.category!;
|
||||
}
|
||||
|
||||
return 'Ovrigt';
|
||||
return 'Övrigt';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -245,7 +245,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
if (pantryAsync.hasError || productsAsync.hasError) {
|
||||
final error = pantryAsync.error ?? productsAsync.error;
|
||||
return ErrorStateView(
|
||||
message: mapErrorToUserMessage(error ?? 'Okant fel'),
|
||||
message: mapErrorToUserMessage(error ?? 'Okänt fel', context),
|
||||
onRetry: () {
|
||||
ref.invalidate(pantryProvider);
|
||||
ref.invalidate(pantryProductsProvider);
|
||||
@@ -272,8 +272,8 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
}
|
||||
final categories = grouped.keys.toList()
|
||||
..sort((a, b) {
|
||||
if (a == 'Ovrigt') return 1;
|
||||
if (b == 'Ovrigt') return -1;
|
||||
if (a == 'Övrigt') return 1;
|
||||
if (b == 'Övrigt') return -1;
|
||||
return a.toLowerCase().compareTo(b.toLowerCase());
|
||||
});
|
||||
|
||||
@@ -281,7 +281,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'Produkter du alltid raknar med att ha hemma.',
|
||||
'Produkter du alltid räknar med att ha hemma.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -324,7 +324,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Lagg till'),
|
||||
: const Text('Lägg till'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -336,8 +336,8 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
const SizedBox(height: 12),
|
||||
if (pantryItems.isEmpty)
|
||||
const EmptyStateView(
|
||||
title: 'Baslagret ar tomt',
|
||||
description: 'Lagg till produkter ovan.',
|
||||
title: 'Baslagret är tomt',
|
||||
description: 'Lägg till produkter ovan.',
|
||||
)
|
||||
else
|
||||
...categories.map((category) {
|
||||
@@ -364,21 +364,21 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Tooltip(
|
||||
message: 'Konsumera (inte tillgangligt i baslager)',
|
||||
message: 'Konsumera (inte tillgängligt i baslager)',
|
||||
child: IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.remove_circle_outline),
|
||||
),
|
||||
),
|
||||
const Tooltip(
|
||||
message: 'Redigera (inte tillgangligt i baslager)',
|
||||
message: 'Redigera (inte tillgängligt i baslager)',
|
||||
child: IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.edit_outlined),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Lagg i inventarie',
|
||||
tooltip: 'Lägg i inventarie',
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
onPressed: () => _addToInventory(item),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appTitle": "Recipe App",
|
||||
"retryAction": "Retry",
|
||||
"loginTitle": "Sign in",
|
||||
"usernameLabel": "Username",
|
||||
"usernameRequired": "Enter your username.",
|
||||
"passwordLabel": "Password",
|
||||
"passwordRequired": "Enter your password.",
|
||||
"loginAction": "Sign in",
|
||||
"sessionExpiredError": "Your session has expired. Sign in again.",
|
||||
"forbiddenError": "You do not have permission to use this feature.",
|
||||
"serverError": "A server error occurred. Try again in a moment.",
|
||||
"networkError": "Network error. Check your connection and try again.",
|
||||
"unexpectedError": "An unexpected error occurred."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"@@locale": "sv",
|
||||
"appTitle": "Recipe App",
|
||||
"retryAction": "Försök igen",
|
||||
"loginTitle": "Logga in",
|
||||
"usernameLabel": "Användarnamn",
|
||||
"usernameRequired": "Ange ditt användarnamn.",
|
||||
"passwordLabel": "Lösenord",
|
||||
"passwordRequired": "Ange ditt lösenord.",
|
||||
"loginAction": "Logga in",
|
||||
"sessionExpiredError": "Din session har gått ut. Logga in igen.",
|
||||
"forbiddenError": "Du saknar behörighet för denna funktion.",
|
||||
"serverError": "Serverfel uppstod. Försök igen om en stund.",
|
||||
"networkError": "Nätverksfel. Kontrollera anslutningen och försök igen.",
|
||||
"unexpectedError": "Ett oväntat fel uppstod."
|
||||
}
|
||||
+12
-1
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'core/l10n/l10n.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
|
||||
void main() {
|
||||
@@ -13,11 +16,19 @@ class RecipeApp extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
return MaterialApp.router(
|
||||
title: 'Recipe App',
|
||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
|
||||
useMaterial3: true,
|
||||
),
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('sv'),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ dependencies:
|
||||
sdk: flutter
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
go_router: ^14.0.0
|
||||
riverpod: ^2.5.1
|
||||
flutter_riverpod: ^2.5.1
|
||||
http: ^1.2.1
|
||||
intl: any
|
||||
shared_preferences: ^2.2.3
|
||||
|
||||
dev_dependencies:
|
||||
@@ -25,3 +28,4 @@ dev_dependencies:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
generate: true
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('Flutter lib should not contain common ASCII fallbacks for Swedish UI text', () {
|
||||
const forbiddenSpellings = <String, String>{
|
||||
'Forsok': 'Försök',
|
||||
'gatt ut': 'gått ut',
|
||||
'behorighet': 'behörighet',
|
||||
'Natverksfel': 'Nätverksfel',
|
||||
'ovantat': 'oväntat',
|
||||
'Lagg': 'Lägg',
|
||||
'Valj': 'Välj',
|
||||
'Mangd': 'Mängd',
|
||||
'Oppnad': 'Öppnad',
|
||||
'Bast fore': 'Bäst före',
|
||||
'Inkop': 'Inköp',
|
||||
'Ovrigt': 'Övrigt',
|
||||
'Okant': 'Okänt',
|
||||
'hamta': 'hämta',
|
||||
'tillgangligt': 'tillgängligt',
|
||||
'raknar': 'räknar',
|
||||
};
|
||||
|
||||
final offenders = <String>[];
|
||||
final files = Directory('lib')
|
||||
.listSync(recursive: true)
|
||||
.whereType<File>()
|
||||
.where((file) => file.path.endsWith('.dart'));
|
||||
|
||||
for (final file in files) {
|
||||
final content = file.readAsStringSync();
|
||||
for (final entry in forbiddenSpellings.entries) {
|
||||
if (content.contains(entry.key)) {
|
||||
offenders.add('${file.path}: found "${entry.key}"; use "${entry.value}"');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
isEmpty,
|
||||
reason: offenders.isEmpty ? null : offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user