1103 lines
43 KiB
Dart
1103 lines
43 KiB
Dart
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../core/constants/app_colors.dart';
|
|
import '../../../core/constants/app_sizes.dart';
|
|
import '../../../core/utils/validators.dart';
|
|
import '../../../domain/entities/system_user.dart';
|
|
import '../../shared/inputs/custom_text_field.dart';
|
|
import '../../shared/inputs/app_search_bar.dart';
|
|
import '../../shared/buttons/primary_button.dart';
|
|
import '../../providers/users_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
|
|
// --- NEW IMPORTS FOR CUSTOM DIALOGS ---
|
|
import '../../shared/dialogs/error_dialog.dart';
|
|
import '../../shared/dialogs/success_dialog.dart';
|
|
|
|
class UsersScreen extends ConsumerStatefulWidget {
|
|
const UsersScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<UsersScreen> createState() => _UsersScreenState();
|
|
}
|
|
|
|
class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
String _searchQuery = '';
|
|
String _roleFilter = 'all'; // 'all', 'admin', 'teacher'
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_searchController.addListener(() {
|
|
setState(() => _searchQuery = _searchController.text.toLowerCase());
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
List<SystemUser> _filter(List<SystemUser> users) {
|
|
var result = users;
|
|
// Apply role filter first
|
|
if (_roleFilter != 'all') {
|
|
result = result.where((u) => u.role == _roleFilter).toList();
|
|
}
|
|
bool isFuzzyMatch(String text, String query) {
|
|
if (query.isEmpty) return true;
|
|
int j = 0;
|
|
for (int i = 0; i < text.length && j < query.length; i++) {
|
|
if (text[i] == query[j]) {
|
|
j++;
|
|
}
|
|
}
|
|
return j == query.length;
|
|
}
|
|
|
|
// Then apply search filter
|
|
if (_searchQuery.isNotEmpty) {
|
|
result = result.where((u) =>
|
|
isFuzzyMatch(u.username.toLowerCase(), _searchQuery) ||
|
|
isFuzzyMatch(u.fullName.toLowerCase(), _searchQuery) ||
|
|
isFuzzyMatch(u.role.toLowerCase(), _searchQuery)
|
|
).toList();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Future<void> _handleRefresh() async {
|
|
await ref.read(usersProvider.notifier).refresh();
|
|
}
|
|
|
|
Future<void> _confirmResetPassword(SystemUser user) async {
|
|
final password = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => _ResetPasswordConfirmationDialog(user: user),
|
|
);
|
|
if (password == null || !mounted) return;
|
|
|
|
try {
|
|
await ref.read(usersProvider.notifier).resetPassword(user.id, adminPassword: password);
|
|
if (!mounted) return;
|
|
|
|
showSuccessDialog(
|
|
context,
|
|
title: 'Password Reset',
|
|
message: 'Password for "${user.username}" reset to "changeme123".'
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
showErrorDialog(context, 'Reset Failed', e.toString().replaceAll('Exception: ', ''));
|
|
}
|
|
}
|
|
|
|
Future<void> _confirmDelete(SystemUser user) async {
|
|
// Show our new custom delete dialog that collects Reason and Password
|
|
final result = await showDialog<Map<String, String>>(
|
|
context: context,
|
|
builder: (ctx) => _DeleteConfirmationDialog(user: user),
|
|
);
|
|
|
|
if (result == null || !mounted) return;
|
|
|
|
try {
|
|
// ✅ Note: You will need to update `usersProvider.deleteUser` to accept these new parameters!
|
|
await ref.read(usersProvider.notifier).deleteUser(
|
|
user.id,
|
|
reason: result['reason']!,
|
|
password: result['password']!,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
// ✅ Replaced SnackBar with Success Dialog
|
|
showSuccessDialog(
|
|
context,
|
|
title: 'User Deleted',
|
|
message: 'User "${user.username}" was permanently deleted.'
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
// ✅ Replaced SnackBar with Error Dialog
|
|
showErrorDialog(
|
|
context,
|
|
'Deletion Failed',
|
|
e.toString().replaceAll('Exception: ', '')
|
|
);
|
|
}
|
|
}
|
|
|
|
void _openModal({SystemUser? user}) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AddEditUserModal(user: user),
|
|
);
|
|
}
|
|
|
|
void _showUserDetailModal(SystemUser user) {
|
|
final currentUser = ref.watch(authProvider).value;
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 450),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.85),
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.5), width: 1.5),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 10, offset: const Offset(0, 4))
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 24,
|
|
backgroundColor: _roleColor(user.role).withValues(alpha: 0.15),
|
|
child: Icon(Icons.person, color: _roleColor(user.role), size: 28),
|
|
),
|
|
const SizedBox(width: AppSizes.p16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(user.fullName, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
|
Text('@${user.username}', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
|
],
|
|
),
|
|
),
|
|
// Action buttons in header
|
|
IconButton(
|
|
icon: const Icon(Icons.lock_reset, color: Colors.orange, size: 20),
|
|
tooltip: 'Reset Password to "changeme123"',
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
_confirmResetPassword(user);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit, color: AppColors.primaryGreen, size: 20),
|
|
tooltip: 'Edit User',
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
_openModal(user: user);
|
|
},
|
|
),
|
|
if (user.id != currentUser?.id)
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
|
|
tooltip: 'Delete User',
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
_confirmDelete(user);
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, size: 20),
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1, thickness: 1),
|
|
// Content
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: _detailRow('Access Role', user.role.toUpperCase().replaceAll('_', ' '))),
|
|
Expanded(child: _detailRow('Date Joined', user.createdAt?.split('T').first ?? '—')),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _detailRow('Email', user.email?.isNotEmpty == true ? user.email! : '—')),
|
|
Expanded(child: _detailRow('Phone', user.phone?.isNotEmpty == true ? user.phone! : '—')),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
_detailRow('Added By', user.addedByName != null ? '${user.addedByName} (@${user.addedByUsername})' : 'System'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _detailRow(String label, String value) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 2),
|
|
Text(value, style: const TextStyle(fontSize: 15, color: AppColors.textPrimary)),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final usersAsync = ref.watch(usersProvider);
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: () => _openModal(),
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Add User'),
|
|
),
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHeader(context),
|
|
const SizedBox(height: AppSizes.p16),
|
|
Expanded(
|
|
child: usersAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (err, _) => Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
|
const SizedBox(height: 16),
|
|
Text('$err', style: const TextStyle(color: Colors.red)),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton.icon(
|
|
onPressed: _handleRefresh,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
data: (users) {
|
|
final filtered = _filter(users);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SegmentedButton<String>(
|
|
segments: [
|
|
ButtonSegment<String>(
|
|
value: 'all',
|
|
label: Text('All (${users.length})'),
|
|
),
|
|
ButtonSegment<String>(
|
|
value: 'admin',
|
|
label: Text('Admin (${users.where((u) => u.role == 'admin').length})'),
|
|
),
|
|
ButtonSegment<String>(
|
|
value: 'teacher',
|
|
label: Text('Teacher (${users.where((u) => u.role == 'teacher').length})'),
|
|
),
|
|
],
|
|
selected: {_roleFilter},
|
|
onSelectionChanged: (Set<String> newSelection) {
|
|
setState(() {
|
|
_roleFilter = newSelection.first;
|
|
});
|
|
},
|
|
style: ButtonStyle(
|
|
backgroundColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.selected)) {
|
|
return AppColors.primaryGreen.withValues(alpha: 0.15);
|
|
}
|
|
return Colors.transparent;
|
|
}),
|
|
foregroundColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.selected)) {
|
|
return AppColors.primaryGreen;
|
|
}
|
|
return AppColors.textSecondary;
|
|
}),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
onRefresh: _handleRefresh,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
if (constraints.maxWidth > 800) {
|
|
return _buildDesktopTable(filtered);
|
|
} else {
|
|
return _buildMobileList(filtered);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
|
|
Widget _buildHeader(BuildContext context) {
|
|
final isDesktop = MediaQuery.of(context).size.width > 800;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('User Management',
|
|
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
|
const SizedBox(height: 4),
|
|
const Text('Manage system accounts and access roles.',
|
|
style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
|
const SizedBox(height: AppSizes.p16),
|
|
IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: AppSearchBar(
|
|
hint: 'Search by username, name or role...',
|
|
controller: _searchController,
|
|
maxWidth: double.infinity,
|
|
),
|
|
),
|
|
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
|
|
|
|
Widget _buildDesktopTable(List<SystemUser> users) {
|
|
return Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 15, offset: const Offset(0, 4))
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
child: SingleChildScrollView(
|
|
child: DataTable(
|
|
showCheckboxColumn: false,
|
|
headingRowColor: WidgetStateProperty.all(AppColors.primaryGreen.withValues(alpha: 0.05)),
|
|
headingTextStyle: const TextStyle(fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
|
columnSpacing: 24,
|
|
dataRowMaxHeight: 65,
|
|
dataRowMinHeight: 50,
|
|
showBottomBorder: true,
|
|
columns: const [
|
|
DataColumn(label: Text('User')),
|
|
DataColumn(label: Text('Username')),
|
|
DataColumn(label: Text('Access Role')),
|
|
DataColumn(label: Text('Contact')),
|
|
DataColumn(label: SizedBox.shrink()), // Trailing icon
|
|
],
|
|
rows: users.isEmpty
|
|
? [const DataRow(cells: [
|
|
DataCell(Text('No users found.')), DataCell(Text('')),
|
|
DataCell(Text('')), DataCell(Text('')), DataCell(Text('')),
|
|
])]
|
|
: users.map((user) => DataRow(
|
|
onSelectChanged: (_) => _showUserDetailModal(user),
|
|
cells: [
|
|
DataCell(
|
|
Row(children: [
|
|
CircleAvatar(
|
|
radius: 18,
|
|
backgroundColor: _roleColor(user.role).withValues(alpha: 0.15),
|
|
child: Text(user.initials, style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: _roleColor(user.role))),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(user.fullName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
|
]),
|
|
),
|
|
DataCell(Text('@${user.username}', style: const TextStyle(color: AppColors.textSecondary))),
|
|
DataCell(_buildRoleChip(user.role)),
|
|
DataCell(Text(user.email ?? user.phone ?? '—', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13))),
|
|
const DataCell(Align(alignment: Alignment.centerRight, child: Icon(Icons.chevron_right, color: Colors.grey))),
|
|
],
|
|
)).toList(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMobileList(List<SystemUser> users) {
|
|
if (users.isEmpty) {
|
|
return const Center(child: Text('No users found.', style: TextStyle(color: AppColors.textSecondary)));
|
|
}
|
|
return ListView.separated(
|
|
itemCount: users.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: AppSizes.p12),
|
|
itemBuilder: (context, index) {
|
|
final user = users[index];
|
|
return InkWell(
|
|
onTap: () => _showUserDetailModal(user),
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(AppSizes.p16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2))],
|
|
border: Border.all(color: Colors.grey.shade100),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 24,
|
|
backgroundColor: _roleColor(user.role).withValues(alpha: 0.15),
|
|
child: Text(user.initials, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: _roleColor(user.role))),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
user.fullName,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
_buildRoleChip(user.role),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text('@${user.username}', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
|
if (user.email != null || user.phone != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
[if (user.email != null) user.email!, if (user.phone != null) user.phone!].join(' · '),
|
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Icon(Icons.chevron_right, color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildRoleChip(String role) {
|
|
final color = _roleColor(role);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20)),
|
|
child: Text(role.toUpperCase().replaceAll('_', ' '),
|
|
style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 11)),
|
|
);
|
|
}
|
|
|
|
Color _roleColor(String role) {
|
|
if (role == 'admin') return Colors.blue;
|
|
return AppColors.primaryGreen;
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// ============================================================
|
|
// ADD / EDIT MODAL
|
|
// ============================================================
|
|
class AddEditUserModal extends ConsumerStatefulWidget {
|
|
final SystemUser? user;
|
|
const AddEditUserModal({super.key, this.user});
|
|
|
|
@override
|
|
ConsumerState<AddEditUserModal> createState() => _AddEditUserModalState();
|
|
}
|
|
|
|
class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late TextEditingController _usernameCtrl;
|
|
late TextEditingController _firstNameCtrl;
|
|
late TextEditingController _middleNameCtrl;
|
|
late TextEditingController _lastNameCtrl;
|
|
late TextEditingController _extCtrl;
|
|
late TextEditingController _emailCtrl;
|
|
late TextEditingController _phoneCtrl;
|
|
String _selectedRole = 'teacher';
|
|
bool _isLoading = false;
|
|
|
|
bool get _isEdit => widget.user != null;
|
|
|
|
Color _roleColor(String role) {
|
|
if (role == 'admin') return Colors.blue;
|
|
return AppColors.primaryGreen;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final u = widget.user;
|
|
_usernameCtrl = TextEditingController(text: u?.username ?? '');
|
|
_firstNameCtrl = TextEditingController(text: u?.firstName ?? '');
|
|
_middleNameCtrl = TextEditingController(text: u?.middleName ?? '');
|
|
_lastNameCtrl = TextEditingController(text: u?.lastName ?? '');
|
|
_extCtrl = TextEditingController(text: u?.extension ?? '');
|
|
_emailCtrl = TextEditingController(text: u?.email ?? '');
|
|
_phoneCtrl = TextEditingController(text: u?.phone ?? '');
|
|
if (u != null) _selectedRole = u.role;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final c in [_usernameCtrl, _firstNameCtrl, _middleNameCtrl, _lastNameCtrl, _extCtrl, _emailCtrl, _phoneCtrl]) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _handleSave() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final notifier = ref.read(usersProvider.notifier);
|
|
if (_isEdit) {
|
|
await notifier.updateUser(
|
|
id: widget.user!.id,
|
|
firstName: _firstNameCtrl.text.trim(),
|
|
middleName: _middleNameCtrl.text.trim(),
|
|
lastName: _lastNameCtrl.text.trim(),
|
|
extension: _extCtrl.text.trim(),
|
|
role: _selectedRole,
|
|
email: _emailCtrl.text.trim(),
|
|
phone: _phoneCtrl.text.trim(),
|
|
);
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop();
|
|
|
|
// ✅ Replaced SnackBar with Success Dialog
|
|
showSuccessDialog(context, title: 'User Updated', message: 'User updated successfully!');
|
|
} else {
|
|
// Create — backend auto-generates a temporary password
|
|
final username = _usernameCtrl.text.trim();
|
|
final tempPassword = await notifier.createUser(
|
|
username: username,
|
|
firstName: _firstNameCtrl.text.trim(),
|
|
middleName: _middleNameCtrl.text.trim(),
|
|
lastName: _lastNameCtrl.text.trim(),
|
|
extension: _extCtrl.text.trim(),
|
|
role: _selectedRole,
|
|
email: _emailCtrl.text.trim(),
|
|
phone: _phoneCtrl.text.trim(),
|
|
);
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop();
|
|
_showCredentialsDialog(context, username: username, tempPassword: tempPassword);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
// ✅ Replaced SnackBar with Error Dialog
|
|
showErrorDialog(
|
|
context,
|
|
'Error',
|
|
e.toString().replaceAll('Exception: ', '')
|
|
);
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _showCredentialsDialog(BuildContext ctx, {required String username, required String tempPassword}) {
|
|
bool copied = false;
|
|
showDialog(
|
|
context: ctx,
|
|
barrierDismissible: false,
|
|
builder: (dialogCtx) => StatefulBuilder(
|
|
builder: (dialogCtx, setDialogState) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSizes.radiusLarge)),
|
|
icon: const Icon(Icons.check_circle, color: AppColors.success, size: 48),
|
|
title: const Text('User Created!', textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.bold)),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'⚠️ Copy and share these credentials now. The temporary password will not be shown again.',
|
|
style: TextStyle(fontSize: 13, color: Colors.orange, fontWeight: FontWeight.w600),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 20),
|
|
_credentialRow('Username', username),
|
|
const SizedBox(height: 10),
|
|
_credentialRow('Temp. Password', tempPassword, highlight: true),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
icon: Icon(copied ? Icons.check : Icons.copy, size: 18),
|
|
label: Text(copied ? 'Copied!' : 'Copy Credentials'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: copied ? AppColors.success : AppColors.primaryGreen,
|
|
side: BorderSide(color: copied ? AppColors.success : AppColors.primaryGreen),
|
|
),
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(
|
|
text: 'Username: $username\nTemporary Password: $tempPassword',
|
|
));
|
|
setDialogState(() => copied = true);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white),
|
|
onPressed: () => Navigator.of(dialogCtx).pop(),
|
|
child: const Text('DONE'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _credentialRow(String label, String value, {bool highlight = false}) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: highlight ? AppColors.primaryGreen.withValues(alpha: 0.07) : Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: highlight ? AppColors.primaryGreen.withValues(alpha: 0.3) : Colors.grey.shade200),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 2),
|
|
Text(value, style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: highlight ? AppColors.primaryGreen : AppColors.textPrimary, letterSpacing: 0.5)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final currentUser = ref.watch(authProvider).value;
|
|
return Dialog(
|
|
backgroundColor: AppColors.surfaceWhite,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSizes.radiusLarge)),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 560),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(children: [
|
|
Icon(_isEdit ? Icons.edit : Icons.person_add, color: AppColors.primaryGreen),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_isEdit ? 'Edit System User' : 'Create New User',
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
|
),
|
|
]),
|
|
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
|
|
],
|
|
),
|
|
const Divider(height: 28),
|
|
|
|
// Name fields
|
|
_field('First Name', Icons.badge, _firstNameCtrl, required: true),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_field('Middle Name', Icons.badge, _middleNameCtrl),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_field('Last Name', Icons.badge, _lastNameCtrl, required: true),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_field('Ext. (Jr, Sr, III)', Icons.text_fields, _extCtrl),
|
|
const SizedBox(height: AppSizes.p12),
|
|
|
|
// Username
|
|
_field('Username', Icons.person, _usernameCtrl, required: !_isEdit, readOnly: _isEdit),
|
|
const SizedBox(height: AppSizes.p12),
|
|
|
|
// Role dropdown
|
|
if (_isEdit && widget.user?.id == currentUser?.id)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
),
|
|
child: Row(children: [
|
|
Icon(Icons.shield, color: _roleColor(widget.user!.role), size: 20),
|
|
const SizedBox(width: 12),
|
|
Text(widget.user!.role.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold, color: _roleColor(widget.user!.role))),
|
|
const SizedBox(width: 8),
|
|
const Icon(Icons.lock, color: Colors.grey, size: 14),
|
|
const SizedBox(width: 4),
|
|
const Text('(Your own role cannot be changed)', style: TextStyle(fontSize: 12, color: Colors.grey)),
|
|
]),
|
|
)
|
|
else
|
|
DropdownButtonFormField<String>(
|
|
value: _selectedRole,
|
|
decoration: const InputDecoration(prefixIcon: Icon(Icons.shield_outlined, color: AppColors.textSecondary)),
|
|
items: const [
|
|
DropdownMenuItem(value: 'teacher', child: Text('Teacher')),
|
|
DropdownMenuItem(value: 'admin', child: Text('Admin')),
|
|
],
|
|
onChanged: (val) => setState(() => _selectedRole = val!),
|
|
),
|
|
const SizedBox(height: AppSizes.p12),
|
|
|
|
// Contact
|
|
_field('Email Address', Icons.email_outlined, _emailCtrl, validator: AppValidators.validateEmail),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_field('Phone Number (Starts with 09)', Icons.phone_outlined, _phoneCtrl, validator: AppValidators.validatePhone),
|
|
const SizedBox(height: AppSizes.p12),
|
|
|
|
if (!_isEdit)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primaryGreen.withValues(alpha: 0.07),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: AppColors.primaryGreen.withValues(alpha: 0.3)),
|
|
),
|
|
child: const Row(children: [
|
|
Icon(Icons.auto_awesome, color: AppColors.primaryGreen, size: 16),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'A secure temporary password will be auto-generated and shown once after creation.',
|
|
style: TextStyle(fontSize: 12, color: AppColors.primaryGreen, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
|
|
const SizedBox(height: AppSizes.p12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCEL')),
|
|
const SizedBox(width: AppSizes.p16),
|
|
SizedBox(
|
|
width: 150,
|
|
child: PrimaryButton(
|
|
label: _isEdit ? 'UPDATE' : 'CREATE',
|
|
isLoading: _isLoading,
|
|
onPressed: _handleSave,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _field(String hint, IconData icon, TextEditingController ctrl, {bool required = false, bool readOnly = false, String? Function(String?)? validator}) {
|
|
return CustomTextField(
|
|
hintText: hint,
|
|
prefixIcon: icon,
|
|
controller: ctrl,
|
|
readOnly: readOnly,
|
|
validator: validator ?? (required ? (v) => AppValidators.validateRequired(v, hint) : null),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// NEW: CUSTOM DELETE CONFIRMATION DIALOG
|
|
// ============================================================
|
|
class _DeleteConfirmationDialog extends StatefulWidget {
|
|
final SystemUser user;
|
|
const _DeleteConfirmationDialog({required this.user});
|
|
|
|
@override
|
|
State<_DeleteConfirmationDialog> createState() => _DeleteConfirmationDialogState();
|
|
}
|
|
|
|
class _DeleteConfirmationDialogState extends State<_DeleteConfirmationDialog> {
|
|
final _reasonCtrl = TextEditingController();
|
|
final _passwordCtrl = TextEditingController();
|
|
bool _obscurePassword = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_reasonCtrl.dispose();
|
|
_passwordCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _submit() {
|
|
final reason = _reasonCtrl.text.trim();
|
|
final password = _passwordCtrl.text;
|
|
|
|
if (reason.isEmpty || password.isEmpty) {
|
|
showErrorDialog(
|
|
context,
|
|
'Missing Fields',
|
|
'You must provide a reason and confirm your password to delete a user.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Return the collected reason and password back to the _confirmDelete handler
|
|
Navigator.pop(context, {
|
|
'reason': reason,
|
|
'password': password,
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSizes.radiusLarge)),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.delete_forever, color: Colors.red),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Delete @${widget.user.username}?',
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red),
|
|
),
|
|
),
|
|
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'This action is irreversible. For security auditing, please provide a reason and confirm using your admin password.',
|
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
|
),
|
|
const Divider(height: 28),
|
|
|
|
// 1. Reason Field
|
|
CustomTextField(
|
|
hintText: 'Reason for deletion',
|
|
prefixIcon: Icons.warning_amber_rounded,
|
|
controller: _reasonCtrl,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// 2. Admin Password Field
|
|
CustomTextField(
|
|
hintText: 'Your Admin Password',
|
|
prefixIcon: Icons.lock_outline,
|
|
controller: _passwordCtrl,
|
|
isPassword: true,
|
|
obscureText: _obscurePassword,
|
|
onToggleVisibility: () => setState(() => _obscurePassword = !_obscurePassword),
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCEL')),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSizes.radiusMedium)),
|
|
),
|
|
onPressed: _submit,
|
|
child: const Text('DELETE', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// CUSTOM RESET PASSWORD CONFIRMATION DIALOG
|
|
// ============================================================
|
|
class _ResetPasswordConfirmationDialog extends StatefulWidget {
|
|
final SystemUser user;
|
|
const _ResetPasswordConfirmationDialog({required this.user});
|
|
|
|
@override
|
|
State<_ResetPasswordConfirmationDialog> createState() => _ResetPasswordConfirmationDialogState();
|
|
}
|
|
|
|
class _ResetPasswordConfirmationDialogState extends State<_ResetPasswordConfirmationDialog> {
|
|
final _passwordCtrl = TextEditingController();
|
|
bool _obscurePassword = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_passwordCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _submit() {
|
|
final password = _passwordCtrl.text;
|
|
|
|
if (password.isEmpty) {
|
|
showErrorDialog(
|
|
context,
|
|
'Missing Password',
|
|
'You must confirm using your admin password to reset a user\'s password.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
Navigator.pop(context, password);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSizes.radiusLarge)),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.lock_reset, color: Colors.orange),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Reset Password?',
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.orange),
|
|
),
|
|
),
|
|
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Reset the password for "${widget.user.username}"?\nTheir new password will be: changeme123\n\nPlease confirm using your admin password.',
|
|
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
|
),
|
|
const Divider(height: 28),
|
|
|
|
CustomTextField(
|
|
hintText: 'Your Admin Password',
|
|
prefixIcon: Icons.lock_outline,
|
|
controller: _passwordCtrl,
|
|
isPassword: true,
|
|
obscureText: _obscurePassword,
|
|
onToggleVisibility: () => setState(() => _obscurePassword = !_obscurePassword),
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCEL')),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.orange,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSizes.radiusMedium)),
|
|
),
|
|
onPressed: _submit,
|
|
child: const Text('RESET', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |