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
+29
View File
@@ -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. > 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 ### Bygg bara backend eller frontend om behövligt
```bash ```bash
+48
View File
@@ -91,6 +91,33 @@ Efter push till Gitea:
Alla tjänster (frontend, backend, databas) startas via Docker Compose enligt `compose.yml`. 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 ## 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;" 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) ## Caddy-konfiguration (reverse proxy)
+3
View File
@@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
+11 -9
View File
@@ -1,21 +1,23 @@
import 'package:flutter/widgets.dart';
import '../l10n/l10n.dart';
import 'api_exception.dart'; import 'api_exception.dart';
String mapErrorToUserMessage(Object error) { String mapErrorToUserMessage(Object error, BuildContext context) {
final l10n = context.l10n;
if (error is ApiException) { if (error is ApiException) {
switch (error.type) { switch (error.type) {
case ApiErrorType.unauthorized: case ApiErrorType.unauthorized:
return 'Din session har gatt ut. Logga in igen.'; return l10n.sessionExpiredError;
case ApiErrorType.forbidden: case ApiErrorType.forbidden:
return 'Du saknar behorighet for denna funktion.'; return l10n.forbiddenError;
case ApiErrorType.server: case ApiErrorType.server:
return 'Serverfel uppstod. Forsok igen om en stund.'; return l10n.serverError;
case ApiErrorType.network: case ApiErrorType.network:
return 'Natverksfel. Kontrollera anslutningen och forsok igen.'; return l10n.networkError;
case ApiErrorType.unknown: case ApiErrorType.unknown:
return error.message.isNotEmpty return error.message.isNotEmpty ? error.message : l10n.unexpectedError;
? error.message
: 'Ett ovantat fel uppstod.';
} }
} }
return 'Ett ovantat fel uppstod.'; return l10n.unexpectedError;
} }
+6
View File
@@ -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)!;
}
+3 -1
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class LoadingStateView extends StatelessWidget { class LoadingStateView extends StatelessWidget {
final String? label; final String? label;
@@ -83,7 +85,7 @@ class ErrorStateView extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton( OutlinedButton(
onPressed: onRetry, onPressed: onRetry,
child: const Text('Forsok igen'), child: Text(context.l10n.retryAction),
), ),
], ],
], ],
@@ -19,7 +19,7 @@ class AuthRepository {
if (data is! Map<String, dynamic>) { if (data is! Map<String, dynamic>) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, type: ApiErrorType.unknown,
message: 'Ogiltigt svar fran servern.', message: 'Ogiltigt svar från servern.',
); );
} }
@@ -38,7 +38,7 @@ class AuthRepository {
} catch (_) { } catch (_) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.network, 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 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../data/auth_providers.dart'; import '../data/auth_providers.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
@@ -41,9 +42,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(authStateProvider); final authState = ref.watch(authStateProvider);
final isLoading = authState is AsyncLoading; final isLoading = authState is AsyncLoading;
final l10n = context.l10n;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Logga in')), appBar: AppBar(title: Text(l10n.loginTitle)),
body: Center( body: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -57,8 +59,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _usernameCtrl, controller: _usernameCtrl,
decoration: decoration: InputDecoration(labelText: l10n.usernameLabel),
const InputDecoration(labelText: 'Användarnamn'),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
autofocus: true, autofocus: true,
enabled: !isLoading, enabled: !isLoading,
@@ -66,7 +67,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
FocusScope.of(context).requestFocus(_passwordFocus), FocusScope.of(context).requestFocus(_passwordFocus),
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return 'Ange ditt användarnamn.'; return l10n.usernameRequired;
} }
return null; return null;
}, },
@@ -75,14 +76,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
TextFormField( TextFormField(
controller: _passwordCtrl, controller: _passwordCtrl,
focusNode: _passwordFocus, focusNode: _passwordFocus,
decoration: const InputDecoration(labelText: 'Lösenord'), decoration: InputDecoration(labelText: l10n.passwordLabel),
obscureText: true, obscureText: true,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
enabled: !isLoading, enabled: !isLoading,
onFieldSubmitted: (_) => _submit(), onFieldSubmitted: (_) => _submit(),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Ange ditt lösenord.'; return l10n.passwordRequired;
} }
return null; return null;
}, },
@@ -93,13 +94,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
else else
FilledButton( FilledButton(
onPressed: _submit, onPressed: _submit,
child: const Text('Logga in'), child: Text(l10n.loginAction),
), ),
if (authState is AsyncError) if (authState is AsyncError)
Padding( Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Text( child: Text(
mapErrorToUserMessage(authState.error!), mapErrorToUserMessage(authState.error!, context),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.error), color: Theme.of(context).colorScheme.error),
@@ -51,7 +51,7 @@ class _ConsumeInventoryScreenState
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e)))); .showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e, context))));
} }
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
@@ -78,7 +78,7 @@ class _ConsumeInventoryScreenState
children: [ children: [
if (itemAsync.hasValue) ...[ if (itemAsync.hasValue) ...[
Text( Text(
'Tillgangligt: ${itemAsync.value!.quantity} ${itemAsync.value!.unit}', 'Tillgängligt: ${itemAsync.value!.quantity} ${itemAsync.value!.unit}',
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -86,7 +86,7 @@ class _ConsumeInventoryScreenState
TextFormField( TextFormField(
controller: _amountController, controller: _amountController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Mangd att konsumera *', labelText: 'Mängd att konsumera *',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixText: itemAsync.maybeWhen( suffixText: itemAsync.maybeWhen(
data: (item) => item.unit, data: (item) => item.unit,
@@ -98,7 +98,7 @@ class _ConsumeInventoryScreenState
autofocus: true, autofocus: true,
enabled: !_saving, enabled: !_saving,
validator: (v) { validator: (v) {
if (v == null || v.trim().isEmpty) return 'Ange mangd'; if (v == null || v.trim().isEmpty) return 'Ange mängd';
final parsed = final parsed =
double.tryParse(v.trim().replaceAll(',', '.')); double.tryParse(v.trim().replaceAll(',', '.'));
if (parsed == null || parsed <= 0) { if (parsed == null || parsed <= 0) {
@@ -26,7 +26,7 @@ class ConsumptionHistoryScreen extends ConsumerWidget {
body: historyAsync.when( body: historyAsync.when(
loading: () => const LoadingStateView(label: 'Laddar historik...'), loading: () => const LoadingStateView(label: 'Laddar historik...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e), message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)), onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)),
), ),
data: (history) { data: (history) {
@@ -91,7 +91,7 @@ class _CreateInventoryScreenState
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
if (_selectedProductId == null) { if (_selectedProductId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Valj en produkt ur listan.')), const SnackBar(content: Text('Välj en produkt ur listan.')),
); );
return; return;
} }
@@ -123,7 +123,7 @@ class _CreateInventoryScreenState
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e)))); .showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e, context))));
} }
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
@@ -131,7 +131,7 @@ class _CreateInventoryScreenState
} }
String _formatDate(DateTime? dt) { 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')}'; return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} }
@@ -145,7 +145,7 @@ class _CreateInventoryScreenState
}); });
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Lagg till inventariepost')), appBar: AppBar(title: const Text('Lägg till inventariepost')),
body: Form( body: Form(
key: _formKey, key: _formKey,
child: ListView( child: ListView(
@@ -183,7 +183,7 @@ class _CreateInventoryScreenState
onChanged: (_loadingProducts || _saving) onChanged: (_loadingProducts || _saving)
? null ? null
: (value) => setState(() => _selectedProductId = value), : (value) => setState(() => _selectedProductId = value),
validator: (value) => value == null ? 'Valj produkt' : null, validator: (value) => value == null ? 'Välj produkt' : null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
@@ -194,14 +194,14 @@ class _CreateInventoryScreenState
child: TextFormField( child: TextFormField(
controller: _quantityController, controller: _quantityController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Mangd *', labelText: 'Mängd *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
decimal: true), decimal: true),
enabled: !_saving, enabled: !_saving,
validator: (v) { 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(',', '.')) == if (double.tryParse(v.trim().replaceAll(',', '.')) ==
null) { null) {
return 'Ogiltigt tal'; return 'Ogiltigt tal';
@@ -267,7 +267,7 @@ class _CreateInventoryScreenState
TextFormField( TextFormField(
controller: _brandController, controller: _brandController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Marke (valfritt)', labelText: 'Märke (valfritt)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
enabled: !_saving, enabled: !_saving,
@@ -280,7 +280,7 @@ class _CreateInventoryScreenState
onPressed: _saving ? null : () => _pickDate(false), onPressed: _saving ? null : () => _pickDate(false),
icon: const Icon(Icons.calendar_today, size: 16), icon: const Icon(Icons.calendar_today, size: 16),
label: Text( label: Text(
'Inkop: ${_formatDate(_purchaseDate)}', 'Inköp: ${_formatDate(_purchaseDate)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -291,7 +291,7 @@ class _CreateInventoryScreenState
onPressed: _saving ? null : () => _pickDate(true), onPressed: _saving ? null : () => _pickDate(true),
icon: const Icon(Icons.event_available, size: 16), icon: const Icon(Icons.event_available, size: 16),
label: Text( label: Text(
'Bast fore: ${_formatDate(_bestBeforeDate)}', 'Bäst före: ${_formatDate(_bestBeforeDate)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -299,7 +299,7 @@ class _CreateInventoryScreenState
], ],
), ),
CheckboxListTile( CheckboxListTile(
title: const Text('Oppnad'), title: const Text('Öppnad'),
value: _opened, value: _opened,
onChanged: onChanged:
_saving ? null : (v) => setState(() => _opened = v ?? false), _saving ? null : (v) => setState(() => _opened = v ?? false),
@@ -36,7 +36,7 @@ class InventoryDetailScreen extends ConsumerWidget {
body: itemAsync.when( body: itemAsync.when(
loading: () => const LoadingStateView(label: 'Laddar...'), loading: () => const LoadingStateView(label: 'Laddar...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e), message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)), onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)),
), ),
data: (item) => ListView( data: (item) => ListView(
@@ -127,7 +127,7 @@ class _DeleteButton extends ConsumerWidget {
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e)))); .showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e, context))));
} }
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
@@ -117,7 +117,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
} }
String _formatDate(DateTime? dt) { 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')}'; 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( body: itemAsync.when(
loading: () => const LoadingStateView(label: 'Laddar...'), loading: () => const LoadingStateView(label: 'Laddar...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e), message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)), onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)),
), ),
data: (item) { data: (item) {
@@ -153,7 +153,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
child: TextFormField( child: TextFormField(
controller: _quantityController, controller: _quantityController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Mangd *', labelText: 'Mängd *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
@@ -161,7 +161,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
enabled: !_saving, enabled: !_saving,
validator: (v) { validator: (v) {
if (v == null || v.trim().isEmpty) { if (v == null || v.trim().isEmpty) {
return 'Ange mangd'; return 'Ange mängd';
} }
if (double.tryParse( if (double.tryParse(
v.trim().replaceAll(',', '.')) == v.trim().replaceAll(',', '.')) ==
@@ -229,7 +229,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
TextFormField( TextFormField(
controller: _brandController, controller: _brandController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Marke', labelText: 'Märke',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
enabled: !_saving, enabled: !_saving,
@@ -242,7 +242,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
onPressed: _saving ? null : () => _pickDate(false), onPressed: _saving ? null : () => _pickDate(false),
icon: const Icon(Icons.calendar_today, size: 16), icon: const Icon(Icons.calendar_today, size: 16),
label: Text( label: Text(
'Inkop: ${_formatDate(_purchaseDate)}', 'Inköp: ${_formatDate(_purchaseDate)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -253,7 +253,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
onPressed: _saving ? null : () => _pickDate(true), onPressed: _saving ? null : () => _pickDate(true),
icon: const Icon(Icons.event_available, size: 16), icon: const Icon(Icons.event_available, size: 16),
label: Text( label: Text(
'Bast fore: ${_formatDate(_bestBeforeDate)}', 'Bäst före: ${_formatDate(_bestBeforeDate)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -261,7 +261,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
], ],
), ),
CheckboxListTile( CheckboxListTile(
title: const Text('Oppnad'), title: const Text('Öppnad'),
value: _opened, value: _opened,
onChanged: _saving onChanged: _saving
? null ? null
@@ -14,9 +14,9 @@ class InventoryScreen extends ConsumerWidget {
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi']; static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
static const _sortOptions = <({String value, String label})>[ static const _sortOptions = <({String value, String label})>[
(value: '', label: 'Senast tillagda'), (value: '', label: 'Senast tillagda'),
(value: 'nameAsc', label: 'Namn A-O'), (value: 'nameAsc', label: 'Namn A-Ö'),
(value: 'bestBeforeAsc', label: 'Bast fore stigande'), (value: 'bestBeforeAsc', label: 'Bäst före stigande'),
(value: 'bestBeforeDesc', label: 'Bast fore fallande'), (value: 'bestBeforeDesc', label: 'Bäst före fallande'),
]; ];
@override @override
@@ -28,7 +28,7 @@ class InventoryScreen extends ConsumerWidget {
return inventoryAsync.when( return inventoryAsync.when(
loading: () => const LoadingStateView(label: 'Laddar inventarie...'), loading: () => const LoadingStateView(label: 'Laddar inventarie...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e), message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(inventoryProvider), onRetry: () => ref.invalidate(inventoryProvider),
), ),
data: (items) { data: (items) {
@@ -89,7 +89,7 @@ class InventoryScreen extends ConsumerWidget {
padding: const EdgeInsets.only(bottom: 88), padding: const EdgeInsets.only(bottom: 88),
children: [ children: [
filterSection, filterSection,
const EmptyStateView(title: 'Inventariet ar tomt.'), const EmptyStateView(title: 'Inventariet är tomt.'),
], ],
), ),
Positioned( Positioned(
@@ -155,7 +155,7 @@ class _InventoryTile extends StatelessWidget {
const Padding( const Padding(
padding: EdgeInsets.only(right: 4), padding: EdgeInsets.only(right: 4),
child: Chip( child: Chip(
label: Text('Oppnad'), label: Text('Öppnad'),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
@@ -232,7 +232,7 @@ class _DeleteInventoryButton extends ConsumerWidget {
} catch (error) { } catch (error) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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( return StatefulBuilder(
builder: (ctx, setDialogState) { builder: (ctx, setDialogState) {
return AlertDialog( return AlertDialog(
title: Text('Lagg "${item.displayName}" i inventarie'), title: Text('Lägg "${item.displayName}" i inventarie'),
content: SizedBox( content: SizedBox(
width: 380, width: 380,
child: Column( child: Column(
@@ -44,7 +44,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
keyboardType: keyboardType:
const TextInputType.numberWithOptions(decimal: true), const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Mangd', labelText: 'Mängd',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
@@ -116,7 +116,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
double.tryParse(quantityController.text.trim().replaceAll(',', '.')); double.tryParse(quantityController.text.trim().replaceAll(',', '.'));
if (quantity == null || quantity <= 0) { if (quantity == null || quantity <= 0) {
setDialogState(() { setDialogState(() {
formError = 'Ange en giltig mangd over 0.'; formError = 'Ange en giltig mängd över 0.';
}); });
return; return;
} }
@@ -126,7 +126,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
'location': selectedLocation, 'location': selectedLocation,
}); });
}, },
child: const Text('Lagg till'), child: const Text('Lägg till'),
), ),
], ],
); );
@@ -158,7 +158,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
} catch (error) { } catch (error) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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) { } catch (error) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(error))), SnackBar(content: Text(mapErrorToUserMessage(error, context))),
); );
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
@@ -215,7 +215,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
} catch (error) { } catch (error) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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 item.category!;
} }
return 'Ovrigt'; return 'Övrigt';
} }
@override @override
@@ -245,7 +245,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
if (pantryAsync.hasError || productsAsync.hasError) { if (pantryAsync.hasError || productsAsync.hasError) {
final error = pantryAsync.error ?? productsAsync.error; final error = pantryAsync.error ?? productsAsync.error;
return ErrorStateView( return ErrorStateView(
message: mapErrorToUserMessage(error ?? 'Okant fel'), message: mapErrorToUserMessage(error ?? 'Okänt fel', context),
onRetry: () { onRetry: () {
ref.invalidate(pantryProvider); ref.invalidate(pantryProvider);
ref.invalidate(pantryProductsProvider); ref.invalidate(pantryProductsProvider);
@@ -272,8 +272,8 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
} }
final categories = grouped.keys.toList() final categories = grouped.keys.toList()
..sort((a, b) { ..sort((a, b) {
if (a == 'Ovrigt') return 1; if (a == 'Övrigt') return 1;
if (b == 'Ovrigt') return -1; if (b == 'Övrigt') return -1;
return a.toLowerCase().compareTo(b.toLowerCase()); return a.toLowerCase().compareTo(b.toLowerCase());
}); });
@@ -281,7 +281,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Text( Text(
'Produkter du alltid raknar med att ha hemma.', 'Produkter du alltid räknar med att ha hemma.',
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -324,7 +324,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
width: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2), 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), const SizedBox(height: 12),
if (pantryItems.isEmpty) if (pantryItems.isEmpty)
const EmptyStateView( const EmptyStateView(
title: 'Baslagret ar tomt', title: 'Baslagret är tomt',
description: 'Lagg till produkter ovan.', description: 'Lägg till produkter ovan.',
) )
else else
...categories.map((category) { ...categories.map((category) {
@@ -364,21 +364,21 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Tooltip( const Tooltip(
message: 'Konsumera (inte tillgangligt i baslager)', message: 'Konsumera (inte tillgängligt i baslager)',
child: IconButton( child: IconButton(
onPressed: null, onPressed: null,
icon: Icon(Icons.remove_circle_outline), icon: Icon(Icons.remove_circle_outline),
), ),
), ),
const Tooltip( const Tooltip(
message: 'Redigera (inte tillgangligt i baslager)', message: 'Redigera (inte tillgängligt i baslager)',
child: IconButton( child: IconButton(
onPressed: null, onPressed: null,
icon: Icon(Icons.edit_outlined), icon: Icon(Icons.edit_outlined),
), ),
), ),
IconButton( IconButton(
tooltip: 'Lagg i inventarie', tooltip: 'Lägg i inventarie',
icon: const Icon(Icons.inventory_2_outlined), icon: const Icon(Icons.inventory_2_outlined),
onPressed: () => _addToInventory(item), onPressed: () => _addToInventory(item),
), ),
@@ -14,7 +14,7 @@ class RecipeRepository {
final data = await _api.getJson(RecipeApiPaths.list, token: token); final data = await _api.getJson(RecipeApiPaths.list, token: token);
if (data is! List) { if (data is! List) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
} }
return data return data
.map((e) => Recipe.fromJson(e as Map<String, dynamic>)) .map((e) => Recipe.fromJson(e as Map<String, dynamic>))
@@ -23,7 +23,7 @@ class RecipeRepository {
rethrow; rethrow;
} catch (_) { } catch (_) {
throw const ApiException( 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); final data = await _api.getJson(RecipeApiPaths.detail(id), token: token);
if (data is! Map<String, dynamic>) { if (data is! Map<String, dynamic>) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
} }
return Recipe.fromJson(data); return Recipe.fromJson(data);
} on ApiException { } on ApiException {
rethrow; rethrow;
} catch (_) { } catch (_) {
throw const ApiException( 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); await _api.postJson(RecipeApiPaths.list, body: body, token: token);
if (data is! Map<String, dynamic>) { if (data is! Map<String, dynamic>) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
} }
return Recipe.fromJson(data); return Recipe.fromJson(data);
} on ApiException { } on ApiException {
@@ -71,7 +71,7 @@ class RecipeRepository {
); );
if (data is! Map<String, dynamic>) { if (data is! Map<String, dynamic>) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
} }
return Recipe.fromJson(data); return Recipe.fromJson(data);
} on ApiException { } on ApiException {
@@ -103,7 +103,7 @@ class RecipeRepository {
); );
if (data is! Map<String, dynamic>) { if (data is! Map<String, dynamic>) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
} }
return ParsedRecipe.fromJson(data); return ParsedRecipe.fromJson(data);
} on ApiException { } on ApiException {
@@ -92,7 +92,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
return; return;
} }
setState(() { setState(() {
_parseError = mapErrorToUserMessage(e); _parseError = mapErrorToUserMessage(e, context);
_isParsing = false; _isParsing = false;
}); });
} }
@@ -146,7 +146,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
return; return;
} }
setState(() { setState(() {
_saveError = mapErrorToUserMessage(e); _saveError = mapErrorToUserMessage(e, context);
_isSaving = false; _isSaving = false;
}); });
} }
@@ -36,7 +36,7 @@ class RecipeDetailScreen extends ConsumerWidget {
body: recipeAsync.when( body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'), loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView( error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error), message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)), onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)),
), ),
data: (recipe) => _RecipeBody(recipe: recipe), data: (recipe) => _RecipeBody(recipe: recipe),
@@ -92,7 +92,7 @@ class _DeleteButton extends ConsumerWidget {
} on ApiException catch (e) { } on ApiException catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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() { String? _validateIngredients() {
if (_ingredients.isEmpty) { if (_ingredients.isEmpty) {
return 'Minst en ingrediens kravs.'; return 'Minst en ingrediens krävs.';
} }
for (final ingredient in _ingredients) { for (final ingredient in _ingredients) {
if (ingredient.productId == null) { if (ingredient.productId == null) {
return 'Valj produkt for alla ingredienser.'; return 'Välj produkt för alla ingredienser.';
} }
final quantity = double.tryParse( final quantity = double.tryParse(
ingredient.quantityCtrl.text.trim().replaceAll(',', '.'), ingredient.quantityCtrl.text.trim().replaceAll(',', '.'),
); );
if (quantity == null || quantity < 0) { 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) { if (ingredient.unit.trim().isEmpty) {
return 'Valj enhet for alla ingredienser.'; return 'Välj enhet för alla ingredienser.';
} }
} }
return null; return null;
@@ -225,7 +225,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
return; return;
} }
setState(() { setState(() {
_saveError = mapErrorToUserMessage(e); _saveError = mapErrorToUserMessage(e, context);
_isSaving = false; _isSaving = false;
}); });
} }
@@ -255,7 +255,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
body: recipeAsync.when( body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'), loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView( error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error), message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)), onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)),
), ),
data: (recipe) { data: (recipe) {
@@ -321,13 +321,13 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _isSaving ? null : _addIngredient, onPressed: _isSaving ? null : _addIngredient,
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Lagg till'), label: const Text('Lägg till'),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant), color: Theme.of(context).colorScheme.onSurfaceVariant),
), ),
@@ -341,7 +341,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
const Card( const Card(
child: Padding( child: Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: Text('Inga ingredienser tillagda an.'), child: Text('Inga ingredienser tillagda än.'),
), ),
), ),
...List.generate( ...List.generate(
@@ -441,14 +441,14 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
child: TextFormField( child: TextFormField(
controller: ingredient.quantityCtrl, controller: ingredient.quantityCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Mangd *', labelText: 'Mängd *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: keyboardType:
const TextInputType.numberWithOptions(decimal: true), const TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return 'Ange mangd'; return 'Ange mängd';
} }
if (double.tryParse(value.trim().replaceAll(',', '.')) == if (double.tryParse(value.trim().replaceAll(',', '.')) ==
null) { null) {
@@ -16,7 +16,7 @@ class RecipesScreen extends ConsumerWidget {
body: recipesAsync.when( body: recipesAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'), loading: () => const LoadingStateView(label: 'Laddar recept...'),
error: (error, _) => ErrorStateView( error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error), message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipesProvider), onRetry: () => ref.invalidate(recipesProvider),
), ),
data: (recipes) { data: (recipes) {
+16
View File
@@ -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."
}
+16
View File
@@ -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
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/l10n/l10n.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
void main() { void main() {
@@ -13,11 +16,19 @@ class RecipeApp extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider); final router = ref.watch(appRouterProvider);
return MaterialApp.router( return MaterialApp.router(
title: 'Recipe App', onGenerateTitle: (context) => context.l10n.appTitle,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green), colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true, useMaterial3: true,
), ),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('sv'),
routerConfig: router, routerConfig: router,
); );
} }
+4
View File
@@ -11,10 +11,13 @@ dependencies:
sdk: flutter sdk: flutter
flutter_web_plugins: flutter_web_plugins:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
go_router: ^14.0.0 go_router: ^14.0.0
riverpod: ^2.5.1 riverpod: ^2.5.1
flutter_riverpod: ^2.5.1 flutter_riverpod: ^2.5.1
http: ^1.2.1 http: ^1.2.1
intl: any
shared_preferences: ^2.2.3 shared_preferences: ^2.2.3
dev_dependencies: dev_dependencies:
@@ -25,3 +28,4 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true 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'),
);
});
}
+34
View File
@@ -90,6 +90,24 @@ Generell regel: NestJS-backenden anvander `PATCH` for partiella uppdateringar, i
- Run command: - Run command:
- `docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flutter` - `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 ### Viktiga verifieringar
- Compose merge valid: - Compose merge valid:
- `docker compose -f compose.yml -f compose.flutter.yml config` - `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 `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. - 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. - 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 ## Nasta tekniska steg
Fortsatt migrering enligt prioritering i [next_steps_flutter.md](next_steps_flutter.md): Fortsatt migrering enligt prioritering i [next_steps_flutter.md](next_steps_flutter.md):