Refactor code structure for improved readability and maintainability
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
Binary file not shown.
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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+1
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
|
||||
+1
@@ -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
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"59aa584fdf100e6c78c785d8a5b565d1de4b48
|
||||
|
||||
_flutter.loader.load({
|
||||
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
@@ -0,0 +1 @@
|
||||
{"app_name":"recipe_flutter","version":"1.0.0","build_number":"1","package_name":"recipe_flutter"}
|
||||
Binary file not shown.
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,106 +14,173 @@ 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();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
try {
|
||||
final profile = await ref.read(profileRepositoryProvider).getProfile();
|
||||
setState(() {
|
||||
_username = profile['username'] ?? '';
|
||||
_email = profile['email'] ?? '';
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
_showErrorMessage(e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_firstNameCtrl.dispose();
|
||||
_lastNameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _updateProfile() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
_formKey.currentState!.save();
|
||||
Future<void> _loadProfile() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
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 {
|
||||
final profile = await ref.read(profileRepositoryProvider).getMe();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_profile = profile;
|
||||
_emailCtrl.text = profile.email;
|
||||
_firstNameCtrl.text = profile.firstName ?? '';
|
||||
_lastNameCtrl.text = profile.lastName ?? '';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorMessage(dynamic error) {
|
||||
final message = mapErrorToUserMessage(error, context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
: _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(
|
||||
children: <Widget>[
|
||||
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(
|
||||
decoration: const InputDecoration(labelText: 'Användarnamn'),
|
||||
initialValue: _username,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Ange ett användarnamn';
|
||||
}
|
||||
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;
|
||||
},
|
||||
onSaved: (value) => _username = value!,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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!,
|
||||
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'),
|
||||
),
|
||||
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(
|
||||
|
||||
Binary file not shown.
@@ -1,70 +1,83 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mockito/annotations.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_exception.dart';
|
||||
import 'package:recipe_flutter/features/profile/data/profile_repository.dart';
|
||||
import 'package:recipe_flutter/features/profile/domain/user_profile.dart';
|
||||
|
||||
// Detta genererar MockRef i en separat fil
|
||||
@GenerateMocks([Ref])
|
||||
import 'profile_repository_test.mocks.dart';
|
||||
void main() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
class MockApiClient extends Mock implements ApiClient {}
|
||||
late ProfileRepository profileRepository;
|
||||
void main() {
|
||||
group('ProfileRepository', () {
|
||||
late ProfileRepository profileRepository;
|
||||
late MockApiClient mockApiClient;
|
||||
late _FakeApiClient fakeClient;
|
||||
late ProviderContainer container;
|
||||
late ProfileRepository repo;
|
||||
|
||||
final profileJson = {
|
||||
'id': 1,
|
||||
'username': 'testuser',
|
||||
'email': 'test@example.com',
|
||||
'role': 'user',
|
||||
'isPremium': false,
|
||||
};
|
||||
|
||||
setUp(() {
|
||||
mockApiClient = MockApiClient();
|
||||
final mockRef = MockRef();
|
||||
profileRepository = ProfileRepository(mockApiClient, mockRef);
|
||||
fakeClient = _FakeApiClient();
|
||||
container = ProviderContainer(
|
||||
overrides: [apiClientProvider.overrideWithValue(fakeClient)],
|
||||
);
|
||||
repo = container.read(profileRepositoryProvider);
|
||||
});
|
||||
|
||||
group('getProfile', () {
|
||||
test('should return profile data when API call is successful', () async {
|
||||
final expectedProfile = {'username': 'testuser', 'email': 'test@example.com'};
|
||||
when(mockApiClient.getJson('/api/profile')).thenAnswer((_) async => expectedProfile);
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
final result = await profileRepository.getProfile();
|
||||
|
||||
expect(result, expectedProfile);
|
||||
verify(mockApiClient.getJson('/api/profile')).called(1);
|
||||
group('getMe', () {
|
||||
test('returns UserProfile on success', () async {
|
||||
fakeClient.setResponse(profileJson);
|
||||
final result = await repo.getMe();
|
||||
expect(result, isA<UserProfile>());
|
||||
expect(result.username, 'testuser');
|
||||
});
|
||||
|
||||
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);
|
||||
test('rethrows ApiException on failure', () async {
|
||||
fakeClient.setError(const ApiException(
|
||||
message: 'Unauthorized',
|
||||
type: ApiErrorType.unauthorized,
|
||||
));
|
||||
expect(() => repo.getMe(), throwsA(isA<ApiException>()));
|
||||
});
|
||||
});
|
||||
|
||||
group('updateProfile', () {
|
||||
test('should return updated profile data when API call is successful', () async {
|
||||
final profileData = {'username': 'newuser', 'email': 'new@example.com'};
|
||||
final expectedProfile = {'username': 'newuser', 'email': 'new@example.com'};
|
||||
when(mockApiClient.patchJson(any, profileData)).thenAnswer((_) async => expectedProfile);
|
||||
|
||||
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);
|
||||
});
|
||||
group('updateMe', () {
|
||||
test('returns updated UserProfile on success', () async {
|
||||
final updated = {...profileJson, 'email': 'new@example.com'};
|
||||
fakeClient.setResponse(updated);
|
||||
final result = await repo.updateMe(email: 'new@example.com');
|
||||
expect(result.email, 'new@example.com');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user