Initial commit
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
||||
class OfficesScreen extends ConsumerWidget {
|
||||
const OfficesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 800,
|
||||
child: !isAdmin
|
||||
? const Center(child: Text('Admin access required.'))
|
||||
: officesAsync.when(
|
||||
data: (offices) {
|
||||
if (offices.isEmpty) {
|
||||
return const Center(child: Text('No offices found.'));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Office Management',
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => context.go('/settings/users'),
|
||||
icon: const Icon(Icons.group),
|
||||
label: const Text('User access'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: offices.length,
|
||||
separatorBuilder: (_, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final office = offices[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.apartment_outlined),
|
||||
title: Text(office.name),
|
||||
trailing: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _showOfficeDialog(
|
||||
context,
|
||||
ref,
|
||||
office: office,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () =>
|
||||
_confirmDelete(context, ref, office),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load offices: $error')),
|
||||
),
|
||||
),
|
||||
floatingActionButton: isAdmin
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => _showOfficeDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Office'),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showOfficeDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
Office? office,
|
||||
}) async {
|
||||
final nameController = TextEditingController(text: office?.name ?? '');
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(office == null ? 'Create Office' : 'Edit Office'),
|
||||
content: TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Office name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Name is required.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final controller = ref.read(officesControllerProvider);
|
||||
if (office == null) {
|
||||
await controller.createOffice(name: name);
|
||||
} else {
|
||||
await controller.updateOffice(id: office.id, name: name);
|
||||
}
|
||||
ref.invalidate(officesProvider);
|
||||
if (context.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: Text(office == null ? 'Create' : 'Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Office office,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete Office'),
|
||||
content: Text('Delete ${office.name}? This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(officesControllerProvider)
|
||||
.deleteOffice(id: office.id);
|
||||
ref.invalidate(officesProvider);
|
||||
if (context.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
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 '../../providers/user_offices_provider.dart';
|
||||
import '../../widgets/responsive_body.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',
|
||||
'admin',
|
||||
];
|
||||
|
||||
final _fullNameController = TextEditingController();
|
||||
|
||||
String? _selectedUserId;
|
||||
String? _selectedRole;
|
||||
Set<String> _selectedOfficeIds = {};
|
||||
AdminUserStatus? _selectedStatus;
|
||||
bool _isSaving = false;
|
||||
bool _isStatusLoading = false;
|
||||
final Map<String, AdminUserStatus> _statusCache = {};
|
||||
final Set<String> _statusLoading = {};
|
||||
final Set<String> _statusErrors = {};
|
||||
Set<String> _prefetchedUserIds = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fullNameController.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 Scaffold(
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 1080,
|
||||
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 ?? [];
|
||||
|
||||
_prefetchStatuses(profiles);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'User Management',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: _buildUserTable(
|
||||
context,
|
||||
profiles,
|
||||
offices,
|
||||
assignments,
|
||||
lastActiveByUser,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserTable(
|
||||
BuildContext context,
|
||||
List<Profile> profiles,
|
||||
List<Office> offices,
|
||||
List<UserOffice> assignments,
|
||||
Map<String, DateTime> lastActiveByUser,
|
||||
) {
|
||||
if (profiles.isEmpty) {
|
||||
return const Center(child: Text('No users found.'));
|
||||
}
|
||||
final officeNameById = {
|
||||
for (final office in offices) office.id: office.name,
|
||||
};
|
||||
|
||||
final officeCountByUser = <String, int>{};
|
||||
for (final assignment in assignments) {
|
||||
officeCountByUser.update(
|
||||
assignment.userId,
|
||||
(value) => value + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 720),
|
||||
child: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowHeight: 46,
|
||||
dataRowMinHeight: 48,
|
||||
dataRowMaxHeight: 64,
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
dividerThickness: 1,
|
||||
headingRowColor: MaterialStateProperty.resolveWith(
|
||||
(states) => Theme.of(context).colorScheme.surfaceVariant,
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('User')),
|
||||
DataColumn(label: Text('Email')),
|
||||
DataColumn(label: Text('Role')),
|
||||
DataColumn(label: Text('Offices')),
|
||||
DataColumn(label: Text('Status')),
|
||||
DataColumn(label: Text('Last active')),
|
||||
],
|
||||
rows: profiles.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final profile = entry.value;
|
||||
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 ?? (isLoading ? 'Loading...' : 'N/A'));
|
||||
final statusLabel = hasError
|
||||
? 'Unavailable'
|
||||
: (status == null
|
||||
? (isLoading ? 'Loading...' : 'Unknown')
|
||||
: (status.isLocked ? 'Locked' : 'Active'));
|
||||
final officeCount = officeCountByUser[profile.id] ?? 0;
|
||||
final officeLabel = officeCount == 0 ? 'None' : '$officeCount';
|
||||
final officeNames = assignments
|
||||
.where((assignment) => assignment.userId == profile.id)
|
||||
.map(
|
||||
(assignment) =>
|
||||
officeNameById[assignment.officeId] ??
|
||||
assignment.officeId,
|
||||
)
|
||||
.toList();
|
||||
final officesText = officeNames.isEmpty
|
||||
? 'No offices'
|
||||
: officeNames.join(', ');
|
||||
final lastActive = _formatLastActive(
|
||||
lastActiveByUser[profile.id]?.toLocal(),
|
||||
);
|
||||
|
||||
return DataRow.byIndex(
|
||||
index: index,
|
||||
onSelectChanged: (selected) {
|
||||
if (selected != true) return;
|
||||
_showUserDialog(context, profile, offices, assignments);
|
||||
},
|
||||
color: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceTint.withOpacity(0.12);
|
||||
}
|
||||
if (index.isEven) {
|
||||
return Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withOpacity(0.6);
|
||||
}
|
||||
return Theme.of(context).colorScheme.surface;
|
||||
}),
|
||||
cells: [
|
||||
DataCell(Text(label)),
|
||||
DataCell(Text(email)),
|
||||
DataCell(Text(profile.role)),
|
||||
DataCell(
|
||||
Tooltip(message: officesText, child: Text(officeLabel)),
|
||||
),
|
||||
DataCell(Text(statusLabel)),
|
||||
DataCell(Text(lastActive)),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<Profile> 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showUserDialog(
|
||||
BuildContext context,
|
||||
Profile profile,
|
||||
List<Office> offices,
|
||||
List<UserOffice> 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<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
_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<void> _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<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) {
|
||||
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<void> _showPasswordResetDialog(String userId) async {
|
||||
final controller = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
await showDialog<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;
|
||||
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<void> _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 _formatLastActive(DateTime? value) {
|
||||
if (value == null) return 'N/A';
|
||||
final now = DateTime.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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user