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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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';
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '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) {
|
||||||
|
|||||||
@@ -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/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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user