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
@@ -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'),
),
],
);
}
}