1421 lines
50 KiB
Dart
1421 lines
50 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:tugas_akhir_supabase/core/theme/app_colors.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/models/group.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/services/group_management_service.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'dart:convert';
|
|
import 'package:tugas_akhir_supabase/screens/admin/group_detail_dialog.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
// Model untuk menampilkan pengguna dan grup yang mereka ikuti
|
|
class UserWithGroups {
|
|
final String userId;
|
|
final String username;
|
|
final String email;
|
|
final String? avatarUrl;
|
|
final bool isActive;
|
|
final DateTime? lastSignIn;
|
|
final List<UserGroup> groups;
|
|
|
|
UserWithGroups({
|
|
required this.userId,
|
|
required this.username,
|
|
required this.email,
|
|
this.avatarUrl,
|
|
required this.isActive,
|
|
this.lastSignIn,
|
|
required this.groups,
|
|
});
|
|
}
|
|
|
|
class UserGroup {
|
|
final String groupId;
|
|
final String groupName;
|
|
final String role;
|
|
|
|
UserGroup({
|
|
required this.groupId,
|
|
required this.groupName,
|
|
required this.role,
|
|
});
|
|
}
|
|
|
|
class CommunityManagement extends StatefulWidget {
|
|
const CommunityManagement({super.key});
|
|
|
|
@override
|
|
_CommunityManagementState createState() => _CommunityManagementState();
|
|
}
|
|
|
|
class _CommunityManagementState extends State<CommunityManagement>
|
|
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
|
final _groupManagementService = GroupManagementService();
|
|
late TabController _tabController;
|
|
|
|
// State variables
|
|
bool _isLoading = true;
|
|
List<Group> _groups = [];
|
|
List<UserWithGroups> _users = [];
|
|
bool _isLoadingUsers = true;
|
|
|
|
// Text controllers for group creation/editing
|
|
final _groupNameController = TextEditingController();
|
|
final _groupDescriptionController = TextEditingController();
|
|
bool _isPublicGroup = true;
|
|
bool _isDefaultGroup = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
_loadGroups();
|
|
_loadUsers(); // Load users data
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_groupNameController.dispose();
|
|
_groupDescriptionController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
Future<void> _loadGroups() async {
|
|
print('[DEBUG] _loadGroups called');
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
print('[DEBUG] Admin: Loading all groups directly from database...');
|
|
|
|
// Gunakan pendekatan langsung tanpa mengandalkan service yang mungkin terpengaruh RLS
|
|
final supabase = Supabase.instance.client;
|
|
final response = await supabase.rpc('get_all_groups_for_admin');
|
|
|
|
if (!mounted) return;
|
|
|
|
print('[DEBUG] Admin: Raw response: $response');
|
|
|
|
List<Group> groups = [];
|
|
|
|
if (response != null) {
|
|
for (final item in response) {
|
|
try {
|
|
// The RPC now returns member_count directly
|
|
final group = Group.fromMap(item);
|
|
groups.add(group);
|
|
print(
|
|
'[DEBUG] Admin: Added group: ${group.name} (${group.id}) with ${group.memberCount} members',
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Admin: Failed to parse group: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
print(
|
|
'[DEBUG] Admin: Loaded ${groups.length} groups directly from database',
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_groups = groups;
|
|
_isLoading = false;
|
|
});
|
|
print('[DEBUG] Admin: State updated with ${_groups.length} groups');
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Admin: Failed to load groups: $e');
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
_showErrorSnackBar('Failed to load groups. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Safety timer untuk mengatasi kemungkinan loading yang tidak berhenti
|
|
Future.delayed(Duration(seconds: 5), () {
|
|
if (mounted && _isLoading) {
|
|
print('[WARNING] Admin: Forcing load completion after timeout');
|
|
setState(() => _isLoading = false);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _loadUsers() async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoadingUsers = true);
|
|
|
|
try {
|
|
print('[DEBUG] Admin: Loading all users with their groups...');
|
|
|
|
// Menggunakan fungsi yang mengembalikan pengguna beserta grup
|
|
final supabase = Supabase.instance.client;
|
|
|
|
// Coba menggunakan fungsi get_users_with_groups
|
|
dynamic response;
|
|
try {
|
|
// Sinkronkan pengguna ke grup default terlebih dahulu
|
|
final syncResult = await supabase.rpc('sync_users_to_default_groups');
|
|
|
|
if (!mounted) return;
|
|
|
|
int added = 0, already = 0, reactivated = 0;
|
|
if (syncResult != null && syncResult is List) {
|
|
for (final item in syncResult) {
|
|
if (item['status'] == 'added_new') added++;
|
|
if (item['status'] == 'already_member') already++;
|
|
if (item['status'] == 'reactivated') reactivated++;
|
|
}
|
|
}
|
|
|
|
print('[DEBUG] Admin: Synchronized users to default groups:');
|
|
print(' - Added: $added users');
|
|
print(' - Already members: $already users');
|
|
print(' - Reactivated: $reactivated users');
|
|
|
|
// Sekarang ambil data pengguna
|
|
response = await supabase.rpc('get_users_with_groups');
|
|
|
|
if (!mounted) return;
|
|
|
|
print(
|
|
'[DEBUG] Admin: Successfully used get_users_with_groups function',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
print('[WARNING] Admin: get_users_with_groups function failed: $e');
|
|
// Fallback ke metode lama jika fungsi tidak tersedia
|
|
response = await supabase.rpc('get_users_for_admin');
|
|
|
|
if (!mounted) return;
|
|
|
|
print('[DEBUG] Admin: Fallback to get_users_for_admin function');
|
|
}
|
|
|
|
print('[DEBUG] Admin: Raw users response: $response');
|
|
|
|
List<UserWithGroups> users = [];
|
|
|
|
if (response != null) {
|
|
for (final dynamic item in response) {
|
|
try {
|
|
// Konversi item ke UserWithGroups
|
|
Map<String, dynamic> userData;
|
|
if (item is Map) {
|
|
userData = Map<String, dynamic>.from(item);
|
|
} else if (item is String) {
|
|
// Jika dikembalikan sebagai JSON string
|
|
userData = jsonDecode(item) as Map<String, dynamic>;
|
|
} else {
|
|
continue; // Skip item yang tidak valid
|
|
}
|
|
|
|
final userId = userData['user_id'] as String? ?? '';
|
|
if (userId.isEmpty) continue; // Skip jika user_id kosong
|
|
|
|
// Mendapatkan grup yang diikuti pengguna
|
|
List<UserGroup> groups = [];
|
|
if (userData.containsKey('groups') && userData['groups'] != null) {
|
|
final groupsData = userData['groups'];
|
|
dynamic groupsList;
|
|
|
|
if (groupsData is String) {
|
|
groupsList = jsonDecode(groupsData);
|
|
} else {
|
|
groupsList = groupsData;
|
|
}
|
|
|
|
if (groupsList is List) {
|
|
for (final groupData in groupsList) {
|
|
Map<String, dynamic> groupMap;
|
|
if (groupData is Map) {
|
|
groupMap = Map<String, dynamic>.from(groupData);
|
|
} else if (groupData is String) {
|
|
groupMap = jsonDecode(groupData) as Map<String, dynamic>;
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
groups.add(
|
|
UserGroup(
|
|
groupId: groupMap['group_id'] as String? ?? '',
|
|
groupName:
|
|
groupMap['group_name'] as String? ?? 'Unknown Group',
|
|
role:
|
|
groupMap['is_default'] == true
|
|
? 'default member'
|
|
: 'member',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
users.add(
|
|
UserWithGroups(
|
|
userId: userId,
|
|
username: userData['username'] as String? ?? 'Unknown User',
|
|
email: userData['email'] as String? ?? 'No Email',
|
|
avatarUrl: userData['avatar_url'] as String?,
|
|
isActive: userData['is_active'] as bool? ?? true,
|
|
lastSignIn:
|
|
null, // Mengabaikan last_sign_in untuk menghindari error
|
|
groups: groups,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Admin: Failed to parse user data: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
print('[DEBUG] Admin: Loaded ${users.length} users');
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_users = users;
|
|
_isLoadingUsers = false;
|
|
});
|
|
print('[DEBUG] Admin: State updated with ${_users.length} users');
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Admin: Failed to load users: $e');
|
|
if (mounted) {
|
|
setState(() => _isLoadingUsers = false);
|
|
_showErrorSnackBar(
|
|
'Failed to load users: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleUserStatus(String userId, bool newStatus) async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoadingUsers = true);
|
|
|
|
try {
|
|
print('[DEBUG] Admin: Toggling user status: $userId to $newStatus');
|
|
|
|
// Panggil RPC untuk mengubah status pengguna
|
|
final supabase = Supabase.instance.client;
|
|
final result = await supabase.rpc(
|
|
'toggle_user_status',
|
|
params: {'user_id_param': userId, 'is_active_param': newStatus},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result == true) {
|
|
_showSuccessSnackBar('User status updated successfully');
|
|
_loadUsers(); // Reload user list
|
|
} else {
|
|
_showErrorSnackBar('Failed to update user status');
|
|
setState(() => _isLoadingUsers = false);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Admin: Failed to toggle user status: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoadingUsers = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _removeUserFromGroup(String userId, String groupId) async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoadingUsers = true);
|
|
|
|
try {
|
|
print('[DEBUG] Admin: Removing user $userId from group $groupId');
|
|
|
|
// Panggil RPC untuk menghapus pengguna dari grup
|
|
final supabase = Supabase.instance.client;
|
|
final result = await supabase.rpc(
|
|
'remove_user_from_group',
|
|
params: {'user_id_param': userId, 'group_id_param': groupId},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result == true) {
|
|
_showSuccessSnackBar('User removed from group successfully');
|
|
_loadUsers(); // Reload user list
|
|
} else {
|
|
_showErrorSnackBar('Failed to remove user from group');
|
|
setState(() => _isLoadingUsers = false);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Admin: Failed to remove user from group: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoadingUsers = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showCreateGroupDialog() {
|
|
// Reset form
|
|
_groupNameController.text = '';
|
|
_groupDescriptionController.text = '';
|
|
_isPublicGroup = true; // Tetap true karena kita menghilangkan opsi ini
|
|
_isDefaultGroup = false; // Default ke false untuk grup baru
|
|
|
|
// Cek apakah sudah ada grup default
|
|
bool hasDefaultGroup = _groups.any((group) => group.isDefault);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter setDialogState) {
|
|
return AlertDialog(
|
|
title: const Text('Create New Group'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: _groupNameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Group Name',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _groupDescriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Default Group',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
Text(
|
|
'All new users join automatically',
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (!hasDefaultGroup)
|
|
Switch(
|
|
value: _isDefaultGroup,
|
|
activeColor: Colors.green,
|
|
onChanged: (value) {
|
|
setDialogState(() {
|
|
_isDefaultGroup = value;
|
|
});
|
|
},
|
|
)
|
|
else
|
|
Tooltip(
|
|
message: 'Default group already exists',
|
|
child: Switch(
|
|
value: false,
|
|
activeColor: Colors.grey,
|
|
onChanged: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (hasDefaultGroup)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
color: Colors.orange,
|
|
size: 16,
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Default group already exists',
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontStyle: FontStyle.italic,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
// Jika sudah ada grup default, pastikan grup baru tidak menjadi default
|
|
if (hasDefaultGroup) {
|
|
_isDefaultGroup = false;
|
|
}
|
|
_createGroup();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Create'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _createGroup() async {
|
|
final name = _groupNameController.text.trim();
|
|
final description = _groupDescriptionController.text.trim();
|
|
|
|
if (name.isEmpty) {
|
|
_showErrorSnackBar('Group name cannot be empty');
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final userId = _groupManagementService.currentUserId;
|
|
if (userId == null) {
|
|
throw Exception('User not authenticated');
|
|
}
|
|
|
|
// Periksa kembali apakah sudah ada grup default
|
|
bool hasDefaultGroup = _groups.any((group) => group.isDefault);
|
|
|
|
// Jika sudah ada grup default, maka grup baru tidak boleh menjadi default
|
|
if (hasDefaultGroup) {
|
|
_isDefaultGroup = false;
|
|
}
|
|
|
|
// Gunakan RPC untuk membuat grup secara atomik
|
|
final createdGroup = await _groupManagementService.createGroupViaRPC(
|
|
name: name,
|
|
description: description,
|
|
isPublic: _isPublicGroup,
|
|
isDefault: _isDefaultGroup,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (createdGroup != null) {
|
|
_showSuccessSnackBar('Group created successfully');
|
|
|
|
// Jika grup adalah default, sinkronkan semua pengguna
|
|
if (_isDefaultGroup) {
|
|
try {
|
|
final supabase = Supabase.instance.client;
|
|
final result = await supabase.rpc('sync_users_to_default_groups');
|
|
|
|
if (!mounted) return;
|
|
|
|
// Log hasil
|
|
int added = 0, already = 0, reactivated = 0;
|
|
if (result != null && result is List) {
|
|
for (final item in result) {
|
|
if (item['status'] == 'added_new') added++;
|
|
if (item['status'] == 'already_member') already++;
|
|
if (item['status'] == 'reactivated') reactivated++;
|
|
}
|
|
}
|
|
|
|
print('[DEBUG] Admin: Synchronized all users to default groups:');
|
|
print(' - Added: $added users');
|
|
print(' - Already members: $already users');
|
|
print(' - Reactivated: $reactivated users');
|
|
|
|
// Jika ada yang berhasil ditambahkan, tampilkan pesan
|
|
if (added > 0 || reactivated > 0) {
|
|
_showSuccessSnackBar(
|
|
'Added ${added + reactivated} users to the default group',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print(
|
|
'[WARNING] Admin: Failed to sync users to default groups: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Refresh groups list dan user list
|
|
_loadGroups();
|
|
_loadUsers();
|
|
} else {
|
|
_showErrorSnackBar('Failed to create group');
|
|
setState(() => _isLoading = false);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to create group: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showDeleteGroupConfirmation(Group group) async {
|
|
final shouldDelete = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: const Text('Delete Group'),
|
|
content: Text(
|
|
'Are you sure you want to delete "${group.name}"? '
|
|
'This action cannot be undone and will delete all messages in this group.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (shouldDelete == true) {
|
|
await _deleteGroup(group);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteGroup(Group group) async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
print(
|
|
'[DEBUG] Admin: Attempting to delete group: ${group.id} (${group.name})',
|
|
);
|
|
final success = await _groupManagementService.deleteGroup(group.id);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (success) {
|
|
print('[DEBUG] Admin: Group deleted successfully');
|
|
_showSuccessSnackBar('Group deleted successfully');
|
|
_loadGroups(); // Refresh groups list
|
|
} else {
|
|
print('[DEBUG] Admin: Group deletion failed - Service returned false');
|
|
_showErrorSnackBar(
|
|
'Failed to delete group. It may be a default group which cannot be deleted.',
|
|
);
|
|
setState(() => _isLoading = false);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Admin: Failed to delete group: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showSuccessSnackBar(String message) {
|
|
if (!mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
|
);
|
|
}
|
|
|
|
void _showErrorSnackBar(String message) {
|
|
if (!mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
|
);
|
|
}
|
|
|
|
void _showEditGroupDialog(Group group) {
|
|
// Set nilai awal
|
|
_groupNameController.text = group.name;
|
|
_groupDescriptionController.text = group.description;
|
|
_isPublicGroup = group.isPublic;
|
|
_isDefaultGroup = group.isDefault;
|
|
|
|
// Cek apakah ada grup default lain selain grup ini
|
|
bool hasOtherDefaultGroup = _groups.any(
|
|
(g) => g.isDefault && g.id != group.id,
|
|
);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter setDialogState) {
|
|
return AlertDialog(
|
|
title: const Text('Edit Group'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: _groupNameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Group Name',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _groupDescriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(8),
|
|
color: group.isDefault ? Colors.green.shade50 : null,
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Default Group',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color:
|
|
group.isDefault
|
|
? Colors.green.shade700
|
|
: null,
|
|
),
|
|
),
|
|
Text(
|
|
'All new users join automatically',
|
|
style: TextStyle(
|
|
color:
|
|
group.isDefault
|
|
? Colors.green.shade700
|
|
: Colors.grey[600],
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (group.isDefault)
|
|
// Jika grup ini adalah default, tampilkan switch yang aktif dan tidak bisa diubah
|
|
Tooltip(
|
|
message:
|
|
'This is a default group and cannot be changed',
|
|
child: Switch(
|
|
value: true,
|
|
activeColor: Colors.green,
|
|
onChanged: null, // Tidak bisa diubah
|
|
),
|
|
)
|
|
else if (hasOtherDefaultGroup)
|
|
// Jika ada grup default lain, switch tidak aktif dan tidak bisa diubah
|
|
Tooltip(
|
|
message: 'Default group already exists',
|
|
child: Switch(
|
|
value: false,
|
|
activeColor: Colors.grey,
|
|
onChanged: null, // Tidak bisa diubah
|
|
),
|
|
)
|
|
else
|
|
// Jika tidak ada grup default lain, bisa mengubah grup ini menjadi default
|
|
Switch(
|
|
value: _isDefaultGroup,
|
|
activeColor: Colors.green,
|
|
onChanged: (value) {
|
|
setDialogState(() {
|
|
_isDefaultGroup = value;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (hasOtherDefaultGroup && !group.isDefault)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
color: Colors.orange,
|
|
size: 16,
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Default group already exists',
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontStyle: FontStyle.italic,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
// Jika grup ini adalah default, tetap default
|
|
// Jika ada grup default lain, grup ini tidak bisa menjadi default
|
|
if (hasOtherDefaultGroup && !group.isDefault) {
|
|
_isDefaultGroup = false;
|
|
}
|
|
_updateGroup(group);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Update'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _updateGroup(Group group) async {
|
|
final name = _groupNameController.text.trim();
|
|
final description = _groupDescriptionController.text.trim();
|
|
|
|
if (name.isEmpty) {
|
|
_showErrorSnackBar('Group name cannot be empty');
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
// Periksa kembali apakah ada grup default lain
|
|
bool hasOtherDefaultGroup = _groups.any(
|
|
(g) => g.isDefault && g.id != group.id,
|
|
);
|
|
|
|
// Jika grup ini bukan default dan ada grup default lain, maka tidak bisa menjadi default
|
|
if (hasOtherDefaultGroup && !group.isDefault) {
|
|
_isDefaultGroup = false;
|
|
}
|
|
|
|
// Jika grup ini adalah default, tetap pertahankan sebagai default
|
|
if (group.isDefault) {
|
|
_isDefaultGroup = true;
|
|
}
|
|
|
|
// Buat objek grup yang diperbarui
|
|
final updatedGroup = Group(
|
|
id: group.id,
|
|
name: name,
|
|
description: description,
|
|
createdBy: group.createdBy,
|
|
isPublic: _isPublicGroup, // Selalu true sesuai permintaan
|
|
isDefault: _isDefaultGroup,
|
|
iconUrl: group.iconUrl,
|
|
createdAt: group.createdAt,
|
|
memberCount: group.memberCount, // Tambahkan ini untuk mencegah null
|
|
);
|
|
|
|
// Panggil service untuk memperbarui grup
|
|
final success = await _groupManagementService.updateGroup(updatedGroup);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (success) {
|
|
_showSuccessSnackBar('Group updated successfully');
|
|
_loadGroups(); // Refresh groups list
|
|
} else {
|
|
_showErrorSnackBar('Failed to update group');
|
|
setState(() => _isLoading = false);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to update group: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
title: const Text(
|
|
'Community Management',
|
|
style: TextStyle(color: AppColors.primary),
|
|
),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
labelColor: AppColors.primary,
|
|
unselectedLabelColor: Colors.grey,
|
|
indicatorColor: AppColors.primary,
|
|
tabs: const [Tab(text: 'Groups'), Tab(text: 'Users')],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
// Groups tab
|
|
_buildGroupsTab(),
|
|
|
|
// Users tab
|
|
_buildUsersTab(),
|
|
],
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: _showCreateGroupDialog,
|
|
backgroundColor: AppColors.primary,
|
|
child: const Icon(Icons.add),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGroupsTab() {
|
|
if (_isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (_groups.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.group, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No groups yet',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Tap the + button to create a new group',
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: _createDefaultGroupAndSyncMembers,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Create Default Group'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _loadGroups,
|
|
child: Column(
|
|
children: [
|
|
// Groups list
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _groups.length,
|
|
itemBuilder: (context, index) {
|
|
final group = _groups[index];
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
elevation: 2,
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.all(16),
|
|
leading: CircleAvatar(
|
|
backgroundColor: AppColors.primary.withOpacity(0.2),
|
|
child: Text(
|
|
group.name.substring(0, 1).toUpperCase(),
|
|
style: const TextStyle(
|
|
color: AppColors.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
group.name,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
if (group.isDefault)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade100,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Text(
|
|
'Default',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.blue,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
if (!group.isPublic)
|
|
Container(
|
|
margin: const EdgeInsets.only(left: 4),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade100,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Text(
|
|
'Private',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.orange,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
group.description,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.people,
|
|
size: 16,
|
|
color: Colors.grey[600],
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${group.memberCount} members',
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.more_vert),
|
|
onPressed: () {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(
|
|
top: Radius.circular(16),
|
|
),
|
|
),
|
|
builder:
|
|
(context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.edit,
|
|
color: Colors.blue,
|
|
),
|
|
title: const Text('Edit Group'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showEditGroupDialog(group);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.people,
|
|
color: Colors.green,
|
|
),
|
|
title: const Text('Manage Members'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showGroupDetailDialog(group);
|
|
},
|
|
),
|
|
if (!group.isDefault)
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.delete,
|
|
color: Colors.red,
|
|
),
|
|
title: const Text('Delete Group'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showDeleteGroupConfirmation(group);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
onTap: () {
|
|
_showGroupDetailDialog(group);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showGroupDetailDialog(Group group) {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => GroupDetailDialog(
|
|
group: group,
|
|
onGroupUpdated: () {
|
|
// Refresh data setelah perubahan grup
|
|
_loadGroups();
|
|
_loadUsers();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUsersTab() {
|
|
if (_isLoadingUsers) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
// Tampilkan tombol refresh jika daftar pengguna kosong
|
|
if (_users.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.people, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No users found',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Refresh'),
|
|
onPressed: () async {
|
|
// Tampilkan loading
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Loading users...'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
|
|
// Muat ulang data pengguna
|
|
await _loadUsers();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _loadUsers,
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _users.length,
|
|
itemBuilder: (context, index) {
|
|
final user = _users[index];
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
elevation: 2,
|
|
child: ExpansionTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: AppColors.primary.withOpacity(0.2),
|
|
backgroundImage:
|
|
user.avatarUrl != null && user.avatarUrl!.isNotEmpty
|
|
? NetworkImage(user.avatarUrl!)
|
|
: null,
|
|
child:
|
|
user.avatarUrl == null || user.avatarUrl!.isEmpty
|
|
? Text(
|
|
user.username.isNotEmpty
|
|
? user.username.substring(0, 1).toUpperCase()
|
|
: '?',
|
|
style: const TextStyle(
|
|
color: AppColors.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
title: Text(
|
|
user.username,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
user.groups.isEmpty
|
|
? 'No groups joined'
|
|
: '${user.groups.length} groups: ${user.groups.map((g) => g.groupName).join(", ")}',
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: Icon(
|
|
user.isActive ? Icons.check_circle : Icons.block,
|
|
color: user.isActive ? Colors.green : Colors.red,
|
|
),
|
|
children: [
|
|
if (user.groups.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
const Text('User is not a member of any group'),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Groups:',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
...user.groups.map(
|
|
(group) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.group,
|
|
size: 16,
|
|
color: Colors.grey[600],
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: RichText(
|
|
text: TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text: group.groupName,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
if (group.groupId.isNotEmpty &&
|
|
group.role.isNotEmpty)
|
|
TextSpan(
|
|
text: ' (${group.role})',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDate(DateTime date) {
|
|
final now = DateTime.now();
|
|
final difference = now.difference(date);
|
|
|
|
if (difference.inDays < 1) {
|
|
return 'Today';
|
|
} else if (difference.inDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (difference.inDays < 7) {
|
|
return '${difference.inDays} days ago';
|
|
} else {
|
|
return '${date.day}/${date.month}/${date.year}';
|
|
}
|
|
}
|
|
|
|
Future<void> _createDefaultGroupAndSyncMembers() async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
// Periksa apakah sudah ada grup default
|
|
bool hasDefaultGroup = _groups.any((group) => group.isDefault);
|
|
|
|
if (hasDefaultGroup) {
|
|
_showErrorSnackBar('Default group already exists');
|
|
setState(() => _isLoading = false);
|
|
return;
|
|
}
|
|
|
|
// Set up a new default group
|
|
_groupNameController.text = 'General';
|
|
_groupDescriptionController.text = 'Default group for all users';
|
|
_isPublicGroup = true;
|
|
_isDefaultGroup = true;
|
|
|
|
// Create the group
|
|
await _createGroup();
|
|
|
|
// Refresh data setelah pembuatan grup
|
|
await _loadGroups();
|
|
await _loadUsers();
|
|
} catch (e) {
|
|
print('[ERROR] Failed to create default group: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
}
|