Files
pyramid/lib/features/spaces/admin/space_admin_members.dart
T
Bernd Steckmeister 0c7d359076 refactor: Gott-Datei space_admin_dialog.dart in 6 Dateien aufgeteilt
Gleiche Technik wie bei den vorigen drei Umbauten: reine Verschiebung per
Dart part/part-of, keine Logikänderung. space_admin_dialog.dart schrumpft
von 2278 auf 19 Zeilen (nur noch Bibliothekskopf). Verifiziert per
automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 11:01:58 +02:00

463 lines
14 KiB
Dart

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<User>? _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<void> _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<void> _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<void> _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<String, dynamic>)[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 }