import 'package:flutter/material.dart'; import '../../theme/m3_motion.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/auth_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/app_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../utils/snackbar.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', 'programmer', 'admin', ]; static const List _religions = [ 'catholic', 'islam', 'protestant', 'other', ]; final _fullNameController = TextEditingController(); final _searchController = TextEditingController(); String? _selectedUserId; String? _selectedRole; String _selectedReligion = 'catholic'; final Set _selectedOfficeIds = {}; bool _isSaving = false; @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 AppErrorView( error: error, onRetry: () { ref.invalidate(profilesProvider); ref.invalidate(officesProvider); ref.invalidate(userOfficesProvider); }, ); } final profiles = profilesAsync.valueOrNull ?? []; final offices = officesAsync.valueOrNull ?? []; final assignments = assignmentsAsync.valueOrNull ?? []; final messages = messagesAsync.valueOrNull ?? []; 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 AppEmptyView( icon: Icons.people_outline, title: 'No users found', subtitle: 'Users who sign up will appear here.', ); } 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 statusAsync = ref.watch(adminUserStatusProvider(profile.id)); return statusAsync.when( data: (s) => Text(s.email ?? 'Unknown'), loading: () => const Text('Loading...'), error: (error, stack) => const Text('Unknown'), ); }, ), 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 statusAsync = ref.watch(adminUserStatusProvider(profile.id)); return statusAsync.when( data: (s) { final statusLabel = s.isLocked ? 'Locked' : 'Active'; return _StatusBadge(label: statusLabel); }, loading: () => _StatusBadge(label: 'Loading'), error: (error, stack) => _StatusBadge(label: 'Unknown'), ); }, ), 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 statusAsync = ref.watch(adminUserStatusProvider(profile.id)); final officesAssigned = officeCountByUser[profile.id] ?? 0; final lastActive = lastActiveByUser[profile.id]; 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}'), statusAsync.when( data: (s) => Text('Email: ${s.email ?? 'Unknown'}'), loading: () => const Text('Email: Loading...'), error: (error, stack) => const Text('Email: Unknown'), ), ], ), trailing: statusAsync.when( data: (s) => _StatusBadge(label: s.isLocked ? 'Locked' : 'Active'), loading: () => _StatusBadge(label: 'Loading'), error: (error, stack) => _StatusBadge(label: 'Unknown'), ), 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, ); }, onPageChanged: (firstRow) { ref .read(adminUserQueryProvider.notifier) .update((q) => q.copyWith(offset: firstRow)); }, isLoading: false, ); return Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const AppPageHeader( title: 'User Management', subtitle: 'Manage user roles and office assignments', ), Expanded(child: listBody), ], ); } Future _showUserDialog( BuildContext context, Profile profile, List offices, List assignments, ) async { final currentOfficeIds = assignments .where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet(); // Populate dialog-backed state so form fields reflect the selected user. if (mounted) { setState(() { _selectedUserId = profile.id; _selectedRole = profile.role; _selectedReligion = profile.religion; _fullNameController.text = profile.fullName; _selectedOfficeIds ..clear() ..addAll(currentOfficeIds); }); } if (!context.mounted) return; await m3ShowDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Update user'), content: SizedBox( width: 520, child: SingleChildScrollView( child: _buildUserForm( context, profile, offices, currentOfficeIds, setDialogState, ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Close'), ), ], ); }, ); }, ); // Clear the temporary selection state after the dialog is closed so the // next dialog starts from a clean slate. if (mounted) { setState(() { _selectedUserId = null; _selectedRole = null; _selectedReligion = 'catholic'; _selectedOfficeIds.clear(); _fullNameController.clear(); }); } } 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), DropdownButtonFormField( key: ValueKey('religion_${_selectedUserId ?? 'none'}'), initialValue: _selectedReligion, items: _religions .map( (r) => DropdownMenuItem( value: r, child: Text(r[0].toUpperCase() + r.substring(1)), ), ) .toList(), onChanged: (value) => setDialogState(() => _selectedReligion = value ?? 'catholic'), decoration: const InputDecoration(labelText: 'Religion'), ), const SizedBox(height: 12), // Email and lock status are retrieved from auth via Edge Function / admin API. Consumer( builder: (context, ref, _) { final statusAsync = ref.watch(adminUserStatusProvider(profile.id)); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ statusAsync.when( data: (s) => Text( 'Email: ${s.email ?? 'Unknown'}', style: Theme.of(context).textTheme.bodySmall, ), loading: () => Text( 'Email: Loading...', style: Theme.of(context).textTheme.bodySmall, ), error: (error, stack) => Text( 'Email: Unknown', style: Theme.of(context).textTheme.bodySmall, ), ), const SizedBox(height: 8), Row( children: [ OutlinedButton.icon( onPressed: _isSaving ? null : () => _showPasswordResetDialog(profile.id), icon: const Icon(Icons.password), label: const Text('Reset password'), ), const SizedBox(width: 12), statusAsync.when( data: (s) => OutlinedButton.icon( onPressed: _isSaving ? null : () => _toggleLock(profile.id, !s.isLocked), icon: Icon(s.isLocked ? Icons.lock_open : Icons.lock), label: Text(s.isLocked ? 'Unlock' : 'Lock'), ), loading: () => OutlinedButton.icon( onPressed: null, icon: const Icon(Icons.lock), label: const Text('Loading...'), ), error: (error, stack) => OutlinedButton.icon( onPressed: _isSaving ? null : () => _toggleLock(profile.id, true), icon: const Icon(Icons.lock), label: const Text('Lock'), ), ), ], ), ], ); }, ), 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) Container( height: 240, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(4), ), child: SingleChildScrollView( child: Column( children: offices.map((office) { return 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: const EdgeInsets.symmetric(horizontal: 8), ); }).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'), ), ), ], ), ], ); } Future _saveChanges( BuildContext context, Profile profile, Set currentOfficeIds, StateSetter setDialogState, ) async { final role = _selectedRole ?? profile.role; final fullName = _fullNameController.text.trim(); if (fullName.isEmpty) { showWarningSnackBar(context, 'Full name is required.'); return false; } if (_selectedOfficeIds.isEmpty) { showWarningSnackBar(context, 'Select at least one office.'); return false; } setDialogState(() => _isSaving = true); try { await ref .read(adminUserControllerProvider) .updateProfile( userId: profile.id, fullName: fullName, role: role, religion: _selectedReligion, ); 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; showSuccessSnackBar(context, 'User "$fullName" updated successfully.'); return true; } catch (error) { if (!context.mounted) return false; showErrorSnackBar(context, 'Update failed: $error'); return false; } finally { setDialogState(() => _isSaving = false); } } Future _showPasswordResetDialog(String userId) async { final controller = TextEditingController(); final formKey = GlobalKey(); await m3ShowDialog( 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; showSuccessSnackBar(context, 'Password updated.'); } catch (error) { final msg = error.toString(); if (msg.contains('Unauthorized') || msg.contains('bad_jwt') || msg.contains('expired')) { await ref.read(authControllerProvider).signOut(); if (!mounted) return; showInfoSnackBar( context, 'Session expired — please sign in again.', ); return; } if (!mounted) return; showErrorSnackBar(context, 'Reset failed: $error'); } }, child: const Text('Update password'), ), ], ); }, ); } Future _toggleLock(String userId, bool locked) async { setState(() => _isSaving = true); try { // Use AdminUserController (Edge Function or direct DB) to lock/unlock. await ref .read(adminUserControllerProvider) .setLock(userId: userId, locked: locked); // Refresh profile streams so other UI updates observe the change. ref.invalidate(profilesProvider); ref.invalidate(currentProfileProvider); if (!mounted) return; showInfoSnackBar( context, locked ? 'User locked (app-level).' : 'User unlocked (app-level).', ); } catch (error) { final msg = error.toString(); if (msg.contains('Unauthorized') || msg.contains('bad_jwt') || msg.contains('expired')) { await ref.read(authControllerProvider).signOut(); if (!mounted) return; showInfoSnackBar(context, 'Session expired — please sign in again.'); return; } if (!mounted) return; showErrorSnackBar(context, 'Lock update failed: $error'); } finally { if (mounted) { setState(() => _isSaving = false); } } } } 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, ), ), ); } }