2089 lines
76 KiB
Dart
2089 lines
76 KiB
Dart
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 '../../shared/widgets/app_pagination.dart';
|
|
import '../../providers/users_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../providers/navigation_provider.dart';
|
|
|
|
// --- NEW IMPORTS FOR CUSTOM DIALOGS ---
|
|
import '../../shared/dialogs/error_dialog.dart';
|
|
import '../../shared/dialogs/success_dialog.dart';
|
|
import '../../shared/modals/custom_modal.dart';
|
|
import '../../shared/modals/reset_requests_modal.dart';
|
|
import 'package:wolt_modal_sheet/wolt_modal_sheet.dart';
|
|
|
|
class UsersScreen extends ConsumerStatefulWidget {
|
|
const UsersScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<UsersScreen> createState() => _UsersScreenState();
|
|
}
|
|
|
|
class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final FocusNode _searchFocusNode = FocusNode();
|
|
final FocusNode _shortcutFocusNode = FocusNode();
|
|
ProviderSubscription<String>? _tabListener;
|
|
String _searchQuery = '';
|
|
String _roleFilter = 'all'; // 'all', 'admin', 'teacher'
|
|
int _currentPage = 1;
|
|
final int _itemsPerPage = 20;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_searchFocusNode.addListener(_onSearchFocusChanged);
|
|
_searchController.addListener(() {
|
|
setState(() {
|
|
_searchQuery = _searchController.text.toLowerCase();
|
|
_currentPage = 1;
|
|
});
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
ref.invalidate(resetRequestsProvider);
|
|
|
|
if (ref.read(activeTabProvider) == 'Users') {
|
|
_shortcutFocusNode.requestFocus();
|
|
}
|
|
|
|
_tabListener = ref.listenManual<String>(activeTabProvider, (
|
|
previous,
|
|
next,
|
|
) {
|
|
if (!mounted) return;
|
|
if (next == 'Users') {
|
|
Future.delayed(const Duration(milliseconds: 120), () {
|
|
if (mounted) {
|
|
_shortcutFocusNode.requestFocus();
|
|
}
|
|
});
|
|
if (previous != 'Users') {
|
|
_searchController.clear();
|
|
setState(() {
|
|
_roleFilter = 'all';
|
|
_searchQuery = '';
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabListener?.close();
|
|
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
|
_searchController.dispose();
|
|
_searchFocusNode.dispose();
|
|
_shortcutFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onSearchFocusChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
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> _toggleStatus(SystemUser user) async {
|
|
try {
|
|
final newActive = await ref
|
|
.read(usersProvider.notifier)
|
|
.toggleStatus(user.id);
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
title: newActive ? 'User Activated' : 'User Deactivated',
|
|
message: newActive
|
|
? '"${user.username}" can now log in to the system.'
|
|
: '"${user.username}" has been deactivated and cannot log in.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Status Change Failed',
|
|
e.toString().replaceAll('Exception: ', ''),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _openModal({SystemUser? user, bool fromDetails = false}) async {
|
|
final success = await AddEditUserModal.show(context, user: user);
|
|
if (fromDetails && user != null && mounted) {
|
|
final users = ref.read(usersProvider).value ?? [];
|
|
final updatedUser = users.firstWhere(
|
|
(u) => u.id == user.id,
|
|
orElse: () => user,
|
|
);
|
|
_showUserDetailModal(updatedUser);
|
|
if (success == true) {
|
|
showSuccessDialog(
|
|
context,
|
|
title: 'User Updated',
|
|
message: 'User updated successfully!',
|
|
);
|
|
}
|
|
} else if (success == true && mounted && user != null) {
|
|
showSuccessDialog(
|
|
context,
|
|
title: 'User Updated',
|
|
message: 'User updated successfully!',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _showUserDetailModal(SystemUser user) {
|
|
final currentUser = ref.watch(authProvider).value;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final textPrimary = isDark ? AppColors.darkTextPrimary : AppColors.textPrimary;
|
|
final textSecondary = isDark ? AppColors.darkTextSecondary : AppColors.textSecondary;
|
|
final headerBg = isDark ? AppColors.darkSurface2 : Colors.grey.shade50;
|
|
final borderCol = isDark ? AppColors.darkBorder : Colors.grey.shade200;
|
|
|
|
CustomModal.show(
|
|
context: context,
|
|
title: 'User Profile Details',
|
|
icon: Icons.person_outline,
|
|
maxWidth: 500,
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header with aligned actions
|
|
Container(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
decoration: BoxDecoration(
|
|
color: headerBg,
|
|
border: Border(bottom: BorderSide(color: borderCol)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 28,
|
|
backgroundColor: _roleColor(
|
|
user.role,
|
|
).withValues(alpha: 0.15),
|
|
child: Text(
|
|
user.initials,
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: _roleColor(user.role),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSizes.p16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
user.fullName,
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: textPrimary,
|
|
),
|
|
),
|
|
Text(
|
|
'@${user.username}',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p20),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.edit, size: 16),
|
|
label: const Text('Edit'),
|
|
onPressed: () {
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
_openModal(user: user, fromDetails: true);
|
|
},
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppColors.primaryGreen,
|
|
side: const BorderSide(color: AppColors.primaryGreen),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.lock_reset, size: 16),
|
|
label: const Text('Reset Pass'),
|
|
onPressed: () {
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
_confirmResetPassword(user);
|
|
},
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Colors.orange,
|
|
side: const BorderSide(color: Colors.orange),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
if (user.id != currentUser?.id)
|
|
OutlinedButton.icon(
|
|
icon: Icon(
|
|
user.isActive ? Icons.block : Icons.check_circle_outline,
|
|
size: 16,
|
|
),
|
|
label: Text(user.isActive ? 'Deactivate' : 'Activate'),
|
|
onPressed: () {
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
_toggleStatus(user);
|
|
},
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: user.isActive ? Colors.red : AppColors.primaryGreen,
|
|
side: BorderSide(color: user.isActive ? Colors.red : AppColors.primaryGreen),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Content layout line by line
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_detailRow(
|
|
'Access Role',
|
|
user.role.toUpperCase().replaceAll('_', ' '),
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
_detailRow(
|
|
'Date Joined',
|
|
user.createdAt?.split('T').first ?? '—',
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
_detailRow(
|
|
'Email',
|
|
user.email?.isNotEmpty == true ? user.email! : '—',
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
_detailRow(
|
|
'Phone',
|
|
user.phone?.isNotEmpty == true ? user.phone! : '—',
|
|
),
|
|
// Status row
|
|
const SizedBox(height: AppSizes.p16),
|
|
_detailRow(
|
|
'Account Status',
|
|
user.isActive ? 'Active' : 'Inactive',
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
_detailRow(
|
|
'Added By',
|
|
user.addedByName != null
|
|
? '${user.addedByName} (@${user.addedByUsername})'
|
|
: 'System',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _detailRow(String label, String value) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final textPrimary = isDark ? AppColors.darkTextPrimary : AppColors.textPrimary;
|
|
final textSecondary = isDark ? AppColors.darkTextSecondary : AppColors.textSecondary;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: textSecondary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
value,
|
|
style: TextStyle(fontSize: 15, color: textPrimary),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAnimatedFilter(String label, String value, int count) {
|
|
final isSelected = _roleFilter == value;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final unselectedBg = Colors.transparent;
|
|
final unselectedBorder = isDark ? AppColors.darkBorder : Colors.grey.shade300;
|
|
final unselectedText = isDark ? AppColors.darkTextSecondary : AppColors.textSecondary;
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
_roleFilter = value;
|
|
_currentPage = 1;
|
|
});
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
margin: const EdgeInsets.only(right: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? AppColors.primaryGreen : unselectedBg,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isSelected ? AppColors.primaryGreen : unselectedBorder,
|
|
),
|
|
boxShadow: isSelected
|
|
? [
|
|
BoxShadow(
|
|
color: AppColors.primaryGreen.withValues(alpha: 0.3),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
]
|
|
: [],
|
|
),
|
|
child: Text(
|
|
'$label ($count)',
|
|
style: TextStyle(
|
|
color: isSelected ? Colors.white : unselectedText,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final usersAsync = ref.watch(usersProvider);
|
|
final resetRequestsAsync = ref.watch(resetRequestsProvider);
|
|
final resetCount = resetRequestsAsync.value?.length ?? 0;
|
|
|
|
return CallbackShortcuts(
|
|
bindings: <ShortcutActivator, VoidCallback>{
|
|
const SingleActivator(LogicalKeyboardKey.keyF, control: true): () {
|
|
_showSearchDialog(context);
|
|
},
|
|
},
|
|
child: Focus(
|
|
focusNode: _shortcutFocusNode,
|
|
autofocus: true,
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
|
floatingActionButton: (MediaQuery.of(context).size.width > 800 ||
|
|
_searchFocusNode.hasFocus ||
|
|
_searchQuery.isNotEmpty)
|
|
? null
|
|
: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Badge(
|
|
isLabelVisible: resetCount > 0,
|
|
label: Text(resetCount.toString()),
|
|
child: FloatingActionButton(
|
|
heroTag: 'reset_requests_fab',
|
|
onPressed: () => ResetRequestsModal.show(context),
|
|
backgroundColor: Colors.orange,
|
|
foregroundColor: Colors.white,
|
|
shape: const CircleBorder(),
|
|
child: const Icon(Icons.lock_clock),
|
|
),
|
|
),
|
|
FloatingActionButton(
|
|
heroTag: 'add_user_fab',
|
|
onPressed: () => _openModal(),
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
shape: const CircleBorder(),
|
|
child: const Icon(Icons.add),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHeader(context, resetCount),
|
|
const SizedBox(height: AppSizes.p24),
|
|
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);
|
|
final int totalPages = (filtered.length / _itemsPerPage).ceil();
|
|
final int startIndex = (_currentPage - 1) * _itemsPerPage;
|
|
final int endIndex = (startIndex + _itemsPerPage).clamp(0, filtered.length);
|
|
final paginated = filtered.isEmpty ? <SystemUser>[] : filtered.sublist(startIndex, endIndex);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_buildAnimatedFilter('All', 'all', users.length),
|
|
_buildAnimatedFilter(
|
|
'Admin',
|
|
'admin',
|
|
users.where((u) => u.role == 'admin').length,
|
|
),
|
|
_buildAnimatedFilter(
|
|
'Teacher',
|
|
'teacher',
|
|
users.where((u) => u.role == 'teacher').length,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (!_searchFocusNode.hasFocus)
|
|
Tooltip(
|
|
richMessage: _searchQuery.isNotEmpty
|
|
? const TextSpan(text: 'Clear Search')
|
|
: const TextSpan(
|
|
text: 'Search Users ',
|
|
children: [
|
|
TextSpan(
|
|
text: '(Ctrl+F)',
|
|
style: TextStyle(fontStyle: FontStyle.italic),
|
|
),
|
|
],
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(
|
|
_searchQuery.isNotEmpty ? Icons.close : Icons.search,
|
|
size: 28,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextPrimary
|
|
: Colors.black87,
|
|
),
|
|
onPressed: () {
|
|
if (_searchQuery.isNotEmpty) {
|
|
_searchController.clear();
|
|
} else {
|
|
_showSearchDialog(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: RefreshIndicator(
|
|
onRefresh: _handleRefresh,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
if (constraints.maxWidth > 800) {
|
|
return _buildDesktopTable(paginated);
|
|
} else {
|
|
return _buildMobileList(paginated);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!_searchFocusNode.hasFocus && totalPages > 1)
|
|
SafeArea(
|
|
top: false,
|
|
child: AppPagination(
|
|
currentPage: _currentPage,
|
|
totalPages: totalPages,
|
|
onPageChanged: (p) => setState(() => _currentPage = p),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(BuildContext context, int resetCount) {
|
|
final isDesktop = MediaQuery.of(context).size.width > 800;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final textPrimary = isDark ? AppColors.darkTextPrimary : AppColors.textPrimary;
|
|
final textSecondary = isDark ? AppColors.darkTextSecondary : AppColors.textSecondary;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'User Management',
|
|
style: TextStyle(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.bold,
|
|
color: textPrimary,
|
|
),
|
|
),
|
|
if (isDesktop)
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Badge(
|
|
isLabelVisible: resetCount > 0,
|
|
label: Text(resetCount.toString()),
|
|
child: ElevatedButton.icon(
|
|
icon: const Icon(Icons.lock_clock, size: 18),
|
|
label: const Text(
|
|
'Password Requests',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.orange,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
onPressed: () => ResetRequestsModal.show(context),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.add, size: 18),
|
|
label: const Text(
|
|
'Add User',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 20,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
onPressed: () => _openModal(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Manage system accounts and access roles.',
|
|
style: TextStyle(fontSize: 14, color: textSecondary),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _showSearchDialog(BuildContext context) async {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_searchFocusNode.requestFocus();
|
|
}
|
|
});
|
|
|
|
await showDialog(
|
|
context: context,
|
|
barrierColor: Colors.black54,
|
|
builder: (context) {
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: kToolbarHeight + 24),
|
|
child: Material(
|
|
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
|
elevation: 4,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AppSearchBar(
|
|
hint: 'Search by username, name or role...',
|
|
controller: _searchController,
|
|
focusNode: _searchFocusNode,
|
|
collapsible: false,
|
|
maxWidth: 600,
|
|
onSubmitted: (val) {
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (mounted) {
|
|
_searchFocusNode.unfocus();
|
|
_shortcutFocusNode.requestFocus();
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
Widget _buildDesktopTable(List<SystemUser> users) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final textPrimary = isDark ? AppColors.darkTextPrimary : AppColors.textPrimary;
|
|
final textSecondary = isDark ? AppColors.darkTextSecondary : AppColors.textSecondary;
|
|
final cardBg = isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: cardBg,
|
|
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(
|
|
isDark
|
|
? Colors.white.withValues(alpha: 0.03)
|
|
: AppColors.primaryGreen.withValues(alpha: 0.05),
|
|
),
|
|
headingTextStyle: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: 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('Status')),
|
|
DataColumn(label: Text('Contact')),
|
|
DataColumn(label: SizedBox.shrink()), // Trailing icon
|
|
],
|
|
rows: users.isEmpty
|
|
? [
|
|
DataRow(
|
|
cells: [
|
|
DataCell(Text('No users found.', style: TextStyle(color: textSecondary))),
|
|
const DataCell(Text('')),
|
|
const DataCell(Text('')),
|
|
const DataCell(Text('')),
|
|
const DataCell(Text('')),
|
|
const 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: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14,
|
|
color: textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
DataCell(
|
|
Text(
|
|
'@${user.username}',
|
|
style: TextStyle(
|
|
color: textSecondary,
|
|
),
|
|
),
|
|
),
|
|
DataCell(_buildRoleChip(user.role)),
|
|
DataCell(_buildStatusChip(user.isActive)),
|
|
DataCell(
|
|
Text(
|
|
user.email ?? user.phone ?? '—',
|
|
style: TextStyle(
|
|
color: textSecondary,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
const DataCell(
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Icon(
|
|
Icons.chevron_right,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMobileList(List<SystemUser> users) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final textPrimary = isDark ? AppColors.darkTextPrimary : AppColors.textPrimary;
|
|
final textSecondary = isDark ? AppColors.darkTextSecondary : AppColors.textSecondary;
|
|
final cardBg = isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite;
|
|
final borderCol = isDark ? AppColors.darkBorder : Colors.grey.shade100;
|
|
|
|
if (users.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'No users found.',
|
|
style: TextStyle(color: 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: cardBg,
|
|
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: borderCol),
|
|
),
|
|
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: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: textPrimary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
_buildRoleChip(user.role),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'@${user.username}',
|
|
style: TextStyle(
|
|
color: 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: TextStyle(
|
|
fontSize: 12,
|
|
color: 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusChip(bool isActive) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? Colors.green.withValues(alpha: 0.1)
|
|
: Colors.grey.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
isActive ? 'Active' : 'Inactive',
|
|
style: TextStyle(
|
|
color: isActive ? Colors.green.shade700 : Colors.grey.shade600,
|
|
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});
|
|
|
|
static Future<bool?> show(BuildContext context, {SystemUser? user}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return WoltModalSheet.show<bool>(
|
|
context: context,
|
|
pageListBuilder: (modalSheetContext) {
|
|
return [
|
|
WoltModalSheetPage(
|
|
backgroundColor: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
|
hasSabGradient: false,
|
|
hasTopBarLayer: false,
|
|
child: AddEditUserModal(user: user),
|
|
),
|
|
];
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
ConsumerState<AddEditUserModal> createState() => _AddEditUserModalState();
|
|
}
|
|
|
|
class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
|
|
final List<GlobalKey<FormState>> _stepKeys = [
|
|
GlobalKey<FormState>(),
|
|
GlobalKey<FormState>(),
|
|
GlobalKey<FormState>(),
|
|
];
|
|
int _currentStep = 0;
|
|
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 {
|
|
for (final key in _stepKeys) {
|
|
if (!(key.currentState?.validate() ?? true)) 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(true);
|
|
} 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(true);
|
|
_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(
|
|
'⚠️ Save 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: 8),
|
|
const Text(
|
|
'🔒 For best protection, remind the user to change their password after first login.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
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}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: highlight
|
|
? AppColors.primaryGreen.withValues(alpha: 0.07)
|
|
: (isDark ? AppColors.darkSurface2 : Colors.grey.shade50),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: highlight
|
|
? AppColors.primaryGreen.withValues(alpha: 0.3)
|
|
: (isDark ? AppColors.darkBorder : Colors.grey.shade200),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isDark ? AppColors.darkTextSecondary : Colors.grey,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.bold,
|
|
color: highlight
|
|
? AppColors.primaryGreen
|
|
: (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary),
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final currentUser = ref.watch(authProvider).value;
|
|
return ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 560),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primaryGreen.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
_isEdit ? Icons.edit : Icons.person_add,
|
|
color: AppColors.primaryGreen,
|
|
size: 22,
|
|
),
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_isEdit ? 'Edit System User' : 'Add New User',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.close,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
const Divider(height: AppSizes.p32),
|
|
|
|
Stepper(
|
|
currentStep: _currentStep,
|
|
type: StepperType.vertical,
|
|
onStepTapped: null,
|
|
onStepContinue: () {
|
|
if (_stepKeys[_currentStep].currentState?.validate() ?? false) {
|
|
if (_currentStep < 2) {
|
|
setState(() => _currentStep += 1);
|
|
} else {
|
|
_handleSave();
|
|
}
|
|
}
|
|
},
|
|
onStepCancel: () {
|
|
if (_currentStep > 0) {
|
|
setState(() => _currentStep -= 1);
|
|
} else {
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
controlsBuilder: (context, details) {
|
|
final isLastStep = _currentStep == 2;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 24),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 150,
|
|
child: PrimaryButton(
|
|
label: isLastStep
|
|
? (_isEdit ? 'UPDATE' : 'CREATE')
|
|
: 'CONTINUE',
|
|
isLoading: _isLoading && isLastStep,
|
|
onPressed: details.onStepContinue ?? () {},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
TextButton(
|
|
onPressed: details.onStepCancel,
|
|
child: Text(_currentStep == 0 ? 'CANCEL' : 'BACK'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
steps: [
|
|
Step(
|
|
title: Text(
|
|
'Personal Information',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
isActive: _currentStep >= 0,
|
|
state: _currentStep > 0
|
|
? StepState.complete
|
|
: StepState.indexed,
|
|
content: Form(
|
|
key: _stepKeys[0],
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isMobile = constraints.maxWidth < 400;
|
|
if (isMobile) {
|
|
return Column(
|
|
children: [
|
|
_field(
|
|
'First Name',
|
|
null,
|
|
_firstNameCtrl,
|
|
required: true,
|
|
textCapitalization: TextCapitalization.words,
|
|
inputFormatters: [
|
|
_TitleCaseTextInputFormatter(),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_field(
|
|
'Middle Name',
|
|
null,
|
|
_middleNameCtrl,
|
|
textCapitalization: TextCapitalization.words,
|
|
inputFormatters: [
|
|
_TitleCaseTextInputFormatter(),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_field(
|
|
'Last Name',
|
|
null,
|
|
_lastNameCtrl,
|
|
required: true,
|
|
textCapitalization: TextCapitalization.words,
|
|
inputFormatters: [
|
|
_TitleCaseTextInputFormatter(),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_buildExtensionField(),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _field(
|
|
'First Name',
|
|
null,
|
|
_firstNameCtrl,
|
|
required: true,
|
|
textCapitalization:
|
|
TextCapitalization.words,
|
|
inputFormatters: [
|
|
_TitleCaseTextInputFormatter(),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
child: _field(
|
|
'Middle Name',
|
|
null,
|
|
_middleNameCtrl,
|
|
textCapitalization:
|
|
TextCapitalization.words,
|
|
inputFormatters: [
|
|
_TitleCaseTextInputFormatter(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: _field(
|
|
'Last Name',
|
|
null,
|
|
_lastNameCtrl,
|
|
required: true,
|
|
textCapitalization:
|
|
TextCapitalization.words,
|
|
inputFormatters: [
|
|
_TitleCaseTextInputFormatter(),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
flex: 1,
|
|
child: _buildExtensionField(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Step(
|
|
title: Text(
|
|
'Account Details',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
isActive: _currentStep >= 1,
|
|
state: _currentStep > 1
|
|
? StepState.complete
|
|
: StepState.indexed,
|
|
content: Form(
|
|
key: _stepKeys[1],
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_field(
|
|
'Username',
|
|
Icons.person,
|
|
_usernameCtrl,
|
|
required: !_isEdit,
|
|
readOnly: _isEdit,
|
|
),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_buildRoleDropdown(currentUser),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Step(
|
|
title: Text(
|
|
'Contact Information',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
isActive: _currentStep >= 2,
|
|
content: Form(
|
|
key: _stepKeys[2],
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildEmailField(),
|
|
const SizedBox(height: AppSizes.p12),
|
|
_field(
|
|
'Phone Number (Starts with 09)',
|
|
Icons.phone_outlined,
|
|
_phoneCtrl,
|
|
validator: AppValidators.validatePhone,
|
|
maxLength: 11,
|
|
keyboardType: TextInputType.phone,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
],
|
|
),
|
|
if (!_isEdit) ...[
|
|
const SizedBox(height: AppSizes.p12),
|
|
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: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(
|
|
Icons.info_outline,
|
|
color: AppColors.primaryGreen,
|
|
size: 16,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: RichText(
|
|
text: TextSpan(
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.primaryGreen,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
children: [
|
|
const TextSpan(
|
|
text: 'Temporary password will be ',
|
|
),
|
|
TextSpan(
|
|
text: '${_usernameCtrl.text.trim().isEmpty ? '<username>' : _usernameCtrl.text.trim()}123',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
const TextSpan(
|
|
text: '. Remind the user to change it after first login.',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildExtensionField() {
|
|
return Autocomplete<String>(
|
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
|
const commonExts = ['Jr.', 'Sr.', 'II', 'III', 'IV'];
|
|
if (textEditingValue.text.isEmpty) {
|
|
return commonExts;
|
|
}
|
|
return commonExts.where(
|
|
(ext) =>
|
|
ext.toLowerCase().contains(textEditingValue.text.toLowerCase()),
|
|
);
|
|
},
|
|
onSelected: (String selection) {
|
|
_extCtrl.text = selection;
|
|
},
|
|
fieldViewBuilder: (context, controller, focusNode, onEditingComplete) {
|
|
controller.addListener(() {
|
|
if (_extCtrl.text != controller.text) {
|
|
_extCtrl.text = controller.text;
|
|
}
|
|
});
|
|
if (controller.text.isEmpty && _extCtrl.text.isNotEmpty) {
|
|
controller.text = _extCtrl.text;
|
|
}
|
|
return CustomTextField(
|
|
hintText: 'Ext. (Jr, Sr, III)',
|
|
prefixIcon: null,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
textCapitalization: TextCapitalization.words,
|
|
inputFormatters: [_TitleCaseTextInputFormatter()],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildRoleDropdown(dynamic currentUser) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
if (_isEdit && widget.user?.id == currentUser?.id) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
|
|
border: Border.all(color: isDark ? AppColors.darkBorder : 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),
|
|
Icon(
|
|
Icons.lock,
|
|
color: isDark ? AppColors.darkTextMuted : Colors.grey,
|
|
size: 14,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'(Your own role cannot be changed)',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: isDark ? AppColors.darkTextSecondary : Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return DropdownButtonFormField<String>(
|
|
initialValue: _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!),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmailField() {
|
|
return Autocomplete<String>(
|
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
|
final text = textEditingValue.text;
|
|
if (!text.contains('@')) {
|
|
return const Iterable<String>.empty();
|
|
}
|
|
final parts = text.split('@');
|
|
final prefix = parts[0];
|
|
final query = parts.length > 1 ? parts[1].toLowerCase() : '';
|
|
const domains = [
|
|
'gmail.com',
|
|
'yahoo.com',
|
|
'outlook.com',
|
|
'hotmail.com',
|
|
'deped.gov.ph',
|
|
];
|
|
return domains
|
|
.where((domain) => domain.toLowerCase().startsWith(query))
|
|
.map((domain) => '$prefix@$domain');
|
|
},
|
|
onSelected: (String selection) {
|
|
_emailCtrl.text = selection;
|
|
},
|
|
optionsViewBuilder:
|
|
(
|
|
BuildContext context,
|
|
AutocompleteOnSelected<String> onSelected,
|
|
Iterable<String> options,
|
|
) {
|
|
return Align(
|
|
alignment: Alignment.topLeft,
|
|
child: Material(
|
|
elevation: 4.0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxHeight: 200,
|
|
maxWidth: 300,
|
|
),
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
shrinkWrap: true,
|
|
itemCount: options.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final String option = options.elementAt(index);
|
|
final display = '@${option.split('@').last}';
|
|
return InkWell(
|
|
onTap: () => onSelected(option),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 12.0,
|
|
),
|
|
child: Text(
|
|
display,
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
fieldViewBuilder: (context, controller, focusNode, onEditingComplete) {
|
|
controller.addListener(() {
|
|
if (_emailCtrl.text != controller.text) {
|
|
_emailCtrl.text = controller.text;
|
|
}
|
|
});
|
|
if (controller.text.isEmpty && _emailCtrl.text.isNotEmpty) {
|
|
controller.text = _emailCtrl.text;
|
|
}
|
|
return CustomTextField(
|
|
hintText: 'Email Address',
|
|
prefixIcon: Icons.email_outlined,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
validator: AppValidators.validateEmail,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _field(
|
|
String hint,
|
|
IconData? icon,
|
|
TextEditingController ctrl, {
|
|
bool required = false,
|
|
bool readOnly = false,
|
|
String? Function(String?)? validator,
|
|
TextCapitalization textCapitalization = TextCapitalization.none,
|
|
int? maxLength,
|
|
TextInputType? keyboardType,
|
|
List<TextInputFormatter>? inputFormatters,
|
|
}) {
|
|
return CustomTextField(
|
|
hintText: hint,
|
|
prefixIcon: icon,
|
|
controller: ctrl,
|
|
readOnly: readOnly,
|
|
validator:
|
|
validator ??
|
|
(required ? (v) => AppValidators.validateRequired(v, hint) : null),
|
|
textCapitalization: textCapitalization,
|
|
maxLength: maxLength,
|
|
keyboardType: keyboardType,
|
|
inputFormatters: inputFormatters,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 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: TextStyle(
|
|
fontSize: 13,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextSecondary
|
|
: 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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// FORMATTER FOR DESKTOP TITLE CASE
|
|
// ============================================================
|
|
class _TitleCaseTextInputFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
if (newValue.text.isEmpty) {
|
|
return newValue;
|
|
}
|
|
|
|
final text = newValue.text;
|
|
final buffer = StringBuffer();
|
|
bool capitalizeNext = true;
|
|
|
|
for (int i = 0; i < text.length; i++) {
|
|
final char = text[i];
|
|
if (char == ' ' || char == '-' || char == '.') {
|
|
buffer.write(char);
|
|
capitalizeNext = true;
|
|
} else {
|
|
if (capitalizeNext) {
|
|
buffer.write(char.toUpperCase());
|
|
capitalizeNext = false;
|
|
} else {
|
|
buffer.write(char);
|
|
}
|
|
}
|
|
}
|
|
|
|
return TextEditingValue(
|
|
text: buffer.toString(),
|
|
selection: newValue.selection,
|
|
);
|
|
}
|
|
}
|