1466 lines
57 KiB
Dart
1466 lines
57 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:tugas_akhir_supabase/core/theme/app_colors.dart';
|
|
import 'package:tugas_akhir_supabase/screens/admin/admin_dashboard.dart';
|
|
|
|
class UserManagement extends StatefulWidget {
|
|
const UserManagement({super.key});
|
|
|
|
@override
|
|
State<UserManagement> createState() => _UserManagementState();
|
|
}
|
|
|
|
class _UserManagementState extends State<UserManagement> {
|
|
final _supabase = Supabase.instance.client;
|
|
bool _isLoading = true;
|
|
List<Map<String, dynamic>> _users = [];
|
|
List<Map<String, dynamic>> _filteredUsers = [];
|
|
String _searchQuery = '';
|
|
|
|
// Modern Color Scheme
|
|
static const Color primaryGreen = Color(0xFF0F6848);
|
|
static const Color lightGreen = Color(0xFF4CAF50);
|
|
static const Color surfaceGreen = Color(0xFFF1F8E9);
|
|
static const Color cardWhite = Colors.white;
|
|
static const Color textPrimary = Color(0xFF1B5E20);
|
|
static const Color textSecondary = Color(0xFF757575);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadUsers();
|
|
}
|
|
|
|
Future<void> _loadUsers() async {
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
debugPrint(
|
|
'Starting to load all users from database using get_all_users function',
|
|
);
|
|
|
|
// Try to execute the get_all_users function
|
|
final response = await _supabase.rpc('get_all_users');
|
|
|
|
debugPrint('get_all_users response type: ${response.runtimeType}');
|
|
debugPrint('get_all_users response length: ${response.length}');
|
|
|
|
// Convert to List<Map>
|
|
final users = List<Map<String, dynamic>>.from(response);
|
|
|
|
// Debug each user
|
|
for (var user in users) {
|
|
debugPrint(
|
|
'User found: ${user['email']} with ID: ${user['user_id']}, role: ${user['role']}',
|
|
);
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_users = users;
|
|
_filteredUsers = _users;
|
|
_isLoading = false;
|
|
|
|
// Debug the final list
|
|
debugPrint('Total users loaded into UI: ${_users.length}');
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error loading users with get_all_users: $e');
|
|
|
|
// Fallback to manual query if the RPC fails
|
|
try {
|
|
debugPrint('Falling back to manual join query');
|
|
|
|
// Directly fetch all users from profiles table
|
|
final profilesResponse = await _supabase
|
|
.from('profiles')
|
|
.select('*')
|
|
.order('created_at', ascending: false);
|
|
|
|
debugPrint('Profiles loaded: ${profilesResponse.length}');
|
|
|
|
// Convert to List<Map>
|
|
final profiles = List<Map<String, dynamic>>.from(profilesResponse);
|
|
|
|
// Debug each profile
|
|
for (var profile in profiles) {
|
|
debugPrint(
|
|
'Profile found: ${profile['email']} with ID: ${profile['user_id']}',
|
|
);
|
|
}
|
|
|
|
// Fetch all user roles
|
|
final rolesResponse = await _supabase.from('user_roles').select('*');
|
|
final roles = List<Map<String, dynamic>>.from(rolesResponse);
|
|
debugPrint('Roles loaded: ${roles.length}');
|
|
|
|
// Join profiles with roles
|
|
for (var profile in profiles) {
|
|
final userId = profile['user_id'];
|
|
|
|
// Find matching role
|
|
final userRole = roles.firstWhere(
|
|
(r) => r['user_id'] == userId,
|
|
orElse: () => {'role': null},
|
|
);
|
|
|
|
// Add role to profile
|
|
profile['role'] = userRole['role'];
|
|
debugPrint('User ${profile['email']} has role: ${profile['role']}');
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_users = profiles;
|
|
_filteredUsers = _users;
|
|
_isLoading = false;
|
|
|
|
// Debug the final list
|
|
debugPrint('Total users loaded into UI: ${_users.length}');
|
|
});
|
|
}
|
|
} catch (fallbackError) {
|
|
debugPrint('Even fallback query failed: $fallbackError');
|
|
|
|
// Last resort: try a direct query to auth.users through RPC
|
|
try {
|
|
debugPrint('Trying last resort query to auth.users');
|
|
|
|
// Create a simple function to get all auth users if it doesn't exist
|
|
final authUsersResponse = await _supabase.rpc('get_all_auth_users');
|
|
|
|
final authUsers = List<Map<String, dynamic>>.from(authUsersResponse);
|
|
debugPrint('Auth users query returned ${authUsers.length} users');
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_users = authUsers;
|
|
_filteredUsers = _users;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (lastError) {
|
|
debugPrint('Last resort query failed: $lastError');
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error: Unable to load users. Please try again.'),
|
|
backgroundColor: Colors.red.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _filterUsers(String query) {
|
|
setState(() {
|
|
_searchQuery = query;
|
|
if (query.isEmpty) {
|
|
_filteredUsers = _users;
|
|
} else {
|
|
_filteredUsers =
|
|
_users.where((user) {
|
|
final email = user['email']?.toString().toLowerCase() ?? '';
|
|
final username =
|
|
user['username']?.toString().toLowerCase() ??
|
|
user['farm_name']?.toString().toLowerCase() ??
|
|
'';
|
|
final searchLower = query.toLowerCase();
|
|
return email.contains(searchLower) ||
|
|
username.contains(searchLower);
|
|
}).toList();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _assignAdminRole(String userId) async {
|
|
// Show confirmation dialog first
|
|
final shouldProceed = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: const Text('Confirm Admin Promotion'),
|
|
content: const Text(
|
|
'Are you sure you want to promote this user to admin? '
|
|
'This will grant them full access to the admin panel.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color.fromARGB(255, 255, 255, 255),
|
|
),
|
|
child: const Text('Promote to Admin'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// If user canceled, don't proceed
|
|
if (shouldProceed != true) return;
|
|
|
|
try {
|
|
// Cek jumlah admin saat ini untuk keamanan
|
|
final adminCountResponse =
|
|
await _supabase
|
|
.from('user_roles')
|
|
.select('*')
|
|
.eq('role', 'admin')
|
|
.count();
|
|
final adminCount = adminCountResponse.count ?? 0;
|
|
|
|
// Cek apakah pengguna sudah memiliki role
|
|
final existingRole =
|
|
await _supabase
|
|
.from('user_roles')
|
|
.select()
|
|
.eq('user_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (existingRole != null) {
|
|
// Update role jika sudah ada
|
|
await _supabase
|
|
.from('user_roles')
|
|
.update({'role': 'admin'})
|
|
.eq('user_id', userId);
|
|
} else {
|
|
// Tambahkan role baru jika belum ada
|
|
await _supabase.from('user_roles').insert({
|
|
'user_id': userId,
|
|
'role': 'admin',
|
|
});
|
|
}
|
|
|
|
// Refresh data
|
|
await _loadUsers();
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('User successfully promoted to admin'),
|
|
backgroundColor: Colors.green.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error assigning admin role: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error: ${e.toString()}'),
|
|
backgroundColor: Colors.red.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _removeAdminRole(String userId) async {
|
|
// Show confirmation dialog first
|
|
final shouldProceed = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: const Text('Confirm Admin Removal'),
|
|
content: const Text(
|
|
'Are you sure you want to remove admin privileges from this user? '
|
|
'They will no longer have access to the admin panel.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color.fromARGB(255, 255, 255, 255),
|
|
),
|
|
child: const Text('Remove Admin'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// If user canceled, don't proceed
|
|
if (shouldProceed != true) return;
|
|
|
|
try {
|
|
// Cek jumlah admin saat ini untuk keamanan
|
|
final adminCountResponse =
|
|
await _supabase
|
|
.from('user_roles')
|
|
.select('*')
|
|
.eq('role', 'admin')
|
|
.count();
|
|
final adminCount = adminCountResponse.count ?? 0;
|
|
|
|
// Pastikan selalu ada minimal 1 admin
|
|
if (adminCount <= 1) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Cannot remove the last admin'),
|
|
backgroundColor: Colors.red.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Hapus role admin
|
|
await _supabase
|
|
.from('user_roles')
|
|
.delete()
|
|
.eq('user_id', userId)
|
|
.eq('role', 'admin');
|
|
|
|
// Refresh data
|
|
await _loadUsers();
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Admin privileges removed'),
|
|
backgroundColor: Colors.green.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error removing admin role: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error: ${e.toString()}'),
|
|
backgroundColor: Colors.red.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _viewUserDetails(Map<String, dynamic> user) async {
|
|
// Show user details in a modal bottom sheet
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => _buildUserDetailsSheet(user),
|
|
);
|
|
}
|
|
|
|
Widget _buildUserDetailsSheet(Map<String, dynamic> user) {
|
|
final isAdmin = user['role'] == 'admin';
|
|
final email = user['email'] ?? 'No Email';
|
|
final username = user['username'] ?? user['farm_name'] ?? 'No Username';
|
|
final fullName = user['full_name'] ?? 'No Name';
|
|
final phone = user['phone'] ?? 'No Phone';
|
|
final address = user['address'] ?? 'No Address';
|
|
final createdAt =
|
|
user['created_at'] != null ? DateTime.parse(user['created_at']) : null;
|
|
final formattedDate =
|
|
createdAt != null
|
|
? '${createdAt.day}/${createdAt.month}/${createdAt.year}'
|
|
: 'Unknown';
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'User Details',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: textPrimary,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
const SizedBox(height: 12),
|
|
|
|
// User avatar and status
|
|
Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 30,
|
|
backgroundColor: isAdmin ? Colors.blue.shade100 : surfaceGreen,
|
|
child:
|
|
user['avatar_url'] != null &&
|
|
user['avatar_url'].toString().isNotEmpty
|
|
? ClipOval(
|
|
child: Image.network(
|
|
user['avatar_url'],
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.cover,
|
|
loadingBuilder: (context, child, loadingProgress) {
|
|
if (loadingProgress == null) return child;
|
|
return Center(
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: isAdmin ? Colors.blue : primaryGreen,
|
|
value:
|
|
loadingProgress.expectedTotalBytes !=
|
|
null
|
|
? loadingProgress
|
|
.cumulativeBytesLoaded /
|
|
loadingProgress
|
|
.expectedTotalBytes!
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
errorBuilder:
|
|
(context, error, stackTrace) => Icon(
|
|
Icons.person,
|
|
size: 30,
|
|
color: isAdmin ? Colors.blue : primaryGreen,
|
|
),
|
|
),
|
|
)
|
|
: Icon(
|
|
Icons.person,
|
|
size: 30,
|
|
color: isAdmin ? Colors.blue : primaryGreen,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
username,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
email,
|
|
style: TextStyle(fontSize: 12, color: textSecondary),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
isAdmin ? Colors.blue.shade100 : surfaceGreen,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
isAdmin ? 'Admin' : 'User',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: isAdmin ? Colors.blue : primaryGreen,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Joined: $formattedDate',
|
|
style: TextStyle(fontSize: 12, color: textSecondary),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// User information
|
|
_buildDetailItem('Full Name', fullName),
|
|
_buildDetailItem('Phone', phone),
|
|
_buildDetailItem('Address', address),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Action buttons
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_buildActionButton(
|
|
isAdmin ? 'Remove Admin' : 'Make Admin',
|
|
isAdmin ? Icons.person_remove : Icons.admin_panel_settings,
|
|
isAdmin ? Colors.red : Colors.blue,
|
|
() {
|
|
Navigator.pop(context);
|
|
if (isAdmin) {
|
|
_removeAdminRole(user['user_id']);
|
|
} else {
|
|
_assignAdminRole(user['user_id']);
|
|
}
|
|
},
|
|
subtitle: 'Requires confirmation',
|
|
),
|
|
_buildActionButton(
|
|
'Reset Password',
|
|
Icons.lock_reset,
|
|
Colors.orange,
|
|
() {
|
|
Navigator.pop(context);
|
|
_showResetPasswordConfirmation(user['email']);
|
|
},
|
|
subtitle: 'Sends email link',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Delete user button (separated for emphasis)
|
|
Center(
|
|
child: _buildActionButton(
|
|
'Delete User',
|
|
Icons.delete_forever,
|
|
Colors.red,
|
|
() {
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text(
|
|
'Swipe the user card from right to left to delete',
|
|
),
|
|
backgroundColor: Colors.orange.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 3),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
subtitle: 'Swipe card to delete',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailItem(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: TextStyle(fontSize: 12, color: textSecondary)),
|
|
const SizedBox(height: 4),
|
|
Text(value, style: const TextStyle(fontSize: 16)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButton(
|
|
String label,
|
|
IconData icon,
|
|
Color color,
|
|
VoidCallback onTap, {
|
|
String? subtitle,
|
|
}) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: color.withOpacity(0.3)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(icon, color: color),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
label,
|
|
style: TextStyle(color: color, fontWeight: FontWeight.w500),
|
|
),
|
|
if (subtitle != null)
|
|
Text(
|
|
subtitle,
|
|
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showResetPasswordConfirmation(String email) async {
|
|
return showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: const Text('Reset Password'),
|
|
content: Text('Send password reset link to $email?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_sendPasswordResetEmail(email);
|
|
},
|
|
style: ElevatedButton.styleFrom(backgroundColor: primaryGreen),
|
|
child: const Text('Send Reset Link'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _sendPasswordResetEmail(String email) async {
|
|
try {
|
|
await _supabase.auth.resetPasswordForEmail(email);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Password reset link sent to $email'),
|
|
backgroundColor: Colors.green.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error sending password reset: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error: ${e.toString()}'),
|
|
backgroundColor: Colors.red.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteUser(String userId, String email) async {
|
|
try {
|
|
// Check if user is an admin
|
|
final userRole =
|
|
await _supabase
|
|
.from('user_roles')
|
|
.select()
|
|
.eq('user_id', userId)
|
|
.eq('role', 'admin')
|
|
.maybeSingle();
|
|
|
|
// If user is an admin, check if they are the last admin
|
|
if (userRole != null) {
|
|
final adminCountResponse =
|
|
await _supabase
|
|
.from('user_roles')
|
|
.select('*')
|
|
.eq('role', 'admin')
|
|
.count();
|
|
|
|
final adminCount = adminCountResponse.count ?? 0;
|
|
|
|
// Ensure there's always at least one admin
|
|
if (adminCount <= 1) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Cannot delete the last admin user'),
|
|
backgroundColor: Colors.red.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Delete the user - this requires admin privileges in Supabase
|
|
await _supabase.rpc('delete_user', params: {'user_id_param': userId});
|
|
|
|
// Refresh data
|
|
await _loadUsers();
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('User $email has been deleted'),
|
|
backgroundColor: Colors.green.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error deleting user: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error: ${e.toString()}'),
|
|
backgroundColor: Colors.red.shade400,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _confirmDismiss(String userId, String email) async {
|
|
// Show a confirmation dialog
|
|
final bool? result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Confirm Delete'),
|
|
content: Text('Are you sure you want to delete user $email?'),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color.fromARGB(255, 255, 255, 255),
|
|
),
|
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
// If the user confirmed deletion, proceed to the full delete confirmation
|
|
if (result == true) {
|
|
// Text controller for the confirmation text field
|
|
final TextEditingController confirmController = TextEditingController();
|
|
bool canDelete = false;
|
|
|
|
// Show second confirmation dialog requiring DELETE text
|
|
final shouldProceed = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(context) => StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
|
titlePadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
|
title: const Text('Final Confirmation'),
|
|
content: Container(
|
|
width: double.maxFinite,
|
|
constraints: const BoxConstraints(maxHeight: 200),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'WARNING: This action cannot be undone.',
|
|
style: TextStyle(
|
|
color: Colors.red,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text('Delete user: $email?'),
|
|
const SizedBox(height: 8),
|
|
const Text('Type "DELETE" to confirm:'),
|
|
Container(
|
|
height: 40,
|
|
margin: const EdgeInsets.only(top: 8),
|
|
child: TextField(
|
|
controller: confirmController,
|
|
autofocus: true,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
canDelete = value == 'DELETE';
|
|
});
|
|
},
|
|
decoration: const InputDecoration(
|
|
hintText: 'Type DELETE in all caps',
|
|
contentPadding: EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 4,
|
|
),
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed:
|
|
canDelete
|
|
? () => Navigator.of(context).pop(true)
|
|
: null,
|
|
style: ElevatedButton.styleFrom(
|
|
foregroundColor: Colors.red,
|
|
backgroundColor: Colors.white,
|
|
disabledBackgroundColor: Colors.grey.shade300,
|
|
),
|
|
child: const Text('Delete User'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
return shouldProceed == true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Menangani tombol kembali
|
|
Future<bool> _onWillPop() async {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (context) => const AdminDashboard()),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return WillPopScope(
|
|
onWillPop: _onWillPop,
|
|
child: Scaffold(
|
|
backgroundColor: Colors.grey[50],
|
|
appBar: AppBar(
|
|
backgroundColor: primaryGreen,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 0,
|
|
title: const Text(
|
|
'User Management',
|
|
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.white),
|
|
),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
automaticallyImplyLeading: false,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.help_outline, color: Colors.white),
|
|
onPressed: () {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text(
|
|
'Swipe users left to delete, pull down to refresh',
|
|
),
|
|
backgroundColor: Colors.black87,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
tooltip: 'Help',
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
// Stats and search container
|
|
Container(
|
|
padding: const EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 16,
|
|
top: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: primaryGreen,
|
|
borderRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(24),
|
|
bottomRight: Radius.circular(24),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// User stats
|
|
Row(
|
|
children: [
|
|
_buildStatCard(
|
|
'Total Users',
|
|
'${_users.length}',
|
|
Icons.people_alt_outlined,
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildStatCard(
|
|
'Admins',
|
|
'${_users.where((u) => u['role'] == 'admin').length}',
|
|
Icons.admin_panel_settings_outlined,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Search bar
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 5,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: TextField(
|
|
onChanged: _filterUsers,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search users...',
|
|
prefixIcon: const Icon(
|
|
Icons.search,
|
|
color: primaryGreen,
|
|
),
|
|
suffixIcon:
|
|
_searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(
|
|
Icons.clear,
|
|
color: Colors.grey,
|
|
),
|
|
onPressed: () => _filterUsers(''),
|
|
)
|
|
: null,
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
vertical: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Gesture hints
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.swipe_left, size: 16, color: primaryGreen),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Swipe left to delete',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
const Icon(Icons.refresh, size: 16, color: primaryGreen),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Pull down to refresh',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// User list
|
|
Expanded(
|
|
child:
|
|
_isLoading
|
|
? Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(color: primaryGreen),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Loading users...',
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: _filteredUsers.isEmpty
|
|
? Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.search_off,
|
|
size: 48,
|
|
color: Colors.grey[400],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_searchQuery.isEmpty
|
|
? 'No users found'
|
|
: 'No matching users',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
if (_searchQuery.isNotEmpty)
|
|
TextButton(
|
|
onPressed: () => _filterUsers(''),
|
|
child: const Text('Clear search'),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: RefreshIndicator(
|
|
color: primaryGreen,
|
|
onRefresh: _loadUsers,
|
|
child: ListView.builder(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
itemCount: _filteredUsers.length,
|
|
itemBuilder: (context, index) {
|
|
final user = _filteredUsers[index];
|
|
final isAdmin = user['role'] == 'admin';
|
|
final userId = user['user_id'];
|
|
final email = user['email'] ?? 'No Email';
|
|
final username =
|
|
user['username'] ??
|
|
user['farm_name'] ??
|
|
'No Username';
|
|
final createdAt =
|
|
user['created_at'] != null
|
|
? DateTime.parse(user['created_at'])
|
|
: null;
|
|
final formattedDate =
|
|
createdAt != null
|
|
? '${createdAt.day}/${createdAt.month}/${createdAt.year}'
|
|
: 'Unknown';
|
|
|
|
return Dismissible(
|
|
key: Key(userId),
|
|
direction: DismissDirection.endToStart,
|
|
confirmDismiss: (direction) async {
|
|
return await _confirmDismiss(userId, email);
|
|
},
|
|
background: Container(
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20.0),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: const Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.delete_forever,
|
|
color: Colors.white,
|
|
size: 28,
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
'Delete',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
onDismissed: (direction) {
|
|
_deleteUser(userId, email);
|
|
},
|
|
child: Card(
|
|
margin: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 6,
|
|
),
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
side: BorderSide(
|
|
color:
|
|
isAdmin
|
|
? Colors.blue.withOpacity(0.3)
|
|
: Colors.grey.withOpacity(0.15),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: InkWell(
|
|
onTap: () => _viewUserDetails(user),
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 12,
|
|
horizontal: 4,
|
|
),
|
|
child: ListTile(
|
|
minLeadingWidth: 36,
|
|
horizontalTitleGap: 6,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
),
|
|
leading: Hero(
|
|
tag: 'avatar-$userId',
|
|
child: CircleAvatar(
|
|
radius: 22,
|
|
backgroundColor:
|
|
isAdmin
|
|
? Colors.blue.withOpacity(0.2)
|
|
: surfaceGreen,
|
|
child:
|
|
user['avatar_url'] != null &&
|
|
user['avatar_url']
|
|
.toString()
|
|
.isNotEmpty
|
|
? ClipOval(
|
|
child: Image.network(
|
|
user['avatar_url'],
|
|
width: 44,
|
|
height: 44,
|
|
fit: BoxFit.cover,
|
|
loadingBuilder: (
|
|
context,
|
|
child,
|
|
loadingProgress,
|
|
) {
|
|
if (loadingProgress ==
|
|
null)
|
|
return child;
|
|
return Center(
|
|
child: SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color:
|
|
Colors.white,
|
|
value:
|
|
loadingProgress
|
|
.expectedTotalBytes !=
|
|
null
|
|
? loadingProgress
|
|
.cumulativeBytesLoaded /
|
|
loadingProgress
|
|
.expectedTotalBytes!
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
errorBuilder:
|
|
(
|
|
context,
|
|
error,
|
|
stackTrace,
|
|
) => Icon(
|
|
Icons.person,
|
|
color:
|
|
isAdmin
|
|
? Colors
|
|
.blue
|
|
: primaryGreen,
|
|
size: 24,
|
|
),
|
|
),
|
|
)
|
|
: Icon(
|
|
Icons.person,
|
|
color:
|
|
isAdmin
|
|
? Colors.blue
|
|
: primaryGreen,
|
|
size: 24,
|
|
),
|
|
),
|
|
),
|
|
title: Text(
|
|
username,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 15,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Email: $email',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Wrap(
|
|
spacing: 4,
|
|
runSpacing: 4,
|
|
crossAxisAlignment:
|
|
WrapCrossAlignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.calendar_today,
|
|
size: 10,
|
|
color: Colors.grey[600],
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
'Joined: $formattedDate',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (isAdmin)
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
vertical: 1,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue
|
|
.withOpacity(0.15),
|
|
borderRadius:
|
|
BorderRadius.circular(
|
|
8,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize:
|
|
MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.verified,
|
|
size: 9,
|
|
color: Colors.blue[700],
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
'Admin',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color:
|
|
Colors.blue[700],
|
|
fontWeight:
|
|
FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
isThreeLine: true,
|
|
trailing: SizedBox(
|
|
width: 28,
|
|
height: 28,
|
|
child: IconButton(
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
icon: Icon(
|
|
isAdmin
|
|
? Icons.person_remove
|
|
: Icons.admin_panel_settings,
|
|
color:
|
|
isAdmin
|
|
? Colors.red
|
|
: primaryGreen,
|
|
size: 18,
|
|
),
|
|
onPressed: () {
|
|
if (isAdmin) {
|
|
_removeAdminRole(userId);
|
|
} else {
|
|
_assignAdminRole(userId);
|
|
}
|
|
},
|
|
tooltip:
|
|
isAdmin
|
|
? 'Remove admin role'
|
|
: 'Make admin',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatCard(String title, String value, IconData icon) {
|
|
return Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.9),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: primaryGreen.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, color: primaryGreen, size: 18),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: primaryGreen,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|