import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:async'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/realtime/realtime_sync.dart'; import '../data/admin_repository.dart'; import '../domain/user_admin.dart'; enum _UserSort { newest, usernameAsc, usernameDesc, roleAdminFirst } class AdminUsersPanel extends ConsumerStatefulWidget { final bool embedded; const AdminUsersPanel({super.key, this.embedded = false}); @override ConsumerState createState() => _AdminUsersPanelState(); } class _AdminUsersPanelState extends ConsumerState { 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 _users = []; ProviderSubscription? _realtimeTickSubscription; Timer? _realtimeDebounce; 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 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(); _realtimeTickSubscription = ref.listenManual( realtimeRefreshTickProvider, (_, __) { if (!mounted) return; _realtimeDebounce?.cancel(); _realtimeDebounce = Timer(const Duration(milliseconds: 600), () { if (!mounted) return; _load(); }); }, ); _load(); } @override void dispose() { _realtimeDebounce?.cancel(); _realtimeTickSubscription?.close(); _searchCtrl.dispose(); super.dispose(); } Future _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 _changeRole(UserAdmin user) async { final newRole = user.isAdmin ? 'user' : 'admin'; final confirmed = await _confirm( context, context.l10n.adminChangeRole, context.l10n.adminChangeRoleConfirm(user.username, 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 _togglePremium(UserAdmin user) async { final newValue = !user.isPremium; final confirmed = await _confirm( context, newValue ? context.l10n.adminGivePremium : context.l10n.adminRemovePremium, context.l10n.adminPremiumConfirm(newValue ? context.l10n.adminGivePremium : context.l10n.adminRemovePremium, user.username), ); if (!confirmed || !mounted) return; try { await ref .read(adminRepositoryProvider) .setPremium(user.id, isPremium: newValue); if (!mounted) return; _load(); } catch (e) { if (!mounted) return; _showError(e); } } Future _toggleRecipeSharing(UserAdmin user) async { final newValue = !user.canShareRecipes; final confirmed = await _confirm( context, newValue ? context.l10n.adminAllowSharing : context.l10n.adminBlockSharing, context.l10n.adminSharingConfirm(newValue ? context.l10n.adminAllowSharing : context.l10n.adminBlockSharing, user.username), ); if (!confirmed || !mounted) return; try { await ref .read(adminRepositoryProvider) .setRecipeSharing(user.id, canShareRecipes: newValue); if (!mounted) return; _load(); } catch (e) { if (!mounted) return; _showError(e); } } Future _resetPassword(UserAdmin user) async { final confirmed = await _confirm( context, context.l10n.adminResetPassword, context.l10n.adminResetPasswordConfirm(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( context: context, builder: (_) => AlertDialog( title: Text(context.l10n.adminTempPasswordTitle), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(context.l10n.adminTempPasswordForUser(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: context.l10n.adminCopyAction, 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: Text(context.l10n.adminCloseAction), ), ], ), ); } catch (e) { if (!mounted) return; _showError(e); } } Future _editEmail(UserAdmin user) async { final controller = TextEditingController(text: user.email); try { final newEmail = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(context.l10n.adminEmailEditTitle(user.username)), content: TextField( controller: controller, keyboardType: TextInputType.emailAddress, decoration: InputDecoration( labelText: context.l10n.adminEmailLabel, border: const OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.cancelAction), ), FilledButton( onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), child: Text(context.l10n.saveAction), ), ], ), ); if (newEmail == null || newEmail.isEmpty || !mounted) return; if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(newEmail)) { _showError(context.l10n.adminEmailInvalid); return; } await ref.read(adminRepositoryProvider).updateEmail(user.id, newEmail); if (!mounted) return; _load(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.adminEmailUpdated)), ); } catch (e) { if (!mounted) return; _showError(e); } finally { controller.dispose(); } } Future _deleteUser(UserAdmin user) async { final confirmed = await _confirm( context, context.l10n.adminDeleteUser, context.l10n.adminDeleteUserConfirm, 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 _createUser() async { final result = await showDialog>( 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(context.l10n.adminUserCreated(result['username']!))), ); } catch (e) { if (!mounted) return; _showError(e); } } void _showError(Object e) { ScaffoldMessenger.of(context).showSnackBar( buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), ); } Future _confirm( BuildContext ctx, String title, String body, { bool destructive = false, }) async { final result = await showDialog( 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: Text(context.l10n.adminConfirmAction), ), ], ), ); return result ?? false; } @override Widget build(BuildContext context) { final theme = Theme.of(context); if (_isLoading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { return buildCopyableErrorPanel( context: context, message: _error!, onRetry: _load, title: 'Kunde inte läsa användare', ); } final visibleUsers = _visibleUsers; if (_users.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.embedded) ...[ Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Användarkonton', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Text( 'Här styr du konton, roller, premium och delning. När listan är tom kan du skapa den första användaren direkt.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 12), FilledButton.icon( onPressed: _createUser, icon: const Icon(Icons.person_add_outlined), label: Text(context.l10n.adminNewUser), ), ], ), ), ), const SizedBox(height: 16), ], Card( child: Padding( padding: const EdgeInsets.all(16), child: Text(context.l10n.adminNoUsers), ), ), ], ); } final list = ListView.builder( shrinkWrap: false, physics: const AlwaysScrollableScrollPhysics(), padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.fromLTRB(16, 8, 16, 80), itemCount: visibleUsers.length, itemBuilder: (ctx, i) => _UserTile( 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]), ), ); if (!widget.embedded) { return list; } return Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ FilterChip( label: const Text('Endast admin'), selected: _filterAdminOnly, onSelected: (value) => setState(() => _filterAdminOnly = value), ), const SizedBox(width: 8), FilterChip( label: const Text('Endast premium'), selected: _filterPremiumOnly, onSelected: (value) => setState(() => _filterPremiumOnly = value), ), const SizedBox(width: 8), FilterChip( label: const Text('Delning avstängd'), selected: _filterSharingOffOnly, onSelected: (value) => setState(() => _filterSharingOffOnly = value), ), if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly) ...[ const SizedBox(width: 8), 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: [ 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', onPressed: _load, ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: Text( 'Visar ${visibleUsers.length} av ${_users.length} användare', style: theme.textTheme.bodySmall, ), ), const SizedBox(width: 8), FilledButton.icon( onPressed: _createUser, icon: const Icon(Icons.person_add_outlined), label: Text(context.l10n.adminNewUser), ), ], ), const SizedBox(height: 12), Expanded(child: list), ], ), ); } } class _UserTile extends StatelessWidget { final UserAdmin user; final VoidCallback onChangeRole; final VoidCallback onTogglePremium; final VoidCallback onToggleRecipeSharing; final VoidCallback onEditEmail; final VoidCallback onResetPassword; final VoidCallback onDelete; const _UserTile({ required this.user, required this.onChangeRole, required this.onTogglePremium, required this.onToggleRecipeSharing, required this.onEditEmail, 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), Wrap( spacing: 4, runSpacing: 2, children: [ ActionChip( label: Text(user.isAdmin ? 'Admin' : 'User'), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, labelStyle: theme.textTheme.labelSmall, tooltip: user.isAdmin ? context.l10n.adminDowngradeToUser : context.l10n.adminUpgradeToAdmin, onPressed: onChangeRole, ), ActionChip( label: Text(user.isPremium ? context.l10n.adminPremiumLabel : context.l10n.adminFreeLabel), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, labelStyle: theme.textTheme.labelSmall, backgroundColor: user.isPremium ? theme.colorScheme.tertiaryContainer : theme.colorScheme.surfaceContainerHighest, tooltip: user.isPremium ? context.l10n.adminRemovePremium : context.l10n.adminGivePremium, onPressed: onTogglePremium, ), ActionChip( label: Text(user.canShareRecipes ? context.l10n.adminSharingOn : context.l10n.adminSharingOff), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, labelStyle: theme.textTheme.labelSmall, backgroundColor: user.canShareRecipes ? theme.colorScheme.secondaryContainer : theme.colorScheme.errorContainer, tooltip: user.canShareRecipes ? context.l10n.adminBlockSharing : context.l10n.adminAllowSharing, onPressed: onToggleRecipeSharing, ), ], ), ], ), isThreeLine: true, trailing: PopupMenuButton( onSelected: (action) { switch (action) { case 'role': onChangeRole(); break; case 'premium': onTogglePremium(); break; case 'sharing': onToggleRecipeSharing(); break; case 'email': onEditEmail(); break; case 'reset': onResetPassword(); break; case 'delete': onDelete(); break; } }, itemBuilder: (_) => [ PopupMenuItem( value: 'role', child: Text( user.isAdmin ? context.l10n.adminDowngradeToUser : context.l10n.adminUpgradeToAdmin, ), ), PopupMenuItem( value: 'premium', child: Text(user.isPremium ? context.l10n.adminRemovePremium : context.l10n.adminGivePremium), ), PopupMenuItem( value: 'sharing', child: Text( user.canShareRecipes ? context.l10n.adminBlockSharing : context.l10n.adminAllowSharing, ), ), PopupMenuItem( value: 'email', child: Text(context.l10n.adminEmailAction), ), PopupMenuItem( value: 'reset', child: Text(context.l10n.adminResetPassword), ), const PopupMenuDivider(), PopupMenuItem( value: 'delete', child: Text( context.l10n.deleteAction, 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(); 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: Text(context.l10n.adminCreateUserTitle), content: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Skapa ett nytt konto med tydlig roll direkt från adminvyn. Du väljer bara uppgifter som krävs för inloggning och åtkomst.', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 12), TextFormField( controller: _usernameCtrl, decoration: InputDecoration(labelText: context.l10n.profileUsernameLabel), validator: (v) => (v == null || v.length < 2) ? context.l10n.adminMinChars2 : null, ), const SizedBox(height: 12), TextFormField( controller: _emailCtrl, decoration: InputDecoration(labelText: context.l10n.adminEmailLabel), keyboardType: TextInputType.emailAddress, validator: (v) { if (v == null || v.isEmpty) return context.l10n.required; if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) { return context.l10n.adminEmailInvalid; } return null; }, ), const SizedBox(height: 12), TextFormField( controller: _passwordCtrl, decoration: InputDecoration( labelText: context.l10n.adminPasswordLabel, 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) ? context.l10n.adminMinChars8 : null, ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: _role, decoration: InputDecoration(labelText: context.l10n.adminRoleLabel), items: [ DropdownMenuItem(value: 'user', child: Text(context.l10n.adminUserRole)), DropdownMenuItem(value: 'admin', child: Text(context.l10n.adminAdminRole)), ], onChanged: (v) => setState(() => _role = v ?? 'user'), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(context.l10n.cancelAction), ), FilledButton( onPressed: () { if (_formKey.currentState!.validate()) { Navigator.pop(context, { 'username': _usernameCtrl.text.trim(), 'email': _emailCtrl.text.trim(), 'password': _passwordCtrl.text, 'role': _role, }); } }, child: Text(context.l10n.adminCreateAction), ), ], ); } }