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
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
["C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\*\\index.html","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter_bootstrap.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_en.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_sv.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations.dart"] ["C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_en.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_sv.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\*\\index.html","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter_bootstrap.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\main.dart.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\fonts\\MaterialIcons-Regular.otf","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\shaders\\ink_sparkle.frag","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\shaders\\stretch_effect.frag","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\AssetManifest.bin","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\AssetManifest.bin.json","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\FontManifest.json","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\NOTICES","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter_service_worker.js"]
@@ -0,0 +1 @@
C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter_service_worker.js: C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\AssetManifest.bin C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\AssetManifest.bin.json C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\FontManifest.json C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\fonts\\MaterialIcons-Regular.otf C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\NOTICES C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\shaders\\ink_sparkle.frag C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\shaders\\stretch_effect.frag C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\canvaskit.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\canvaskit.js.symbols C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\canvaskit.wasm C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\chromium\\canvaskit.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\chromium\\canvaskit.js.symbols C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\chromium\\canvaskit.wasm C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm.js.symbols C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm.wasm C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm_heavy.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm_heavy.js.symbols C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm_heavy.wasm C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\wimp.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\wimp.js.symbols C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\wimp.wasm C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter_bootstrap.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\index.html C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\main.dart.js C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\version.json
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
: C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\web\\index.html
@@ -0,0 +1 @@
{"inputs":["C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\AssetManifest.bin","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\AssetManifest.bin.json","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\FontManifest.json","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\fonts\\MaterialIcons-Regular.otf","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\NOTICES","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\shaders\\ink_sparkle.frag","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\assets\\shaders\\stretch_effect.frag","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\canvaskit.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\canvaskit.js.symbols","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\canvaskit.wasm","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\chromium\\canvaskit.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\chromium\\canvaskit.js.symbols","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\chromium\\canvaskit.wasm","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm.js.symbols","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm.wasm","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm_heavy.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm_heavy.js.symbols","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\skwasm_heavy.wasm","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\wimp.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\wimp.js.symbols","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\canvaskit\\wimp.wasm","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter_bootstrap.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\index.html","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\main.dart.js","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\version.json"],"outputs":["C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\build\\web\\flutter_service_worker.js"]}
Binary file not shown.
@@ -0,0 +1 @@
"DQA="
@@ -0,0 +1 @@
[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]}]
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"59aa584fdf100e6c78c785d8a5b565d1de4b48
_flutter.loader.load({ _flutter.loader.load({
serviceWorkerSettings: { serviceWorkerSettings: {
serviceWorkerVersion: "3071527921" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */ serviceWorkerVersion: "1494425110" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
} }
}); });
@@ -0,0 +1,31 @@
'use strict';
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
try {
await self.registration.unregister();
} catch (e) {
console.warn('Failed to unregister the service worker:', e);
}
try {
const clients = await self.clients.matchAll({
type: 'window',
});
// Reload clients to ensure they are not using the old service worker.
clients.forEach((client) => {
if (client.url && 'navigate' in client) {
client.navigate(client.url);
}
});
} catch (e) {
console.warn('Failed to navigate some service worker clients:', e);
}
})()
);
});
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"app_name":"recipe_flutter","version":"1.0.0","build_number":"1","package_name":"recipe_flutter"}
Binary file not shown.
+1 -1
View File
@@ -24,7 +24,7 @@ class ApiClient {
if (token != null) 'Authorization': 'Bearer $token', 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( final response = await _client.get(
Uri.parse('$baseUrl$path'), Uri.parse('$baseUrl$path'),
headers: _headers(token: token), headers: _headers(token: token),
+9
View File
@@ -28,6 +28,15 @@ class PantryApiPaths {
static String remove(int id) => '/pantry/$id'; 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 { class MealPlanApiPaths {
static const list = '/meal-plan'; 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/app_shell.dart';
import '../ui/async_state_views.dart'; import '../ui/async_state_views.dart';
import '../../features/auth/data/auth_providers.dart'; import '../../features/auth/data/auth_providers.dart';
import '../../core/auth/jwt_decoder.dart';
import '../../features/auth/presentation/login_screen.dart'; import '../../features/auth/presentation/login_screen.dart';
import '../../features/profile/presentation/profile_screen.dart'; import '../../features/profile/presentation/profile_screen.dart';
import '../../features/recipes/presentation/create_recipe_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/meal_plan/presentation/meal_plan_screen.dart';
import '../../features/pantry/presentation/pantry_screen.dart'; import '../../features/pantry/presentation/pantry_screen.dart';
import '../../features/import/presentation/import_screen.dart'; import '../../features/import/presentation/import_screen.dart';
import '../../features/admin/presentation/admin_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) { final appRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider); final authState = ref.watch(authStateProvider);
@@ -28,7 +30,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
initialLocation: '/', initialLocation: '/',
redirect: (context, state) { redirect: (context, state) {
final isLoading = authState.isLoading; final isLoading = authState.isLoading;
final token = authState.valueOrNull; final token = authState.maybeWhen(data: (t) => t, orElse: () => null);
final isLoggedIn = token != null && token.isNotEmpty; final isLoggedIn = token != null && token.isNotEmpty;
final location = state.matchedLocation; final location = state.matchedLocation;
final isSplash = location == '/'; final isSplash = location == '/';
@@ -194,6 +196,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/profile', path: '/profile',
builder: (context, state) => const ProfileScreen(), 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'; 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 { class AppShell extends ConsumerWidget {
final String location; final String location;
final Widget child; final Widget child;
@@ -14,7 +21,7 @@ class AppShell extends ConsumerWidget {
required this.child, required this.child,
}); });
static const _destinations = [ static const _baseDestinations = [
_AppDestination( _AppDestination(
path: '/recipes', path: '/recipes',
title: 'Recept', title: 'Recept',
@@ -53,8 +60,13 @@ class AppShell extends ConsumerWidget {
), ),
]; ];
int _selectedIndex() { List<_AppDestination> _destinations(bool isAdmin) => [
final index = _destinations.indexWhere( ..._baseDestinations,
if (isAdmin) _adminDestination,
];
int _selectedIndex(List<_AppDestination> destinations) {
final index = destinations.indexWhere(
(destination) => location.startsWith(destination.path), (destination) => location.startsWith(destination.path),
); );
return index < 0 ? 0 : index; return index < 0 ? 0 : index;
@@ -62,8 +74,10 @@ class AppShell extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedIndex = _selectedIndex(); final isAdmin = ref.watch(isAdminProvider);
final selectedDestination = _destinations[selectedIndex]; final dests = _destinations(isAdmin);
final selectedIndex = _selectedIndex(dests);
final selectedDestination = dests[selectedIndex];
final isWide = MediaQuery.of(context).size.width >= 900; final isWide = MediaQuery.of(context).size.width >= 900;
Future<void> logout() async { Future<void> logout() async {
@@ -74,7 +88,7 @@ class AppShell extends ConsumerWidget {
} }
void navigateTo(int index) { void navigateTo(int index) {
final target = _destinations[index].path; final target = dests[index].path;
if (target != location && context.mounted) { if (target != location && context.mounted) {
context.go(target); context.go(target);
} }
@@ -98,7 +112,7 @@ class AppShell extends ConsumerWidget {
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onDestinationSelected: navigateTo, onDestinationSelected: navigateTo,
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: _destinations destinations: dests
.map( .map(
(destination) => NavigationRailDestination( (destination) => NavigationRailDestination(
icon: Icon(destination.icon), icon: Icon(destination.icon),
@@ -117,7 +131,7 @@ class AppShell extends ConsumerWidget {
: NavigationBar( : NavigationBar(
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onDestinationSelected: navigateTo, onDestinationSelected: navigateTo,
destinations: _destinations destinations: dests
.map( .map(
(destination) => NavigationDestination( (destination) => NavigationDestination(
icon: Icon(destination.icon), 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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_providers.dart'; import '../../../core/api/api_providers.dart';
import '../../../core/platform/platform_providers.dart'; import '../../../core/platform/platform_providers.dart';
import '../../../core/auth/jwt_decoder.dart';
import 'auth_repository.dart'; import 'auth_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) { final authRepositoryProvider = Provider<AuthRepository>((ref) {
@@ -32,3 +33,9 @@ class AuthNotifier extends AsyncNotifier<String?> {
state = const AsyncData(null); 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; List<ParsedReceiptItem>? _items;
Future<void> _pickFile() async { Future<void> _pickFile() async {
final result = await FilePicker.platform.pickFiles( final result = await FilePicker.pickFiles(
type: FileType.custom, type: FileType.custom,
allowedExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp'], allowedExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp'],
withData: true, withData: true,
@@ -42,7 +42,7 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
// File picker // File picker
Future<void> _pickFile() async { Future<void> _pickFile() async {
final result = await FilePicker.platform.pickFiles( final result = await FilePicker.pickFiles(
type: FileType.custom, type: FileType.custom,
allowedExtensions: _allowedExtensions, allowedExtensions: _allowedExtensions,
withData: true, // needed on Flutter web to get bytes withData: true, // needed on Flutter web to get bytes
@@ -14,8 +14,17 @@ class InventoryQuery {
const InventoryQuery({required this.location, required this.sort}); const InventoryQuery({required this.location, required this.sort});
} }
final inventoryLocationFilterProvider = StateProvider<String>((ref) => ''); class _StringNotifier extends Notifier<String> {
final inventorySortFilterProvider = StateProvider<String>((ref) => ''); _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 inventoryQueryProvider = Provider<InventoryQuery>((ref) {
final location = ref.watch(inventoryLocationFilterProvider); final location = ref.watch(inventoryLocationFilterProvider);
@@ -373,7 +373,11 @@ class _DeleteButton extends ConsumerWidget {
); );
if (confirmed == true) { if (confirmed == true) {
try { 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) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))), SnackBar(content: Text(mapErrorToUserMessage(e, context))),
@@ -11,7 +11,15 @@ final mealPlanRepositoryProvider = Provider<MealPlanRepository>((ref) {
return MealPlanRepository(ref.watch(apiClientProvider)); 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 mealPlanWeekProvider = Provider<MealPlanWeek>((ref) {
final offset = ref.watch(mealPlanWeekOffsetProvider); final offset = ref.watch(mealPlanWeekOffsetProvider);
@@ -12,14 +12,14 @@ class MealPlanRepository {
Future<List<MealPlanEntry>> fetchEntries(String from, String to, {String? token}) async { Future<List<MealPlanEntry>> fetchEntries(String from, String to, {String? token}) async {
try { 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) { if (data is! List) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.', message: 'Ogiltigt svar från servern.',
); );
} }
return data return (data as List)
.map((item) => MealPlanEntry.fromJson(item as Map<String, dynamic>)) .map((item) => MealPlanEntry.fromJson(item as Map<String, dynamic>))
.toList(); .toList();
} on ApiException { } on ApiException {
@@ -34,14 +34,14 @@ class MealPlanRepository {
Future<List<ShoppingItem>> fetchShoppingList(String from, String to, {String? token}) async { Future<List<ShoppingItem>> fetchShoppingList(String from, String to, {String? token}) async {
try { 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) { if (data is! List) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.', message: 'Ogiltigt svar från servern.',
); );
} }
return data return (data as List)
.map((item) => ShoppingItem.fromJson(item as Map<String, dynamic>)) .map((item) => ShoppingItem.fromJson(item as Map<String, dynamic>))
.toList(); .toList();
} on ApiException { } on ApiException {
@@ -56,14 +56,14 @@ class MealPlanRepository {
Future<List<InventoryCompareItem>> fetchInventoryCompare(String from, String to, {String? token}) async { Future<List<InventoryCompareItem>> fetchInventoryCompare(String from, String to, {String? token}) async {
try { 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) { if (data is! List) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.', message: 'Ogiltigt svar från servern.',
); );
} }
return data return (data as List)
.map((item) => InventoryCompareItem.fromJson(item as Map<String, dynamic>)) .map((item) => InventoryCompareItem.fromJson(item as Map<String, dynamic>))
.toList(); .toList();
} on ApiException { } on ApiException {
@@ -87,8 +87,8 @@ class _MealPlanScreenState extends ConsumerState<MealPlanScreen> {
); );
} }
final recipes = recipesAsync.valueOrNull ?? const <Recipe>[]; final recipes = recipesAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const <Recipe>[];
final dashboard = dashboardAsync.valueOrNull ?? final dashboard = dashboardAsync.maybeWhen(data: (d) => d, orElse: () => null) ??
const MealPlanDashboard( const MealPlanDashboard(
entries: [], entries: [],
shoppingItems: [], shoppingItems: [],
@@ -254,8 +254,8 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
); );
} }
final pantryItems = pantryAsync.valueOrNull ?? const []; final pantryItems = pantryAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const [];
final products = productsAsync.valueOrNull ?? const []; final products = productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const [];
final productById = {for (final product in products) product.id: product}; final productById = {for (final product in products) product.id: product};
final pantryProductIds = pantryItems.map((e) => e.productId).toSet(); final pantryProductIds = pantryItems.map((e) => e.productId).toSet();
final availableProducts = products final availableProducts = products
@@ -1,12 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/guarded_api_call.dart'; import '../../../core/api/guarded_api_call.dart';
import '../../../core/api/api_exception.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../domain/user_profile.dart';
final profileRepositoryProvider = Provider<ProfileRepository>((ref) { final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
final apiClient = ref.read(apiClientProvider); return ProfileRepository(ref.watch(apiClientProvider), ref);
return ProfileRepository(apiClient, ref);
}); });
class ProfileRepository { class ProfileRepository {
@@ -15,17 +15,28 @@ class ProfileRepository {
ProfileRepository(this._apiClient, this._ref); ProfileRepository(this._apiClient, this._ref);
Future<Map<String, dynamic>> getProfile() async { Future<UserProfile> getMe() async {
return guardedApiCall( final data = await guardedApiCall(
_ref, _ref,
() => _apiClient.getJson('/api/profile'), () => _apiClient.getJson(UserApiPaths.me),
); );
return UserProfile.fromJson(data);
} }
Future<Map<String, dynamic>> updateProfile(Map<String, dynamic> profileData) async { Future<UserProfile> updateMe({
return guardedApiCall( 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, _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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.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 { class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key}); const ProfileScreen({super.key});
@@ -12,111 +14,178 @@ class ProfileScreen extends ConsumerStatefulWidget {
class _ProfileScreenState extends ConsumerState<ProfileScreen> { class _ProfileScreenState extends ConsumerState<ProfileScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
String _username = '';
String _email = '';
bool _isLoading = true; bool _isLoading = true;
bool _isSaving = false;
String? _error;
UserProfile? _profile;
late final TextEditingController _emailCtrl;
late final TextEditingController _firstNameCtrl;
late final TextEditingController _lastNameCtrl;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_emailCtrl = TextEditingController();
_firstNameCtrl = TextEditingController();
_lastNameCtrl = TextEditingController();
_loadProfile(); _loadProfile();
} }
@override
void dispose() {
_emailCtrl.dispose();
_firstNameCtrl.dispose();
_lastNameCtrl.dispose();
super.dispose();
}
Future<void> _loadProfile() async { Future<void> _loadProfile() async {
setState(() {
_isLoading = true;
_error = null;
});
try { try {
final profile = await ref.read(profileRepositoryProvider).getProfile(); final profile = await ref.read(profileRepositoryProvider).getMe();
if (!mounted) return;
setState(() { setState(() {
_username = profile['username'] ?? ''; _profile = profile;
_email = profile['email'] ?? ''; _emailCtrl.text = profile.email;
_isLoading = false; _firstNameCtrl.text = profile.firstName ?? '';
_lastNameCtrl.text = profile.lastName ?? '';
}); });
} catch (e) { } catch (e) {
_showErrorMessage(e); if (!mounted) return;
setState(() { setState(() => _error = mapErrorToUserMessage(e, context));
_isLoading = false; } finally {
}); if (mounted) setState(() => _isLoading = false);
} }
} }
Future<void> _updateProfile() async { Future<void> _save() async {
if (_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save(); setState(() => _isSaving = true);
setState(() { try {
_isLoading = true; final updated = await ref.read(profileRepositoryProvider).updateMe(
}); email: _emailCtrl.text.trim(),
try { firstName: _firstNameCtrl.text.trim().isEmpty ? null : _firstNameCtrl.text.trim(),
await ref.read(profileRepositoryProvider).updateProfile({ lastName: _lastNameCtrl.text.trim().isEmpty ? null : _lastNameCtrl.text.trim(),
'username': _username, );
'email': _email, if (!mounted) return;
}); setState(() => _profile = updated);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profil uppdaterad!')), const SnackBar(content: Text('Profil sparad!')),
); );
} catch (e) { } catch (e) {
_showErrorMessage(e); if (!mounted) return;
} finally { ScaffoldMessenger.of(context).showSnackBar(
setState(() { SnackBar(content: Text(mapErrorToUserMessage(e, context))),
_isLoading = false; );
}); } finally {
} if (mounted) setState(() => _isSaving = false);
} }
} }
void _showErrorMessage(dynamic error) { Future<void> _logout() async {
final message = mapErrorToUserMessage(error, context); await ref.read(authStateProvider.notifier).logout();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( 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 body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: Padding( : _error != null
padding: const EdgeInsets.all(16.0), ? Center(
child: Form( child: Column(
key: _formKey, mainAxisSize: MainAxisSize.min,
child: Column( children: [
children: <Widget>[ Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
TextFormField( const SizedBox(height: 16),
decoration: const InputDecoration(labelText: 'Användarnamn'), FilledButton(onPressed: _loadProfile, child: const Text('Försök igen')),
initialValue: _username, ],
validator: (value) { ),
if (value == null || value.isEmpty) { )
return 'Ange ett användarnamn'; : SingleChildScrollView(
} padding: const EdgeInsets.all(24),
return null; child: Form(
}, key: _formKey,
onSaved: (value) => _username = value!, 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 { Future<List<Recipe>> fetchRecipes({String? token}) async {
try { try {
final data = await _api.getJson(RecipeApiPaths.list, token: token); final dynamic data = await _api.getJson(RecipeApiPaths.list, token: token);
if (data is! List) { if (data is! List) {
throw const ApiException( throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.'); type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
} }
return data return (data as List)
.map((e) => Recipe.fromJson(e as Map<String, dynamic>)) .map((e) => Recipe.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
} on ApiException { } on ApiException {
@@ -21,8 +21,8 @@ class RecipeDetailScreen extends ConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(recipeAsync.valueOrNull?.title ?? 'Recept'), title: Text(recipeAsync.maybeWhen(data: (d) => d, orElse: () => null)?.title ?? 'Recept'),
actions: recipeAsync.valueOrNull == null actions: recipeAsync.maybeWhen(data: (d) => d, orElse: () => null) == null
? [] ? []
: [ : [
IconButton( IconButton(
Binary file not shown.
@@ -1,70 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mockito/annotations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:recipe_flutter/core/api/api_client.dart'; import 'package:recipe_flutter/core/api/api_client.dart';
import 'package:recipe_flutter/core/api/api_exception.dart'; import 'package:recipe_flutter/core/api/api_exception.dart';
import 'package:recipe_flutter/features/profile/data/profile_repository.dart'; import 'package:recipe_flutter/features/profile/data/profile_repository.dart';
import 'package:recipe_flutter/features/profile/domain/user_profile.dart';
class _FakeApiClient extends ApiClient {
dynamic _nextResponse;
Exception? _nextError;
void setResponse(dynamic response) {
_nextResponse = response;
_nextError = null;
}
void setError(Exception error) {
_nextError = error;
_nextResponse = null;
}
@override
Future<dynamic> getJson(String path, {String? token}) async {
if (_nextError != null) throw _nextError!;
return _nextResponse;
}
@override
Future<dynamic> patchJson(String path, {Object? body, String? token}) async {
if (_nextError != null) throw _nextError!;
return _nextResponse;
}
}
// Detta genererar MockRef i en separat fil
@GenerateMocks([Ref])
import 'profile_repository_test.mocks.dart';
void main() { void main() {
late _FakeApiClient fakeClient;
late ProviderContainer container;
late ProfileRepository repo;
class MockApiClient extends Mock implements ApiClient {} final profileJson = {
late ProfileRepository profileRepository; 'id': 1,
void main() { 'username': 'testuser',
group('ProfileRepository', () { 'email': 'test@example.com',
late ProfileRepository profileRepository; 'role': 'user',
late MockApiClient mockApiClient; 'isPremium': false,
};
setUp(() { setUp(() {
mockApiClient = MockApiClient(); fakeClient = _FakeApiClient();
final mockRef = MockRef(); container = ProviderContainer(
profileRepository = ProfileRepository(mockApiClient, mockRef); overrides: [apiClientProvider.overrideWithValue(fakeClient)],
);
repo = container.read(profileRepositoryProvider);
});
tearDown(() => container.dispose());
group('getMe', () {
test('returns UserProfile on success', () async {
fakeClient.setResponse(profileJson);
final result = await repo.getMe();
expect(result, isA<UserProfile>());
expect(result.username, 'testuser');
}); });
group('getProfile', () { test('rethrows ApiException on failure', () async {
test('should return profile data when API call is successful', () async { fakeClient.setError(const ApiException(
final expectedProfile = {'username': 'testuser', 'email': 'test@example.com'}; message: 'Unauthorized',
when(mockApiClient.getJson('/api/profile')).thenAnswer((_) async => expectedProfile); type: ApiErrorType.unauthorized,
));
final result = await profileRepository.getProfile(); expect(() => repo.getMe(), throwsA(isA<ApiException>()));
expect(result, expectedProfile);
verify(mockApiClient.getJson('/api/profile')).called(1);
});
test('should throw ApiException when API call fails', () async {
when(mockApiClient.getJson('/api/profile')).thenThrow(ApiException(message: 'Failed to fetch profile', type: ApiErrorType.server));
expect(() => profileRepository.getProfile(), throwsA(isA<ApiException>()));
verify(mockApiClient.getJson('/api/profile')).called(1);
});
}); });
});
group('updateProfile', () { group('updateMe', () {
test('should return updated profile data when API call is successful', () async { test('returns updated UserProfile on success', () async {
final profileData = {'username': 'newuser', 'email': 'new@example.com'}; final updated = {...profileJson, 'email': 'new@example.com'};
final expectedProfile = {'username': 'newuser', 'email': 'new@example.com'}; fakeClient.setResponse(updated);
when(mockApiClient.patchJson(any, profileData)).thenAnswer((_) async => expectedProfile); final result = await repo.updateMe(email: 'new@example.com');
expect(result.email, 'new@example.com');
final result = await profileRepository.updateProfile(profileData);
expect(result, expectedProfile);
verify(mockApiClient.patchJson(any, profileData)).called(1);
});
test('should throw ApiException when API call fails', () async {
final profileData = {'username': 'newuser', 'email': 'new@example.com'};
when(mockApiClient.patchJson(any, profileData)).thenThrow(ApiException(message: 'Failed to update profile', type: ApiErrorType.server));
expect(() => profileRepository.updateProfile(profileData), throwsA(isA<ApiException>()));
verify(mockApiClient.patchJson(any, profileData)).called(1);
});
}); });
}); });
} }