From 2e117718a7e4403aebd1d25443c61393db47c339 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 19:16:23 +0200 Subject: [PATCH] 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. --- README.md | 29 +++++++++++ TEKNISK_BESKRIVNING.md | 48 +++++++++++++++++++ flutter/l10n.yaml | 3 ++ flutter/lib/core/api/api_error_mapper.dart | 20 ++++---- flutter/lib/core/l10n/l10n.dart | 6 +++ flutter/lib/core/ui/async_state_views.dart | 4 +- .../features/auth/data/auth_repository.dart | 4 +- .../auth/presentation/login_screen.dart | 17 +++---- .../consume_inventory_screen.dart | 8 ++-- .../consumption_history_screen.dart | 2 +- .../presentation/create_inventory_screen.dart | 22 ++++----- .../presentation/inventory_detail_screen.dart | 4 +- .../presentation/inventory_edit_screen.dart | 18 +++---- .../presentation/inventory_screen.dart | 14 +++--- .../pantry/presentation/pantry_screen.dart | 36 +++++++------- .../recipes/data/recipe_repository.dart | 14 +++--- .../presentation/create_recipe_screen.dart | 4 +- .../presentation/recipe_detail_screen.dart | 4 +- .../presentation/recipe_edit_screen.dart | 22 ++++----- .../recipes/presentation/recipes_screen.dart | 2 +- flutter/lib/l10n/app_en.arb | 16 +++++++ flutter/lib/l10n/app_sv.arb | 16 +++++++ flutter/lib/main.dart | 13 ++++- flutter/pubspec.yaml | 4 ++ .../core/swedish_strings_regression_test.dart | 47 ++++++++++++++++++ teknisk_beskrivning_flutter.md | 34 +++++++++++++ 26 files changed, 315 insertions(+), 96 deletions(-) create mode 100644 flutter/l10n.yaml create mode 100644 flutter/lib/core/l10n/l10n.dart create mode 100644 flutter/lib/l10n/app_en.arb create mode 100644 flutter/lib/l10n/app_sv.arb create mode 100644 flutter/test/core/swedish_strings_regression_test.dart diff --git a/README.md b/README.md index 3a85996e..1e2cdb41 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,35 @@ Backend API är tillgänglig på `http://localhost:8080` (eller via Caddy proxy) > Stacken använder lokala Docker-images, hälsokontroller och startordning mellan databasen, API:t och frontend för stabilare uppstarter och Portainer-deployer. +### Drift-snabbguide + +Använd dessa kommandon konsekvent beroende på vilken del av systemet som ska vara uppe. + +**Huvudappen (recept.gynther.se):** +```bash +docker compose build recipe-frontend recipe-api +docker compose up -d recipe-db recipe-api recipe-frontend +``` + +**Endast backend:** +```bash +docker compose build recipe-api +docker compose up -d recipe-db recipe-api +``` + +**Flutter-spåret (test.gynther.se):** +```bash +docker compose -f compose.yml -f compose.flutter.yml build recipe-flutter +docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter +``` + +**Viktigt:** `docker compose build ...` bygger bara image. Tjänsten startar först efter `docker compose up -d ...`. + +**Om orphan-varningen:** +- Varning om orphan-containers är väntad när huvudappen körs med bara `compose.yml` men Flutter tidigare startats med `compose.flutter.yml`. +- Det är normalt ofarligt. +- Kör inte `docker compose down --remove-orphans` om du inte avser att även stoppa Flutter-spåret. + ### Bygg bara backend eller frontend om behövligt ```bash diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index 6d6862cf..978f43ed 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -91,6 +91,33 @@ Efter push till Gitea: Alla tjänster (frontend, backend, databas) startas via Docker Compose enligt `compose.yml`. +### Rekommenderat kommandomonster + +For att undvika forvirring mellan huvudappen och Flutter-sparat bor dessa kommandon anvandas konsekvent: + +**Huvudappen (Next.js + API + DB):** +```bash +docker compose build recipe-frontend recipe-api +docker compose up -d recipe-db recipe-api recipe-frontend +``` + +**Enbart backend:** +```bash +docker compose build recipe-api +docker compose up -d recipe-db recipe-api +``` + +**Flutter-sparat (separat klient):** +```bash +docker compose -f compose.yml -f compose.flutter.yml build recipe-flutter +docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter +``` + +Tumregel: +- `compose.yml` styr huvudappen pa `recept.gynther.se`. +- `compose.yml` + `compose.flutter.yml` styr Flutter-klienten pa `test.gynther.se`. +- Att bygga en image startar inte containern; `docker compose up -d ...` kravs alltid efter build. + --- ## Container- och deployupplägg @@ -113,6 +140,27 @@ docker exec recipe-api npx prisma migrate dev --name migration_name docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;" ``` +### Orphan-containers vid blandade compose-filer + +Vid arbete med bade huvudappen och Flutter-sparet kan Docker Compose visa varningen om `orphan containers`, ofta for `recipe-flutter`. + +Detta betyder normalt bara att: +- en container startades med en annan compose-filskombination tidigare, +- och att den inte finns med i kommandot du kor just nu. + +Exempel: +- `docker compose up -d recipe-frontend` kanner inte till `recipe-flutter` eftersom den bara finns i `compose.flutter.yml`. +- `docker compose -f compose.yml -f compose.flutter.yml up -d recipe-flutter` kanner till Flutter-sparet. + +Varningen ar i sig inte ett fel och paverkar inte Prisma-migrationer eller databasens schema. + +Stada endast bort orphan-containers om du verkligen vill stoppa dem: +```bash +docker compose down --remove-orphans +``` + +Obs: detta kan stoppa `recipe-flutter`, som da maste startas igen med override-filen. + --- ## Caddy-konfiguration (reverse proxy) diff --git a/flutter/l10n.yaml b/flutter/l10n.yaml new file mode 100644 index 00000000..b7572565 --- /dev/null +++ b/flutter/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart \ No newline at end of file diff --git a/flutter/lib/core/api/api_error_mapper.dart b/flutter/lib/core/api/api_error_mapper.dart index 8f2eeea1..019df5f7 100644 --- a/flutter/lib/core/api/api_error_mapper.dart +++ b/flutter/lib/core/api/api_error_mapper.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; } diff --git a/flutter/lib/core/l10n/l10n.dart b/flutter/lib/core/l10n/l10n.dart new file mode 100644 index 00000000..525612ec --- /dev/null +++ b/flutter/lib/core/l10n/l10n.dart @@ -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)!; +} \ No newline at end of file diff --git a/flutter/lib/core/ui/async_state_views.dart b/flutter/lib/core/ui/async_state_views.dart index aecf634a..6c28a1ee 100644 --- a/flutter/lib/core/ui/async_state_views.dart +++ b/flutter/lib/core/ui/async_state_views.dart @@ -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), ), ], ], diff --git a/flutter/lib/features/auth/data/auth_repository.dart b/flutter/lib/features/auth/data/auth_repository.dart index d07feb42..3e374031 100644 --- a/flutter/lib/features/auth/data/auth_repository.dart +++ b/flutter/lib/features/auth/data/auth_repository.dart @@ -19,7 +19,7 @@ class AuthRepository { if (data is! Map) { 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.', ); } } diff --git a/flutter/lib/features/auth/presentation/login_screen.dart b/flutter/lib/features/auth/presentation/login_screen.dart index fb95cfee..fed6724f 100644 --- a/flutter/lib/features/auth/presentation/login_screen.dart +++ b/flutter/lib/features/auth/presentation/login_screen.dart @@ -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 { 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 { 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 { 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 { 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 { 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), diff --git a/flutter/lib/features/inventory/presentation/consume_inventory_screen.dart b/flutter/lib/features/inventory/presentation/consume_inventory_screen.dart index 90e343e9..76d31d4b 100644 --- a/flutter/lib/features/inventory/presentation/consume_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/consume_inventory_screen.dart @@ -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) { diff --git a/flutter/lib/features/inventory/presentation/consumption_history_screen.dart b/flutter/lib/features/inventory/presentation/consumption_history_screen.dart index aafee345..2f6daa8d 100644 --- a/flutter/lib/features/inventory/presentation/consumption_history_screen.dart +++ b/flutter/lib/features/inventory/presentation/consumption_history_screen.dart @@ -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) { diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index b2a4c217..615e9f1d 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -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), diff --git a/flutter/lib/features/inventory/presentation/inventory_detail_screen.dart b/flutter/lib/features/inventory/presentation/inventory_detail_screen.dart index 093045f2..8c0aac87 100644 --- a/flutter/lib/features/inventory/presentation/inventory_detail_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_detail_screen.dart @@ -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))), ); } } diff --git a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart index 4e402129..9cbfb8b0 100644 --- a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart @@ -109,7 +109,7 @@ class _InventoryEditScreenState extends ConsumerState { } 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 { } 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 { 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 { 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 { 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 { TextFormField( controller: _brandController, decoration: const InputDecoration( - labelText: 'Marke', + labelText: 'Märke', border: OutlineInputBorder(), ), enabled: !_saving, @@ -242,7 +242,7 @@ class _InventoryEditScreenState extends ConsumerState { 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 { 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 { ], ), CheckboxListTile( - title: const Text('Oppnad'), + title: const Text('Öppnad'), value: _opened, onChanged: _saving ? null diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index 0d9faa16..a3d421d3 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -14,9 +14,9 @@ class InventoryScreen extends ConsumerWidget { static const _locationOptions = ['', '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))), ); } }, diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index b1c6d9c7..edcbce08 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -33,7 +33,7 @@ class _PantryScreenState extends ConsumerState { 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 { keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( - labelText: 'Mangd', + labelText: 'Mängd', border: OutlineInputBorder(), ), ), @@ -116,7 +116,7 @@ class _PantryScreenState extends ConsumerState { 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 { 'location': selectedLocation, }); }, - child: const Text('Lagg till'), + child: const Text('Lägg till'), ), ], ); @@ -158,7 +158,7 @@ class _PantryScreenState extends ConsumerState { } 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 { } 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 { } 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 { return item.category!; } - return 'Ovrigt'; + return 'Övrigt'; } @override @@ -245,7 +245,7 @@ class _PantryScreenState extends ConsumerState { 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 { } 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 { 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 { width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Lagg till'), + : const Text('Lägg till'), ), ], ), @@ -336,8 +336,8 @@ class _PantryScreenState extends ConsumerState { 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 { 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), ), diff --git a/flutter/lib/features/recipes/data/recipe_repository.dart b/flutter/lib/features/recipes/data/recipe_repository.dart index 02555202..5f4c6f26 100644 --- a/flutter/lib/features/recipes/data/recipe_repository.dart +++ b/flutter/lib/features/recipes/data/recipe_repository.dart @@ -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)) @@ -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) { 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) { 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) { 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) { 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 { diff --git a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart index f32a5e3d..6d1bb07f 100644 --- a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart +++ b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart @@ -92,7 +92,7 @@ class _CreateRecipeScreenState extends ConsumerState { return; } setState(() { - _parseError = mapErrorToUserMessage(e); + _parseError = mapErrorToUserMessage(e, context); _isParsing = false; }); } @@ -146,7 +146,7 @@ class _CreateRecipeScreenState extends ConsumerState { return; } setState(() { - _saveError = mapErrorToUserMessage(e); + _saveError = mapErrorToUserMessage(e, context); _isSaving = false; }); } diff --git a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart index b8530f35..938dce41 100644 --- a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart @@ -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))), ); } } diff --git a/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart index 5ccc822a..6626e4fb 100644 --- a/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart @@ -152,20 +152,20 @@ class _RecipeEditScreenState extends ConsumerState { 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 { return; } setState(() { - _saveError = mapErrorToUserMessage(e); + _saveError = mapErrorToUserMessage(e, context); _isSaving = false; }); } @@ -255,7 +255,7 @@ class _RecipeEditScreenState extends ConsumerState { 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 { 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 { 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 { 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) { diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart index ca3c1cae..508a0160 100644 --- a/flutter/lib/features/recipes/presentation/recipes_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -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) { diff --git a/flutter/lib/l10n/app_en.arb b/flutter/lib/l10n/app_en.arb new file mode 100644 index 00000000..c2f0267e --- /dev/null +++ b/flutter/lib/l10n/app_en.arb @@ -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." +} \ No newline at end of file diff --git a/flutter/lib/l10n/app_sv.arb b/flutter/lib/l10n/app_sv.arb new file mode 100644 index 00000000..48f6a87e --- /dev/null +++ b/flutter/lib/l10n/app_sv.arb @@ -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." +} \ No newline at end of file diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 85314a89..964e678b 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -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, ); } diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index a6d57662..2b41623e 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -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 diff --git a/flutter/test/core/swedish_strings_regression_test.dart b/flutter/test/core/swedish_strings_regression_test.dart new file mode 100644 index 00000000..e32782e3 --- /dev/null +++ b/flutter/test/core/swedish_strings_regression_test.dart @@ -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 = { + '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 = []; + final files = Directory('lib') + .listSync(recursive: true) + .whereType() + .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'), + ); + }); +} \ No newline at end of file diff --git a/teknisk_beskrivning_flutter.md b/teknisk_beskrivning_flutter.md index 7a1aa8b8..b0116d57 100644 --- a/teknisk_beskrivning_flutter.md +++ b/teknisk_beskrivning_flutter.md @@ -90,6 +90,24 @@ Generell regel: NestJS-backenden anvander `PATCH` for partiella uppdateringar, i - Run command: - `docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter` +### Rekommenderat kommandomonster + +For att minska risken for fel startordning eller missforstand mellan huvudappen och Flutter-sparet: + +**Nar huvudappen ska vara uppe:** +- `docker compose up -d recipe-db recipe-api recipe-frontend` + +**Nar Flutter-klienten ska vara uppe:** +- `docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter` + +**Nar bade huvudappen och Flutter testas parallellt:** +1. Starta huvudappen med `compose.yml`. +2. Starta sedan Flutter med override-filen `compose.flutter.yml`. + +Viktigt: +- `docker compose build ...` bygger bara image. +- `docker compose up -d ...` kravs alltid for att containern faktiskt ska starta. + ### Viktiga verifieringar - Compose merge valid: - `docker compose -f compose.yml -f compose.flutter.yml config` @@ -108,6 +126,22 @@ Generell regel: NestJS-backenden anvander `PATCH` for partiella uppdateringar, i - Om `recipe-flutter` inte ar i `proxy` natverket blir det 502 fran extern Caddy. - Om browser visar gammal JS kan gamla API-URL:er leva kvar i cache/service worker. - Login med email fungerar inte om backend forvantar username. +- `recipe-flutter` kan stoppas av `docker compose down --remove-orphans` om kommandot kors utan override-filen och Flutter-sparat tidigare varit uppe. +- En orphan-varning for `recipe-flutter` ar normalt forvantad nar man kor huvudappen med bara `compose.yml`; det betyder inte att backend eller Prisma ar trasiga. + +### Orphan-varning i praktiken + +Om du ser en varning om orphan-containers under arbete med huvudappen betyder det oftast att `recipe-flutter` tidigare startats via: + +- `docker compose -f compose.yml -f compose.flutter.yml up -d recipe-flutter` + +och att du nu kor ett kommando som bara anvander `compose.yml`. + +Detta ar normalt och ofarligt sa lange du vet vilken stack du avser att kora. + +Om `test.gynther.se` slutar svara efter städning med `--remove-orphans`, starta om Flutter-sparet med: + +- `docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter` ## Nasta tekniska steg Fortsatt migrering enligt prioritering i [next_steps_flutter.md](next_steps_flutter.md):