import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/office.dart'; import '../../models/profile.dart'; import '../../models/ticket_message.dart'; import '../../models/user_office.dart'; import '../../providers/admin_user_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../theme/app_surfaces.dart'; import '../../providers/user_offices_provider.dart'; import '../../utils/app_time.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; class UserManagementScreen extends ConsumerStatefulWidget { const UserManagementScreen({super.key}); @override ConsumerState createState() => _UserManagementScreenState(); } class _UserManagementScreenState extends ConsumerState { static const List _roles = [ 'standard', 'dispatcher', 'it_staff', 'admin', ]; final _fullNameController = TextEditingController(); final _searchController = TextEditingController(); String? _selectedUserId; String? _selectedRole; Set _selectedOfficeIds = {}; AdminUserStatus? _selectedStatus; bool _isSaving = false; bool _isStatusLoading = false; final Map _statusCache = {}; final Set _statusLoading = {}; final Set _statusErrors = {}; Set _prefetchedUserIds = {}; @override void dispose() { _fullNameController.dispose(); _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isAdmin = ref.watch(isAdminProvider); final profilesAsync = ref.watch(profilesProvider); final officesAsync = ref.watch(officesProvider); final assignmentsAsync = ref.watch(userOfficesProvider); final messagesAsync = ref.watch(ticketMessagesAllProvider); return ResponsiveBody( maxWidth: double.infinity, child: !isAdmin ? const Center(child: Text('Admin access required.')) : _buildContent( context, profilesAsync, officesAsync, assignmentsAsync, messagesAsync, ), ); } Widget _buildContent( BuildContext context, AsyncValue> profilesAsync, AsyncValue> officesAsync, AsyncValue> assignmentsAsync, AsyncValue> messagesAsync, ) { if (profilesAsync.isLoading || officesAsync.isLoading || assignmentsAsync.isLoading || messagesAsync.isLoading) { return const Center(child: CircularProgressIndicator()); } if (profilesAsync.hasError || officesAsync.hasError || assignmentsAsync.hasError || messagesAsync.hasError) { final error = profilesAsync.error ?? officesAsync.error ?? assignmentsAsync.error ?? messagesAsync.error ?? 'Unknown error'; return Center(child: Text('Failed to load data: $error')); } final profiles = profilesAsync.valueOrNull ?? []; final offices = officesAsync.valueOrNull ?? []; final assignments = assignmentsAsync.valueOrNull ?? []; final messages = messagesAsync.valueOrNull ?? []; _prefetchStatuses(profiles); final lastActiveByUser = {}; for (final message in messages) { final senderId = message.senderId; if (senderId == null) continue; final current = lastActiveByUser[senderId]; if (current == null || message.createdAt.isAfter(current)) { lastActiveByUser[senderId] = message.createdAt; } } if (profiles.isEmpty) { return const Center(child: Text('No users found.')); } final query = _searchController.text.trim().toLowerCase(); final filteredProfiles = query.isEmpty ? profiles : profiles.where((profile) { final label = profile.fullName.isNotEmpty ? profile.fullName : profile.id; return label.toLowerCase().contains(query) || profile.id.toLowerCase().contains(query); }).toList(); final officeCountByUser = {}; for (final assignment in assignments) { officeCountByUser.update( assignment.userId, (value) => value + 1, ifAbsent: () => 1, ); } final listBody = TasQAdaptiveList( items: filteredProfiles, filterHeader: SizedBox( width: 320, child: TextField( controller: _searchController, onChanged: (_) => setState(() {}), decoration: const InputDecoration( labelText: 'Search name', prefixIcon: Icon(Icons.search), ), ), ), columns: [ TasQColumn( header: 'User', cellBuilder: (context, profile) { final label = profile.fullName.isEmpty ? profile.id : profile.fullName; return Text(label); }, ), TasQColumn( header: 'Email', cellBuilder: (context, profile) { final status = _statusCache[profile.id]; final hasError = _statusErrors.contains(profile.id); final email = hasError ? 'Unavailable' : (status?.email ?? 'Unknown'); return Text(email); }, ), TasQColumn( header: 'Role', cellBuilder: (context, profile) => Text(profile.role), ), TasQColumn( header: 'Offices', cellBuilder: (context, profile) { final officesAssigned = officeCountByUser[profile.id] ?? 0; return Text(officesAssigned == 0 ? 'None' : '$officesAssigned'); }, ), TasQColumn( header: 'Status', cellBuilder: (context, profile) { final status = _statusCache[profile.id]; final hasError = _statusErrors.contains(profile.id); final isLoading = _statusLoading.contains(profile.id); final statusLabel = _userStatusLabel(status, hasError, isLoading); return _StatusBadge(label: statusLabel); }, ), TasQColumn( header: 'Last active', cellBuilder: (context, profile) { final lastActive = lastActiveByUser[profile.id]; return Text(_formatLastActiveLabel(lastActive)); }, ), ], onRowTap: (profile) => _showUserDialog(context, profile, offices, assignments), mobileTileBuilder: (context, profile, actions) { final label = profile.fullName.isEmpty ? profile.id : profile.fullName; final status = _statusCache[profile.id]; final hasError = _statusErrors.contains(profile.id); final isLoading = _statusLoading.contains(profile.id); final email = hasError ? 'Unavailable' : (status?.email ?? 'Unknown'); final officesAssigned = officeCountByUser[profile.id] ?? 0; final lastActive = lastActiveByUser[profile.id]; final statusLabel = _userStatusLabel(status, hasError, isLoading); return Card( child: ListTile( dense: true, visualDensity: VisualDensity.compact, title: Text(label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 2), Text('Role: ${profile.role}'), Text('Offices: $officesAssigned'), Text('Last active: ${_formatLastActiveLabel(lastActive)}'), const SizedBox(height: 4), MonoText('ID ${profile.id}'), Text('Email: $email'), ], ), trailing: _StatusBadge(label: statusLabel), onTap: () => _showUserDialog(context, profile, offices, assignments), ), ); }, onRequestRefresh: () { // For server-side pagination, update the query provider ref.read(adminUserQueryProvider.notifier).state = const AdminUserQuery( offset: 0, limit: 50, ); }, isLoading: false, ); return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( alignment: Alignment.center, child: Text( 'User Management', textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), ), const SizedBox(height: 16), Expanded(child: listBody), ], ), ); } Future _showUserDialog( BuildContext context, Profile profile, List offices, List assignments, ) async { await _selectUser(profile); final currentOfficeIds = assignments .where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet(); if (!context.mounted) return; await showDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Update user'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( child: _buildUserForm( context, profile, offices, currentOfficeIds, setDialogState, ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Close'), ), ], ); }, ); }, ); } void _ensureStatusLoaded(String userId) { if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) { return; } _statusLoading.add(userId); _statusErrors.remove(userId); ref .read(adminUserControllerProvider) .fetchStatus(userId) .then((status) { if (!mounted) return; setState(() { _statusCache[userId] = status; _statusLoading.remove(userId); }); }) .catchError((_) { if (!mounted) return; setState(() { _statusLoading.remove(userId); _statusErrors.add(userId); }); }); } void _prefetchStatuses(List profiles) { final ids = profiles.map((profile) => profile.id).toSet(); final missing = ids.difference(_prefetchedUserIds); if (missing.isEmpty) return; _prefetchedUserIds = ids; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; for (final userId in missing) { _ensureStatusLoaded(userId); } }); } Widget _buildUserForm( BuildContext context, Profile profile, List offices, Set currentOfficeIds, StateSetter setDialogState, ) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( controller: _fullNameController, decoration: const InputDecoration(labelText: 'Full name'), ), const SizedBox(height: 12), DropdownButtonFormField( key: ValueKey('role_${_selectedUserId ?? 'none'}'), initialValue: _selectedRole, items: _roles .map((role) => DropdownMenuItem(value: role, child: Text(role))) .toList(), onChanged: (value) => setDialogState(() => _selectedRole = value), decoration: const InputDecoration(labelText: 'Role'), ), const SizedBox(height: 12), _buildStatusRow(profile), const SizedBox(height: 16), Text('Offices', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), if (offices.isEmpty) const Text('No offices available.'), if (offices.isNotEmpty) Column( children: offices .map( (office) => CheckboxListTile( value: _selectedOfficeIds.contains(office.id), onChanged: _isSaving ? null : (selected) { setDialogState(() { if (selected == true) { _selectedOfficeIds.add(office.id); } else { _selectedOfficeIds.remove(office.id); } }); }, title: Text(office.name), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), ) .toList(), ), const SizedBox(height: 16), Row( children: [ Expanded( child: FilledButton( onPressed: _isSaving ? null : () async { final saved = await _saveChanges( context, profile, currentOfficeIds, setDialogState, ); if (saved && context.mounted) { Navigator.of(context).pop(); } }, child: _isSaving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save changes'), ), ), ], ), ], ); } Widget _buildStatusRow(Profile profile) { final email = _selectedStatus?.email; final isLocked = _selectedStatus?.isLocked ?? false; final lockLabel = isLocked ? 'Unlock' : 'Lock'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Email: ${email ?? 'Loading...'}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), Row( children: [ OutlinedButton.icon( onPressed: _isStatusLoading ? null : () => _showPasswordResetDialog(profile.id), icon: const Icon(Icons.password), label: const Text('Reset password'), ), const SizedBox(width: 12), OutlinedButton.icon( onPressed: _isStatusLoading ? null : () => _toggleLock(profile.id, !isLocked), icon: Icon(isLocked ? Icons.lock_open : Icons.lock), label: Text(lockLabel), ), ], ), ], ); } Future _selectUser(Profile profile) async { setState(() { _selectedUserId = profile.id; _selectedRole = profile.role; _fullNameController.text = profile.fullName; _selectedStatus = null; _isStatusLoading = true; }); final assignments = ref.read(userOfficesProvider).valueOrNull ?? []; final officeIds = assignments .where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet(); setState(() => _selectedOfficeIds = officeIds); try { final status = await ref .read(adminUserControllerProvider) .fetchStatus(profile.id); if (mounted) { setState(() { _selectedStatus = status; _statusCache[profile.id] = status; }); } } catch (_) { if (mounted) { setState( () => _selectedStatus = AdminUserStatus(email: null, bannedUntil: null), ); } } finally { if (mounted) { setState(() => _isStatusLoading = false); } } } Future _saveChanges( BuildContext context, Profile profile, Set currentOfficeIds, StateSetter setDialogState, ) async { final role = _selectedRole ?? profile.role; final fullName = _fullNameController.text.trim(); if (fullName.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Full name is required.'))); return false; } if (_selectedOfficeIds.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Select at least one office.')), ); return false; } setDialogState(() => _isSaving = true); try { await ref .read(adminUserControllerProvider) .updateProfile(userId: profile.id, fullName: fullName, role: role); final toAdd = _selectedOfficeIds.difference(currentOfficeIds); final toRemove = currentOfficeIds.difference(_selectedOfficeIds); final controller = ref.read(userOfficesControllerProvider); for (final officeId in toAdd) { await controller.assignUserOffice( userId: profile.id, officeId: officeId, ); } for (final officeId in toRemove) { await controller.removeUserOffice( userId: profile.id, officeId: officeId, ); } ref.invalidate(profilesProvider); ref.invalidate(userOfficesProvider); if (!context.mounted) return true; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('User updated.'))); return true; } catch (error) { if (!context.mounted) return false; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Update failed: $error'))); return false; } finally { setDialogState(() => _isSaving = false); } } Future _showPasswordResetDialog(String userId) async { final controller = TextEditingController(); final formKey = GlobalKey(); await showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: const Text('Set temporary password'), content: Form( key: formKey, child: TextFormField( controller: controller, decoration: const InputDecoration(labelText: 'New password'), obscureText: true, validator: (value) { if (value == null || value.trim().length < 8) { return 'Use at least 8 characters.'; } return null; }, ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () async { if (!formKey.currentState!.validate()) return; try { await ref .read(adminUserControllerProvider) .setPassword( userId: userId, password: controller.text.trim(), ); if (!dialogContext.mounted) return; Navigator.of(dialogContext).pop(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Password updated.')), ); } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Reset failed: $error')), ); } }, child: const Text('Update password'), ), ], ); }, ); } Future _toggleLock(String userId, bool locked) async { setState(() => _isStatusLoading = true); try { await ref .read(adminUserControllerProvider) .setLock(userId: userId, locked: locked); final status = await ref .read(adminUserControllerProvider) .fetchStatus(userId); if (!mounted) return; setState(() => _selectedStatus = status); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(locked ? 'User locked.' : 'User unlocked.')), ); } catch (error) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Lock update failed: $error'))); } finally { if (mounted) { setState(() => _isStatusLoading = false); } } } } String _userStatusLabel( AdminUserStatus? status, bool hasError, bool isLoading, ) { if (isLoading) return 'Loading'; if (hasError) return 'Status error'; if (status == null) return 'Unknown'; return status.isLocked ? 'Locked' : 'Active'; } String _formatLastActiveLabel(DateTime? value) { if (value == null) return 'N/A'; final now = AppTime.now(); final diff = now.difference(value); if (diff.inMinutes < 1) return 'Just now'; if (diff.inHours < 1) return '${diff.inMinutes}m ago'; if (diff.inDays < 1) return '${diff.inHours}h ago'; if (diff.inDays < 7) return '${diff.inDays}d ago'; final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); return '${value.year}-$month-$day'; } class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.label}); final String label; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final isError = label.toLowerCase().contains('error'); final background = isError ? scheme.errorContainer : scheme.secondaryContainer; final foreground = isError ? scheme.onErrorContainer : scheme.onSecondaryContainer; return Badge( backgroundColor: background, label: Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.3, ), ), ); } }