part of '../space_admin_dialog.dart'; class _MembersTab extends ConsumerStatefulWidget { final Room space; const _MembersTab({required this.space}); @override ConsumerState<_MembersTab> createState() => _MembersTabState(); } class _MembersTabState extends ConsumerState<_MembersTab> { List? _members; bool _loading = true; String? _error; String _filter = ''; final _filterCtrl = TextEditingController(); @override void initState() { super.initState(); _load(); } @override void dispose() { _filterCtrl.dispose(); super.dispose(); } Future _load() async { try { final members = await widget.space.requestParticipants([Membership.join, Membership.invite]); if (mounted) setState(() { _members = members; _loading = false; }); } catch (e) { if (mounted) setState(() { _error = e.toString(); _loading = false; }); } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); if (_loading) { return Center( child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2)); } if (_error != null) { return Center( child: Text(_error!, style: TextStyle(color: pt.fgDim, fontSize: 13))); } final members = _members ?? []; final filtered = _filter.isEmpty ? members : members .where((m) => (m.displayName ?? m.id) .toLowerCase() .contains(_filter.toLowerCase()) || m.id.toLowerCase().contains(_filter.toLowerCase())) .toList() ..sort((a, b) => (b.powerLevel).compareTo(a.powerLevel)); final ownPower = widget.space.ownPowerLevel; return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ Expanded( child: _SearchField( controller: _filterCtrl, hint: 'Mitglieder suchen…', pt: pt, onChanged: (v) => setState(() => _filter = v), ), ), if (ownPower >= _kPowerMod) ...[ const SizedBox(width: 8), _InviteButton(space: widget.space), ], ], ), ), Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8), itemCount: filtered.length, itemBuilder: (context, i) { final member = filtered[i]; return _MemberTile( member: member, space: widget.space, ownPower: ownPower, onChanged: _load, ); }, ), ), ], ); } } class _InviteButton extends ConsumerStatefulWidget { final Room space; const _InviteButton({required this.space}); @override ConsumerState<_InviteButton> createState() => _InviteButtonState(); } class _InviteButtonState extends ConsumerState<_InviteButton> { bool _inviting = false; String? _inviteError; void _showInvite() { final pt = PyramidTheme.of(context); final ctrl = TextEditingController(); showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setS) => AlertDialog( backgroundColor: pt.bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rLg)), title: Text('Zu Space einladen', style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _Field(controller: ctrl, hint: '@user:server.org', pt: pt), const SizedBox(height: 6), Text('Lädt zu Space und allen Kanälen ein.', style: TextStyle(color: pt.fgDim, fontSize: 11)), if (_inviteError != null) ...[ const SizedBox(height: 6), Text(_inviteError!, style: TextStyle(color: pt.danger, fontSize: 12)), ], ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), FilledButton( style: FilledButton.styleFrom(backgroundColor: pt.accent), onPressed: _inviting ? null : () async { final userId = ctrl.text.trim(); if (userId.isEmpty) return; setS(() { _inviting = true; _inviteError = null; }); try { // 1. Invite to space itself await widget.space.invite(userId); // 2. Invite to every joined child room final client = widget.space.client; for (final child in widget.space.spaceChildren) { final roomId = child.roomId; if (roomId == null) continue; final room = client.getRoomById(roomId); if (room == null || room.membership == Membership.leave) continue; try { await room.invite(userId); } catch (_) {} } if (ctx.mounted) Navigator.pop(ctx); } catch (e) { setS(() { _inviteError = e.toString().split('\n').first; }); } finally { setS(() => _inviting = false); } }, child: _inviting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('Einladen', style: TextStyle(color: Colors.white)), ), ], ), ), ); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return IconButton( icon: Icon(Icons.person_add_outlined, color: pt.accent, size: 20), tooltip: 'Einladen', onPressed: _showInvite, ); } } class _MemberTile extends ConsumerStatefulWidget { final User member; final Room space; final int ownPower; final VoidCallback onChanged; const _MemberTile({ required this.member, required this.space, required this.ownPower, required this.onChanged, }); @override ConsumerState<_MemberTile> createState() => _MemberTileState(); } class _MemberTileState extends ConsumerState<_MemberTile> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final member = widget.member; final power = member.powerLevel; final name = member.displayName ?? member.id; final client = ref.watch(matrixClientProvider).valueOrNull; final avatarUri = member.avatarUrl; final isMe = member.id == widget.space.client.userID; final canManage = widget.ownPower > power && !isMe; Widget avatar; final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; if (client != null && avatarUri != null) { avatar = MxcAvatar( mxcUri: avatarUri, client: client, size: 32, borderRadius: BorderRadius.circular(16), placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 32), ); } else { avatar = _InitialAvatar(letter: letter, pt: pt, size: 32); } return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Container( margin: const EdgeInsets.symmetric(vertical: 1), decoration: BoxDecoration( color: _hovered ? pt.bg2 : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: ListTile( dense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 8), leading: avatar, title: Text(name, style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), subtitle: Text(member.id, style: TextStyle(color: pt.fgDim, fontSize: 11), overflow: TextOverflow.ellipsis), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), decoration: BoxDecoration( color: _powerColor(power, pt).withAlpha(30), borderRadius: BorderRadius.circular(6), ), child: Text(_powerLabel(power), style: TextStyle( color: _powerColor(power, pt), fontSize: 11, fontWeight: FontWeight.w600)), ), if (canManage) ...[ const SizedBox(width: 4), _MemberActions( member: member, space: widget.space, ownPower: widget.ownPower, onChanged: widget.onChanged), ], ], ), ), ), ); } } class _MemberActions extends ConsumerWidget { final User member; final Room space; final int ownPower; final VoidCallback onChanged; const _MemberActions({ required this.member, required this.space, required this.ownPower, required this.onChanged, }); @override Widget build(BuildContext context, WidgetRef ref) { final pt = PyramidTheme.of(context); return PopupMenuButton<_MemberAction>( icon: Icon(Icons.more_vert, color: pt.fgDim, size: 18), color: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rBase)), itemBuilder: (_) => [ if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerMod) const PopupMenuItem( value: _MemberAction.makeMod, child: Text('Zum Moderator machen'), ), if (ownPower >= _kPowerAdmin && member.powerLevel == _kPowerMod) const PopupMenuItem( value: _MemberAction.removeMod, child: Text('Moderator-Rechte entziehen'), ), if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerAdmin) const PopupMenuItem( value: _MemberAction.makeAdmin, child: Text('Zum Admin machen'), ), const PopupMenuItem( value: _MemberAction.kick, child: Text('Kicken', style: TextStyle(color: Color(0xFFF59E0B))), ), const PopupMenuItem( value: _MemberAction.ban, child: Text('Bannen', style: TextStyle(color: Color(0xFFED4245))), ), ], onSelected: (action) => _handleAction(context, action), ); } Future _handleAction(BuildContext context, _MemberAction action) async { switch (action) { case _MemberAction.makeMod: await _setPower(context, _kPowerMod); case _MemberAction.removeMod: await _setPower(context, _kPowerMember); case _MemberAction.makeAdmin: _confirmAdmin(context); case _MemberAction.kick: _confirmKick(context); case _MemberAction.ban: _confirmBan(context); } } Future _setPower(BuildContext context, int level) async { try { // NICHT space.setPower() — das merged nur mit dem lokalen Cache und // kann die users-Map des Servers zerstören (Admin-Aussperrung). await updatePowerLevelsSafely(space, (content) { (content['users'] as Map)[member.id] = level; }); onChanged(); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString()))); } } } void _confirmAdmin(BuildContext context) { showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Admin machen?'), content: Text( '${member.displayName ?? member.id} zum Admin befördern? ' 'Admin-Rechte können nicht einfach entzogen werden.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen')), TextButton( onPressed: () async { Navigator.pop(context); await _setPower(context, _kPowerAdmin); }, child: const Text('Admin machen', style: TextStyle(color: Color(0xFFED4245))), ), ], ), ); } void _confirmKick(BuildContext context) { showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Mitglied kicken?'), content: Text('${member.displayName ?? member.id} aus dem Space entfernen?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen')), TextButton( onPressed: () async { Navigator.pop(context); try { await space.kick(member.id); onChanged(); } catch (_) {} }, child: const Text('Kicken', style: TextStyle(color: Color(0xFFF59E0B))), ), ], ), ); } void _confirmBan(BuildContext context) { showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Mitglied bannen?'), content: Text( '${member.displayName ?? member.id} dauerhaft aus dem Space ausschließen?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen')), TextButton( onPressed: () async { Navigator.pop(context); try { await space.ban(member.id); onChanged(); } catch (_) {} }, child: const Text('Bannen', style: TextStyle(color: Color(0xFFED4245))), ), ], ), ); } } enum _MemberAction { makeMod, removeMod, makeAdmin, kick, ban }