Refactor code structure for improved readability and maintainability

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-04-23 21:14:46 +02:00
parent cd4274575e
commit db1128ceaf
49 changed files with 285993 additions and 175 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ class ApiClient {
if (token != null) 'Authorization': 'Bearer $token',
};
Future<Map<String, dynamic>> getJson(String path, {String? token}) async {
Future<dynamic> getJson(String path, {String? token}) async {
final response = await _client.get(
Uri.parse('$baseUrl$path'),
headers: _headers(token: token),
+9
View File
@@ -28,6 +28,15 @@ class PantryApiPaths {
static String remove(int id) => '/pantry/$id';
}
class UserApiPaths {
static const me = '/users/me';
static const list = '/users';
static String setRole(int id) => '/users/$id/role';
static String setPremium(int id) => '/users/$id/premium';
static String delete(int id) => '/users/$id';
static String resetPassword(int id) => '/users/$id/reset-password';
}
class MealPlanApiPaths {
static const list = '/meal-plan';
+26
View File
@@ -0,0 +1,26 @@
import 'dart:convert';
/// Decodes a JWT token payload without verifying signature.
/// Returns the decoded claims or an empty map on failure.
Map<String, dynamic> decodeJwtPayload(String token) {
try {
final parts = token.split('.');
if (parts.length != 3) return {};
// Normalize base64url to standard base64.
final payload = base64Url.normalize(parts[1]);
final decoded = utf8.decode(base64Url.decode(payload));
return json.decode(decoded) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
/// Returns the role claim from a JWT token. Defaults to 'user'.
String jwtRole(String? token) {
if (token == null || token.isEmpty) return 'user';
final claims = decodeJwtPayload(token);
return claims['role'] as String? ?? 'user';
}
/// Returns true if the JWT token contains role == 'admin'.
bool jwtIsAdmin(String? token) => jwtRole(token) == 'admin';
+11 -1
View File
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../ui/app_shell.dart';
import '../ui/async_state_views.dart';
import '../../features/auth/data/auth_providers.dart';
import '../../core/auth/jwt_decoder.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
import '../../features/recipes/presentation/create_recipe_screen.dart';
@@ -20,6 +21,7 @@ import '../../features/inventory/presentation/consumption_history_screen.dart';
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
import '../../features/pantry/presentation/pantry_screen.dart';
import '../../features/import/presentation/import_screen.dart';
import '../../features/admin/presentation/admin_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
@@ -28,7 +30,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
initialLocation: '/',
redirect: (context, state) {
final isLoading = authState.isLoading;
final token = authState.valueOrNull;
final token = authState.maybeWhen(data: (t) => t, orElse: () => null);
final isLoggedIn = token != null && token.isNotEmpty;
final location = state.matchedLocation;
final isSplash = location == '/';
@@ -194,6 +196,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/admin',
redirect: (context, state) {
final token = ref.read(authStateProvider).maybeWhen(data: (t) => t, orElse: () => null);
return jwtIsAdmin(token) ? null : '/recipes';
},
builder: (context, state) => const AdminScreen(),
),
],
),
],
+22 -8
View File
@@ -4,6 +4,13 @@ import 'package:go_router/go_router.dart';
import '../../features/auth/data/auth_providers.dart';
const _adminDestination = _AppDestination(
path: '/admin',
title: 'Admin',
icon: Icons.admin_panel_settings_outlined,
label: 'Admin',
);
class AppShell extends ConsumerWidget {
final String location;
final Widget child;
@@ -14,7 +21,7 @@ class AppShell extends ConsumerWidget {
required this.child,
});
static const _destinations = [
static const _baseDestinations = [
_AppDestination(
path: '/recipes',
title: 'Recept',
@@ -53,8 +60,13 @@ class AppShell extends ConsumerWidget {
),
];
int _selectedIndex() {
final index = _destinations.indexWhere(
List<_AppDestination> _destinations(bool isAdmin) => [
..._baseDestinations,
if (isAdmin) _adminDestination,
];
int _selectedIndex(List<_AppDestination> destinations) {
final index = destinations.indexWhere(
(destination) => location.startsWith(destination.path),
);
return index < 0 ? 0 : index;
@@ -62,8 +74,10 @@ class AppShell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedIndex = _selectedIndex();
final selectedDestination = _destinations[selectedIndex];
final isAdmin = ref.watch(isAdminProvider);
final dests = _destinations(isAdmin);
final selectedIndex = _selectedIndex(dests);
final selectedDestination = dests[selectedIndex];
final isWide = MediaQuery.of(context).size.width >= 900;
Future<void> logout() async {
@@ -74,7 +88,7 @@ class AppShell extends ConsumerWidget {
}
void navigateTo(int index) {
final target = _destinations[index].path;
final target = dests[index].path;
if (target != location && context.mounted) {
context.go(target);
}
@@ -98,7 +112,7 @@ class AppShell extends ConsumerWidget {
selectedIndex: selectedIndex,
onDestinationSelected: navigateTo,
labelType: NavigationRailLabelType.all,
destinations: _destinations
destinations: dests
.map(
(destination) => NavigationRailDestination(
icon: Icon(destination.icon),
@@ -117,7 +131,7 @@ class AppShell extends ConsumerWidget {
: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: navigateTo,
destinations: _destinations
destinations: dests
.map(
(destination) => NavigationDestination(
icon: Icon(destination.icon),
@@ -0,0 +1,72 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/guarded_api_call.dart';
import '../domain/user_admin.dart';
final adminRepositoryProvider = Provider<AdminRepository>((ref) {
return AdminRepository(ref.watch(apiClientProvider), ref);
});
class AdminRepository {
final ApiClient _apiClient;
final Ref _ref;
AdminRepository(this._apiClient, this._ref);
Future<List<UserAdmin>> listUsers() async {
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(UserApiPaths.list),
);
return (data as List<dynamic>).map((e) => UserAdmin.fromJson(e as Map<String, dynamic>)).toList();
}
Future<UserAdmin> setRole(int userId, String newRole) async {
final data = await guardedApiCall(
_ref,
() => _apiClient.patchJson(UserApiPaths.setRole(userId), body: {'role': newRole}),
);
return UserAdmin.fromJson(data);
}
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) async {
final data = await guardedApiCall(
_ref,
() => _apiClient.patchJson(UserApiPaths.setPremium(userId), body: {'isPremium': isPremium}),
);
return UserAdmin.fromJson(data);
}
Future<UserAdmin> createUser({
required String username,
required String email,
required String password,
String role = 'user',
}) async {
final data = await guardedApiCall(
_ref,
() => _apiClient.postJson(UserApiPaths.list, body: {
'username': username,
'email': email,
'password': password,
'role': role,
}),
);
return UserAdmin.fromJson(data as Map<String, dynamic>);
}
Future<void> deleteUser(int userId) => guardedApiCall(
_ref,
() => _apiClient.deleteJson(UserApiPaths.delete(userId)),
);
/// Returns `{ temporaryPassword, to, subject, body }`.
Future<Map<String, dynamic>> resetPassword(int userId) async {
final result = await guardedApiCall<dynamic>(
_ref,
() => _apiClient.postJson(UserApiPaths.resetPassword(userId)),
);
return (result as Map<String, dynamic>);
}
}
@@ -0,0 +1,40 @@
/// Model for a user as returned by the admin endpoint.
class UserAdmin {
final int id;
final String username;
final String email;
final String? firstName;
final String? lastName;
final String role;
final bool isPremium;
final DateTime? createdAt;
const UserAdmin({
required this.id,
required this.username,
required this.email,
this.firstName,
this.lastName,
required this.role,
required this.isPremium,
this.createdAt,
});
factory UserAdmin.fromJson(Map<String, dynamic> json) => UserAdmin(
id: json['id'] as int,
username: json['username'] as String,
email: json['email'] as String? ?? '',
firstName: json['firstName'] as String?,
lastName: json['lastName'] as String?,
role: json['role'] as String? ?? 'user',
isPremium: json['isPremium'] as bool? ?? false,
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) : null,
);
bool get isAdmin => role == 'admin';
String get displayName {
final parts = [firstName, lastName].where((s) => s != null && s.isNotEmpty).toList();
return parts.isNotEmpty ? parts.join(' ') : username;
}
}
@@ -0,0 +1,413 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../data/admin_repository.dart';
import '../domain/user_admin.dart';
class AdminScreen extends ConsumerStatefulWidget {
const AdminScreen({super.key});
@override
ConsumerState<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends ConsumerState<AdminScreen> {
bool _isLoading = true;
String? _error;
List<UserAdmin> _users = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final users = await ref.read(adminRepositoryProvider).listUsers();
if (!mounted) return;
setState(() => _users = users);
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _changeRole(UserAdmin user) async {
final newRole = user.isAdmin ? 'user' : 'admin';
final confirmed = await _confirm(
context,
'Ändra roll',
'Ändra ${user.username} till $newRole?',
);
if (!confirmed || !mounted) return;
try {
await ref.read(adminRepositoryProvider).setRole(user.id, newRole);
if (!mounted) return;
_load();
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
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}?',
);
if (!confirmed || !mounted) return;
try {
final result = await ref.read(adminRepositoryProvider).resetPassword(user.id);
if (!mounted) return;
final tempPw = result['temporaryPassword'] as String? ?? '';
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Tillfälligt lösenord'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Lösenord för ${user.username}:'),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: SelectableText(
tempPw,
style: const TextStyle(fontFamily: 'monospace', fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Kopiera',
onPressed: () => Clipboard.setData(ClipboardData(text: tempPw)),
),
],
),
const SizedBox(height: 8),
const Text('Användaren måste byta lösenord vid nästa inloggning.', style: TextStyle(fontSize: 12)),
],
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Stäng'))],
),
);
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
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.',
destructive: true,
);
if (!confirmed || !mounted) return;
try {
await ref.read(adminRepositoryProvider).deleteUser(user.id);
if (!mounted) return;
_load();
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
Future<void> _createUser() async {
final result = await showDialog<Map<String, String>>(
context: context,
builder: (_) => const _CreateUserDialog(),
);
if (result == null || !mounted) return;
try {
await ref.read(adminRepositoryProvider).createUser(
username: result['username']!,
email: result['email']!,
password: result['password']!,
role: result['role'] ?? 'user',
);
if (!mounted) return;
_load();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Användare ${result['username']} skapad.')),
);
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
void _showError(Object e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(mapErrorToUserMessage(e, context)),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
Future<bool> _confirm(BuildContext ctx, String title, String body, {bool destructive = false}) async {
final result = await showDialog<bool>(
context: ctx,
builder: (_) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(onPressed: () => Navigator.pop(_, false), child: const Text('Avbryt')),
TextButton(
onPressed: () => Navigator.pop(_, true),
style: destructive
? TextButton.styleFrom(foregroundColor: Theme.of(ctx).colorScheme.error)
: null,
child: const Text('Bekräfta'),
),
],
),
);
return result ?? false;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Admin Användare'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Uppdatera',
onPressed: _isLoading ? null : _load,
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _createUser,
icon: const Icon(Icons.person_add_outlined),
label: const Text('Ny användare'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('Försök igen')),
],
),
)
: _users.isEmpty
? const Center(child: Text('Inga användare hittades.'))
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 80),
itemCount: _users.length,
itemBuilder: (ctx, i) => _UserTile(
user: _users[i],
onChangeRole: () => _changeRole(_users[i]),
onResetPassword: () => _resetPassword(_users[i]),
onDelete: () => _deleteUser(_users[i]),
),
),
);
}
}
class _UserTile extends StatelessWidget {
final UserAdmin user;
final VoidCallback onChangeRole;
final VoidCallback onResetPassword;
final VoidCallback onDelete;
const _UserTile({
required this.user,
required this.onChangeRole,
required this.onResetPassword,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: user.isAdmin
? theme.colorScheme.primaryContainer
: theme.colorScheme.secondaryContainer,
child: Icon(
user.isAdmin ? Icons.shield_outlined : Icons.person_outline,
color: user.isAdmin
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSecondaryContainer,
size: 20,
),
),
title: Text(user.displayName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.email, style: theme.textTheme.bodySmall),
Row(
children: [
Chip(
label: Text(user.role),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
),
if (user.isPremium) ...[
const SizedBox(width: 4),
Chip(
label: const Text('Premium'),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
backgroundColor: theme.colorScheme.tertiaryContainer,
),
],
],
),
],
),
isThreeLine: true,
trailing: PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'role':
onChangeRole();
case 'reset':
onResetPassword();
case 'delete':
onDelete();
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'role',
child: Text(user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin'),
),
const PopupMenuItem(value: 'reset', child: Text('Återställ lösenord')),
const PopupMenuDivider(),
PopupMenuItem(
value: 'delete',
child: Text('Ta bort', style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
),
);
}
}
class _CreateUserDialog extends StatefulWidget {
const _CreateUserDialog();
@override
State<_CreateUserDialog> createState() => _CreateUserDialogState();
}
class _CreateUserDialogState extends State<_CreateUserDialog> {
final _formKey = GlobalKey<FormState>();
final _usernameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
String _role = 'user';
bool _obscure = true;
@override
void dispose() {
_usernameCtrl.dispose();
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Skapa användare'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _usernameCtrl,
decoration: const InputDecoration(labelText: 'Användarnamn'),
validator: (v) => (v == null || v.length < 2) ? 'Minst 2 tecken' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'E-post'),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return 'Obligatoriskt';
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) return 'Ogiltig e-post';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordCtrl,
decoration: InputDecoration(
labelText: 'Lösenord',
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
obscureText: _obscure,
validator: (v) => (v == null || v.length < 8) ? 'Minst 8 tecken' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _role,
decoration: const InputDecoration(labelText: 'Roll'),
items: const [
DropdownMenuItem(value: 'user', child: Text('Användare')),
DropdownMenuItem(value: 'admin', child: Text('Admin')),
],
onChanged: (v) => setState(() => _role = v ?? 'user'),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Avbryt')),
FilledButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
Navigator.pop(context, {
'username': _usernameCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'password': _passwordCtrl.text,
'role': _role,
});
}
},
child: const Text('Skapa'),
),
],
);
}
}
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/platform/platform_providers.dart';
import '../../../core/auth/jwt_decoder.dart';
import 'auth_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
@@ -32,3 +33,9 @@ class AuthNotifier extends AsyncNotifier<String?> {
state = const AsyncData(null);
}
}
/// Synchronously derived provider — true when the current token has role == 'admin'.
final isAdminProvider = Provider<bool>((ref) {
final token = ref.watch(authStateProvider).maybeWhen(data: (t) => t, orElse: () => null);
return jwtIsAdmin(token);
});
@@ -20,7 +20,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
List<ParsedReceiptItem>? _items;
Future<void> _pickFile() async {
final result = await FilePicker.platform.pickFiles(
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp'],
withData: true,
@@ -42,7 +42,7 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
// ── File picker ──────────────────────────────────────────────────────────
Future<void> _pickFile() async {
final result = await FilePicker.platform.pickFiles(
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: _allowedExtensions,
withData: true, // needed on Flutter web to get bytes
@@ -14,8 +14,17 @@ class InventoryQuery {
const InventoryQuery({required this.location, required this.sort});
}
final inventoryLocationFilterProvider = StateProvider<String>((ref) => '');
final inventorySortFilterProvider = StateProvider<String>((ref) => '');
class _StringNotifier extends Notifier<String> {
_StringNotifier(this._initial);
final String _initial;
@override
String build() => _initial;
}
final inventoryLocationFilterProvider =
NotifierProvider<_StringNotifier, String>(() => _StringNotifier(''));
final inventorySortFilterProvider =
NotifierProvider<_StringNotifier, String>(() => _StringNotifier(''));
final inventoryQueryProvider = Provider<InventoryQuery>((ref) {
final location = ref.watch(inventoryLocationFilterProvider);
@@ -373,7 +373,11 @@ class _DeleteButton extends ConsumerWidget {
);
if (confirmed == true) {
try {
await ref.read(inventoryRepositoryProvider).deleteItem(item.id);
final token = await ref.read(authStateProvider.future);
await ref
.read(inventoryRepositoryProvider)
.deleteInventoryItem(item.id, token: token);
ref.invalidate(inventoryProvider);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
@@ -11,7 +11,15 @@ final mealPlanRepositoryProvider = Provider<MealPlanRepository>((ref) {
return MealPlanRepository(ref.watch(apiClientProvider));
});
final mealPlanWeekOffsetProvider = StateProvider<int>((ref) => 0);
class _IntNotifier extends Notifier<int> {
_IntNotifier(this._initial);
final int _initial;
@override
int build() => _initial;
}
final mealPlanWeekOffsetProvider =
NotifierProvider<_IntNotifier, int>(() => _IntNotifier(0));
final mealPlanWeekProvider = Provider<MealPlanWeek>((ref) {
final offset = ref.watch(mealPlanWeekOffsetProvider);
@@ -12,14 +12,14 @@ class MealPlanRepository {
Future<List<MealPlanEntry>> fetchEntries(String from, String to, {String? token}) async {
try {
final data = await _api.getJson(MealPlanApiPaths.listByRange(from, to), token: token);
final dynamic data = await _api.getJson(MealPlanApiPaths.listByRange(from, to), token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.',
);
}
return data
return (data as List)
.map((item) => MealPlanEntry.fromJson(item as Map<String, dynamic>))
.toList();
} on ApiException {
@@ -34,14 +34,14 @@ class MealPlanRepository {
Future<List<ShoppingItem>> fetchShoppingList(String from, String to, {String? token}) async {
try {
final data = await _api.getJson(MealPlanApiPaths.shoppingList(from, to), token: token);
final dynamic data = await _api.getJson(MealPlanApiPaths.shoppingList(from, to), token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.',
);
}
return data
return (data as List)
.map((item) => ShoppingItem.fromJson(item as Map<String, dynamic>))
.toList();
} on ApiException {
@@ -56,14 +56,14 @@ class MealPlanRepository {
Future<List<InventoryCompareItem>> fetchInventoryCompare(String from, String to, {String? token}) async {
try {
final data = await _api.getJson(MealPlanApiPaths.inventoryCompare(from, to), token: token);
final dynamic data = await _api.getJson(MealPlanApiPaths.inventoryCompare(from, to), token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.',
);
}
return data
return (data as List)
.map((item) => InventoryCompareItem.fromJson(item as Map<String, dynamic>))
.toList();
} on ApiException {
@@ -87,8 +87,8 @@ class _MealPlanScreenState extends ConsumerState<MealPlanScreen> {
);
}
final recipes = recipesAsync.valueOrNull ?? const <Recipe>[];
final dashboard = dashboardAsync.valueOrNull ??
final recipes = recipesAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const <Recipe>[];
final dashboard = dashboardAsync.maybeWhen(data: (d) => d, orElse: () => null) ??
const MealPlanDashboard(
entries: [],
shoppingItems: [],
@@ -254,8 +254,8 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
);
}
final pantryItems = pantryAsync.valueOrNull ?? const [];
final products = productsAsync.valueOrNull ?? const [];
final pantryItems = pantryAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const [];
final products = productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const [];
final productById = {for (final product in products) product.id: product};
final pantryProductIds = pantryItems.map((e) => e.productId).toSet();
final availableProducts = products
@@ -1,12 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/guarded_api_call.dart';
import '../../../core/api/api_exception.dart';
import '../../auth/data/auth_providers.dart';
import '../domain/user_profile.dart';
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
final apiClient = ref.read(apiClientProvider);
return ProfileRepository(apiClient, ref);
return ProfileRepository(ref.watch(apiClientProvider), ref);
});
class ProfileRepository {
@@ -15,17 +15,28 @@ class ProfileRepository {
ProfileRepository(this._apiClient, this._ref);
Future<Map<String, dynamic>> getProfile() async {
return guardedApiCall(
Future<UserProfile> getMe() async {
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson('/api/profile'),
() => _apiClient.getJson(UserApiPaths.me),
);
return UserProfile.fromJson(data);
}
Future<Map<String, dynamic>> updateProfile(Map<String, dynamic> profileData) async {
return guardedApiCall(
Future<UserProfile> updateMe({
String? email,
String? firstName,
String? lastName,
}) async {
final body = <String, dynamic>{
if (email != null) 'email': email,
if (firstName != null) 'firstName': firstName,
if (lastName != null) 'lastName': lastName,
};
final data = await guardedApiCall(
_ref,
() => _apiClient.patchJson('/api/profile', profileData),
() => _apiClient.patchJson(UserApiPaths.me, body: body),
);
return UserProfile.fromJson(data);
}
}
@@ -0,0 +1,42 @@
class UserProfile {
final int id;
final String username;
final String email;
final String? firstName;
final String? lastName;
final String role;
const UserProfile({
required this.id,
required this.username,
required this.email,
this.firstName,
this.lastName,
required this.role,
});
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
id: json['id'] as int,
username: json['username'] as String,
email: json['email'] as String? ?? '',
firstName: json['firstName'] as String?,
lastName: json['lastName'] as String?,
role: json['role'] as String? ?? 'user',
);
bool get isAdmin => role == 'admin';
UserProfile copyWith({
String? email,
String? firstName,
String? lastName,
}) =>
UserProfile(
id: id,
username: username,
email: email ?? this.email,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
role: role,
);
}
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../data/profile_repository.dart';
import '../../auth/data/auth_providers.dart';
import '../data/profile_repository.dart';
import '../domain/user_profile.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@@ -12,111 +14,178 @@ class ProfileScreen extends ConsumerStatefulWidget {
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
final _formKey = GlobalKey<FormState>();
String _username = '';
String _email = '';
bool _isLoading = true;
bool _isSaving = false;
String? _error;
UserProfile? _profile;
late final TextEditingController _emailCtrl;
late final TextEditingController _firstNameCtrl;
late final TextEditingController _lastNameCtrl;
@override
void initState() {
super.initState();
_emailCtrl = TextEditingController();
_firstNameCtrl = TextEditingController();
_lastNameCtrl = TextEditingController();
_loadProfile();
}
@override
void dispose() {
_emailCtrl.dispose();
_firstNameCtrl.dispose();
_lastNameCtrl.dispose();
super.dispose();
}
Future<void> _loadProfile() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final profile = await ref.read(profileRepositoryProvider).getProfile();
final profile = await ref.read(profileRepositoryProvider).getMe();
if (!mounted) return;
setState(() {
_username = profile['username'] ?? '';
_email = profile['email'] ?? '';
_isLoading = false;
_profile = profile;
_emailCtrl.text = profile.email;
_firstNameCtrl.text = profile.firstName ?? '';
_lastNameCtrl.text = profile.lastName ?? '';
});
} catch (e) {
_showErrorMessage(e);
setState(() {
_isLoading = false;
});
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _updateProfile() async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
setState(() {
_isLoading = true;
});
try {
await ref.read(profileRepositoryProvider).updateProfile({
'username': _username,
'email': _email,
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profil uppdaterad!')),
);
} catch (e) {
_showErrorMessage(e);
} finally {
setState(() {
_isLoading = false;
});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSaving = true);
try {
final updated = await ref.read(profileRepositoryProvider).updateMe(
email: _emailCtrl.text.trim(),
firstName: _firstNameCtrl.text.trim().isEmpty ? null : _firstNameCtrl.text.trim(),
lastName: _lastNameCtrl.text.trim().isEmpty ? null : _lastNameCtrl.text.trim(),
);
if (!mounted) return;
setState(() => _profile = updated);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profil sparad!')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
);
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
void _showErrorMessage(dynamic error) {
final message = mapErrorToUserMessage(error, context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
Future<void> _logout() async {
await ref.read(authStateProvider.notifier).logout();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Användarprofil'),
title: const Text('Profil'),
actions: [
IconButton(
onPressed: _logout,
icon: const Icon(Icons.logout),
tooltip: 'Logga ut',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
TextFormField(
decoration: const InputDecoration(labelText: 'Användarnamn'),
initialValue: _username,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Ange ett användarnamn';
}
return null;
},
onSaved: (value) => _username = value!,
: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _loadProfile, child: const Text('Försök igen')),
],
),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Read-only username
Text('Användarnamn', style: theme.textTheme.labelMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Text(_profile?.username ?? '', style: theme.textTheme.bodyLarge),
const Divider(height: 32),
// Role badge
if (_profile?.isAdmin == true)
Chip(
label: const Text('Admin'),
avatar: const Icon(Icons.shield_outlined, size: 16),
backgroundColor: theme.colorScheme.primaryContainer,
labelStyle: TextStyle(color: theme.colorScheme.onPrimaryContainer),
),
if (_profile?.isAdmin == true) const SizedBox(height: 16),
// Editable fields
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'E-post',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return 'Ange en e-postadress';
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) return 'Ogiltig e-postadress';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _firstNameCtrl,
decoration: const InputDecoration(
labelText: 'Förnamn',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _lastNameCtrl,
decoration: const InputDecoration(
labelText: 'Efternamn',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isSaving ? null : _save,
child: _isSaving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Spara'),
),
),
],
),
TextFormField(
decoration: const InputDecoration(labelText: 'E-post'),
initialValue: _email,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Ange en e-postadress';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
return 'Ange en giltig e-postadress';
}
return null;
},
onSaved: (value) => _email = value!,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _updateProfile,
child: const Text('Spara'),
),
],
),
),
),
),
);
}
}
@@ -12,12 +12,12 @@ class RecipeRepository {
Future<List<Recipe>> fetchRecipes({String? token}) async {
try {
final data = await _api.getJson(RecipeApiPaths.list, token: token);
final dynamic data = await _api.getJson(RecipeApiPaths.list, token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return data
return (data as List)
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
.toList();
} on ApiException {
@@ -21,8 +21,8 @@ class RecipeDetailScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: Text(recipeAsync.valueOrNull?.title ?? 'Recept'),
actions: recipeAsync.valueOrNull == null
title: Text(recipeAsync.maybeWhen(data: (d) => d, orElse: () => null)?.title ?? 'Recept'),
actions: recipeAsync.maybeWhen(data: (d) => d, orElse: () => null) == null
? []
: [
IconButton(