import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../profile/user_status.dart'; import '../profile/user_status_cache_provider.dart'; import 'agent_activity/agent_activity_sheet.dart'; import 'agent_activity/working_bots_provider.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; class MembersSheet extends HookConsumerWidget { final Channel channel; final String? currentPubkey; const MembersSheet({ super.key, required this.channel, required this.currentPubkey, }); @override Widget build(BuildContext context, WidgetRef ref) { final membersAsync = ref.watch(channelMembersProvider(channel.id)); final allMembers = membersAsync.asData?.value ?? const []; final people = allMembers.where((member) => !member.isBot).toList(); final bots = allMembers.where((member) => member.isBot).toList(); final userCache = ref.watch(userCacheProvider); final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); final statusCache = ref.watch(userStatusCacheProvider); final bottomClearance = Grid.md + MediaQuery.viewPaddingOf(context).bottom; // Determine if the current user can manage members. final currentMember = allMembers.cast().firstWhere( (m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), orElse: () => null, ); final canManage = currentMember != null && currentMember.isElevated && !channel.isArchived; void openActivity(ChannelMember bot) { final navigator = Navigator.of(context); navigator.pop(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!navigator.mounted) return; showBuzzModalBottomSheet( context: navigator.context, isScrollControlled: true, showDragHandle: true, builder: (_) => AgentActivitySheet( channelId: channel.id, agentPubkey: bot.pubkey, ), ); }); } // Preload profiles for all members so avatars appear. useEffect(() { if (allMembers.isNotEmpty) { ref .read(userCacheProvider.notifier) .preload(allMembers.map((m) => m.pubkey).toList()); // Track user statuses for people (not bots). final peoplePubkeys = allMembers .where((m) => !m.isBot) .map((m) => m.pubkey) .toList(); if (peoplePubkeys.isNotEmpty) { ref.read(userStatusCacheProvider.notifier).track(peoplePubkeys); } } return null; }, [allMembers.length]); return Padding( key: const ValueKey('members-sheet-content-padding'), padding: EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, 0), child: ConstrainedBox( key: const ValueKey('members-sheet-viewport'), constraints: BoxConstraints(maxHeight: 400 + bottomClearance), child: membersAsync.when( data: (_) => ListView( key: const ValueKey('members-sheet-list'), shrinkWrap: true, padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance), children: [ if (people.isNotEmpty) ...[ _SectionLabel(label: 'People · ${people.length}'), for (final member in people) _MemberTile( member: member, currentPubkey: currentPubkey, profile: userCache[member.pubkey.toLowerCase()], canManage: canManage, isSelf: member.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), channelId: channel.id, userStatus: statusCache[member.pubkey.toLowerCase()], ), ], if (bots.isNotEmpty) ...[ const SizedBox(height: Grid.xxs), _SectionLabel(label: 'Agents · ${bots.length}'), for (final bot in bots) _MemberTile( member: bot, currentPubkey: currentPubkey, profile: userCache[bot.pubkey.toLowerCase()], canManage: canManage, isSelf: false, channelId: channel.id, isWorking: typingBotPubkeys.contains( bot.pubkey.toLowerCase(), ), onViewActivity: () => openActivity(bot), onActivityTap: typingBotPubkeys.contains(bot.pubkey.toLowerCase()) ? () => openActivity(bot) : null, ), ], if (people.isEmpty && bots.isEmpty) Center( child: Text( 'No members found.', style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), ), ), ], ), loading: () => const Center( child: BuzzLoadingIndicator( size: 44, semanticLabel: 'Loading members', ), ), error: (error, _) => Center( child: Text( error.toString(), style: context.textTheme.bodySmall?.copyWith( color: context.colors.error, ), ), ), ), ), ); } } class _SectionLabel extends StatelessWidget { final String label; const _SectionLabel({required this.label}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half), child: Text( label, style: context.textTheme.labelMedium?.copyWith( color: context.colors.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), ); } } const _changeableRoles = ['admin', 'member', 'guest']; String _roleLabel(String role) { if (role == 'bot') return 'Agent'; if (role.isEmpty) return 'Member'; return '${role[0].toUpperCase()}${role.substring(1)}'; } class _MemberTile extends ConsumerWidget { final ChannelMember member; final String? currentPubkey; final UserProfile? profile; final bool canManage; final bool isSelf; final String channelId; final bool isWorking; final VoidCallback? onActivityTap; final VoidCallback? onViewActivity; final UserStatus? userStatus; const _MemberTile({ required this.member, required this.currentPubkey, required this.profile, required this.canManage, required this.isSelf, required this.channelId, this.isWorking = false, this.onActivityTap, this.onViewActivity, this.userStatus, }); @override Widget build(BuildContext context, WidgetRef ref) { final label = isSelf ? 'You' : (profile?.displayName?.trim().isNotEmpty == true ? profile!.displayName!.trim() : member.labelFor(currentPubkey)); // Named members initial from their name; unnamed ones stay keyed to the // hex public key so the compact-npub label doesn't render `N` for all. final hasName = profile?.displayName?.trim().isNotEmpty == true; final initial = isSelf || hasName ? label[0].toUpperCase() : (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?'); final showManagementActions = canManage && !isSelf && !member.isOwner; final showMenu = showManagementActions || onViewActivity != null; return ListTile( contentPadding: EdgeInsets.zero, leading: _MemberAvatar( avatarUrl: profile?.avatarUrl, initial: initial, isAgent: member.isBot || profile?.isAgent == true, ), title: Text(label), subtitle: isWorking ? Row( mainAxisSize: MainAxisSize.min, children: [ BuzzLoadingIndicator( size: 14, color: context.appColors.success, semanticLabel: 'Agent working', ), const SizedBox(width: Grid.half), Text( 'Working\u2026', style: context.textTheme.bodySmall?.copyWith( color: context.appColors.success, fontWeight: FontWeight.w600, ), ), ], ) : userStatus != null && !userStatus!.isEmpty ? Text( '${userStatus!.emoji.isNotEmpty ? '${userStatus!.emoji} ' : ''}${userStatus!.text}', style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ) : Text( _roleLabel(member.role), style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), ), trailing: showMenu ? IconButton( icon: const Icon(LucideIcons.ellipsis, size: 18), onPressed: () => _showMemberActions( context, ref, showManagementActions: showManagementActions, ), visualDensity: VisualDensity.compact, ) : null, onTap: onActivityTap, ); } void _showMemberActions( BuildContext context, WidgetRef ref, { required bool showManagementActions, }) { final label = isSelf ? 'You' : (profile?.displayName?.trim().isNotEmpty == true ? profile!.displayName!.trim() : member.labelFor(currentPubkey)); final canChangeRole = showManagementActions && !member.isBot; showBuzzModalBottomSheet( context: context, title: label, showDragHandle: true, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (onViewActivity != null) ListTile( leading: Icon( LucideIcons.activity, size: 18, color: context.colors.primary, ), title: const Text('View activity'), onTap: () { Navigator.of(sheetContext).pop(); WidgetsBinding.instance.addPostFrameCallback((_) { onViewActivity?.call(); }); }, ), if (showManagementActions) ...[ if (canChangeRole) ...[ const SizedBox(height: Grid.xxs), _RoleSelector( selectedRole: member.role, onChanged: (role) async { Navigator.of(sheetContext).pop(); await ref .read(channelActionsProvider) .changeMemberRole( channelId: channelId, pubkey: member.pubkey, role: role, ); }, ), const SizedBox(height: Grid.xs), ], ListTile( leading: Icon( LucideIcons.userMinus, size: 18, color: context.colors.error, ), title: Text( 'Remove from channel', style: TextStyle(color: context.colors.error), ), onTap: () async { Navigator.of(context).pop(); final confirmed = await showBuzzDialog( context: context, builder: (context) => AlertDialog( title: const Text('Remove member'), content: Text('Remove $label from this channel?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), child: Text( 'Remove', style: TextStyle(color: context.colors.error), ), ), ], ), ); if (confirmed == true) { await ref .read(channelActionsProvider) .removeMember( channelId: channelId, pubkey: member.pubkey, ); } }, ), ], const SizedBox(height: Grid.xxs), ], ), ), ); } } class _RoleSelector extends StatelessWidget { final String selectedRole; final ValueChanged onChanged; const _RoleSelector({required this.selectedRole, required this.onChanged}); @override Widget build(BuildContext context) { final hasKnownRole = _changeableRoles.contains(selectedRole); return Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Role', style: context.textTheme.labelMedium?.copyWith( color: context.colors.onSurfaceVariant, ), ), const SizedBox(height: Grid.xxs), SizedBox( width: double.infinity, child: SegmentedButton( segments: [ for (final role in _changeableRoles) ButtonSegment( value: role, label: Text(_roleLabel(role)), ), ], selected: hasKnownRole ? {selectedRole} : const {}, emptySelectionAllowed: !hasKnownRole, showSelectedIcon: false, style: ButtonStyle( visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, textStyle: WidgetStatePropertyAll(context.textTheme.labelSmall), ), onSelectionChanged: (roles) { if (roles.isEmpty) return; final role = roles.single; if (role == selectedRole) return; onChanged(role); }, ), ), ], ), ); } } class _MemberAvatar extends StatelessWidget { final String? avatarUrl; final String initial; final bool isAgent; const _MemberAvatar({ required this.avatarUrl, required this.initial, required this.isAgent, }); @override Widget build(BuildContext context) { return AvatarImage( imageUrl: avatarUrl, radius: 20, fallback: Text(initial), isAgent: isAgent, ); } }