feat: user status toggle, temp-pw change, multi-file upload

User Management:
- Add is_active column to users table (migration-safe)
- Block deactivated users at login with clear error message
- Toggle Active/Inactive button replaces Delete in detail modal
- Delete button and _DeleteConfirmationDialog fully removed
- Status chip added to desktop table and detail modal
- Temp password changed from random to username+123
- Create user note now shows the exact password format
- Credentials dialog updated with security reminder

Documents:
- Complete multi-file upload redesign
- file_picker allows multiple files (Android + Windows)
- Auto-detect document type from filename similarity
- Per-file manual dropdown override
- Sequential upload with per-file progress bar
- Done/error summary banner
This commit is contained in:
ssbg04 committed 2026-07-25 01:02:49 +08:00
1 parent 4a27cf4853
commit 3ed477327c
9 files changed
+918 -743

No files matched your search

@@ -8,6 +8,7 @@ class SystemUser {
final String role;
final String? email;
final String? phone;
final bool isActive;
final String? createdAt;
final String? addedByUsername;
final String? addedByName;
@@ -22,6 +23,7 @@ class SystemUser {
required this.role,
this.email,
this.phone,
this.isActive = true,
this.createdAt,
this.addedByUsername,
this.addedByName,
@@ -52,6 +54,7 @@ class SystemUser {
role: json['role'] as String,
email: json['email'] as String?,
phone: json['phone'] as String?,
isActive: (json['is_active'] as int? ?? 1) == 1,
createdAt: json['created_at'] as String?,
addedByUsername: json['added_by_username'] as String?,
addedByName: json['added_by_name'] as String?,
@@ -125,4 +125,16 @@ class UserRepository {
throw Exception(e.response?.data['message'] ?? 'Failed to delete user.');
}
}
Future<bool> toggleStatus(int id) async {
try {
final options = await _getAuthOptions();
final response = await _dio.put('/users/$id/status', options: options);
return (response.data['is_active'] as int) == 1;
} on DioException catch (e) {
throw Exception(
e.response?.data['message'] ?? 'Failed to update user status.',
);
}
}
}
@@ -116,4 +116,16 @@ class UsersNotifier extends AsyncNotifier<List<SystemUser>> {
rethrow;
}
}
/// Toggles is_active for a user. Returns the new active state.
Future<bool> toggleStatus(int id) async {
try {
final newActive =
await ref.read(userRepositoryProvider).toggleStatus(id);
await refresh();
return newActive;
} catch (e) {
rethrow;
}
}
}
File diff suppressed because it is too large. Load diff
+82 -180
View File
@@ -120,40 +120,24 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
}
}
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;
Future<void> _toggleStatus(SystemUser user) async {
try {
// ✅ Note: You will need to update `usersProvider.deleteUser` to accept these new parameters!
await ref
final newActive = await ref
.read(usersProvider.notifier)
.deleteUser(
user.id,
reason: result['reason']!,
password: result['password']!,
);
.toggleStatus(user.id);
if (!mounted) return;
// ✅ Replaced SnackBar with Success Dialog
showSuccessDialog(
context,
title: 'User Deleted',
message: 'User "${user.username}" was permanently deleted.',
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;
// ✅ Replaced SnackBar with Error Dialog
showErrorDialog(
context,
'Deletion Failed',
'Status Change Failed',
e.toString().replaceAll('Exception: ', ''),
);
}
@@ -293,15 +277,18 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
),
if (user.id != currentUser?.id)
OutlinedButton.icon(
icon: const Icon(Icons.delete_outline, size: 16),
label: const Text('Delete'),
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();
_confirmDelete(user);
_toggleStatus(user);
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
foregroundColor: user.isActive ? Colors.red : AppColors.primaryGreen,
side: BorderSide(color: user.isActive ? Colors.red : AppColors.primaryGreen),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
@@ -341,6 +328,12 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
'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',
@@ -727,6 +720,7 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
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
],
@@ -739,6 +733,7 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
DataCell(Text('')),
DataCell(Text('')),
DataCell(Text('')),
DataCell(Text('')),
],
),
]
@@ -784,6 +779,7 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
),
),
DataCell(_buildRoleChip(user.role)),
DataCell(_buildStatusChip(user.isActive)),
DataCell(
Text(
user.email ?? user.phone ?? '—',
@@ -935,6 +931,26 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
);
}
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;
@@ -1107,7 +1123,7 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'⚠️ Copy and share these credentials now. The temporary password will not be shown again.',
'⚠️ Save these credentials now. The temporary password will not be shown again.',
style: TextStyle(
fontSize: 13,
color: Colors.orange,
@@ -1115,6 +1131,15 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
),
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),
@@ -1511,21 +1536,38 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
),
),
),
child: const Row(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.auto_awesome,
const Icon(
Icons.info_outline,
color: AppColors.primaryGreen,
size: 16,
),
SizedBox(width: 8),
const 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,
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.',
),
],
),
),
),
@@ -1741,146 +1783,6 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
}
}
// ============================================================
// 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
// ============================================================