feat: Enhance admin user management with search, filtering, and sorting capabilities
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 09:22:19 +02:00
parent 84ccabe2fe
commit 8e6e0e96b8
3 changed files with 278 additions and 22 deletions
@@ -7,6 +7,8 @@ import '../../../core/l10n/l10n.dart';
import '../data/admin_repository.dart';
import '../domain/user_admin.dart';
enum _UserSort { newest, usernameAsc, usernameDesc, roleAdminFirst }
class AdminUsersPanel extends ConsumerStatefulWidget {
final bool embedded;
@@ -19,14 +21,65 @@ class AdminUsersPanel extends ConsumerStatefulWidget {
class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
bool _isLoading = true;
String? _error;
String _search = '';
late final TextEditingController _searchCtrl;
_UserSort _sort = _UserSort.newest;
bool _filterAdminOnly = false;
bool _filterPremiumOnly = false;
bool _filterSharingOffOnly = false;
List<UserAdmin> _users = [];
String _sortLabel(_UserSort sort) => switch (sort) {
_UserSort.newest => 'Nyast',
_UserSort.usernameAsc => 'Användarnamn A-Ö',
_UserSort.usernameDesc => 'Användarnamn Ö-A',
_UserSort.roleAdminFirst => 'Admin först',
};
List<UserAdmin> get _visibleUsers {
final query = _search.trim().toLowerCase();
final filtered = _users.where((user) {
if (_filterAdminOnly && !user.isAdmin) return false;
if (_filterPremiumOnly && !user.isPremium) return false;
if (_filterSharingOffOnly && user.canShareRecipes) return false;
if (query.isEmpty) return true;
return user.username.toLowerCase().contains(query) ||
user.displayName.toLowerCase().contains(query) ||
user.email.toLowerCase().contains(query) ||
user.id.toString().contains(query);
}).toList();
filtered.sort((a, b) {
switch (_sort) {
case _UserSort.newest:
return b.id.compareTo(a.id);
case _UserSort.usernameAsc:
return a.username.toLowerCase().compareTo(b.username.toLowerCase());
case _UserSort.usernameDesc:
return b.username.toLowerCase().compareTo(a.username.toLowerCase());
case _UserSort.roleAdminFirst:
if (a.isAdmin == b.isAdmin) {
return a.username.toLowerCase().compareTo(b.username.toLowerCase());
}
return a.isAdmin ? -1 : 1;
}
});
return filtered;
}
@override
void initState() {
super.initState();
_searchCtrl = TextEditingController();
_load();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
@@ -292,6 +345,22 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeFilters = <String>[
if (_filterAdminOnly) 'Endast admin',
if (_filterPremiumOnly) 'Endast premium',
if (_filterSharingOffOnly) 'Delning avstängd',
];
final searchSummary = _search.trim().isEmpty
? 'Ingen aktiv sökning'
: 'Sökning: "${_search.trim()}"';
final filterSummary = activeFilters.isEmpty
? 'Inga aktiva snabbfilter'
: 'Aktiva filter: ${activeFilters.join(', ')}';
final viewSummary = 'Sortering: ${_sortLabel(_sort)}$searchSummary$filterSummary';
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
@@ -303,6 +372,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
title: 'Kunde inte läsa användare',
);
}
final visibleUsers = _visibleUsers;
if (_users.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -344,19 +415,19 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
final list = ListView.builder(
shrinkWrap: false,
physics: null,
physics: const AlwaysScrollableScrollPhysics(),
padding: widget.embedded
? EdgeInsets.zero
: const EdgeInsets.fromLTRB(16, 8, 16, 80),
itemCount: _users.length,
itemCount: visibleUsers.length,
itemBuilder: (ctx, i) => _UserTile(
user: _users[i],
onChangeRole: () => _changeRole(_users[i]),
onTogglePremium: () => _togglePremium(_users[i]),
onToggleRecipeSharing: () => _toggleRecipeSharing(_users[i]),
onEditEmail: () => _editEmail(_users[i]),
onResetPassword: () => _resetPassword(_users[i]),
onDelete: () => _deleteUser(_users[i]),
user: visibleUsers[i],
onChangeRole: () => _changeRole(visibleUsers[i]),
onTogglePremium: () => _togglePremium(visibleUsers[i]),
onToggleRecipeSharing: () => _toggleRecipeSharing(visibleUsers[i]),
onEditEmail: () => _editEmail(visibleUsers[i]),
onResetPassword: () => _resetPassword(visibleUsers[i]),
onDelete: () => _deleteUser(visibleUsers[i]),
),
);
@@ -364,7 +435,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
return list;
}
return SingleChildScrollView(
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -396,9 +467,79 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilterChip(
label: const Text('Endast admin'),
selected: _filterAdminOnly,
onSelected: (value) => setState(() => _filterAdminOnly = value),
),
FilterChip(
label: const Text('Endast premium'),
selected: _filterPremiumOnly,
onSelected: (value) => setState(() => _filterPremiumOnly = value),
),
FilterChip(
label: const Text('Delning avstängd'),
selected: _filterSharingOffOnly,
onSelected: (value) => setState(() => _filterSharingOffOnly = value),
),
if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly)
TextButton.icon(
onPressed: () {
setState(() {
_filterAdminOnly = false;
_filterPremiumOnly = false;
_filterSharingOffOnly = false;
});
},
icon: const Icon(Icons.clear_all),
label: const Text('Rensa filter'),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Spacer(),
SizedBox(
width: 260,
child: DropdownButtonFormField<_UserSort>(
initialValue: _sort,
decoration: const InputDecoration(
labelText: 'Sortering',
border: OutlineInputBorder(),
isDense: true,
),
items: _UserSort.values
.map(
(value) => DropdownMenuItem<_UserSort>(
value: value,
child: Text(_sortLabel(value)),
),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => _sort = value);
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.search),
hintText: 'Sök användare, e-post eller id',
),
onChanged: (value) => setState(() => _search = value),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Uppdatera',
@@ -407,13 +548,84 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
],
),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.tune),
title: const Text('Aktiv vy: Användare'),
subtitle: Text(viewSummary),
dense: true,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (_search.trim().isNotEmpty)
OutlinedButton.icon(
onPressed: () {
_searchCtrl.clear();
setState(() => _search = '');
},
icon: const Icon(Icons.search_off),
label: const Text('Rensa sök'),
),
if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly)
OutlinedButton.icon(
onPressed: () {
setState(() {
_filterAdminOnly = false;
_filterPremiumOnly = false;
_filterSharingOffOnly = false;
});
},
icon: const Icon(Icons.filter_alt_off),
label: const Text('Rensa filter'),
),
if (_sort != _UserSort.newest)
OutlinedButton.icon(
onPressed: () => setState(() => _sort = _UserSort.newest),
icon: const Icon(Icons.sort),
label: const Text('Återställ sortering'),
),
TextButton.icon(
onPressed: () {
_searchCtrl.clear();
setState(() {
_search = '';
_sort = _UserSort.newest;
_filterAdminOnly = false;
_filterPremiumOnly = false;
_filterSharingOffOnly = false;
});
},
icon: const Icon(Icons.refresh),
label: const Text('Återställ vy'),
),
],
),
],
),
),
),
const SizedBox(height: 8),
Text(
'Visar ${visibleUsers.length} av ${_users.length} användare',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _createUser,
icon: const Icon(Icons.person_add_outlined),
label: Text(context.l10n.adminNewUser),
),
const SizedBox(height: 16),
list,
Expanded(child: list),
],
),
);