From 0c7d359076db27e11d8f0f76cdd5c1a394f5a71f Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Fri, 3 Jul 2026 11:01:58 +0200 Subject: [PATCH] refactor: Gott-Datei space_admin_dialog.dart in 6 Dateien aufgeteilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGES.md | 2 + PROGRESS.md | 48 + ROADMAP.md | 6 +- .../spaces/admin/space_admin_channels.dart | 587 +++++ .../spaces/admin/space_admin_core.dart | 199 ++ .../spaces/admin/space_admin_members.dart | 462 ++++ .../spaces/admin/space_admin_overview.dart | 572 +++++ .../spaces/admin/space_admin_permissions.dart | 201 ++ .../spaces/admin/space_admin_shared.dart | 232 ++ lib/features/spaces/space_admin_dialog.dart | 2271 +---------------- 10 files changed, 2313 insertions(+), 2267 deletions(-) create mode 100644 lib/features/spaces/admin/space_admin_channels.dart create mode 100644 lib/features/spaces/admin/space_admin_core.dart create mode 100644 lib/features/spaces/admin/space_admin_members.dart create mode 100644 lib/features/spaces/admin/space_admin_overview.dart create mode 100644 lib/features/spaces/admin/space_admin_permissions.dart create mode 100644 lib/features/spaces/admin/space_admin_shared.dart diff --git a/CHANGES.md b/CHANGES.md index 7af9d13..e7c1b99 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1165,3 +1165,5 @@ 2026-07-03 10:58 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-03 11:00 [Edit] /home/steggi/pyramid/ROADMAP.md 2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.md +2026-07-03 11:01 [Edit] /home/steggi/pyramid/ROADMAP.md +2026-07-03 11:01 [Edit] /home/steggi/pyramid/PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md index e4f1d90..6b03544 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,6 +13,54 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: --- +## 2026-07-03 – M2: Gott-Datei `space_admin_dialog.dart` aufgeteilt (6 Dateien) + +**Erledigt:** Vierter Durchgang derselben `part`/`part of`-Technik: +`features/spaces/space_admin_dialog.dart` (2278 Zeilen, 32 Klassen/Enums + +6 lose Top-Level-Helfer) aufgeteilt in 6 Dateien unter +`features/spaces/admin/`: `space_admin_core.dart` (`SpaceAdminDialog`, +`_SpaceAdminDialogState`, `_DialogHeader` + die breit genutzten Helfer +`_kSpaceBannerEvent`, `updatePowerLevelsSafely` (öffentliche Funktion – +schützt vor Selbst-Aussperrung beim Ändern der Power-Levels, genutzt von +Members- UND Permissions-Tab), `_kPowerAdmin`/`_kPowerMod`/`_kPowerMember`, +`_powerLabel`, `_powerColor`), `space_admin_overview.dart` (`_OverviewTab`+State, +`_SpaceVisibilityToggle`+State, `_DangerZone`), `space_admin_members.dart` +(`_MembersTab`+State, `_InviteButton`+State, `_MemberTile`+State, +`_MemberActions`, `_MemberAction`), `space_admin_permissions.dart` +(`_PermissionsTab`+State, `_PermissionRow`), `space_admin_channels.dart` +(`_ChannelsTab`+State, `_ChannelItem`+State, `_CreateChannelDialog`+State, +`_AddExistingDialog`+State + die dort lokal genutzten Helfer `_kSpaceChild`/ +`_kSpaceParent`/`_kVoiceChannelType`), `space_admin_shared.dart` +(`_SectionLabel`, `_Field`, `_SearchField`, `_AvatarEditor`, `_InitialAvatar`). +`space_admin_dialog.dart` selbst: nur noch Bibliothekskopf (19 Zeilen). + +Auffällig beim Durchlesen (nicht angefasst, nur notiert): `updatePowerLevelsSafely` +ist als einzige Funktion in dieser Datei öffentlich (kein `_`-Präfix), wird +aber nirgends außerhalb von `space_admin_dialog.dart` importiert/aufgerufen – +vermutlich für einen mal geplanten, nie gebrauchten externen Aufrufer public +gemacht. Bewusst nicht auf privat zurückgestuft, um diesen Schritt nicht mit +einer inhaltlichen Änderung zu vermischen; wäre ein Kandidat für den nächsten +"toter Code"-Durchgang. + +Verifikation wie bei den drei vorigen Umbauten: automatisierte +Klammer-Zählung für Blockgrenzen, alle 32 Blöcke + 4 Helfer-Gruppen +wortwörtlich wiedergefunden, `class`/`enum`-Anzahl (32) und +`Widget build(`-Methoden (20) vorher/nachher identisch, `flutter analyze` +unverändert bei 1 bekanntem Hinweis. + +**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Space- +Verwaltung öffnen – Übersicht (Sichtbarkeit umschalten, Banner ändern, +Danger-Zone-Buttons NUR ansehen, nicht bestätigen), Mitglieder (einladen, +Rolle ändern, kicken/bannen – wenn möglich an einem Test-Space, nicht an +Utas echtem), Berechtigungen, Kanäle (erstellen, bestehenden Raum hinzufügen). +Danach `chat_view.dart` (2116 Zeilen) als letzte der 5 in der Ist-Analyse +gefundenen Gott-Dateien – damit wäre der komplette „Ordnerstruktur +vereinheitlichen"-Katalog aus M2 abgearbeitet (Häkchen dann setzen). + +**Stolperfallen:** Keine neuen. + +--- + ## 2026-07-03 – M2: Gott-Datei `rooms_panel.dart` aufgeteilt (8 Dateien) **Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt: diff --git a/ROADMAP.md b/ROADMAP.md index 0ade17f..11651f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,8 +85,10 @@ lassen, ohne dass andere Module angefasst werden müssen. aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt"); `features/rooms/rooms_panel.dart` (2555 Zeilen) ebenso in 8 `part`-Dateien unter `features/rooms/panel/` aufgeteilt (PROGRESS.md - 2026-07-03 „rooms_panel.dart aufgeteilt") – restliche Gott-Dateien - `space_admin_dialog.dart`, `chat_view.dart` aus der Ist-Analyse noch offen) + 2026-07-03 „rooms_panel.dart aufgeteilt"); `features/spaces/space_admin_dialog.dart` + (2278 Zeilen) ebenso in 6 `part`-Dateien unter `features/spaces/admin/` + aufgeteilt (PROGRESS.md 2026-07-03 „space_admin_dialog.dart aufgeteilt") + – restliche Gott-Datei `chat_view.dart` aus der Ist-Analyse noch offen) - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit ## M3 – Calls: Logik, UI & Audio (Discord als Referenz) diff --git a/lib/features/spaces/admin/space_admin_channels.dart b/lib/features/spaces/admin/space_admin_channels.dart new file mode 100644 index 0000000..69b11e9 --- /dev/null +++ b/lib/features/spaces/admin/space_admin_channels.dart @@ -0,0 +1,587 @@ +part of '../space_admin_dialog.dart'; + +const _kSpaceChild = 'm.space.child'; +const _kSpaceParent = 'm.space.parent'; +const _kVoiceChannelType = 'io.pyramid.voice_channel'; + +class _ChannelsTab extends ConsumerStatefulWidget { + final Room space; + const _ChannelsTab({required this.space}); + + @override + ConsumerState<_ChannelsTab> createState() => _ChannelsTabState(); +} + +class _ChannelsTabState extends ConsumerState<_ChannelsTab> { + bool _busy = false; + + List<({String roomId, Room? room, bool isVoice})> _getChildren(Client client) { + final childStates = widget.space.states[_kSpaceChild] ?? {}; + final result = <({String roomId, Room? room, bool isVoice})>[]; + for (final entry in childStates.entries) { + final content = entry.value.content; + if (content.isEmpty) continue; + final childRoomId = entry.key; + final room = client.getRoomById(childRoomId); + final createContent = room?.getState('m.room.create')?.content; + final isVoice = createContent?['type'] == _kVoiceChannelType; + result.add((roomId: childRoomId, room: room, isVoice: isVoice)); + } + result.sort((a, b) { + final nameA = a.room?.getLocalizedDisplayname() ?? a.roomId; + final nameB = b.room?.getLocalizedDisplayname() ?? b.roomId; + return nameA.compareTo(nameB); + }); + return result; + } + + Future _removeChild(String roomId) async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + setState(() => _busy = true); + try { + await client.setRoomStateWithKey(widget.space.id, _kSpaceChild, roomId, {}); + if (mounted) setState(() {}); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _addChild(String roomId) async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + final serverName = client.userID?.split(':').last ?? ''; + try { + await client.setRoomStateWithKey( + widget.space.id, + _kSpaceChild, + roomId, + {'via': [serverName], 'suggested': false}, + ); + // Best-effort: set parent in child room + try { + await client.setRoomStateWithKey( + roomId, + _kSpaceParent, + widget.space.id, + {'via': [serverName], 'canonical': true}, + ); + } catch (_) {} + if (mounted) setState(() {}); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + Future _createAndAdd({ + required String name, + required bool isVoice, + required Client client, + }) async { + setState(() => _busy = true); + try { + final serverName = client.userID?.split(':').last ?? ''; + final roomId = await client.createRoom( + name: name, + visibility: Visibility.private, + preset: CreateRoomPreset.privateChat, + creationContent: isVoice ? {'type': _kVoiceChannelType} : null, + initialState: [ + StateEvent( + type: 'm.room.join_rules', + content: {'join_rule': 'restricted', 'allow': [ + {'type': 'm.room_membership', 'room_id': widget.space.id} + ]}, + ), + ], + ); + await client.setRoomStateWithKey( + widget.space.id, + _kSpaceChild, + roomId, + {'via': [serverName], 'suggested': false}, + ); + try { + await client.setRoomStateWithKey( + roomId, + _kSpaceParent, + widget.space.id, + {'via': [serverName], 'canonical': true}, + ); + } catch (_) {} + if (mounted) setState(() {}); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _showCreateDialog(BuildContext context, Client client) { + showDialog( + context: context, + builder: (_) => _CreateChannelDialog( + onConfirm: (name, isVoice) => + _createAndAdd(name: name, isVoice: isVoice, client: client), + ), + ); + } + + void _showAddExistingDialog(BuildContext context, Client client) { + final childStates = widget.space.states[_kSpaceChild] ?? {}; + final existingIds = { + for (final e in childStates.entries) + if (e.value.content.isNotEmpty) e.key + }; + final available = client.rooms + .where((r) => !r.isSpace && !existingIds.contains(r.id)) + .toList() + ..sort((a, b) => a.getLocalizedDisplayname() + .compareTo(b.getLocalizedDisplayname())); + + showDialog( + context: context, + builder: (_) => _AddExistingDialog( + rooms: available, + client: client, + onConfirm: (roomId) => _addChild(roomId), + ), + ); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final client = ref.watch(matrixClientProvider).valueOrNull; + final ownPower = widget.space.ownPowerLevel; + final canManage = ownPower >= _kPowerMod; + + if (client == null) { + return Center( + child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2)); + } + + final children = _getChildren(client); + + return Column( + children: [ + if (canManage) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.add, size: 16), + label: const Text('Kanal erstellen'), + style: OutlinedButton.styleFrom( + foregroundColor: pt.accent, + side: BorderSide(color: pt.accent), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rSm)), + ), + onPressed: _busy + ? null + : () => _showCreateDialog(context, client), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.link, size: 16), + label: const Text('Raum hinzufügen'), + style: OutlinedButton.styleFrom( + foregroundColor: pt.fgDim, + side: BorderSide(color: pt.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rSm)), + ), + onPressed: _busy + ? null + : () => _showAddExistingDialog(context, client), + ), + ), + ], + ), + ), + if (children.isEmpty) + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, color: pt.fgDim, size: 40), + const SizedBox(height: 8), + Text('Noch keine Kanäle', + style: TextStyle(color: pt.fgDim, fontSize: 13)), + ], + ), + ), + ) + else + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + itemCount: children.length, + itemBuilder: (context, i) { + final child = children[i]; + final name = child.room?.getLocalizedDisplayname() ?? + child.roomId; + return _ChannelItem( + name: name, + isVoice: child.isVoice, + roomId: child.roomId, + pt: pt, + canRemove: canManage, + onRemove: () => _removeChild(child.roomId), + ); + }, + ), + ), + ], + ); + } +} + +class _ChannelItem extends StatefulWidget { + final String name; + final bool isVoice; + final String roomId; + final PyramidTheme pt; + final bool canRemove; + final VoidCallback onRemove; + + const _ChannelItem({ + required this.name, + required this.isVoice, + required this.roomId, + required this.pt, + required this.canRemove, + required this.onRemove, + }); + + @override + State<_ChannelItem> createState() => _ChannelItemState(); +} + +class _ChannelItemState extends State<_ChannelItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + 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: 10, vertical: 0), + leading: Icon( + widget.isVoice + ? Icons.volume_up_outlined + : Icons.tag, + color: pt.fgDim, + size: 18, + ), + title: Text( + widget.name, + style: TextStyle( + color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + widget.isVoice ? 'Sprachkanal' : 'Textkanal', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + trailing: widget.canRemove && _hovered + ? IconButton( + icon: Icon(Icons.link_off, + color: const Color(0xFFED4245), size: 18), + tooltip: 'Aus Space entfernen', + onPressed: widget.onRemove, + ) + : null, + ), + ), + ); + } +} + +class _CreateChannelDialog extends StatefulWidget { + final void Function(String name, bool isVoice) onConfirm; + const _CreateChannelDialog({required this.onConfirm}); + + @override + State<_CreateChannelDialog> createState() => _CreateChannelDialogState(); +} + +class _CreateChannelDialogState extends State<_CreateChannelDialog> { + final _ctrl = TextEditingController(); + bool _isVoice = false; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return AlertDialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rLg)), + title: Text('Neuen Kanal erstellen', + style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel('Name', pt), + const SizedBox(height: 6), + _Field(controller: _ctrl, hint: 'kanal-name', pt: pt), + const SizedBox(height: 16), + _SectionLabel('Typ', pt), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => setState(() => _isVoice = false), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: !_isVoice ? pt.accent.withAlpha(40) : pt.bg0, + border: Border.all( + color: !_isVoice ? pt.accent : pt.border), + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.tag, + color: !_isVoice ? pt.accent : pt.fgDim, + size: 16), + const SizedBox(width: 6), + Text('Text', + style: TextStyle( + color: !_isVoice ? pt.accent : pt.fgDim, + fontSize: 13, + fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: () => setState(() => _isVoice = true), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _isVoice ? pt.accent.withAlpha(40) : pt.bg0, + border: Border.all( + color: _isVoice ? pt.accent : pt.border), + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.volume_up_outlined, + color: _isVoice ? pt.accent : pt.fgDim, + size: 16), + const SizedBox(width: 6), + Text('Sprache', + style: TextStyle( + color: _isVoice ? pt.accent : pt.fgDim, + fontSize: 13, + fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: pt.accent), + onPressed: () { + final name = _ctrl.text.trim(); + if (name.isEmpty) return; + Navigator.pop(context); + widget.onConfirm(name, _isVoice); + }, + child: const Text('Erstellen', + style: TextStyle(color: Colors.white)), + ), + ], + ); + } +} + +class _AddExistingDialog extends StatefulWidget { + final List rooms; + final Client client; + final void Function(String roomId) onConfirm; + + const _AddExistingDialog({ + required this.rooms, + required this.client, + required this.onConfirm, + }); + + @override + State<_AddExistingDialog> createState() => _AddExistingDialogState(); +} + +class _AddExistingDialogState extends State<_AddExistingDialog> { + String _filter = ''; + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final filtered = _filter.isEmpty + ? widget.rooms + : widget.rooms + .where((r) => r + .getLocalizedDisplayname() + .toLowerCase() + .contains(_filter.toLowerCase())) + .toList(); + + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rLg)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Raum hinzufügen', + style: TextStyle( + color: pt.fg, + fontSize: 17, + fontWeight: FontWeight.w700)), + ), + IconButton( + icon: Icon(Icons.close, color: pt.fgDim, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 12), + _SearchField( + controller: _ctrl, + hint: 'Raum suchen…', + pt: pt, + onChanged: (v) => setState(() => _filter = v), + ), + ], + ), + ), + const Divider(height: 1), + if (filtered.isEmpty) + Expanded( + child: Center( + child: Text('Keine Räume gefunden', + style: TextStyle(color: pt.fgDim, fontSize: 13)), + ), + ) + else + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + itemCount: filtered.length, + itemBuilder: (context, i) { + final room = filtered[i]; + final name = room.getLocalizedDisplayname(); + final letter = + name.isNotEmpty ? name[0].toUpperCase() : '?'; + final avatarUri = room.avatar; + final createContent = + room.getState('m.room.create')?.content; + final isVoice = + createContent?['type'] == _kVoiceChannelType; + return ListTile( + dense: true, + leading: avatarUri != null + ? MxcAvatar( + mxcUri: avatarUri, + client: widget.client, + size: 32, + borderRadius: BorderRadius.circular(pt.rSm), + placeholder: (_) => _InitialAvatar( + letter: letter, pt: pt, size: 32), + ) + : _InitialAvatar(letter: letter, pt: pt, size: 32), + title: Text(name, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + subtitle: Text( + isVoice ? 'Sprachkanal' : 'Textkanal', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + trailing: + Icon(isVoice ? Icons.volume_up_outlined : Icons.tag, + color: pt.fgDim, size: 16), + onTap: () { + Navigator.pop(context); + widget.onConfirm(room.id); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/spaces/admin/space_admin_core.dart b/lib/features/spaces/admin/space_admin_core.dart new file mode 100644 index 0000000..55d2917 --- /dev/null +++ b/lib/features/spaces/admin/space_admin_core.dart @@ -0,0 +1,199 @@ +part of '../space_admin_dialog.dart'; + +const _kSpaceBannerEvent = 'io.pyramid.space.banner'; + +Future updatePowerLevelsSafely( + Room room, + void Function(Map content) mutate, +) async { + Map content; + try { + content = Map.from( + await room.client + .getRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, ''), + ); + } on MatrixException catch (e) { + if (e.errcode == 'M_NOT_FOUND') { + content = {}; + } else { + rethrow; + } + } + final users = content['users']; + content['users'] = + users is Map ? Map.from(users) : {}; + mutate(content); + + // Selbst-Aussperr-Schutz: Nach der Änderung muss der eigene Account immer + // noch hoch genug stehen, um Power-Levels erneut zu ändern — sonst gäbe es + // kein Zurück mehr. Greift bei versehentlicher Selbst-Degradierung ODER + // wenn ein neu geschriebenes Event den eigenen Eintrag nicht enthält. + final myId = room.client.userID; + if (myId != null) { + int asInt(Object? v, int fallback) => v is int ? v : fallback; + final usersMap = content['users'] as Map; + final myLevel = asInt(usersMap[myId], asInt(content['users_default'], 0)); + final events = content['events']; + final neededForPl = (events is Map && events['m.room.power_levels'] != null) + ? asInt(events['m.room.power_levels'], 50) + : asInt(content['state_default'], 50); + if (myLevel < neededForPl) { + throw Exception( + 'Abgebrochen, um eine Selbst-Aussperrung zu verhindern: ' + 'Diese Änderung würde dein Level auf $myLevel senken, ' + 'aber zum Verwalten der Berechtigungen sind $neededForPl nötig.', + ); + } + } + + await room.client + .setRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, '', content); +} + +const _kPowerAdmin = 100; +const _kPowerMod = 50; +const _kPowerMember = 0; + +String _powerLabel(int level) { + if (level >= _kPowerAdmin) return 'Admin'; + if (level >= _kPowerMod) return 'Moderator'; + return 'Mitglied'; +} + +Color _powerColor(int level, PyramidTheme pt) { + if (level >= _kPowerAdmin) return const Color(0xFFED4245); + if (level >= _kPowerMod) return const Color(0xFFF59E0B); + return pt.fgDim; +} + +class SpaceAdminDialog extends ConsumerStatefulWidget { + final Room space; + const SpaceAdminDialog({super.key, required this.space}); + + @override + ConsumerState createState() => _SpaceAdminDialogState(); +} + +class _SpaceAdminDialogState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabs; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rLg)), + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680), + child: Column( + children: [ + _DialogHeader(space: widget.space, pt: pt, tabs: _tabs), + Expanded( + child: TabBarView( + controller: _tabs, + children: [ + _OverviewTab(space: widget.space), + _MembersTab(space: widget.space), + _PermissionsTab(space: widget.space), + _ChannelsTab(space: widget.space), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _DialogHeader extends ConsumerWidget { + final Room space; + final PyramidTheme pt; + final TabController tabs; + const _DialogHeader({required this.space, required this.pt, required this.tabs}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final client = ref.watch(matrixClientProvider).valueOrNull; + final name = space.getLocalizedDisplayname(); + final avatarUri = space.avatar; + final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + + Widget avatar; + if (client != null && avatarUri != null) { + avatar = MxcAvatar( + mxcUri: avatarUri, + client: client, + size: 40, + borderRadius: BorderRadius.circular(pt.rBase), + placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 40), + ); + } else { + avatar = _InitialAvatar(letter: letter, pt: pt, size: 40); + } + + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 12, 12), + child: Row( + children: [ + avatar, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: TextStyle( + color: pt.fg, + fontSize: 17, + fontWeight: FontWeight.w700)), + Text('Space-Einstellungen', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + IconButton( + icon: Icon(Icons.close, color: pt.fgDim, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + TabBar( + controller: tabs, + labelColor: pt.accent, + unselectedLabelColor: pt.fgDim, + indicatorColor: pt.accent, + indicatorSize: TabBarIndicatorSize.label, + tabs: const [ + Tab(text: 'Übersicht'), + Tab(text: 'Mitglieder'), + Tab(text: 'Berechtigungen'), + Tab(text: 'Kanäle'), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/spaces/admin/space_admin_members.dart b/lib/features/spaces/admin/space_admin_members.dart new file mode 100644 index 0000000..4c83159 --- /dev/null +++ b/lib/features/spaces/admin/space_admin_members.dart @@ -0,0 +1,462 @@ +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 } diff --git a/lib/features/spaces/admin/space_admin_overview.dart b/lib/features/spaces/admin/space_admin_overview.dart new file mode 100644 index 0000000..c5e4308 --- /dev/null +++ b/lib/features/spaces/admin/space_admin_overview.dart @@ -0,0 +1,572 @@ +part of '../space_admin_dialog.dart'; + +class _OverviewTab extends ConsumerStatefulWidget { + final Room space; + const _OverviewTab({required this.space}); + + @override + ConsumerState<_OverviewTab> createState() => _OverviewTabState(); +} + +class _OverviewTabState extends ConsumerState<_OverviewTab> { + late TextEditingController _nameCtrl; + late TextEditingController _topicCtrl; + bool _saving = false; + String? _error; + String? _success; + Uint8List? _pendingBanner; + String? _bannerMxcUri; + Uint8List? _pendingAvatar; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController( + text: widget.space.getLocalizedDisplayname()); + _topicCtrl = TextEditingController(text: widget.space.topic); + // Prefer local override (set on last save) over room state event + final overrides = ref.read(spaceBannerOverrideProvider); + _bannerMxcUri = overrides.containsKey(widget.space.id) + ? overrides[widget.space.id] + : widget.space.getState(_kSpaceBannerEvent)?.content['url'] as String?; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _topicCtrl.dispose(); + super.dispose(); + } + + Future _pickBanner() async { + final result = await FilePicker.platform + .pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + final cropped = await showDialog( + context: context, + builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), + ); + if (cropped == null || !mounted) return; + setState(() => _pendingBanner = cropped); + } + + Future _pickAvatar() async { + final result = await FilePicker.platform + .pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + setState(() => _pendingAvatar = raw); + } + + Future _removeAvatar() async { + setState(() => _pendingAvatar = null); + try { + await widget.space.client.setRoomStateWithKey( + widget.space.id, EventTypes.RoomAvatar, '', {}); + } catch (_) {} + } + + Future _removeBanner() async { + setState(() { _pendingBanner = null; _bannerMxcUri = null; }); + try { + await widget.space.client.setRoomStateWithKey( + widget.space.id, _kSpaceBannerEvent, '', {}); + } catch (_) {} + } + + Future _save() async { + setState(() { _saving = true; _error = null; _success = null; }); + // Welcher Schritt gerade läuft — für präzise Fehlermeldungen statt eines + // pauschalen "keine Berechtigung" (das auch von Upload-Fehlern kam). + var step = 'Speichern'; + try { + final client = widget.space.client; + final newName = _nameCtrl.text.trim(); + final newTopic = _topicCtrl.text.trim(); + if (newName.isNotEmpty && + newName != widget.space.getLocalizedDisplayname()) { + step = 'Name ändern'; + await widget.space.setName(newName); + } + if (newTopic != widget.space.topic) { + step = 'Beschreibung ändern'; + await widget.space.setDescription(newTopic); + } + if (_pendingAvatar != null) { + step = 'Avatar hochladen'; + final uri = await client.uploadContent( + _pendingAvatar!, + filename: 'space_avatar.jpg', + contentType: 'image/jpeg', + ); + step = 'Avatar setzen'; + await _setStateWithRepair( + client, + EventTypes.RoomAvatar, + {'url': uri.toString()}, + ); + setState(() => _pendingAvatar = null); + } + if (_pendingBanner != null) { + step = 'Banner hochladen'; + final uri = await client.uploadContent( + _pendingBanner!, + filename: 'space_banner.jpg', + contentType: 'image/jpeg', + ); + step = 'Banner setzen'; + await _setStateWithRepair( + client, + _kSpaceBannerEvent, + {'url': uri.toString()}, + ); + final uriStr = uri.toString(); + // Update local override immediately so header updates before next sync + ref.read(spaceBannerOverrideProvider.notifier).update( + (m) => {...m, widget.space.id: uriStr}, + ); + setState(() { + _bannerMxcUri = uriStr; + _pendingBanner = null; + }); + } + setState(() { _success = 'Gespeichert'; }); + } catch (e) { + // Server-Fehlertext anzeigen (MatrixException liefert lesbare Meldung) — + // nicht pauschal auf "Admin-Level erforderlich" mappen. + final raw = e is MatrixException + ? '${e.errcode}: ${e.errorMessage}' + : e.toString().split('\n').first; + var detail = ''; + if (e is MatrixException && e.errcode == 'M_FORBIDDEN') { + detail = '\n${_permDiagnostics()}'; + // Zusätzlich die SERVER-Sicht der Power-Levels holen — die lokale + // Kopie kann von der des Servers abweichen. + try { + final pl = await widget.space.client.getRoomStateWithKey( + widget.space.id, EventTypes.RoomPowerLevels, ''); + detail += + '\nServer-PL: users=${pl['users']}, state_default=${pl['state_default']}, events=${pl['events']}'; + } on MatrixException catch (se) { + detail += '\nServer-PL: ${se.errcode} (Event fehlt auch serverseitig)'; + } catch (_) {} + } + setState(() { + _error = '$step fehlgeschlagen – $raw$detail'; + }); + } finally { + setState(() { _saving = false; }); + } + } + + /// Sendet ein State-Event; bei M_FORBIDDEN in einem Raum OHNE + /// power_levels-Event (Continuwuity-Sonderfall: Server verweigert dann + /// Custom-State trotz Creator-Rechten) werden einmalig Standard-Power-Levels + /// mit uns als Admin angelegt und der Vorgang wiederholt. + Future _setStateWithRepair( + Client client, + String type, + Map content, + ) async { + try { + await client.setRoomStateWithKey(widget.space.id, type, '', content); + } on MatrixException catch (e) { + if (e.errcode != 'M_FORBIDDEN') rethrow; + final repaired = await _ensurePowerLevels(client); + if (!repaired) rethrow; + await client.setRoomStateWithKey(widget.space.id, type, '', content); + } + } + + /// Legt Standard-Power-Levels an, falls der Raum keine hat. + /// Gibt true zurück, wenn das Event neu angelegt wurde. + Future _ensurePowerLevels(Client client) async { + try { + await client.getRoomStateWithKey( + widget.space.id, EventTypes.RoomPowerLevels, ''); + return false; // existiert bereits — Ablehnung hat andere Ursache + } catch (_) { + // fehlt (M_NOT_FOUND) → anlegen + } + try { + await client.setRoomStateWithKey( + widget.space.id, + EventTypes.RoomPowerLevels, + '', + { + 'users': {client.userID!: 100}, + 'users_default': 0, + 'events': const {}, + 'events_default': 0, + 'state_default': 50, + 'ban': 50, + 'kick': 50, + 'redact': 50, + 'invite': 0, + 'notifications': {'room': 50}, + }, + ); + return true; + } catch (_) { + return false; + } + } + + /// Kompakte Berechtigungs-Diagnose für M_FORBIDDEN-Fehler: zeigt, wie der + /// Server die Lage vermutlich sieht (Raumversion, Creator, Power-Level). + String _permDiagnostics() { + try { + final space = widget.space; + final create = space.getState(EventTypes.RoomCreate); + final version = create?.content['room_version'] ?? '1'; + final creator = create?.senderId ?? '?'; + final own = space.ownPowerLevel; + final needed = space.powerForChangingStateEvent(_kSpaceBannerEvent); + final hasPl = space.getState(EventTypes.RoomPowerLevels) != null; + return 'Diagnose: dein Level $own, benötigt $needed, ' + 'Raumversion $version, Creator $creator' + '${hasPl ? '' : ', kein power_levels-Event vorhanden!'}'; + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final client = ref.watch(matrixClientProvider).valueOrNull; + // SDK-Berechtigungsprüfung (berücksichtigt events-Overrides und + // state_default) statt hartcodiertem Admin-Level 100. + final canEdit = widget.space.canChangeStateEvent(EventTypes.RoomName) || + widget.space.ownPowerLevel >= _kPowerAdmin; + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Banner ──────────────────────────────────────────────────────── + _SectionLabel('Banner', pt), + const SizedBox(height: 6), + GestureDetector( + onTap: canEdit ? _pickBanner : null, + child: Stack( + children: [ + AspectRatio( + aspectRatio: 2.4, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: pt.bg0, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: _pendingBanner != null + ? Image.memory(_pendingBanner!, + fit: BoxFit.cover, width: double.infinity) + : (_bannerMxcUri != null && client != null + ? MxcImage( + mxcUri: _bannerMxcUri!, + client: client, + fit: BoxFit.cover, + width: double.infinity, + placeholder: Container(color: pt.bg0), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, + size: 24, color: pt.fgDim), + const SizedBox(height: 4), + Text('Banner hinzufügen', + style: TextStyle( + color: pt.fgDim, fontSize: 12)), + ], + ), + )), + ), // Container + ), // AspectRatio + if (canEdit) + Positioned( + right: 6, + bottom: 6, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_bannerMxcUri != null || _pendingBanner != null) + GestureDetector( + onTap: _removeBanner, + child: Container( + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFED4245).withAlpha(200), + borderRadius: BorderRadius.circular(5), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.delete_outline, + size: 11, color: Colors.white), + SizedBox(width: 3), + Text('Entfernen', + style: TextStyle( + color: Colors.white, fontSize: 11)), + ], + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: pt.bg0.withAlpha(200), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_rounded, + size: 11, color: pt.fgMuted), + const SizedBox(width: 3), + Text('Banner ändern', + style: TextStyle( + color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // ── Avatar ─────────────────────────────────────────────────────── + _SectionLabel('Serverbild', pt), + const SizedBox(height: 8), + _AvatarEditor( + space: widget.space, + pendingAvatar: _pendingAvatar, + canEdit: canEdit, + onPick: _pickAvatar, + onRemove: _removeAvatar, + pt: pt, + client: client, + ), + const SizedBox(height: 16), + // ── Name / Topic ───────────────────────────────────────────────── + _SectionLabel('Name', pt), + const SizedBox(height: 6), + _Field(controller: _nameCtrl, hint: 'Space-Name', pt: pt), + const SizedBox(height: 16), + _SectionLabel('Beschreibung', pt), + const SizedBox(height: 6), + _Field( + controller: _topicCtrl, + hint: 'Worum geht es in diesem Space?', + maxLines: 4, + pt: pt), + const SizedBox(height: 8), + _SpaceVisibilityToggle(space: widget.space), + const SizedBox(height: 20), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, + style: const TextStyle(color: Color(0xFFED4245), fontSize: 12)), + ), + if (_success != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_success!, + style: const TextStyle(color: Color(0xFF57F287), fontSize: 12)), + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: pt.accent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase)), + ), + child: _saving + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.bg0)) + : const Text('Speichern', + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 24), + _DangerZone(space: widget.space), + ], + ), + ); + } +} + +class _SpaceVisibilityToggle extends ConsumerStatefulWidget { + final Room space; + const _SpaceVisibilityToggle({required this.space}); + + @override + ConsumerState<_SpaceVisibilityToggle> createState() => + _SpaceVisibilityToggleState(); +} + +class _SpaceVisibilityToggleState + extends ConsumerState<_SpaceVisibilityToggle> { + bool? _isPublic; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final joinRules = widget.space.joinRules; + setState(() { + _isPublic = joinRules == JoinRules.public; + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + Future _toggle(bool value) async { + setState(() => _isPublic = value); + try { + await widget.space.setJoinRules( + value ? JoinRules.public : JoinRules.invite); + } catch (_) { + setState(() => _isPublic = !value); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + if (_loading) return const SizedBox.shrink(); + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Öffentlicher Space', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w600)), + Text('Jeder kann diesem Space beitreten', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + Switch( + value: _isPublic ?? false, + onChanged: _toggle, + activeThumbColor: pt.accent, + ), + ], + ); + } +} + +class _DangerZone extends ConsumerWidget { + final Room space; + const _DangerZone({required this.space}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + return Container( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFED4245).withAlpha(80)), + borderRadius: BorderRadius.circular(pt.rBase), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Gefahrenzone', + style: TextStyle( + color: const Color(0xFFED4245), + fontSize: 13, + fontWeight: FontWeight.w700)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Space verlassen', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w600)), + Text('Du verlässt diesen Space', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + OutlinedButton( + onPressed: () => _confirmLeave(context, ref), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFED4245), + side: const BorderSide(color: Color(0xFFED4245)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Verlassen'), + ), + ], + ), + ], + ), + ); + } + + void _confirmLeave(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Space verlassen?'), + content: Text( + 'Möchtest du "${space.getLocalizedDisplayname()}" wirklich verlassen?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + Navigator.pop(context); + Navigator.pop(context); + await space.leave(); + }, + child: const Text('Verlassen', + style: TextStyle(color: Color(0xFFED4245))), + ), + ], + ), + ); + } +} diff --git a/lib/features/spaces/admin/space_admin_permissions.dart b/lib/features/spaces/admin/space_admin_permissions.dart new file mode 100644 index 0000000..61d2606 --- /dev/null +++ b/lib/features/spaces/admin/space_admin_permissions.dart @@ -0,0 +1,201 @@ +part of '../space_admin_dialog.dart'; + +class _PermissionsTab extends ConsumerStatefulWidget { + final Room space; + const _PermissionsTab({required this.space}); + + @override + ConsumerState<_PermissionsTab> createState() => _PermissionsTabState(); +} + +class _PermissionsTabState extends ConsumerState<_PermissionsTab> { + bool _saving = false; + String? _status; + + // Current power level values (loaded from room state) + late Map _levels; + + static const _defaults = { + 'events_default': 0, + 'state_default': 50, + 'invite': 0, + 'kick': 50, + 'ban': 50, + 'redact': 50, + }; + + static const _labels = { + 'events_default': 'Nachrichten senden', + 'state_default': 'Raum-Einstellungen ändern', + 'invite': 'Mitglieder einladen', + 'kick': 'Mitglieder kicken', + 'ban': 'Mitglieder bannen', + 'redact': 'Nachrichten löschen', + }; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + final content = widget.space + .getState(EventTypes.RoomPowerLevels) + ?.content ?? + {}; + _levels = { + for (final key in _defaults.keys) + key: (content[key] as int?) ?? _defaults[key]!, + }; + } + + Future _save() async { + setState(() { _saving = true; _status = null; }); + try { + // Server-Stand holen und nur die geänderten Schlüssel mergen — der + // lokale Cache kann unvollständig sein und würde users/events wipen. + await updatePowerLevelsSafely(widget.space, (content) { + for (final entry in _levels.entries) { + content[entry.key] = entry.value; + } + }); + setState(() => _status = 'Gespeichert'); + } catch (e) { + final msg = e is MatrixException + ? '${e.errcode}: ${e.errorMessage}' + : e.toString().split('\n').first; + setState(() => _status = 'Fehler: $msg'); + } finally { + setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final ownPower = widget.space.ownPowerLevel; + final canEdit = ownPower >= _kPowerAdmin; + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Mindestberechtigungen', + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8)), + const SizedBox(height: 4), + Text( + 'Lege fest, welche Stufe ein Mitglied mindestens haben muss, um diese Aktion auszuführen.', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + const SizedBox(height: 16), + ...(_labels.entries.map((e) => _PermissionRow( + label: e.value, + value: _levels[e.key]!, + enabled: canEdit, + pt: pt, + onChanged: (v) => setState(() => _levels[e.key] = v), + ))), + if (canEdit) ...[ + const SizedBox(height: 20), + if (_status != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_status!, + style: TextStyle( + color: _status!.startsWith('F') + ? const Color(0xFFED4245) + : const Color(0xFF57F287), + fontSize: 12)), + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: pt.accent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase)), + ), + child: _saving + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.bg0)) + : const Text('Speichern', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600)), + ), + ), + ], + ], + ), + ); + } +} + +class _PermissionRow extends StatelessWidget { + final String label; + final int value; + final bool enabled; + final PyramidTheme pt; + final ValueChanged onChanged; + + const _PermissionRow({ + required this.label, + required this.value, + required this.enabled, + required this.pt, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Text(label, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500)), + ), + if (enabled) + DropdownButton( + value: value, + dropdownColor: pt.bg2, + underline: const SizedBox.shrink(), + style: TextStyle(color: pt.fg, fontSize: 13), + items: const [ + DropdownMenuItem(value: 0, child: Text('Mitglied (0)')), + DropdownMenuItem(value: 50, child: Text('Moderator (50)')), + DropdownMenuItem(value: 100, child: Text('Admin (100)')), + ], + onChanged: (v) => v != null ? onChanged(v) : null, + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: _powerColor(value, pt).withAlpha(30), + borderRadius: BorderRadius.circular(6), + ), + child: Text(_powerLabel(value), + style: TextStyle( + color: _powerColor(value, pt), + fontSize: 12, + fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} diff --git a/lib/features/spaces/admin/space_admin_shared.dart b/lib/features/spaces/admin/space_admin_shared.dart new file mode 100644 index 0000000..5c5a2be --- /dev/null +++ b/lib/features/spaces/admin/space_admin_shared.dart @@ -0,0 +1,232 @@ +part of '../space_admin_dialog.dart'; + +class _SectionLabel extends StatelessWidget { + final String text; + final PyramidTheme pt; + const _SectionLabel(this.text, this.pt); + + @override + Widget build(BuildContext context) { + return Text(text, + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8)); + } +} + +class _Field extends StatelessWidget { + final TextEditingController controller; + final String hint; + final int maxLines; + final PyramidTheme pt; + + const _Field({ + required this.controller, + required this.hint, + this.maxLines = 1, + required this.pt, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: pt.fgDim), + filled: true, + fillColor: pt.bg0, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.accent), + ), + ), + ); + } +} + +class _SearchField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final PyramidTheme pt; + final ValueChanged onChanged; + + const _SearchField({ + required this.controller, + required this.hint, + required this.pt, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + onChanged: onChanged, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: pt.fgDim), + prefixIcon: Icon(Icons.search, color: pt.fgDim, size: 18), + filled: true, + fillColor: pt.bg0, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.accent), + ), + ), + ); + } +} + +class _AvatarEditor extends StatelessWidget { + final Room space; + final Uint8List? pendingAvatar; + final bool canEdit; + final VoidCallback onPick; + final VoidCallback onRemove; + final PyramidTheme pt; + final dynamic client; + + const _AvatarEditor({ + required this.space, + required this.pendingAvatar, + required this.canEdit, + required this.onPick, + required this.onRemove, + required this.pt, + required this.client, + }); + + @override + Widget build(BuildContext context) { + final letter = space.getLocalizedDisplayname().isNotEmpty + ? space.getLocalizedDisplayname()[0].toUpperCase() + : '?'; + final avatarUri = space.avatar; + final hasAvatar = pendingAvatar != null || avatarUri != null; + + Widget avatarWidget; + if (pendingAvatar != null) { + avatarWidget = Image.memory(pendingAvatar!, fit: BoxFit.cover, + width: 64, height: 64); + } else if (avatarUri != null && client != null) { + avatarWidget = MxcAvatar( + mxcUri: avatarUri, + client: client, + size: 64, + borderRadius: BorderRadius.circular(32), + placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 64), + ); + } else { + avatarWidget = _InitialAvatar(letter: letter, pt: pt, size: 64); + } + + return Row( + children: [ + GestureDetector( + onTap: canEdit ? onPick : null, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: avatarWidget, + ), + if (canEdit) + Positioned( + right: -2, + bottom: -2, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: pt.accent, + shape: BoxShape.circle, + border: Border.all(color: pt.bg1, width: 2), + ), + child: const Icon(Icons.edit, size: 10, color: Colors.white), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + canEdit ? 'Klicken zum Ändern' : 'Serverbild', + style: TextStyle(color: pt.fg, fontSize: 13, + fontWeight: FontWeight.w500), + ), + if (canEdit && hasAvatar) + GestureDetector( + onTap: onRemove, + child: Text('Entfernen', + style: TextStyle( + color: const Color(0xFFED4245), fontSize: 12)), + ), + ], + ), + ], + ); + } +} + +class _InitialAvatar extends StatelessWidget { + final String letter; + final PyramidTheme pt; + final double size; + const _InitialAvatar( + {required this.letter, required this.pt, required this.size}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(size / 2), + ), + child: Center( + child: Text(letter, + style: TextStyle( + color: pt.fg, + fontSize: size * 0.4, + fontWeight: FontWeight.w700)), + ), + ); + } +} diff --git a/lib/features/spaces/space_admin_dialog.dart b/lib/features/spaces/space_admin_dialog.dart index 3b0b8cd..07ed467 100644 --- a/lib/features/spaces/space_admin_dialog.dart +++ b/lib/features/spaces/space_admin_dialog.dart @@ -10,2269 +10,10 @@ import 'package:pyramid/core/theme.dart'; import 'package:pyramid/widgets/banner_crop_dialog.dart'; import 'package:pyramid/widgets/mxc_image.dart'; -const _kSpaceBannerEvent = 'io.pyramid.space.banner'; -/// Ändert die Power-Levels auf Basis des aktuellen SERVER-Stands (nicht des -/// lokalen Caches!). Das SDK-`setPower` und ein lokal fehlendes -/// power_levels-Event haben sonst zur Folge, dass die users-Map des Servers -/// mit einem Minimal-Event überschrieben wird — und Admins sich selbst -/// aussperren (genau das ist hier bereits passiert). -Future updatePowerLevelsSafely( - Room room, - void Function(Map content) mutate, -) async { - Map content; - try { - content = Map.from( - await room.client - .getRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, ''), - ); - } on MatrixException catch (e) { - if (e.errcode == 'M_NOT_FOUND') { - content = {}; - } else { - rethrow; - } - } - final users = content['users']; - content['users'] = - users is Map ? Map.from(users) : {}; - mutate(content); - - // Selbst-Aussperr-Schutz: Nach der Änderung muss der eigene Account immer - // noch hoch genug stehen, um Power-Levels erneut zu ändern — sonst gäbe es - // kein Zurück mehr. Greift bei versehentlicher Selbst-Degradierung ODER - // wenn ein neu geschriebenes Event den eigenen Eintrag nicht enthält. - final myId = room.client.userID; - if (myId != null) { - int asInt(Object? v, int fallback) => v is int ? v : fallback; - final usersMap = content['users'] as Map; - final myLevel = asInt(usersMap[myId], asInt(content['users_default'], 0)); - final events = content['events']; - final neededForPl = (events is Map && events['m.room.power_levels'] != null) - ? asInt(events['m.room.power_levels'], 50) - : asInt(content['state_default'], 50); - if (myLevel < neededForPl) { - throw Exception( - 'Abgebrochen, um eine Selbst-Aussperrung zu verhindern: ' - 'Diese Änderung würde dein Level auf $myLevel senken, ' - 'aber zum Verwalten der Berechtigungen sind $neededForPl nötig.', - ); - } - } - - await room.client - .setRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, '', content); -} - -// Power level labels -const _kPowerAdmin = 100; -const _kPowerMod = 50; -const _kPowerMember = 0; - -String _powerLabel(int level) { - if (level >= _kPowerAdmin) return 'Admin'; - if (level >= _kPowerMod) return 'Moderator'; - return 'Mitglied'; -} - -Color _powerColor(int level, PyramidTheme pt) { - if (level >= _kPowerAdmin) return const Color(0xFFED4245); - if (level >= _kPowerMod) return const Color(0xFFF59E0B); - return pt.fgDim; -} - -class SpaceAdminDialog extends ConsumerStatefulWidget { - final Room space; - const SpaceAdminDialog({super.key, required this.space}); - - @override - ConsumerState createState() => _SpaceAdminDialogState(); -} - -class _SpaceAdminDialogState extends ConsumerState - with SingleTickerProviderStateMixin { - late TabController _tabs; - - @override - void initState() { - super.initState(); - _tabs = TabController(length: 4, vsync: this); - } - - @override - void dispose() { - _tabs.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - return Dialog( - backgroundColor: pt.bg1, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rLg)), - insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680), - child: Column( - children: [ - _DialogHeader(space: widget.space, pt: pt, tabs: _tabs), - Expanded( - child: TabBarView( - controller: _tabs, - children: [ - _OverviewTab(space: widget.space), - _MembersTab(space: widget.space), - _PermissionsTab(space: widget.space), - _ChannelsTab(space: widget.space), - ], - ), - ), - ], - ), - ), - ); - } -} - -// ─── Header + TabBar ───────────────────────────────────────────────────────── - -class _DialogHeader extends ConsumerWidget { - final Room space; - final PyramidTheme pt; - final TabController tabs; - const _DialogHeader({required this.space, required this.pt, required this.tabs}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(matrixClientProvider).valueOrNull; - final name = space.getLocalizedDisplayname(); - final avatarUri = space.avatar; - final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; - - Widget avatar; - if (client != null && avatarUri != null) { - avatar = MxcAvatar( - mxcUri: avatarUri, - client: client, - size: 40, - borderRadius: BorderRadius.circular(pt.rBase), - placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 40), - ); - } else { - avatar = _InitialAvatar(letter: letter, pt: pt, size: 40); - } - - return Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: pt.border)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 12, 12), - child: Row( - children: [ - avatar, - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, - style: TextStyle( - color: pt.fg, - fontSize: 17, - fontWeight: FontWeight.w700)), - Text('Space-Einstellungen', - style: TextStyle(color: pt.fgDim, fontSize: 12)), - ], - ), - ), - IconButton( - icon: Icon(Icons.close, color: pt.fgDim, size: 20), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ), - TabBar( - controller: tabs, - labelColor: pt.accent, - unselectedLabelColor: pt.fgDim, - indicatorColor: pt.accent, - indicatorSize: TabBarIndicatorSize.label, - tabs: const [ - Tab(text: 'Übersicht'), - Tab(text: 'Mitglieder'), - Tab(text: 'Berechtigungen'), - Tab(text: 'Kanäle'), - ], - ), - ], - ), - ); - } -} - -// ─── Overview Tab ───────────────────────────────────────────────────────────── - -class _OverviewTab extends ConsumerStatefulWidget { - final Room space; - const _OverviewTab({required this.space}); - - @override - ConsumerState<_OverviewTab> createState() => _OverviewTabState(); -} - -class _OverviewTabState extends ConsumerState<_OverviewTab> { - late TextEditingController _nameCtrl; - late TextEditingController _topicCtrl; - bool _saving = false; - String? _error; - String? _success; - Uint8List? _pendingBanner; - String? _bannerMxcUri; - Uint8List? _pendingAvatar; - - @override - void initState() { - super.initState(); - _nameCtrl = TextEditingController( - text: widget.space.getLocalizedDisplayname()); - _topicCtrl = TextEditingController(text: widget.space.topic); - // Prefer local override (set on last save) over room state event - final overrides = ref.read(spaceBannerOverrideProvider); - _bannerMxcUri = overrides.containsKey(widget.space.id) - ? overrides[widget.space.id] - : widget.space.getState(_kSpaceBannerEvent)?.content['url'] as String?; - } - - @override - void dispose() { - _nameCtrl.dispose(); - _topicCtrl.dispose(); - super.dispose(); - } - - Future _pickBanner() async { - final result = await FilePicker.platform - .pickFiles(type: FileType.image, withData: true); - final file = result?.files.first; - if (file == null) return; - final raw = file.bytes ?? await file.xFile.readAsBytes(); - if (!mounted) return; - final cropped = await showDialog( - context: context, - builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), - ); - if (cropped == null || !mounted) return; - setState(() => _pendingBanner = cropped); - } - - Future _pickAvatar() async { - final result = await FilePicker.platform - .pickFiles(type: FileType.image, withData: true); - final file = result?.files.first; - if (file == null) return; - final raw = file.bytes ?? await file.xFile.readAsBytes(); - if (!mounted) return; - setState(() => _pendingAvatar = raw); - } - - Future _removeAvatar() async { - setState(() => _pendingAvatar = null); - try { - await widget.space.client.setRoomStateWithKey( - widget.space.id, EventTypes.RoomAvatar, '', {}); - } catch (_) {} - } - - Future _removeBanner() async { - setState(() { _pendingBanner = null; _bannerMxcUri = null; }); - try { - await widget.space.client.setRoomStateWithKey( - widget.space.id, _kSpaceBannerEvent, '', {}); - } catch (_) {} - } - - Future _save() async { - setState(() { _saving = true; _error = null; _success = null; }); - // Welcher Schritt gerade läuft — für präzise Fehlermeldungen statt eines - // pauschalen "keine Berechtigung" (das auch von Upload-Fehlern kam). - var step = 'Speichern'; - try { - final client = widget.space.client; - final newName = _nameCtrl.text.trim(); - final newTopic = _topicCtrl.text.trim(); - if (newName.isNotEmpty && - newName != widget.space.getLocalizedDisplayname()) { - step = 'Name ändern'; - await widget.space.setName(newName); - } - if (newTopic != widget.space.topic) { - step = 'Beschreibung ändern'; - await widget.space.setDescription(newTopic); - } - if (_pendingAvatar != null) { - step = 'Avatar hochladen'; - final uri = await client.uploadContent( - _pendingAvatar!, - filename: 'space_avatar.jpg', - contentType: 'image/jpeg', - ); - step = 'Avatar setzen'; - await _setStateWithRepair( - client, - EventTypes.RoomAvatar, - {'url': uri.toString()}, - ); - setState(() => _pendingAvatar = null); - } - if (_pendingBanner != null) { - step = 'Banner hochladen'; - final uri = await client.uploadContent( - _pendingBanner!, - filename: 'space_banner.jpg', - contentType: 'image/jpeg', - ); - step = 'Banner setzen'; - await _setStateWithRepair( - client, - _kSpaceBannerEvent, - {'url': uri.toString()}, - ); - final uriStr = uri.toString(); - // Update local override immediately so header updates before next sync - ref.read(spaceBannerOverrideProvider.notifier).update( - (m) => {...m, widget.space.id: uriStr}, - ); - setState(() { - _bannerMxcUri = uriStr; - _pendingBanner = null; - }); - } - setState(() { _success = 'Gespeichert'; }); - } catch (e) { - // Server-Fehlertext anzeigen (MatrixException liefert lesbare Meldung) — - // nicht pauschal auf "Admin-Level erforderlich" mappen. - final raw = e is MatrixException - ? '${e.errcode}: ${e.errorMessage}' - : e.toString().split('\n').first; - var detail = ''; - if (e is MatrixException && e.errcode == 'M_FORBIDDEN') { - detail = '\n${_permDiagnostics()}'; - // Zusätzlich die SERVER-Sicht der Power-Levels holen — die lokale - // Kopie kann von der des Servers abweichen. - try { - final pl = await widget.space.client.getRoomStateWithKey( - widget.space.id, EventTypes.RoomPowerLevels, ''); - detail += - '\nServer-PL: users=${pl['users']}, state_default=${pl['state_default']}, events=${pl['events']}'; - } on MatrixException catch (se) { - detail += '\nServer-PL: ${se.errcode} (Event fehlt auch serverseitig)'; - } catch (_) {} - } - setState(() { - _error = '$step fehlgeschlagen – $raw$detail'; - }); - } finally { - setState(() { _saving = false; }); - } - } - - /// Sendet ein State-Event; bei M_FORBIDDEN in einem Raum OHNE - /// power_levels-Event (Continuwuity-Sonderfall: Server verweigert dann - /// Custom-State trotz Creator-Rechten) werden einmalig Standard-Power-Levels - /// mit uns als Admin angelegt und der Vorgang wiederholt. - Future _setStateWithRepair( - Client client, - String type, - Map content, - ) async { - try { - await client.setRoomStateWithKey(widget.space.id, type, '', content); - } on MatrixException catch (e) { - if (e.errcode != 'M_FORBIDDEN') rethrow; - final repaired = await _ensurePowerLevels(client); - if (!repaired) rethrow; - await client.setRoomStateWithKey(widget.space.id, type, '', content); - } - } - - /// Legt Standard-Power-Levels an, falls der Raum keine hat. - /// Gibt true zurück, wenn das Event neu angelegt wurde. - Future _ensurePowerLevels(Client client) async { - try { - await client.getRoomStateWithKey( - widget.space.id, EventTypes.RoomPowerLevels, ''); - return false; // existiert bereits — Ablehnung hat andere Ursache - } catch (_) { - // fehlt (M_NOT_FOUND) → anlegen - } - try { - await client.setRoomStateWithKey( - widget.space.id, - EventTypes.RoomPowerLevels, - '', - { - 'users': {client.userID!: 100}, - 'users_default': 0, - 'events': const {}, - 'events_default': 0, - 'state_default': 50, - 'ban': 50, - 'kick': 50, - 'redact': 50, - 'invite': 0, - 'notifications': {'room': 50}, - }, - ); - return true; - } catch (_) { - return false; - } - } - - /// Kompakte Berechtigungs-Diagnose für M_FORBIDDEN-Fehler: zeigt, wie der - /// Server die Lage vermutlich sieht (Raumversion, Creator, Power-Level). - String _permDiagnostics() { - try { - final space = widget.space; - final create = space.getState(EventTypes.RoomCreate); - final version = create?.content['room_version'] ?? '1'; - final creator = create?.senderId ?? '?'; - final own = space.ownPowerLevel; - final needed = space.powerForChangingStateEvent(_kSpaceBannerEvent); - final hasPl = space.getState(EventTypes.RoomPowerLevels) != null; - return 'Diagnose: dein Level $own, benötigt $needed, ' - 'Raumversion $version, Creator $creator' - '${hasPl ? '' : ', kein power_levels-Event vorhanden!'}'; - } catch (_) { - return ''; - } - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - final client = ref.watch(matrixClientProvider).valueOrNull; - // SDK-Berechtigungsprüfung (berücksichtigt events-Overrides und - // state_default) statt hartcodiertem Admin-Level 100. - final canEdit = widget.space.canChangeStateEvent(EventTypes.RoomName) || - widget.space.ownPowerLevel >= _kPowerAdmin; - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Banner ──────────────────────────────────────────────────────── - _SectionLabel('Banner', pt), - const SizedBox(height: 6), - GestureDetector( - onTap: canEdit ? _pickBanner : null, - child: Stack( - children: [ - AspectRatio( - aspectRatio: 2.4, - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: pt.bg0, - borderRadius: BorderRadius.circular(pt.rBase), - border: Border.all(color: pt.border), - ), - clipBehavior: Clip.hardEdge, - child: _pendingBanner != null - ? Image.memory(_pendingBanner!, - fit: BoxFit.cover, width: double.infinity) - : (_bannerMxcUri != null && client != null - ? MxcImage( - mxcUri: _bannerMxcUri!, - client: client, - fit: BoxFit.cover, - width: double.infinity, - placeholder: Container(color: pt.bg0), - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add_photo_alternate_outlined, - size: 24, color: pt.fgDim), - const SizedBox(height: 4), - Text('Banner hinzufügen', - style: TextStyle( - color: pt.fgDim, fontSize: 12)), - ], - ), - )), - ), // Container - ), // AspectRatio - if (canEdit) - Positioned( - right: 6, - bottom: 6, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_bannerMxcUri != null || _pendingBanner != null) - GestureDetector( - onTap: _removeBanner, - child: Container( - margin: const EdgeInsets.only(right: 4), - padding: const EdgeInsets.symmetric( - horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFED4245).withAlpha(200), - borderRadius: BorderRadius.circular(5), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.delete_outline, - size: 11, color: Colors.white), - SizedBox(width: 3), - Text('Entfernen', - style: TextStyle( - color: Colors.white, fontSize: 11)), - ], - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: pt.bg0.withAlpha(200), - borderRadius: BorderRadius.circular(5), - border: Border.all(color: pt.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.edit_rounded, - size: 11, color: pt.fgMuted), - const SizedBox(width: 3), - Text('Banner ändern', - style: TextStyle( - color: pt.fgMuted, fontSize: 11)), - ], - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - // ── Avatar ─────────────────────────────────────────────────────── - _SectionLabel('Serverbild', pt), - const SizedBox(height: 8), - _AvatarEditor( - space: widget.space, - pendingAvatar: _pendingAvatar, - canEdit: canEdit, - onPick: _pickAvatar, - onRemove: _removeAvatar, - pt: pt, - client: client, - ), - const SizedBox(height: 16), - // ── Name / Topic ───────────────────────────────────────────────── - _SectionLabel('Name', pt), - const SizedBox(height: 6), - _Field(controller: _nameCtrl, hint: 'Space-Name', pt: pt), - const SizedBox(height: 16), - _SectionLabel('Beschreibung', pt), - const SizedBox(height: 6), - _Field( - controller: _topicCtrl, - hint: 'Worum geht es in diesem Space?', - maxLines: 4, - pt: pt), - const SizedBox(height: 8), - _SpaceVisibilityToggle(space: widget.space), - const SizedBox(height: 20), - if (_error != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text(_error!, - style: const TextStyle(color: Color(0xFFED4245), fontSize: 12)), - ), - if (_success != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text(_success!, - style: const TextStyle(color: Color(0xFF57F287), fontSize: 12)), - ), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _saving ? null : _save, - style: FilledButton.styleFrom( - backgroundColor: pt.accent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rBase)), - ), - child: _saving - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: pt.bg0)) - : const Text('Speichern', - style: TextStyle( - color: Colors.white, fontWeight: FontWeight.w600)), - ), - ), - const SizedBox(height: 24), - _DangerZone(space: widget.space), - ], - ), - ); - } -} - -class _SpaceVisibilityToggle extends ConsumerStatefulWidget { - final Room space; - const _SpaceVisibilityToggle({required this.space}); - - @override - ConsumerState<_SpaceVisibilityToggle> createState() => - _SpaceVisibilityToggleState(); -} - -class _SpaceVisibilityToggleState - extends ConsumerState<_SpaceVisibilityToggle> { - bool? _isPublic; - bool _loading = true; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - try { - final joinRules = widget.space.joinRules; - setState(() { - _isPublic = joinRules == JoinRules.public; - _loading = false; - }); - } catch (_) { - setState(() => _loading = false); - } - } - - Future _toggle(bool value) async { - setState(() => _isPublic = value); - try { - await widget.space.setJoinRules( - value ? JoinRules.public : JoinRules.invite); - } catch (_) { - setState(() => _isPublic = !value); - } - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - if (_loading) return const SizedBox.shrink(); - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Öffentlicher Space', - style: TextStyle( - color: pt.fg, - fontSize: 13, - fontWeight: FontWeight.w600)), - Text('Jeder kann diesem Space beitreten', - style: TextStyle(color: pt.fgDim, fontSize: 12)), - ], - ), - ), - Switch( - value: _isPublic ?? false, - onChanged: _toggle, - activeThumbColor: pt.accent, - ), - ], - ); - } -} - -class _DangerZone extends ConsumerWidget { - final Room space; - const _DangerZone({required this.space}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pt = PyramidTheme.of(context); - return Container( - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFED4245).withAlpha(80)), - borderRadius: BorderRadius.circular(pt.rBase), - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Gefahrenzone', - style: TextStyle( - color: const Color(0xFFED4245), - fontSize: 13, - fontWeight: FontWeight.w700)), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Space verlassen', - style: TextStyle( - color: pt.fg, - fontSize: 13, - fontWeight: FontWeight.w600)), - Text('Du verlässt diesen Space', - style: TextStyle(color: pt.fgDim, fontSize: 12)), - ], - ), - ), - OutlinedButton( - onPressed: () => _confirmLeave(context, ref), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFED4245), - side: const BorderSide(color: Color(0xFFED4245)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), - ), - child: const Text('Verlassen'), - ), - ], - ), - ], - ), - ); - } - - void _confirmLeave(BuildContext context, WidgetRef ref) { - showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Space verlassen?'), - content: Text( - 'Möchtest du "${space.getLocalizedDisplayname()}" wirklich verlassen?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Abbrechen')), - TextButton( - onPressed: () async { - Navigator.pop(context); - Navigator.pop(context); - await space.leave(); - }, - child: const Text('Verlassen', - style: TextStyle(color: Color(0xFFED4245))), - ), - ], - ), - ); - } -} - -// ─── Members Tab ────────────────────────────────────────────────────────────── - -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 } - -// ─── Permissions Tab ────────────────────────────────────────────────────────── - -class _PermissionsTab extends ConsumerStatefulWidget { - final Room space; - const _PermissionsTab({required this.space}); - - @override - ConsumerState<_PermissionsTab> createState() => _PermissionsTabState(); -} - -class _PermissionsTabState extends ConsumerState<_PermissionsTab> { - bool _saving = false; - String? _status; - - // Current power level values (loaded from room state) - late Map _levels; - - static const _defaults = { - 'events_default': 0, - 'state_default': 50, - 'invite': 0, - 'kick': 50, - 'ban': 50, - 'redact': 50, - }; - - static const _labels = { - 'events_default': 'Nachrichten senden', - 'state_default': 'Raum-Einstellungen ändern', - 'invite': 'Mitglieder einladen', - 'kick': 'Mitglieder kicken', - 'ban': 'Mitglieder bannen', - 'redact': 'Nachrichten löschen', - }; - - @override - void initState() { - super.initState(); - _load(); - } - - void _load() { - final content = widget.space - .getState(EventTypes.RoomPowerLevels) - ?.content ?? - {}; - _levels = { - for (final key in _defaults.keys) - key: (content[key] as int?) ?? _defaults[key]!, - }; - } - - Future _save() async { - setState(() { _saving = true; _status = null; }); - try { - // Server-Stand holen und nur die geänderten Schlüssel mergen — der - // lokale Cache kann unvollständig sein und würde users/events wipen. - await updatePowerLevelsSafely(widget.space, (content) { - for (final entry in _levels.entries) { - content[entry.key] = entry.value; - } - }); - setState(() => _status = 'Gespeichert'); - } catch (e) { - final msg = e is MatrixException - ? '${e.errcode}: ${e.errorMessage}' - : e.toString().split('\n').first; - setState(() => _status = 'Fehler: $msg'); - } finally { - setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - final ownPower = widget.space.ownPowerLevel; - final canEdit = ownPower >= _kPowerAdmin; - - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Mindestberechtigungen', - style: TextStyle( - color: pt.fgDim, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.8)), - const SizedBox(height: 4), - Text( - 'Lege fest, welche Stufe ein Mitglied mindestens haben muss, um diese Aktion auszuführen.', - style: TextStyle(color: pt.fgDim, fontSize: 12)), - const SizedBox(height: 16), - ...(_labels.entries.map((e) => _PermissionRow( - label: e.value, - value: _levels[e.key]!, - enabled: canEdit, - pt: pt, - onChanged: (v) => setState(() => _levels[e.key] = v), - ))), - if (canEdit) ...[ - const SizedBox(height: 20), - if (_status != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text(_status!, - style: TextStyle( - color: _status!.startsWith('F') - ? const Color(0xFFED4245) - : const Color(0xFF57F287), - fontSize: 12)), - ), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _saving ? null : _save, - style: FilledButton.styleFrom( - backgroundColor: pt.accent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rBase)), - ), - child: _saving - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: pt.bg0)) - : const Text('Speichern', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600)), - ), - ), - ], - ], - ), - ); - } -} - -class _PermissionRow extends StatelessWidget { - final String label; - final int value; - final bool enabled; - final PyramidTheme pt; - final ValueChanged onChanged; - - const _PermissionRow({ - required this.label, - required this.value, - required this.enabled, - required this.pt, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Expanded( - child: Text(label, - style: TextStyle( - color: pt.fg, - fontSize: 13, - fontWeight: FontWeight.w500)), - ), - if (enabled) - DropdownButton( - value: value, - dropdownColor: pt.bg2, - underline: const SizedBox.shrink(), - style: TextStyle(color: pt.fg, fontSize: 13), - items: const [ - DropdownMenuItem(value: 0, child: Text('Mitglied (0)')), - DropdownMenuItem(value: 50, child: Text('Moderator (50)')), - DropdownMenuItem(value: 100, child: Text('Admin (100)')), - ], - onChanged: (v) => v != null ? onChanged(v) : null, - ) - else - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: _powerColor(value, pt).withAlpha(30), - borderRadius: BorderRadius.circular(6), - ), - child: Text(_powerLabel(value), - style: TextStyle( - color: _powerColor(value, pt), - fontSize: 12, - fontWeight: FontWeight.w600)), - ), - ], - ), - ); - } -} - -// ─── Channels Tab ───────────────────────────────────────────────────────────── - -const _kSpaceChild = 'm.space.child'; -const _kSpaceParent = 'm.space.parent'; -const _kVoiceChannelType = 'io.pyramid.voice_channel'; - -class _ChannelsTab extends ConsumerStatefulWidget { - final Room space; - const _ChannelsTab({required this.space}); - - @override - ConsumerState<_ChannelsTab> createState() => _ChannelsTabState(); -} - -class _ChannelsTabState extends ConsumerState<_ChannelsTab> { - bool _busy = false; - - List<({String roomId, Room? room, bool isVoice})> _getChildren(Client client) { - final childStates = widget.space.states[_kSpaceChild] ?? {}; - final result = <({String roomId, Room? room, bool isVoice})>[]; - for (final entry in childStates.entries) { - final content = entry.value.content; - if (content.isEmpty) continue; - final childRoomId = entry.key; - final room = client.getRoomById(childRoomId); - final createContent = room?.getState('m.room.create')?.content; - final isVoice = createContent?['type'] == _kVoiceChannelType; - result.add((roomId: childRoomId, room: room, isVoice: isVoice)); - } - result.sort((a, b) { - final nameA = a.room?.getLocalizedDisplayname() ?? a.roomId; - final nameB = b.room?.getLocalizedDisplayname() ?? b.roomId; - return nameA.compareTo(nameB); - }); - return result; - } - - Future _removeChild(String roomId) async { - final client = ref.read(matrixClientProvider).valueOrNull; - if (client == null) return; - setState(() => _busy = true); - try { - await client.setRoomStateWithKey(widget.space.id, _kSpaceChild, roomId, {}); - if (mounted) setState(() {}); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.toString()))); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - Future _addChild(String roomId) async { - final client = ref.read(matrixClientProvider).valueOrNull; - if (client == null) return; - final serverName = client.userID?.split(':').last ?? ''; - try { - await client.setRoomStateWithKey( - widget.space.id, - _kSpaceChild, - roomId, - {'via': [serverName], 'suggested': false}, - ); - // Best-effort: set parent in child room - try { - await client.setRoomStateWithKey( - roomId, - _kSpaceParent, - widget.space.id, - {'via': [serverName], 'canonical': true}, - ); - } catch (_) {} - if (mounted) setState(() {}); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.toString()))); - } - } - } - - Future _createAndAdd({ - required String name, - required bool isVoice, - required Client client, - }) async { - setState(() => _busy = true); - try { - final serverName = client.userID?.split(':').last ?? ''; - final roomId = await client.createRoom( - name: name, - visibility: Visibility.private, - preset: CreateRoomPreset.privateChat, - creationContent: isVoice ? {'type': _kVoiceChannelType} : null, - initialState: [ - StateEvent( - type: 'm.room.join_rules', - content: {'join_rule': 'restricted', 'allow': [ - {'type': 'm.room_membership', 'room_id': widget.space.id} - ]}, - ), - ], - ); - await client.setRoomStateWithKey( - widget.space.id, - _kSpaceChild, - roomId, - {'via': [serverName], 'suggested': false}, - ); - try { - await client.setRoomStateWithKey( - roomId, - _kSpaceParent, - widget.space.id, - {'via': [serverName], 'canonical': true}, - ); - } catch (_) {} - if (mounted) setState(() {}); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.toString()))); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - void _showCreateDialog(BuildContext context, Client client) { - showDialog( - context: context, - builder: (_) => _CreateChannelDialog( - onConfirm: (name, isVoice) => - _createAndAdd(name: name, isVoice: isVoice, client: client), - ), - ); - } - - void _showAddExistingDialog(BuildContext context, Client client) { - final childStates = widget.space.states[_kSpaceChild] ?? {}; - final existingIds = { - for (final e in childStates.entries) - if (e.value.content.isNotEmpty) e.key - }; - final available = client.rooms - .where((r) => !r.isSpace && !existingIds.contains(r.id)) - .toList() - ..sort((a, b) => a.getLocalizedDisplayname() - .compareTo(b.getLocalizedDisplayname())); - - showDialog( - context: context, - builder: (_) => _AddExistingDialog( - rooms: available, - client: client, - onConfirm: (roomId) => _addChild(roomId), - ), - ); - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - final client = ref.watch(matrixClientProvider).valueOrNull; - final ownPower = widget.space.ownPowerLevel; - final canManage = ownPower >= _kPowerMod; - - if (client == null) { - return Center( - child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2)); - } - - final children = _getChildren(client); - - return Column( - children: [ - if (canManage) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - icon: const Icon(Icons.add, size: 16), - label: const Text('Kanal erstellen'), - style: OutlinedButton.styleFrom( - foregroundColor: pt.accent, - side: BorderSide(color: pt.accent), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rSm)), - ), - onPressed: _busy - ? null - : () => _showCreateDialog(context, client), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - icon: const Icon(Icons.link, size: 16), - label: const Text('Raum hinzufügen'), - style: OutlinedButton.styleFrom( - foregroundColor: pt.fgDim, - side: BorderSide(color: pt.border), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rSm)), - ), - onPressed: _busy - ? null - : () => _showAddExistingDialog(context, client), - ), - ), - ], - ), - ), - if (children.isEmpty) - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.forum_outlined, color: pt.fgDim, size: 40), - const SizedBox(height: 8), - Text('Noch keine Kanäle', - style: TextStyle(color: pt.fgDim, fontSize: 13)), - ], - ), - ), - ) - else - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - itemCount: children.length, - itemBuilder: (context, i) { - final child = children[i]; - final name = child.room?.getLocalizedDisplayname() ?? - child.roomId; - return _ChannelItem( - name: name, - isVoice: child.isVoice, - roomId: child.roomId, - pt: pt, - canRemove: canManage, - onRemove: () => _removeChild(child.roomId), - ); - }, - ), - ), - ], - ); - } -} - -class _ChannelItem extends StatefulWidget { - final String name; - final bool isVoice; - final String roomId; - final PyramidTheme pt; - final bool canRemove; - final VoidCallback onRemove; - - const _ChannelItem({ - required this.name, - required this.isVoice, - required this.roomId, - required this.pt, - required this.canRemove, - required this.onRemove, - }); - - @override - State<_ChannelItem> createState() => _ChannelItemState(); -} - -class _ChannelItemState extends State<_ChannelItem> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - 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: 10, vertical: 0), - leading: Icon( - widget.isVoice - ? Icons.volume_up_outlined - : Icons.tag, - color: pt.fgDim, - size: 18, - ), - title: Text( - widget.name, - style: TextStyle( - color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - widget.isVoice ? 'Sprachkanal' : 'Textkanal', - style: TextStyle(color: pt.fgDim, fontSize: 11), - ), - trailing: widget.canRemove && _hovered - ? IconButton( - icon: Icon(Icons.link_off, - color: const Color(0xFFED4245), size: 18), - tooltip: 'Aus Space entfernen', - onPressed: widget.onRemove, - ) - : null, - ), - ), - ); - } -} - -class _CreateChannelDialog extends StatefulWidget { - final void Function(String name, bool isVoice) onConfirm; - const _CreateChannelDialog({required this.onConfirm}); - - @override - State<_CreateChannelDialog> createState() => _CreateChannelDialogState(); -} - -class _CreateChannelDialogState extends State<_CreateChannelDialog> { - final _ctrl = TextEditingController(); - bool _isVoice = false; - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - return AlertDialog( - backgroundColor: pt.bg1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rLg)), - title: Text('Neuen Kanal erstellen', - style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel('Name', pt), - const SizedBox(height: 6), - _Field(controller: _ctrl, hint: 'kanal-name', pt: pt), - const SizedBox(height: 16), - _SectionLabel('Typ', pt), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => setState(() => _isVoice = false), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: !_isVoice ? pt.accent.withAlpha(40) : pt.bg0, - border: Border.all( - color: !_isVoice ? pt.accent : pt.border), - borderRadius: BorderRadius.circular(pt.rSm), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.tag, - color: !_isVoice ? pt.accent : pt.fgDim, - size: 16), - const SizedBox(width: 6), - Text('Text', - style: TextStyle( - color: !_isVoice ? pt.accent : pt.fgDim, - fontSize: 13, - fontWeight: FontWeight.w600)), - ], - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: GestureDetector( - onTap: () => setState(() => _isVoice = true), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: _isVoice ? pt.accent.withAlpha(40) : pt.bg0, - border: Border.all( - color: _isVoice ? pt.accent : pt.border), - borderRadius: BorderRadius.circular(pt.rSm), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.volume_up_outlined, - color: _isVoice ? pt.accent : pt.fgDim, - size: 16), - const SizedBox(width: 6), - Text('Sprache', - style: TextStyle( - color: _isVoice ? pt.accent : pt.fgDim, - fontSize: 13, - fontWeight: FontWeight.w600)), - ], - ), - ), - ), - ), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Abbrechen')), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: pt.accent), - onPressed: () { - final name = _ctrl.text.trim(); - if (name.isEmpty) return; - Navigator.pop(context); - widget.onConfirm(name, _isVoice); - }, - child: const Text('Erstellen', - style: TextStyle(color: Colors.white)), - ), - ], - ); - } -} - -class _AddExistingDialog extends StatefulWidget { - final List rooms; - final Client client; - final void Function(String roomId) onConfirm; - - const _AddExistingDialog({ - required this.rooms, - required this.client, - required this.onConfirm, - }); - - @override - State<_AddExistingDialog> createState() => _AddExistingDialogState(); -} - -class _AddExistingDialogState extends State<_AddExistingDialog> { - String _filter = ''; - final _ctrl = TextEditingController(); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - final filtered = _filter.isEmpty - ? widget.rooms - : widget.rooms - .where((r) => r - .getLocalizedDisplayname() - .toLowerCase() - .contains(_filter.toLowerCase())) - .toList(); - - return Dialog( - backgroundColor: pt.bg1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rLg)), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text('Raum hinzufügen', - style: TextStyle( - color: pt.fg, - fontSize: 17, - fontWeight: FontWeight.w700)), - ), - IconButton( - icon: Icon(Icons.close, color: pt.fgDim, size: 20), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 12), - _SearchField( - controller: _ctrl, - hint: 'Raum suchen…', - pt: pt, - onChanged: (v) => setState(() => _filter = v), - ), - ], - ), - ), - const Divider(height: 1), - if (filtered.isEmpty) - Expanded( - child: Center( - child: Text('Keine Räume gefunden', - style: TextStyle(color: pt.fgDim, fontSize: 13)), - ), - ) - else - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4), - itemCount: filtered.length, - itemBuilder: (context, i) { - final room = filtered[i]; - final name = room.getLocalizedDisplayname(); - final letter = - name.isNotEmpty ? name[0].toUpperCase() : '?'; - final avatarUri = room.avatar; - final createContent = - room.getState('m.room.create')?.content; - final isVoice = - createContent?['type'] == _kVoiceChannelType; - return ListTile( - dense: true, - leading: avatarUri != null - ? MxcAvatar( - mxcUri: avatarUri, - client: widget.client, - size: 32, - borderRadius: BorderRadius.circular(pt.rSm), - placeholder: (_) => _InitialAvatar( - letter: letter, pt: pt, size: 32), - ) - : _InitialAvatar(letter: letter, pt: pt, size: 32), - title: Text(name, - style: TextStyle( - color: pt.fg, - fontSize: 13, - fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis), - subtitle: Text( - isVoice ? 'Sprachkanal' : 'Textkanal', - style: TextStyle(color: pt.fgDim, fontSize: 11), - ), - trailing: - Icon(isVoice ? Icons.volume_up_outlined : Icons.tag, - color: pt.fgDim, size: 16), - onTap: () { - Navigator.pop(context); - widget.onConfirm(room.id); - }, - ); - }, - ), - ), - ], - ), - ), - ); - } -} - -// ─── Shared helpers ─────────────────────────────────────────────────────────── - -class _SectionLabel extends StatelessWidget { - final String text; - final PyramidTheme pt; - const _SectionLabel(this.text, this.pt); - - @override - Widget build(BuildContext context) { - return Text(text, - style: TextStyle( - color: pt.fgDim, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.8)); - } -} - -class _Field extends StatelessWidget { - final TextEditingController controller; - final String hint; - final int maxLines; - final PyramidTheme pt; - - const _Field({ - required this.controller, - required this.hint, - this.maxLines = 1, - required this.pt, - }); - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - maxLines: maxLines, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: InputDecoration( - hintText: hint, - hintStyle: TextStyle(color: pt.fgDim), - filled: true, - fillColor: pt.bg0, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(pt.rSm), - borderSide: BorderSide(color: pt.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(pt.rSm), - borderSide: BorderSide(color: pt.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(pt.rSm), - borderSide: BorderSide(color: pt.accent), - ), - ), - ); - } -} - -class _SearchField extends StatelessWidget { - final TextEditingController controller; - final String hint; - final PyramidTheme pt; - final ValueChanged onChanged; - - const _SearchField({ - required this.controller, - required this.hint, - required this.pt, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - onChanged: onChanged, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: InputDecoration( - hintText: hint, - hintStyle: TextStyle(color: pt.fgDim), - prefixIcon: Icon(Icons.search, color: pt.fgDim, size: 18), - filled: true, - fillColor: pt.bg0, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - isDense: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(pt.rSm), - borderSide: BorderSide(color: pt.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(pt.rSm), - borderSide: BorderSide(color: pt.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(pt.rSm), - borderSide: BorderSide(color: pt.accent), - ), - ), - ); - } -} - -// ─── Avatar Editor ─────────────────────────────────────────────────────────── - -class _AvatarEditor extends StatelessWidget { - final Room space; - final Uint8List? pendingAvatar; - final bool canEdit; - final VoidCallback onPick; - final VoidCallback onRemove; - final PyramidTheme pt; - final dynamic client; - - const _AvatarEditor({ - required this.space, - required this.pendingAvatar, - required this.canEdit, - required this.onPick, - required this.onRemove, - required this.pt, - required this.client, - }); - - @override - Widget build(BuildContext context) { - final letter = space.getLocalizedDisplayname().isNotEmpty - ? space.getLocalizedDisplayname()[0].toUpperCase() - : '?'; - final avatarUri = space.avatar; - final hasAvatar = pendingAvatar != null || avatarUri != null; - - Widget avatarWidget; - if (pendingAvatar != null) { - avatarWidget = Image.memory(pendingAvatar!, fit: BoxFit.cover, - width: 64, height: 64); - } else if (avatarUri != null && client != null) { - avatarWidget = MxcAvatar( - mxcUri: avatarUri, - client: client, - size: 64, - borderRadius: BorderRadius.circular(32), - placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 64), - ); - } else { - avatarWidget = _InitialAvatar(letter: letter, pt: pt, size: 64); - } - - return Row( - children: [ - GestureDetector( - onTap: canEdit ? onPick : null, - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: pt.border), - ), - clipBehavior: Clip.hardEdge, - child: avatarWidget, - ), - if (canEdit) - Positioned( - right: -2, - bottom: -2, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: pt.accent, - shape: BoxShape.circle, - border: Border.all(color: pt.bg1, width: 2), - ), - child: const Icon(Icons.edit, size: 10, color: Colors.white), - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - canEdit ? 'Klicken zum Ändern' : 'Serverbild', - style: TextStyle(color: pt.fg, fontSize: 13, - fontWeight: FontWeight.w500), - ), - if (canEdit && hasAvatar) - GestureDetector( - onTap: onRemove, - child: Text('Entfernen', - style: TextStyle( - color: const Color(0xFFED4245), fontSize: 12)), - ), - ], - ), - ], - ); - } -} - -class _InitialAvatar extends StatelessWidget { - final String letter; - final PyramidTheme pt; - final double size; - const _InitialAvatar( - {required this.letter, required this.pt, required this.size}); - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(size / 2), - ), - child: Center( - child: Text(letter, - style: TextStyle( - color: pt.fg, - fontSize: size * 0.4, - fontWeight: FontWeight.w700)), - ), - ); - } -} +part 'admin/space_admin_core.dart'; +part 'admin/space_admin_overview.dart'; +part 'admin/space_admin_members.dart'; +part 'admin/space_admin_permissions.dart'; +part 'admin/space_admin_channels.dart'; +part 'admin/space_admin_shared.dart';