Add Swedish localization for various app actions and inventory management strings

This commit is contained in:
Nils-Johan Gynther
2026-05-02 15:42:00 +02:00
parent 4e81f56225
commit 2563738fcf
24 changed files with 4510 additions and 366 deletions
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../data/admin_repository.dart';
import '../domain/ai_model_info.dart';
@@ -60,7 +61,7 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('Försök igen')),
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
],
),
);
@@ -70,7 +71,7 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Översikt över AI-funktioner som backend exponerar.',
context.l10n.adminAiDescription,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
@@ -98,7 +99,7 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
],
),
const SizedBox(height: 8),
Text('Sida: ${model.path}', style: theme.textTheme.bodySmall),
Text('${context.l10n.adminPagePrefix}${model.path}', style: theme.textTheme.bodySmall),
],
),
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../data/admin_repository.dart';
import '../domain/pending_product.dart';
@@ -74,7 +75,7 @@ class _AdminPendingProductsPanelState
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('Försök igen')),
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
],
),
);
@@ -84,7 +85,7 @@ class _AdminPendingProductsPanelState
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Inga väntande produktförslag.',
context.l10n.adminNoPendingProducts,
style: theme.textTheme.bodyMedium,
),
),
@@ -106,10 +107,10 @@ class _AdminPendingProductsPanelState
children: [
if (product.displayName != product.name)
Text(product.name, style: theme.textTheme.bodySmall),
Text('Kategori: ${product.categoryPath ?? ''}'),
Text('Föreslagen av: ${product.ownerUsername ?? ''}'),
Text('${context.l10n.adminCategoryPrefix}${product.categoryPath ?? ''}'),
Text('${context.l10n.adminSuggestedByPrefix}${product.ownerUsername ?? ''}'),
Text(
'Datum: ${product.createdAt == null ? '' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}',
'${context.l10n.adminDatePrefix}${product.createdAt == null ? '' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}',
style: theme.textTheme.bodySmall,
),
],
@@ -122,13 +123,13 @@ class _AdminPendingProductsPanelState
onPressed: isProcessing
? null
: () => _handleAction(product, 'active'),
child: const Text('Godkänn'),
child: Text(context.l10n.adminApproveAction),
),
OutlinedButton(
onPressed: isProcessing
? null
: () => _handleAction(product, 'rejected'),
child: const Text('Avvisa'),
child: Text(context.l10n.adminRejectAction),
),
],
),
@@ -143,7 +144,7 @@ class _AdminPendingProductsPanelState
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Godkänn eller avvisa väntande produktförslag direkt från profilsidan.',
context.l10n.adminPendingDescription,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../data/admin_repository.dart';
import '../domain/admin_ai_categorize_result.dart';
import '../domain/admin_category_node.dart';
@@ -78,17 +79,17 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
String _sortLabel(_ProductSort sort) {
switch (sort) {
case _ProductSort.newest:
return 'Sortera: Nyast';
return context.l10n.adminSortNewest;
case _ProductSort.oldest:
return 'Sortera: Äldst';
return context.l10n.adminSortOldest;
case _ProductSort.nameAsc:
return 'Sortera: Namn A-Ö';
return context.l10n.adminSortNameAsc;
case _ProductSort.nameDesc:
return 'Sortera: Namn Ö-A';
return context.l10n.adminSortNameDesc;
case _ProductSort.categoryAsc:
return 'Sortera: Kategori A-Ö';
return context.l10n.adminSortCategoryAsc;
case _ProductSort.categoryDesc:
return 'Sortera: Kategori Ö-A';
return context.l10n.adminSortCategoryDesc;
}
}
@@ -126,7 +127,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produkter uppdaterade.')),
SnackBar(content: Text(context.l10n.adminProductsUpdated)),
);
} catch (e) {
if (!mounted) return;
@@ -148,7 +149,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
if (!mounted) return;
if (suggestions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inga AI-förslag att visa.')),
SnackBar(content: Text(context.l10n.adminNoAiSuggestions)),
);
return;
}
@@ -175,7 +176,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('AI-förslag tillämpade på ${selectedProductIds.length} produkter.')),
SnackBar(content: Text(context.l10n.adminAiApplied(selectedProductIds.length))),
);
} catch (e) {
if (!mounted) return;
@@ -203,12 +204,12 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
builder: (dialogContext) {
return StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: const Text('Slå ihop produkter'),
title: Text(context.l10n.adminMergeProductsTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Välj vilken produkt som ska flyttas in i den andra:'),
Text(context.l10n.adminMergeProductsHint),
const SizedBox(height: 12),
SegmentedButton<int>(
segments: [
@@ -236,11 +237,11 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Slå ihop'),
child: Text(context.l10n.adminMergeAction),
),
],
),
@@ -260,7 +261,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produkter sammanslagna.')),
SnackBar(content: Text(context.l10n.adminProductsMerged)),
);
} catch (e) {
if (!mounted) return;
@@ -275,16 +276,16 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Ta bort produkt'),
content: Text('Ta bort ${product.displayName}? Produkten kan återställas senare.'),
title: Text(context.l10n.adminDeleteProductTitle),
content: Text(context.l10n.adminDeleteProductConfirm(product.displayName)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Ta bort'),
child: Text(context.l10n.deleteAction),
),
],
),
@@ -297,7 +298,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produkt borttagen.')),
SnackBar(content: Text(context.l10n.adminProductDeleted)),
);
} catch (e) {
if (!mounted) return;
@@ -432,7 +433,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('Försök igen')),
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
],
),
);
@@ -442,10 +443,10 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Sök produkt',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.adminSearchProduct,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onChanged: (value) => setState(() => _search = value),
),
@@ -470,7 +471,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
},
),
FilterChip(
label: const Text('Visa raderade'),
label: Text(context.l10n.adminShowDeleted),
selected: _showDeletedOnly,
onSelected: (value) {
setState(() {
@@ -483,7 +484,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
},
),
FilterChip(
label: const Text('Endast okategoriserade'),
label: Text(context.l10n.adminShowUncategorized),
selected: _showUncategorizedOnly,
onSelected: _showDeletedOnly
? null
@@ -494,14 +495,14 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
width: 260,
child: DropdownButtonFormField<String>(
initialValue: _bulkCategoryValue,
decoration: const InputDecoration(
labelText: 'Bulk: sätt kategori',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.adminBulkSetCategory,
border: const OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String>(
DropdownMenuItem<String>(
value: '__remove__',
child: Text('Ta bort kategori'),
child: Text(context.l10n.adminRemoveCategory),
),
...categoryOptions.map(
(option) => DropdownMenuItem<String>(
@@ -555,13 +556,13 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text('Återställ valda (${_selectedIds.length})'),
: Text(context.l10n.adminRestoreSelected(_selectedIds.length))
),
],
),
const SizedBox(height: 16),
if (filtered.isEmpty)
const Text('Inga produkter matchar filtret.')
Text(context.l10n.adminNoProductsFound)
else
...filtered.map(
(product) => Card(
@@ -609,15 +610,15 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
'row-category-${product.id}-${_rowCategoryFor(product)}',
),
initialValue: _rowCategoryFor(product),
decoration: const InputDecoration(
labelText: 'Kategori (inline)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.adminInlineCategory,
border: const OutlineInputBorder(),
isDense: true,
),
items: [
const DropdownMenuItem<String>(
DropdownMenuItem<String>(
value: '__remove__',
child: Text('Ingen kategori'),
child: Text(context.l10n.adminNoCategory),
),
...categoryOptions.map(
(option) => DropdownMenuItem<String>(
@@ -645,7 +646,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Spara'),
label: Text(context.l10n.saveAction),
),
],
),
@@ -657,13 +658,13 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
TextButton.icon(
onPressed: () => _restoreProduct(product),
icon: const Icon(Icons.restore),
label: const Text('Återställ'),
label: Text(context.l10n.adminRestoreAction),
)
else
TextButton.icon(
onPressed: () => _removeProduct(product),
icon: const Icon(Icons.delete_outline),
label: const Text('Ta bort'),
label: Text(context.l10n.deleteAction),
),
],
),
@@ -707,7 +708,7 @@ class _AiApplyDialogState extends State<_AiApplyDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('AI-förslag'),
title: Text(context.l10n.adminAiSuggestionsTitle),
content: SizedBox(
width: 700,
child: SingleChildScrollView(
@@ -744,13 +745,13 @@ class _AiApplyDialogState extends State<_AiApplyDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: _selected.isEmpty
? null
: () => Navigator.pop(context, _selected),
child: Text('Tillämpa (${_selected.length})'),
child: Text(context.l10n.adminApplySelected(_selected.length)),
),
],
);
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../data/admin_repository.dart';
import '../domain/user_admin.dart';
@@ -47,8 +48,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
final newRole = user.isAdmin ? 'user' : 'admin';
final confirmed = await _confirm(
context,
'Ändra roll',
'Ändra ${user.username} till $newRole?',
context.l10n.adminChangeRole,
context.l10n.adminChangeRoleConfirm(user.username, newRole),
);
if (!confirmed || !mounted) return;
try {
@@ -65,8 +66,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
final newValue = !user.isPremium;
final confirmed = await _confirm(
context,
newValue ? 'Ge Premium' : 'Ta bort Premium',
'${newValue ? 'Ge' : 'Ta bort'} Premium för ${user.username}?',
newValue ? context.l10n.adminGivePremium : context.l10n.adminRemovePremium,
context.l10n.adminPremiumConfirm(newValue ? context.l10n.adminGivePremium : context.l10n.adminRemovePremium, user.username),
);
if (!confirmed || !mounted) return;
try {
@@ -85,8 +86,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
final newValue = !user.canShareRecipes;
final confirmed = await _confirm(
context,
newValue ? 'Tillåt receptdelning' : 'Blockera receptdelning',
'${newValue ? 'Tillåt' : 'Blockera'} receptdelning för ${user.username}?',
newValue ? context.l10n.adminAllowSharing : context.l10n.adminBlockSharing,
context.l10n.adminSharingConfirm(newValue ? context.l10n.adminAllowSharing : context.l10n.adminBlockSharing, user.username),
);
if (!confirmed || !mounted) return;
try {
@@ -104,8 +105,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
Future<void> _resetPassword(UserAdmin user) async {
final confirmed = await _confirm(
context,
'Återställ lösenord',
'Generera ett tillfälligt lösenord för ${user.username}?',
context.l10n.adminResetPassword,
context.l10n.adminResetPasswordConfirm(user.username),
);
if (!confirmed || !mounted) return;
try {
@@ -115,12 +116,12 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Tillfälligt lösenord'),
title: Text(context.l10n.adminTempPasswordTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Lösenord för ${user.username}:'),
Text(context.l10n.adminTempPasswordForUser(user.username)),
const SizedBox(height: 8),
Row(
children: [
@@ -135,7 +136,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Kopiera',
tooltip: context.l10n.adminCopyAction,
onPressed: () => Clipboard.setData(
ClipboardData(text: tempPw),
),
@@ -152,7 +153,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Stäng'),
child: Text(context.l10n.adminCloseAction),
),
],
),
@@ -169,37 +170,37 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
final newEmail = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text('Ändra e-post för ${user.username}'),
title: Text(context.l10n.adminEmailEditTitle(user.username)),
content: TextField(
controller: controller,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-post',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.adminEmailLabel,
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, controller.text.trim()),
child: const Text('Spara'),
child: Text(context.l10n.saveAction),
),
],
),
);
if (newEmail == null || newEmail.isEmpty || !mounted) return;
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(newEmail)) {
_showError('Ogiltig e-postadress.');
_showError(context.l10n.adminEmailInvalid);
return;
}
await ref.read(adminRepositoryProvider).updateEmail(user.id, newEmail);
if (!mounted) return;
_load();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('E-post uppdaterad.')),
SnackBar(content: Text(context.l10n.adminEmailUpdated)),
);
} catch (e) {
if (!mounted) return;
@@ -212,8 +213,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
Future<void> _deleteUser(UserAdmin user) async {
final confirmed = await _confirm(
context,
'Ta bort användare',
'Ta bort ${user.username} permanent? Detta går inte att ångra.',
context.l10n.adminDeleteUser,
context.l10n.adminDeleteUserConfirm,
destructive: true,
);
if (!confirmed || !mounted) return;
@@ -243,7 +244,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
if (!mounted) return;
_load();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Användare ${result['username']} skapad.')),
SnackBar(content: Text(context.l10n.adminUserCreated(result['username']!))),
);
} catch (e) {
if (!mounted) return;
@@ -283,7 +284,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
foregroundColor: Theme.of(ctx).colorScheme.error,
)
: null,
child: const Text('Bekräfta'),
child: Text(context.l10n.adminConfirmAction),
),
],
),
@@ -304,7 +305,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('Försök igen')),
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
],
),
);
@@ -317,11 +318,11 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
FilledButton.icon(
onPressed: _createUser,
icon: const Icon(Icons.person_add_outlined),
label: const Text('Ny användare'),
label: Text(context.l10n.adminNewUser),
),
const SizedBox(height: 16),
],
const Text('Inga användare hittades.'),
Text(context.l10n.adminNoUsers),
],
);
}
@@ -357,7 +358,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
children: [
Expanded(
child: Text(
'Hantera användare direkt från profilsidan.',
context.l10n.adminUsersDescription,
style: theme.textTheme.bodyMedium,
),
),
@@ -372,7 +373,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
FilledButton.icon(
onPressed: _createUser,
icon: const Icon(Icons.person_add_outlined),
label: const Text('Ny användare'),
label: Text(context.l10n.adminNewUser),
),
const SizedBox(height: 16),
list,
@@ -432,29 +433,29 @@ class _UserTile extends StatelessWidget {
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
tooltip: user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin',
tooltip: user.isAdmin ? context.l10n.adminDowngradeToUser : context.l10n.adminUpgradeToAdmin,
onPressed: onChangeRole,
),
ActionChip(
label: Text(user.isPremium ? 'Premium' : 'Free'),
label: Text(user.isPremium ? context.l10n.adminPremiumLabel : context.l10n.adminFreeLabel),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
backgroundColor: user.isPremium
? theme.colorScheme.tertiaryContainer
: theme.colorScheme.surfaceContainerHighest,
tooltip: user.isPremium ? 'Ta bort Premium' : 'Ge Premium',
tooltip: user.isPremium ? context.l10n.adminRemovePremium : context.l10n.adminGivePremium,
onPressed: onTogglePremium,
),
ActionChip(
label: Text(user.canShareRecipes ? 'Delning: På' : 'Delning: Av'),
label: Text(user.canShareRecipes ? context.l10n.adminSharingOn : context.l10n.adminSharingOff),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
backgroundColor: user.canShareRecipes
? theme.colorScheme.secondaryContainer
: theme.colorScheme.errorContainer,
tooltip: user.canShareRecipes ? 'Blockera receptdelning' : 'Tillåt receptdelning',
tooltip: user.canShareRecipes ? context.l10n.adminBlockSharing : context.l10n.adminAllowSharing,
onPressed: onToggleRecipeSharing,
),
],
@@ -489,34 +490,34 @@ class _UserTile extends StatelessWidget {
PopupMenuItem(
value: 'role',
child: Text(
user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin',
user.isAdmin ? context.l10n.adminDowngradeToUser : context.l10n.adminUpgradeToAdmin,
),
),
PopupMenuItem(
value: 'premium',
child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'),
child: Text(user.isPremium ? context.l10n.adminRemovePremium : context.l10n.adminGivePremium),
),
PopupMenuItem(
value: 'sharing',
child: Text(
user.canShareRecipes
? 'Blockera receptdelning'
: 'Tillåt receptdelning',
? context.l10n.adminBlockSharing
: context.l10n.adminAllowSharing,
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'email',
child: Text('Ändra e-post'),
child: Text(context.l10n.adminEmailAction),
),
const PopupMenuItem(
PopupMenuItem(
value: 'reset',
child: Text('Återställ lösenord'),
child: Text(context.l10n.adminResetPassword),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'delete',
child: Text(
'Ta bort',
context.l10n.deleteAction,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
@@ -553,7 +554,7 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Skapa användare'),
title: Text(context.l10n.adminCreateUserTitle),
content: Form(
key: _formKey,
child: SingleChildScrollView(
@@ -562,19 +563,19 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
children: [
TextFormField(
controller: _usernameCtrl,
decoration: const InputDecoration(labelText: 'Användarnamn'),
decoration: InputDecoration(labelText: context.l10n.profileUsernameLabel),
validator: (v) =>
(v == null || v.length < 2) ? 'Minst 2 tecken' : null,
(v == null || v.length < 2) ? context.l10n.adminMinChars2 : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'E-post'),
decoration: InputDecoration(labelText: context.l10n.adminEmailLabel),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return 'Obligatoriskt';
if (v == null || v.isEmpty) return context.l10n.required;
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) {
return 'Ogiltig e-post';
return context.l10n.adminEmailInvalid;
}
return null;
},
@@ -583,7 +584,7 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
TextFormField(
controller: _passwordCtrl,
decoration: InputDecoration(
labelText: 'Lösenord',
labelText: context.l10n.adminPasswordLabel,
suffixIcon: IconButton(
icon: Icon(
_obscure
@@ -595,15 +596,15 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
),
obscureText: _obscure,
validator: (v) =>
(v == null || v.length < 8) ? 'Minst 8 tecken' : null,
(v == null || v.length < 8) ? context.l10n.adminMinChars8 : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _role,
decoration: const InputDecoration(labelText: 'Roll'),
items: const [
DropdownMenuItem(value: 'user', child: Text('Användare')),
DropdownMenuItem(value: 'admin', child: Text('Admin')),
decoration: InputDecoration(labelText: context.l10n.adminRoleLabel),
items: [
DropdownMenuItem(value: 'user', child: Text(context.l10n.adminUserRole)),
DropdownMenuItem(value: 'admin', child: Text(context.l10n.adminAdminRole)),
],
onChanged: (v) => setState(() => _role = v ?? 'user'),
),
@@ -614,7 +615,7 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () {
@@ -627,7 +628,7 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
});
}
},
child: const Text('Skapa'),
child: Text(context.l10n.adminCreateAction),
),
],
);
@@ -1,13 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../core/l10n/l10n.dart';
/// Visar en dialogruta med ett felmeddelande och en kopieringsknapp.
void showErrorDialog(BuildContext context, String errorMessage) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Fel'),
title: Text(context.l10n.errorDialogTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -17,17 +19,17 @@ void showErrorDialog(BuildContext context, String errorMessage) {
),
actions: <Widget>[
TextButton(
child: const Text('Stäng'),
child: Text(context.l10n.errorDialogClose),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('Kopiera'),
child: Text(context.l10n.errorDialogCopy),
onPressed: () {
Clipboard.setData(ClipboardData(text: errorMessage));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Felmeddelande kopierat!')),
SnackBar(content: Text(context.l10n.errorDialogCopied)),
);
},
),
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/utils/global_error_handler.dart';
import '../../../core/l10n/l10n.dart';
import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart';
@@ -113,8 +114,7 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ladda upp en PDF eller bild, eller ange en receptlänk — '
'receptet importeras och öppnas direkt i redigeringsläget.',
context.l10n.importTabDescription,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
@@ -122,16 +122,16 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
// ── Metodväljare ────────────────────────────────────────────────
SegmentedButton<_Method>(
segments: const [
segments: [
ButtonSegment(
value: _Method.file,
label: Text('Fil / PDF'),
icon: Icon(Icons.upload_file_outlined),
label: Text(context.l10n.importFileTabLabel),
icon: const Icon(Icons.upload_file_outlined),
),
ButtonSegment(
value: _Method.url,
label: Text('Länk'),
icon: Icon(Icons.link),
label: Text(context.l10n.importLinkTabLabel),
icon: const Icon(Icons.link),
),
],
selected: {_method},
@@ -147,7 +147,7 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
icon: const Icon(Icons.attach_file),
label: Text(
_pickedFile == null
? 'Välj fil (PDF, PNG, JPG, WEBP, BMP)'
? context.l10n.importChooseFileAction
: _pickedFile!.name,
),
),
@@ -168,11 +168,11 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
keyboardType: TextInputType.url,
autofocus: true,
enabled: !_isLoading,
decoration: const InputDecoration(
labelText: 'Receptlänk',
hintText: 'https://exempel.se/recept/...',
prefixIcon: Icon(Icons.link),
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.importLinkLabel,
hintText: context.l10n.importLinkHint,
prefixIcon: const Icon(Icons.link),
border: const OutlineInputBorder(),
),
onSubmitted: (_) {
if (_canSubmit) _submit();
@@ -187,7 +187,7 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
const LinearProgressIndicator(),
const SizedBox(height: 8),
Text(
'Tolkar receptet — detta kan ta upp till en minut...',
context.l10n.importFileProcessing,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
@@ -200,8 +200,8 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
onPressed: _canSubmit ? _submit : null,
icon: const Icon(Icons.auto_awesome_outlined),
label: Text(_method == _Method.file
? 'Importera fil'
: 'Importera från länk'),
? context.l10n.importFileAction
: context.l10n.importLinkAction),
),
const SizedBox(height: 24),
@@ -212,7 +212,7 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
TextButton.icon(
onPressed: () => context.push('/recipes/create'),
icon: const Icon(Icons.edit_outlined),
label: const Text('Skriv in recept istället'),
label: Text(context.l10n.importWriteInstead),
),
],
),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
@@ -65,8 +66,8 @@ class _ConsumeInventoryScreenState
return Scaffold(
appBar: AppBar(
title: itemAsync.maybeWhen(
data: (item) => Text('Konsumera: ${item.productName}'),
orElse: () => const Text('Konsumera'),
data: (item) => Text(context.l10n.inventoryConsumeNameTitle(item.productName)),
orElse: () => Text(context.l10n.inventoryConsumeAction),
),
),
body: Padding(
@@ -78,7 +79,10 @@ class _ConsumeInventoryScreenState
children: [
if (itemAsync.hasValue) ...[
Text(
'Tillgängligt: ${itemAsync.value!.quantity} ${itemAsync.value!.unit}',
context.l10n.inventoryAvailableLabel(
itemAsync.value!.quantity.toString(),
itemAsync.value!.unit,
),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
@@ -86,7 +90,7 @@ class _ConsumeInventoryScreenState
TextFormField(
controller: _amountController,
decoration: InputDecoration(
labelText: 'Mängd att konsumera *',
labelText: context.l10n.inventoryConsumeAmountLabel,
border: const OutlineInputBorder(),
suffixText: itemAsync.maybeWhen(
data: (item) => item.unit,
@@ -98,11 +102,11 @@ class _ConsumeInventoryScreenState
autofocus: true,
enabled: !_saving,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Ange mängd';
if (v == null || v.trim().isEmpty) return context.l10n.quantityHint;
final parsed =
double.tryParse(v.trim().replaceAll(',', '.'));
if (parsed == null || parsed <= 0) {
return 'Ange ett positivt tal';
return context.l10n.enterPositiveNumber;
}
return null;
},
@@ -110,9 +114,9 @@ class _ConsumeInventoryScreenState
const SizedBox(height: 12),
TextFormField(
controller: _commentController,
decoration: const InputDecoration(
labelText: 'Kommentar (valfri)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.commentOptionalLabel,
border: const OutlineInputBorder(),
),
enabled: !_saving,
),
@@ -126,7 +130,7 @@ class _ConsumeInventoryScreenState
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Konsumera'),
: Text(context.l10n.inventoryConsumeAction),
),
],
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../data/inventory_providers.dart';
import '../domain/inventory_consumption.dart';
@@ -19,20 +20,20 @@ class ConsumptionHistoryScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: itemAsync.maybeWhen(
data: (item) => Text('Historik: ${item.productName}'),
orElse: () => const Text('Konsumtionshistorik'),
data: (item) => Text(context.l10n.inventoryHistoryTitle(item.productName)),
orElse: () => Text(context.l10n.inventoryHistoryAction),
),
),
body: historyAsync.when(
loading: () => const LoadingStateView(label: 'Laddar historik...'),
loading: () => LoadingStateView(label: context.l10n.inventoryHistoryLoading),
error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)),
),
data: (history) {
if (history.isEmpty) {
return const EmptyStateView(
title: 'Ingen konsumtionshistorik finns.',
return EmptyStateView(
title: context.l10n.inventoryHistoryEmpty,
);
}
return ListView.separated(
@@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
@@ -102,7 +103,7 @@ class _CreateInventoryScreenState
if (!_formKey.currentState!.validate()) return;
if (_selectedProductId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Välj en produkt ur listan.')),
SnackBar(content: Text(context.l10n.inventorySelectProduct)),
);
return;
}
@@ -142,7 +143,7 @@ class _CreateInventoryScreenState
}
String _formatDate(DateTime? dt) {
if (dt == null) return 'Välj datum';
if (dt == null) return context.l10n.selectDateLabel;
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
@@ -165,7 +166,7 @@ class _CreateInventoryScreenState
.toList();
return Scaffold(
appBar: AppBar(title: const Text('Lägg till inventariepost')),
appBar: AppBar(title: Text(context.l10n.inventoryCreateTitle)),
body: Form(
key: _formKey,
child: ListView(
@@ -176,7 +177,7 @@ class _CreateInventoryScreenState
value: _selectedProductId,
isLoading: _loadingProducts,
enabled: !_saving,
label: 'Produkt *',
label: context.l10n.inventoryProductLabel,
onChanged: (value) => setState(() => _selectedProductId = value),
),
const SizedBox(height: 12),
@@ -187,18 +188,18 @@ class _CreateInventoryScreenState
flex: 2,
child: TextFormField(
controller: _quantityController,
decoration: const InputDecoration(
labelText: 'Mängd *',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.quantityLabel,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
enabled: !_saving,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Ange mängd';
if (v == null || v.trim().isEmpty) return context.l10n.quantityHint;
if (double.tryParse(v.trim().replaceAll(',', '.')) ==
null) {
return 'Ogiltigt tal';
return context.l10n.invalidNumber;
}
return null;
},
@@ -211,9 +212,9 @@ class _CreateInventoryScreenState
? null
: _unitController.text.trim(),
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Enhet *',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.unitLabel,
border: const OutlineInputBorder(),
),
items: unitOptions
.map(
@@ -228,7 +229,7 @@ class _CreateInventoryScreenState
: (value) =>
setState(() => _unitController.text = value ?? ''),
validator: (value) =>
(value == null || value.trim().isEmpty) ? 'Ange enhet' : null,
(value == null || value.trim().isEmpty) ? context.l10n.quantityHint : null,
),
),
],
@@ -239,9 +240,9 @@ class _CreateInventoryScreenState
? null
: _locationController.text.trim(),
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Plats (valfri)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.locationOptionalLabel,
border: const OutlineInputBorder(),
),
items: inventoryLocationOptions
.map(
@@ -260,9 +261,9 @@ class _CreateInventoryScreenState
const SizedBox(height: 12),
TextFormField(
controller: _brandController,
decoration: const InputDecoration(
labelText: 'Märke (valfritt)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.brandOptionalLabel,
border: const OutlineInputBorder(),
),
enabled: !_saving,
),
@@ -274,7 +275,7 @@ class _CreateInventoryScreenState
onPressed: _saving ? null : () => _pickDate(false),
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
'Inköp: ${_formatDate(_purchaseDate)}',
'${context.l10n.inventoryPurchaseDatePrefix}${_formatDate(_purchaseDate)}',
overflow: TextOverflow.ellipsis,
),
),
@@ -285,7 +286,7 @@ class _CreateInventoryScreenState
onPressed: _saving ? null : () => _pickDate(true),
icon: const Icon(Icons.event_available, size: 16),
label: Text(
'Bäst före: ${_formatDate(_bestBeforeDate)}',
'${context.l10n.inventoryBestBeforeDatePrefix}${_formatDate(_bestBeforeDate)}',
overflow: TextOverflow.ellipsis,
),
),
@@ -293,7 +294,7 @@ class _CreateInventoryScreenState
],
),
CheckboxListTile(
title: const Text('Öppnad'),
title: Text(context.l10n.openedLabel),
value: _opened,
onChanged:
_saving ? null : (v) => setState(() => _opened = v ?? false),
@@ -302,9 +303,9 @@ class _CreateInventoryScreenState
),
TextFormField(
controller: _commentController,
decoration: const InputDecoration(
labelText: 'Kommentar (valfri)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.commentOptionalLabel,
border: const OutlineInputBorder(),
),
maxLines: 2,
enabled: !_saving,
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
@@ -20,12 +21,12 @@ class InventoryDetailScreen extends ConsumerWidget {
appBar: AppBar(
title: itemAsync.maybeWhen(
data: (item) => Text(item.productName),
orElse: () => const Text('Inventarie'),
orElse: () => Text(context.l10n.inventoryTitle),
),
actions: [
if (itemAsync.hasValue) ...[
IconButton(
tooltip: 'Redigera',
tooltip: context.l10n.editTooltip,
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/inventory/$itemId/edit'),
),
@@ -34,7 +35,7 @@ class InventoryDetailScreen extends ConsumerWidget {
],
),
body: itemAsync.when(
loading: () => const LoadingStateView(label: 'Laddar...'),
loading: () => LoadingStateView(label: context.l10n.loadingLabel),
error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)),
@@ -42,33 +43,33 @@ class InventoryDetailScreen extends ConsumerWidget {
data: (item) => ListView(
padding: const EdgeInsets.all(16),
children: [
_InfoRow(label: 'Produkt', value: item.productName),
_InfoRow(label: context.l10n.inventoryProductLabel, value: item.productName),
_InfoRow(
label: 'Mängd',
label: context.l10n.inventoryQuantityDisplayLabel,
value: '${item.quantity} ${item.unit}',
),
if (item.location != null && item.location!.isNotEmpty)
_InfoRow(label: 'Plats', value: item.location!),
_InfoRow(label: context.l10n.inventoryLocationDisplayLabel, value: item.location!),
if (item.brand != null && item.brand!.isNotEmpty)
_InfoRow(label: 'Märke', value: item.brand!),
_InfoRow(label: context.l10n.inventoryBrandDisplayLabel, value: item.brand!),
if (item.purchaseDate != null)
_InfoRow(label: 'Inköpsdatum', value: _formatDate(item.purchaseDate!)),
_InfoRow(label: context.l10n.inventoryPurchaseDateLabel, value: _formatDate(item.purchaseDate!)),
if (item.bestBeforeDate != null)
_InfoRow(label: 'Bäst före', value: _formatDate(item.bestBeforeDate!)),
_InfoRow(label: 'Öppnad', value: item.opened ? 'Ja' : 'Nej'),
_InfoRow(label: context.l10n.inventoryBestBeforeLabel, value: _formatDate(item.bestBeforeDate!)),
_InfoRow(label: context.l10n.openedLabel, value: item.opened ? context.l10n.yesLabel : context.l10n.noLabel),
if (item.comment != null && item.comment!.isNotEmpty)
_InfoRow(label: 'Kommentar', value: item.comment!),
_InfoRow(label: context.l10n.commentLabel, value: item.comment!),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () => context.push('/inventory/$itemId/consume'),
icon: const Icon(Icons.remove_circle_outline),
label: const Text('Konsumera'),
label: Text(context.l10n.inventoryConsumeAction),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => context.push('/inventory/$itemId/history'),
icon: const Icon(Icons.history),
label: const Text('Konsumtionshistorik'),
label: Text(context.l10n.inventoryHistoryAction),
),
],
),
@@ -94,22 +95,22 @@ class _DeleteButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: 'Ta bort',
tooltip: context.l10n.deleteTooltip,
icon: const Icon(Icons.delete_outline),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ta bort inventariepost?'),
content: const Text('Åtgärden kan inte ångras.'),
title: Text(context.l10n.inventoryDeleteTitle),
content: Text(context.l10n.cannotBeUndone),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Ta bort'),
child: Text(context.l10n.deleteAction),
),
],
),
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
@@ -117,7 +118,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
}
String _formatDate(DateTime? dt) {
if (dt == null) return 'Välj datum';
if (dt == null) return context.l10n.selectDateLabel;
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
@@ -126,9 +127,9 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
final itemAsync = ref.watch(inventoryDetailProvider(widget.itemId));
return Scaffold(
appBar: AppBar(title: const Text('Redigera inventariepost')),
appBar: AppBar(title: Text(context.l10n.inventoryEditTitle)),
body: itemAsync.when(
loading: () => const LoadingStateView(label: 'Laddar...'),
loading: () => LoadingStateView(label: context.l10n.loadingLabel),
error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)),
@@ -179,9 +180,9 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
? null
: _unitController.text.trim(),
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Enhet *',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.unitLabel,
border: const OutlineInputBorder(),
),
items: unitOptions
.map(
@@ -196,7 +197,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
: (value) =>
setState(() => _unitController.text = value ?? ''),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Ange enhet'
? context.l10n.quantityHint
: null,
),
),
@@ -208,9 +209,9 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
? null
: _locationController.text.trim(),
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Plats',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.locationLabel,
border: const OutlineInputBorder(),
),
items: inventoryLocationOptions
.map(
@@ -228,12 +229,12 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _brandController,
decoration: const InputDecoration(
labelText: 'Märke',
border: OutlineInputBorder(),
),
enabled: !_saving,
decoration: InputDecoration(
labelText: context.l10n.brandLabel,
border: const OutlineInputBorder(),
),
enabled: !_saving,
),
const SizedBox(height: 12),
Row(
children: [
@@ -242,7 +243,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
onPressed: _saving ? null : () => _pickDate(false),
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
'Inköp: ${_formatDate(_purchaseDate)}',
'${context.l10n.inventoryPurchaseDatePrefix}${_formatDate(_purchaseDate)}',
overflow: TextOverflow.ellipsis,
),
),
@@ -253,7 +254,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
onPressed: _saving ? null : () => _pickDate(true),
icon: const Icon(Icons.event_available, size: 16),
label: Text(
'Bäst före: ${_formatDate(_bestBeforeDate)}',
'${context.l10n.inventoryBestBeforeDatePrefix}${_formatDate(_bestBeforeDate)}',
overflow: TextOverflow.ellipsis,
),
),
@@ -261,7 +262,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
],
),
CheckboxListTile(
title: const Text('Öppnad'),
title: Text(context.l10n.openedLabel),
value: _opened,
onChanged: _saving
? null
@@ -271,9 +272,9 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
),
TextFormField(
controller: _commentController,
decoration: const InputDecoration(
labelText: 'Kommentar',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.commentLabel,
border: const OutlineInputBorder(),
),
maxLines: 2,
enabled: !_saving,
@@ -288,7 +289,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Spara'),
: Text(context.l10n.saveAction),
),
],
),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../data/inventory_providers.dart';
import 'swipeable_inventory_tile.dart';
@@ -11,11 +12,11 @@ class InventoryScreen extends ConsumerWidget {
const InventoryScreen({super.key});
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
static const _sortOptions = <({String value, String label})>[
(value: '', label: 'Senast tillagda'),
(value: 'nameAsc', label: 'Namn A-Ö'),
(value: 'bestBeforeAsc', label: 'Bäst före stigande'),
(value: 'bestBeforeDesc', label: 'Bäst före fallande'),
List<({String value, String label})> _sortOptions(BuildContext context) => [
(value: '', label: context.l10n.inventorySortLatest),
(value: 'nameAsc', label: context.l10n.inventorySortNameAsc),
(value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc),
(value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc),
];
@override
@@ -25,7 +26,7 @@ class InventoryScreen extends ConsumerWidget {
final inventoryAsync = ref.watch(inventoryProvider);
return inventoryAsync.when(
loading: () => const LoadingStateView(label: 'Laddar inventarie...'),
loading: () => LoadingStateView(label: context.l10n.inventoryLoading),
error: (e, _) => ErrorStateView(
message: mapErrorToUserMessage(e, context),
onRetry: () => ref.invalidate(inventoryProvider),
@@ -36,9 +37,9 @@ class InventoryScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Filter och sortering',
style: TextStyle(fontWeight: FontWeight.w600),
Text(
context.l10n.inventoryFilterAndSort,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
@@ -47,7 +48,7 @@ class InventoryScreen extends ConsumerWidget {
children: _locationOptions
.map(
(option) => ChoiceChip(
label: Text(option.isEmpty ? 'Alla' : option),
label: Text(option.isEmpty ? context.l10n.inventoryAllFilter : option),
selected: location == option,
onSelected: (_) => ref
.read(inventoryLocationFilterProvider.notifier)
@@ -60,11 +61,11 @@ class InventoryScreen extends ConsumerWidget {
DropdownButtonFormField<String>(
initialValue: sort,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Sortering',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.inventorySortLabel,
border: const OutlineInputBorder(),
),
items: _sortOptions
items: _sortOptions(context)
.map(
(option) => DropdownMenuItem<String>(
value: option.value,
@@ -89,7 +90,7 @@ class InventoryScreen extends ConsumerWidget {
padding: const EdgeInsets.only(bottom: 88),
children: [
filterSection,
const EmptyStateView(title: 'Inventariet är tomt.'),
EmptyStateView(title: context.l10n.inventoryEmpty),
],
),
Positioned(
@@ -98,7 +99,7 @@ class InventoryScreen extends ConsumerWidget {
child: FloatingActionButton.extended(
onPressed: () => context.push('/inventory/create'),
icon: const Icon(Icons.add),
label: const Text('Lägg till'),
label: Text(context.l10n.addAction),
),
),
],
@@ -125,13 +126,13 @@ class InventoryScreen extends ConsumerWidget {
FloatingActionButton.extended(
onPressed: () => context.push('/inventory/create'),
icon: const Icon(Icons.add),
label: const Text('Lägg till'),
label: Text(context.l10n.addAction),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
onPressed: () => context.go('/recipes'),
icon: const Icon(Icons.restaurant_menu),
label: const Text('Recept'),
label: Text(context.l10n.inventoryRecipesAction),
),
],
),
@@ -5,6 +5,7 @@ import 'package:logging/logging.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../auth/data/auth_providers.dart';
@@ -44,7 +45,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
return StatefulBuilder(
builder: (ctx, setDialogState) {
return AlertDialog(
title: Text('Lägg "${item.displayName}" i inventarie'),
title: Text(context.l10n.pantryAddToInventoryTitle(item.displayName)),
content: SizedBox(
width: 380,
child: Column(
@@ -53,18 +54,18 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
TextField(
controller: quantityController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Mängd',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.inventoryQuantityDisplayLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedUnit,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Enhet',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.unitLabel,
border: const OutlineInputBorder(),
),
items: unitOptions
.map((option) => DropdownMenuItem<String>(
@@ -81,14 +82,14 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
DropdownButtonFormField<String>(
initialValue: selectedLocation,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Plats (valfri)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.locationOptionalLabel,
border: const OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String>(
DropdownMenuItem<String>(
value: null,
child: Text('Ingen plats vald'),
child: Text(context.l10n.pantryNoLocation),
),
...inventoryLocationOptions.map(
(location) => DropdownMenuItem<String>(
@@ -114,7 +115,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () {
@@ -123,7 +124,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
);
if (quantity == null || quantity <= 0) {
setDialogState(() {
formError = 'Ange en giltig mängd över 0.';
formError = context.l10n.pantryInvalidQuantity;
});
return;
}
@@ -133,7 +134,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
'location': selectedLocation,
});
},
child: const Text('Lägg till'),
child: Text(context.l10n.addAction),
),
],
);
@@ -159,7 +160,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
ref.invalidate(inventoryProvider);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${item.displayName} tillagd i inventarie.')),
SnackBar(content: Text(context.l10n.pantryItemAdded(item.displayName))),
);
} catch (error) {
_logger.severe('Failed to add item to inventory: $error');
@@ -195,16 +196,16 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ta bort från baslager?'),
content: Text('Vill du ta bort "${item.displayName}"?'),
title: Text(context.l10n.pantryRemoveTitle),
content: Text(context.l10n.pantryRemoveContent(item.displayName)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Ta bort'),
child: Text(context.l10n.deleteAction),
),
],
),
@@ -233,7 +234,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
if (item.category != null && item.category!.trim().isNotEmpty) {
return item.category!;
}
return 'Övrigt';
return context.l10n.pantryOtherCategory;
}
@override
@@ -242,7 +243,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
final productsAsync = ref.watch(pantryProductsProvider);
if (pantryAsync.isLoading || productsAsync.isLoading) {
return const LoadingStateView(label: 'Laddar baslager...');
return LoadingStateView(label: context.l10n.pantryLoading);
}
if (pantryAsync.hasError || productsAsync.hasError) {
@@ -292,11 +293,11 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Produkter du alltid räknar med att ha hemma.',
context.l10n.pantryDescription,
style: Theme.of(context).textTheme.bodyMedium,
),
IconButton(
tooltip: 'Gå till recept',
tooltip: context.l10n.pantryGoToRecipesTooltip,
icon: const Icon(Icons.restaurant_menu),
onPressed: () => context.go('/recipes'),
),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../admin/presentation/admin_ai_panel.dart';
import '../../admin/presentation/admin_pending_products_panel.dart';
import '../../admin/presentation/admin_products_panel.dart';
@@ -86,7 +87,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
if (!mounted) return;
setState(() => _profile = updated);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profil sparad!')),
SnackBar(content: Text(context.l10n.profileSaved)),
);
} catch (e) {
if (!mounted) return;
@@ -119,15 +120,15 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
String _tabLabel(_ProfileTab tab) {
switch (tab) {
case _ProfileTab.profile:
return 'Min profil';
return context.l10n.profileMyProfileTab;
case _ProfileTab.database:
return 'Databas';
return context.l10n.profileDatabaseTab;
case _ProfileTab.users:
return 'Användare';
return context.l10n.profileUsersTab;
case _ProfileTab.suggestions:
return 'Förslag';
return context.l10n.profilePendingTab;
case _ProfileTab.ai:
return 'AI';
return context.l10n.profileAiTab;
}
}
@@ -158,7 +159,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Användarnamn',
context.l10n.profileUsernameLabel,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -168,15 +169,15 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
const Divider(height: 32),
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'E-post',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.profileEmailLabel,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return 'Ange en e-postadress';
if (v == null || v.isEmpty) return context.l10n.profileEmailHint;
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) {
return 'Ogiltig e-postadress';
return context.l10n.profileEmailInvalid;
}
return null;
},
@@ -184,17 +185,17 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
const SizedBox(height: 16),
TextFormField(
controller: _firstNameCtrl,
decoration: const InputDecoration(
labelText: 'Förnamn',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.profileFirstNameLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _lastNameCtrl,
decoration: const InputDecoration(
labelText: 'Efternamn',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.profileLastNameLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 24),
@@ -208,7 +209,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara ändringar'),
: Text(context.l10n.profileSaveAction),
),
),
],
@@ -230,11 +231,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
String tabLabel(_DatabaseTab tab) {
switch (tab) {
case _DatabaseTab.inventory:
return 'Inventarie';
return context.l10n.profileInventoryTab;
case _DatabaseTab.pantry:
return 'Baslager';
return context.l10n.profilePantryTab;
case _DatabaseTab.products:
return 'Produkter';
return context.l10n.profileProductsTab;
}
}
@@ -282,20 +283,18 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
case _DatabaseTab.inventory:
activeSection = sectionCard(
icon: Icons.inventory_2_outlined,
title: 'Inventarie',
description:
'Lägg till, uppdatera och konsumera varor i ditt inventarie. Detta motsvarar inventarievyn under Databas i recipe-frontend.',
title: context.l10n.profileInventoryTab,
description: context.l10n.profileInventoryDescription,
onPressed: () => context.go('/inventory'),
buttonLabel: 'Öppna inventarie',
buttonLabel: context.l10n.profileOpenInventory,
);
case _DatabaseTab.pantry:
activeSection = sectionCard(
icon: Icons.storefront_outlined,
title: 'Baslager',
description:
'Hantera varor du alltid räknar med att ha hemma. Detta motsvarar baslagervyn under Databas i recipe-frontend.',
title: context.l10n.profilePantryTab,
description: context.l10n.profilePantryDescription,
onPressed: () => context.go('/baslager'),
buttonLabel: 'Öppna baslager',
buttonLabel: context.l10n.profileOpenPantry,
);
case _DatabaseTab.products:
activeSection = const AdminProductsPanel(embedded: true);
@@ -305,7 +304,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Databasfliken samlar samma huvudområden som i recipe-frontend.',
context.l10n.profileDatabaseDescription,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
@@ -366,7 +365,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _loadProfile, child: const Text('Försök igen')),
FilledButton(onPressed: _loadProfile, child: Text(context.l10n.retryAction)),
],
),
);
@@ -424,7 +423,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
child: OutlinedButton.icon(
onPressed: _logout,
icon: const Icon(Icons.logout),
label: const Text('Logga ut'),
label: Text(context.l10n.logoutAction),
),
),
],
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/l10n/l10n.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
import '../domain/parsed_recipe.dart';
@@ -84,7 +85,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
Future<void> _parseMarkdown() async {
final markdown = _markdownCtrl.text.trim();
if (markdown.isEmpty) {
setState(() => _parseError = 'Klistra in eller skriv ett recept i Markdown-format.');
setState(() => _parseError = context.l10n.recipeCreateMarkdownHint);
return;
}
setState(() {
@@ -117,7 +118,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
Future<void> _save() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty) {
setState(() => _saveError = 'Receptnamnet får inte vara tomt.');
setState(() => _saveError = context.l10n.recipeCreateNameRequired);
return;
}
@@ -175,7 +176,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
return Scaffold(
appBar: AppBar(
title:
Text(_step == _Step.input ? 'Nytt recept' : 'Granska ingredienser'),
Text(_step == _Step.input ? context.l10n.recipeCreateTitle : context.l10n.recipeCreateReviewIngredients),
leading: _step == _Step.review
? IconButton(
icon: const Icon(Icons.arrow_back),
@@ -202,9 +203,9 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
hintText: '# Receptnamn\n\n## Ingredienser\n- 500 g köttfärs\n- 1 st lök\n\n## Tillvägagångssätt\nStek löken...',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: context.l10n.recipeCreateMarkdownPlaceholder,
border: const OutlineInputBorder(),
alignLabelWithHint: true,
),
),
@@ -230,7 +231,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Granska ingredienser'),
: Text(context.l10n.recipeCreateReviewIngredients),
),
),
),
@@ -248,22 +249,22 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Receptnamn'),
decoration: InputDecoration(labelText: context.l10n.recipeEditNameLabel),
),
const SizedBox(height: 12),
TextField(
controller: _servingsCtrl,
decoration: const InputDecoration(
labelText: 'Antal portioner (valfritt)'),
decoration: InputDecoration(
labelText: context.l10n.recipeEditServingsLabel),
keyboardType: TextInputType.number,
),
if (parsed.ingredients.isNotEmpty) ...[
const SizedBox(height: 20),
Text('Ingredienser',
Text(context.l10n.recipeEditIngredientsLabel,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Bocka av ingredienser att inkludera och välj rätt produkt.',
context.l10n.recipeCreateIngredientsHint,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
@@ -295,7 +296,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara recept'),
: Text(context.l10n.recipeCreateSaveAction),
),
),
),
@@ -321,7 +322,7 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
title: Text(label),
subtitle: ing.suggestions.isEmpty
? Text(
'Ingen produkt hittades — ingrediensen hoppas över.',
context.l10n.recipeCreateNoProductFound,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12),
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/auth/jwt_decoder.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
@@ -38,31 +39,31 @@ class RecipeDetailScreen extends ConsumerWidget {
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/recipes'),
tooltip: 'Tillbaka till receptlistan',
tooltip: context.l10n.recipeDetailBackToList,
),
actions: recipe == null
? []
: [
if (isOwner)
IconButton(
tooltip: recipe.isPublic ? 'Gör privat' : 'Gör publik',
tooltip: recipe.isPublic ? context.l10n.recipeDetailMakePrivate : context.l10n.recipeDetailMakePublic,
icon: Icon(recipe.isPublic ? Icons.public : Icons.lock_outline),
onPressed: () => _toggleVisibility(context, ref, recipe),
),
if (isOwner)
IconButton(
tooltip: 'Dela med användare',
tooltip: context.l10n.recipeDetailShareWithUser,
icon: const Icon(Icons.person_add_alt_1_outlined),
onPressed: () => _shareRecipe(context, ref, recipe),
),
IconButton(
tooltip: 'Redigera',
tooltip: context.l10n.editTooltip,
icon: const Icon(Icons.edit_outlined),
onPressed: () =>
context.push('/recipes/$recipeId/edit'),
),
IconButton(
tooltip: 'Gå till inventarie',
tooltip: context.l10n.recipeDetailGoToInventory,
icon: const Icon(Icons.inventory_2_outlined),
onPressed: () => context.go('/inventory'),
),
@@ -70,7 +71,7 @@ class RecipeDetailScreen extends ConsumerWidget {
],
),
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
loading: () => LoadingStateView(label: context.l10n.recipeDetailLoading),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)),
@@ -184,8 +185,8 @@ class RecipeDetailScreen extends ConsumerWidget {
SnackBar(
content: Text(
!recipe.isPublic
? 'Receptet är nu publikt.'
: 'Receptet är nu privat.',
? context.l10n.recipeDetailNowPublic
: context.l10n.recipeDetailNowPrivate,
),
),
);
@@ -206,13 +207,13 @@ class RecipeDetailScreen extends ConsumerWidget {
final result = await showDialog<(_ShareAction, String)>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Dela recept'),
title: Text(context.l10n.recipeDetailShareTitle),
content: TextField(
controller: ctrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Användarnamn',
hintText: 't.ex. anna',
decoration: InputDecoration(
labelText: context.l10n.recipeDetailUsernameLabel,
hintText: context.l10n.recipeDetailUsernameHint,
),
onSubmitted: (_) => Navigator.pop(
context,
@@ -222,21 +223,21 @@ class RecipeDetailScreen extends ConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
TextButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.unshare, ctrl.text.trim()),
),
child: const Text('Ta bort delning'),
child: Text(context.l10n.recipeDetailRemoveShare),
),
FilledButton(
onPressed: () => Navigator.pop(
context,
(_ShareAction.share, ctrl.text.trim()),
),
child: const Text('Dela'),
child: Text(context.l10n.recipeDetailShareAction),
),
],
),
@@ -272,8 +273,8 @@ class RecipeDetailScreen extends ConsumerWidget {
SnackBar(
content: Text(
action == _ShareAction.unshare
? 'Delning borttagen för $trimmed.'
: 'Receptet delades med $trimmed.',
? context.l10n.recipeDetailSharingRemoved(trimmed)
: context.l10n.recipeDetailSharedWith(trimmed),
),
),
);
@@ -310,7 +311,7 @@ class _DeleteButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: 'Ta bort',
tooltip: context.l10n.deleteTooltip,
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDelete(context, ref),
);
@@ -320,19 +321,18 @@ class _DeleteButton extends ConsumerWidget {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Ta bort recept?'),
content: Text(
'Vill du ta bort "${recipe.title}"? Åtgärden kan inte ångras.'),
title: Text(context.l10n.recipeDetailDeleteTitle),
content: Text(context.l10n.recipeDetailDeleteContent(recipe.title)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Avbryt'),
child: Text(context.l10n.cancelAction),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error),
onPressed: () => Navigator.pop(context, true),
child: const Text('Ta bort'),
child: Text(context.l10n.deleteAction),
),
],
),
@@ -385,14 +385,14 @@ class _RecipeBody extends StatelessWidget {
children: [
const Icon(Icons.people_outline, size: 16),
const SizedBox(width: 4),
Text('${recipe.servings} portioner',
Text('${recipe.servings} ${context.l10n.recipeDetailServings}',
style: theme.textTheme.bodySmall),
],
),
],
if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Ingredienser', style: theme.textTheme.titleMedium),
Text(context.l10n.recipeDetailIngredients, style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
...recipe.ingredients.map((ing) {
final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity);
@@ -443,7 +443,7 @@ class _RecipeBody extends StatelessWidget {
if (recipe.instructions != null &&
recipe.instructions!.isNotEmpty) ...[
const SizedBox(height: 32),
Text('Tillvägagångssätt', style: theme.textTheme.titleMedium),
Text(context.l10n.recipeDetailInstructions, style: theme.textTheme.titleMedium),
const SizedBox(height: 16),
..._buildSteps(recipe.instructions!, theme),
],
@@ -7,6 +7,7 @@ import '../../../core/api/api_exception.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
@@ -152,20 +153,20 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
String? _validateIngredients() {
if (_ingredients.isEmpty) {
return 'Minst en ingrediens krävs.';
return context.l10n.recipeEditMinIngredients;
}
for (final ingredient in _ingredients) {
if (ingredient.productId == null) {
return 'Välj produkt för alla ingredienser.';
return context.l10n.recipeEditSelectProduct;
}
final quantity = double.tryParse(
ingredient.quantityCtrl.text.trim().replaceAll(',', '.'),
);
if (quantity == null || quantity < 0) {
return 'Ange giltig mängd för alla ingredienser.';
return context.l10n.recipeEditValidQuantity;
}
if (ingredient.unit.trim().isEmpty) {
return 'Välj enhet för alla ingredienser.';
return context.l10n.recipeEditSelectUnit;
}
}
return null;
@@ -237,7 +238,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Redigera recept'),
title: Text(context.l10n.recipeEditTitle),
actions: [
if (_initialized)
TextButton(
@@ -248,12 +249,12 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara'),
: Text(context.l10n.saveAction),
),
],
),
body: recipeAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
loading: () => LoadingStateView(label: context.l10n.recipeDetailLoading),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)),
@@ -276,27 +277,27 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
children: [
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Receptnamn'),
decoration: InputDecoration(labelText: context.l10n.recipeEditNameLabel),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Ange ett receptnamn.' : null,
(v == null || v.trim().isEmpty) ? context.l10n.recipeEditNameRequired : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descCtrl,
decoration:
const InputDecoration(labelText: 'Beskrivning (valfritt)'),
InputDecoration(labelText: context.l10n.recipeEditDescriptionLabel),
maxLines: 3,
),
const SizedBox(height: 16),
TextFormField(
controller: _servingsCtrl,
decoration:
const InputDecoration(labelText: 'Antal portioner (valfritt)'),
InputDecoration(labelText: context.l10n.recipeEditServingsLabel),
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (int.tryParse(v.trim()) == null) {
return 'Ange ett heltal.';
return context.l10n.recipeEditServingsInvalid;
}
return null;
},
@@ -305,7 +306,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
TextFormField(
controller: _instructionsCtrl,
decoration:
const InputDecoration(labelText: 'Tillvägagångssätt (valfritt)'),
InputDecoration(labelText: context.l10n.recipeEditInstructionsLabel),
maxLines: 10,
textAlignVertical: TextAlignVertical.top,
),
@@ -314,20 +315,20 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
children: [
Expanded(
child: Text(
'Ingredienser',
context.l10n.recipeEditIngredientsLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
OutlinedButton.icon(
onPressed: _isSaving ? null : _addIngredient,
icon: const Icon(Icons.add),
label: const Text('Lägg till'),
label: Text(context.l10n.addAction),
),
],
),
const SizedBox(height: 8),
Text(
'Välj produkt, mängd och enhet för varje ingrediens.',
context.l10n.recipeEditIngredientsHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
@@ -338,10 +339,10 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
child: LinearProgressIndicator(),
),
if (_ingredients.isEmpty)
const Card(
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Inga ingredienser tillagda än.'),
padding: const EdgeInsets.all(16),
child: Text(context.l10n.recipeEditNoIngredients),
),
),
...List.generate(
@@ -366,7 +367,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara ändringar'),
: Text(context.l10n.recipeEditSaveChanges),
),
const SizedBox(height: 40),
],
@@ -387,14 +388,14 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
children: [
Expanded(
child: Text(
'Ingrediens ${index + 1}',
'${context.l10n.recipeEditIngredientPrefix}${index + 1}',
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
onPressed: _isSaving ? null : () => _removeIngredient(index),
icon: const Icon(Icons.delete_outline),
tooltip: 'Ta bort ingrediens',
tooltip: context.l10n.recipeEditRemoveIngredient,
),
],
),
@@ -440,19 +441,19 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
Expanded(
child: TextFormField(
controller: ingredient.quantityCtrl,
decoration: const InputDecoration(
labelText: 'Mängd *',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.quantityLabel,
border: const OutlineInputBorder(),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Ange mängd';
return context.l10n.quantityHint;
}
if (double.tryParse(value.trim().replaceAll(',', '.')) ==
null) {
return 'Ogiltigt tal';
return context.l10n.invalidNumber;
}
return null;
},
@@ -463,9 +464,9 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
child: DropdownButtonFormField<String>(
initialValue: ingredient.unit.trim().isEmpty ? null : ingredient.unit,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Enhet *',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.unitLabel,
border: const OutlineInputBorder(),
),
items: unitOptions
.map(
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../data/recipe_providers.dart';
import '../data/recipes_grid_provider.dart';
@@ -22,16 +23,16 @@ class RecipesScreen extends ConsumerWidget {
return Stack(
children: [
recipesAsync.when(
loading: () => const LoadingStateView(label: 'Laddar recept...'),
loading: () => LoadingStateView(label: context.l10n.recipesLoading),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(recipesProvider),
),
data: (recipes) {
if (recipes.isEmpty) {
return const EmptyStateView(
title: 'Inga recept hittades',
description: 'Lägg till ett recept för att komma igång.',
return EmptyStateView(
title: context.l10n.recipesEmpty,
description: context.l10n.recipesEmptyDescription,
);
}
@@ -95,7 +96,7 @@ class RecipesScreen extends ConsumerWidget {
right: 16,
bottom: 16,
child: FloatingActionButton(
tooltip: 'Nytt recept',
tooltip: context.l10n.recipesNewTooltip,
onPressed: () => context.push('/recipes/create'),
child: const Icon(Icons.add),
),