tasq/lib/screens/admin/user_management_screen.dart

747 lines
24 KiB
Dart

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/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<UserManagementScreen> createState() =>
_UserManagementScreenState();
}
class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
static const List<String> _roles = [
'standard',
'dispatcher',
'it_staff',
'programmer',
'admin',
];
static const List<String> _religions = [
'catholic',
'islam',
'protestant',
'other',
];
final _fullNameController = TextEditingController();
final _searchController = TextEditingController();
String? _selectedUserId;
String? _selectedRole;
String _selectedReligion = 'catholic';
final Set<String> _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<List<Profile>> profilesAsync,
AsyncValue<List<Office>> officesAsync,
AsyncValue<List<UserOffice>> assignmentsAsync,
AsyncValue<List<TicketMessage>> 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 ?? [];
final lastActiveByUser = <String, DateTime>{};
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 = <String, int>{};
for (final assignment in assignments) {
officeCountByUser.update(
assignment.userId,
(value) => value + 1,
ifAbsent: () => 1,
);
}
final listBody = TasQAdaptiveList<Profile>(
items: filteredProfiles,
filterHeader: SizedBox(
width: 320,
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Search name',
prefixIcon: Icon(Icons.search),
),
),
),
columns: [
TasQColumn<Profile>(
header: 'User',
cellBuilder: (context, profile) {
final label = profile.fullName.isEmpty
? profile.id
: profile.fullName;
return Text(label);
},
),
TasQColumn<Profile>(
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<Profile>(
header: 'Role',
cellBuilder: (context, profile) => Text(profile.role),
),
TasQColumn<Profile>(
header: 'Offices',
cellBuilder: (context, profile) {
final officesAssigned = officeCountByUser[profile.id] ?? 0;
return Text(officesAssigned == 0 ? 'None' : '$officesAssigned');
},
),
TasQColumn<Profile>(
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<Profile>(
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 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<void> _showUserDialog(
BuildContext context,
Profile profile,
List<Office> offices,
List<UserOffice> 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<void>(
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<Office> offices,
Set<String> 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<String>(
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<String>(
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<bool> _saveChanges(
BuildContext context,
Profile profile,
Set<String> 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<void> _showPasswordResetDialog(String userId) async {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
await m3ShowDialog<void>(
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<void> _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,
),
),
);
}
}