587 lines
18 KiB
Dart
587 lines
18 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/community/models/group.dart';
|
|
|
|
class GroupMember {
|
|
final String userId;
|
|
final String username;
|
|
final String? avatarUrl;
|
|
final String role;
|
|
final DateTime joinedAt;
|
|
|
|
GroupMember({
|
|
required this.userId,
|
|
required this.username,
|
|
this.avatarUrl,
|
|
required this.role,
|
|
required this.joinedAt,
|
|
});
|
|
|
|
factory GroupMember.fromMap(Map<String, dynamic> map) {
|
|
return GroupMember(
|
|
userId: map['user_id'],
|
|
username: map['username'] ?? 'Unknown User',
|
|
avatarUrl: map['avatar_url'],
|
|
role: map['role'] ?? 'member',
|
|
joinedAt:
|
|
map['joined_at'] != null
|
|
? DateTime.parse(map['joined_at'])
|
|
: DateTime.now(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class NonMember {
|
|
final String userId;
|
|
final String username;
|
|
final String? avatarUrl;
|
|
final String email;
|
|
|
|
NonMember({
|
|
required this.userId,
|
|
required this.username,
|
|
this.avatarUrl,
|
|
required this.email,
|
|
});
|
|
|
|
factory NonMember.fromMap(Map<String, dynamic> map) {
|
|
return NonMember(
|
|
userId: map['user_id'],
|
|
username: map['username'] ?? 'Unknown User',
|
|
avatarUrl: map['avatar_url'],
|
|
email: map['email'] ?? '',
|
|
);
|
|
}
|
|
}
|
|
|
|
class GroupDetailDialog extends StatefulWidget {
|
|
final Group group;
|
|
final Function() onGroupUpdated;
|
|
|
|
const GroupDetailDialog({
|
|
super.key,
|
|
required this.group,
|
|
required this.onGroupUpdated,
|
|
});
|
|
|
|
@override
|
|
State<GroupDetailDialog> createState() => _GroupDetailDialogState();
|
|
}
|
|
|
|
class _GroupDetailDialogState extends State<GroupDetailDialog>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
bool _isLoading = true;
|
|
List<GroupMember> _members = [];
|
|
List<NonMember> _nonMembers = [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
_loadGroupMembers();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadGroupMembers() async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final supabase = Supabase.instance.client;
|
|
|
|
// Load group members
|
|
final membersResponse = await supabase.rpc(
|
|
'get_group_members',
|
|
params: {'group_id_param': widget.group.id},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
List<GroupMember> members = [];
|
|
if (membersResponse != null) {
|
|
for (final item in membersResponse) {
|
|
members.add(GroupMember.fromMap(item));
|
|
}
|
|
}
|
|
|
|
// Load non-members
|
|
final nonMembersResponse = await supabase.rpc(
|
|
'get_users_not_in_group',
|
|
params: {'group_id_param': widget.group.id},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
List<NonMember> nonMembers = [];
|
|
if (nonMembersResponse != null) {
|
|
for (final item in nonMembersResponse) {
|
|
nonMembers.add(NonMember.fromMap(item));
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_members = members;
|
|
_nonMembers = nonMembers;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to load group members: $e');
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
_showErrorSnackBar(
|
|
'Failed to load group members: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _removeUserFromGroup(String userId) async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final supabase = Supabase.instance.client;
|
|
final result = await supabase.rpc(
|
|
'remove_user_from_group',
|
|
params: {'user_id_param': userId, 'group_id_param': widget.group.id},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result == true) {
|
|
_showSuccessSnackBar('User removed from group successfully');
|
|
await _loadGroupMembers(); // Reload members
|
|
widget.onGroupUpdated(); // Refresh parent
|
|
} else {
|
|
_showErrorSnackBar('Failed to remove user from group');
|
|
setState(() => _isLoading = false);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to remove user from group: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _addUserToGroup(String userId) async {
|
|
if (!mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final supabase = Supabase.instance.client;
|
|
final result = await supabase.rpc(
|
|
'add_user_to_group',
|
|
params: {
|
|
'user_id_param': userId,
|
|
'group_id_param': widget.group.id,
|
|
'role_param': 'member',
|
|
},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result == true) {
|
|
_showSuccessSnackBar('User added to group successfully');
|
|
await _loadGroupMembers(); // Reload members
|
|
widget.onGroupUpdated(); // Refresh parent
|
|
} else {
|
|
_showErrorSnackBar('Failed to add user to group');
|
|
setState(() => _isLoading = false);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to add user to group: $e');
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Error: ${e.toString().split('Exception:').last.trim()}',
|
|
);
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showErrorSnackBar(String message) {
|
|
if (!mounted) return;
|
|
|
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
|
scaffoldMessenger.clearSnackBars();
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
|
);
|
|
}
|
|
|
|
void _showSuccessSnackBar(String message) {
|
|
if (!mounted) return;
|
|
|
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
|
scaffoldMessenger.clearSnackBars();
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
|
);
|
|
}
|
|
|
|
void _showRemoveConfirmation(GroupMember member) {
|
|
if (widget.group.isDefault) {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: const Text('Remove from Default Group?'),
|
|
content: Text(
|
|
'This is a default group. "${member.username}" will be temporarily removed but will rejoin if default group settings change.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_removeUserFromGroup(member.userId);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Remove'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: const Text('Remove Member?'),
|
|
content: Text(
|
|
'Are you sure you want to remove "${member.username}" from this group?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_removeUserFromGroup(member.userId);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Remove'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
child: Container(
|
|
width: double.maxFinite,
|
|
constraints: BoxConstraints(
|
|
maxWidth: 600,
|
|
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Header with group info
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(16),
|
|
topRight: Radius.circular(16),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
CircleAvatar(
|
|
backgroundColor: Colors.white.withOpacity(0.3),
|
|
child: Text(
|
|
widget.group.name.substring(0, 1).toUpperCase(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.group.name,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
if (widget.group.isDefault)
|
|
Container(
|
|
margin: const EdgeInsets.only(
|
|
top: 4,
|
|
right: 4,
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.3),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Text(
|
|
'Default Group',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'${_members.length} members',
|
|
style: TextStyle(
|
|
color: Colors.white.withOpacity(0.8),
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
if (widget.group.description.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
widget.group.description,
|
|
style: TextStyle(
|
|
color: Colors.white.withOpacity(0.9),
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
|
|
// Tabs
|
|
TabBar(
|
|
controller: _tabController,
|
|
labelColor: AppColors.primary,
|
|
unselectedLabelColor: Colors.grey,
|
|
tabs: const [Tab(text: 'Members'), Tab(text: 'Add Members')],
|
|
),
|
|
|
|
// Tab content
|
|
Expanded(
|
|
child:
|
|
_isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
// Members tab
|
|
_buildMembersTab(),
|
|
|
|
// Add members tab
|
|
_buildAddMembersTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMembersTab() {
|
|
if (_members.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.people, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No members in this group',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Add members from the "Add Members" tab',
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
itemCount: _members.length,
|
|
itemBuilder: (context, index) {
|
|
final member = _members[index];
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: AppColors.primary.withOpacity(0.2),
|
|
backgroundImage:
|
|
member.avatarUrl != null && member.avatarUrl!.isNotEmpty
|
|
? NetworkImage(member.avatarUrl!)
|
|
: null,
|
|
child:
|
|
member.avatarUrl == null || member.avatarUrl!.isEmpty
|
|
? Text(
|
|
member.username.substring(0, 1).toUpperCase(),
|
|
style: const TextStyle(
|
|
color: AppColors.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
title: Text(
|
|
member.username,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Text(
|
|
'Joined: ${_formatDate(member.joinedAt)} • ${member.role}',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
|
|
onPressed: () => _showRemoveConfirmation(member),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildAddMembersTab() {
|
|
if (_nonMembers.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.people, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'All users are already members',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Refresh'),
|
|
onPressed: _loadGroupMembers,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
itemCount: _nonMembers.length,
|
|
itemBuilder: (context, index) {
|
|
final nonMember = _nonMembers[index];
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: AppColors.primary.withOpacity(0.2),
|
|
backgroundImage:
|
|
nonMember.avatarUrl != null && nonMember.avatarUrl!.isNotEmpty
|
|
? NetworkImage(nonMember.avatarUrl!)
|
|
: null,
|
|
child:
|
|
nonMember.avatarUrl == null || nonMember.avatarUrl!.isEmpty
|
|
? Text(
|
|
nonMember.username.substring(0, 1).toUpperCase(),
|
|
style: const TextStyle(
|
|
color: AppColors.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
title: Text(
|
|
nonMember.username,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Text(
|
|
nonMember.email,
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.add_circle_outline, color: Colors.green),
|
|
onPressed: () => _addUserToGroup(nonMember.userId),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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}';
|
|
}
|
|
}
|
|
}
|