refactor: Gott-Datei space_admin_dialog.dart in 6 Dateien aufgeteilt

Gleiche Technik wie bei den vorigen drei Umbauten: reine Verschiebung per
Dart part/part-of, keine Logikänderung. space_admin_dialog.dart schrumpft
von 2278 auf 19 Zeilen (nur noch Bibliothekskopf). Verifiziert per
automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-03 11:01:58 +02:00
parent a863fa3b9a
commit 0c7d359076
10 changed files with 2313 additions and 2267 deletions
+2
View File
@@ -1165,3 +1165,5 @@
2026-07-03 10:58 [Edit] /home/steggi/pyramid/PROGRESS.md 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/ROADMAP.md
2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.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
+48
View File
@@ -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) ## 2026-07-03 M2: Gott-Datei `rooms_panel.dart` aufgeteilt (8 Dateien)
**Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt: **Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt:
+4 -2
View File
@@ -85,8 +85,10 @@ lassen, ohne dass andere Module angefasst werden müssen.
aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt"); aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt");
`features/rooms/rooms_panel.dart` (2555 Zeilen) ebenso in 8 `features/rooms/rooms_panel.dart` (2555 Zeilen) ebenso in 8
`part`-Dateien unter `features/rooms/panel/` aufgeteilt (PROGRESS.md `part`-Dateien unter `features/rooms/panel/` aufgeteilt (PROGRESS.md
2026-07-03 „rooms_panel.dart aufgeteilt") restliche Gott-Dateien 2026-07-03 „rooms_panel.dart aufgeteilt"); `features/spaces/space_admin_dialog.dart`
`space_admin_dialog.dart`, `chat_view.dart` aus der Ist-Analyse noch offen) (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 - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
## M3 Calls: Logik, UI & Audio (Discord als Referenz) ## M3 Calls: Logik, UI & Audio (Discord als Referenz)
@@ -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<void> _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<void> _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<void> _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<Room> 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);
},
);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,199 @@
part of '../space_admin_dialog.dart';
const _kSpaceBannerEvent = 'io.pyramid.space.banner';
Future<void> updatePowerLevelsSafely(
Room room,
void Function(Map<String, dynamic> content) mutate,
) async {
Map<String, dynamic> content;
try {
content = Map<String, dynamic>.from(
await room.client
.getRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, ''),
);
} on MatrixException catch (e) {
if (e.errcode == 'M_NOT_FOUND') {
content = <String, dynamic>{};
} else {
rethrow;
}
}
final users = content['users'];
content['users'] =
users is Map ? Map<String, dynamic>.from(users) : <String, dynamic>{};
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<String, dynamic>;
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<SpaceAdminDialog> createState() => _SpaceAdminDialogState();
}
class _SpaceAdminDialogState extends ConsumerState<SpaceAdminDialog>
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'),
],
),
],
),
);
}
}
@@ -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<User>? _members;
bool _loading = true;
String? _error;
String _filter = '';
final _filterCtrl = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_filterCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final members =
await widget.space.requestParticipants([Membership.join, Membership.invite]);
if (mounted) setState(() { _members = members; _loading = false; });
} catch (e) {
if (mounted) setState(() { _error = e.toString(); _loading = false; });
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
if (_loading) {
return Center(
child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2));
}
if (_error != null) {
return Center(
child: Text(_error!, style: TextStyle(color: pt.fgDim, fontSize: 13)));
}
final members = _members ?? [];
final filtered = _filter.isEmpty
? members
: members
.where((m) =>
(m.displayName ?? m.id)
.toLowerCase()
.contains(_filter.toLowerCase()) ||
m.id.toLowerCase().contains(_filter.toLowerCase()))
.toList()
..sort((a, b) => (b.powerLevel).compareTo(a.powerLevel));
final ownPower = widget.space.ownPowerLevel;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Expanded(
child: _SearchField(
controller: _filterCtrl,
hint: 'Mitglieder suchen…',
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
),
if (ownPower >= _kPowerMod) ...[
const SizedBox(width: 8),
_InviteButton(space: widget.space),
],
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: filtered.length,
itemBuilder: (context, i) {
final member = filtered[i];
return _MemberTile(
member: member,
space: widget.space,
ownPower: ownPower,
onChanged: _load,
);
},
),
),
],
);
}
}
class _InviteButton extends ConsumerStatefulWidget {
final Room space;
const _InviteButton({required this.space});
@override
ConsumerState<_InviteButton> createState() => _InviteButtonState();
}
class _InviteButtonState extends ConsumerState<_InviteButton> {
bool _inviting = false;
String? _inviteError;
void _showInvite() {
final pt = PyramidTheme.of(context);
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setS) => AlertDialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
title: Text('Zu Space einladen',
style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Field(controller: ctrl, hint: '@user:server.org', pt: pt),
const SizedBox(height: 6),
Text('Lädt zu Space und allen Kanälen ein.',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
if (_inviteError != null) ...[
const SizedBox(height: 6),
Text(_inviteError!,
style: TextStyle(color: pt.danger, fontSize: 12)),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: pt.accent),
onPressed: _inviting
? null
: () async {
final userId = ctrl.text.trim();
if (userId.isEmpty) return;
setS(() { _inviting = true; _inviteError = null; });
try {
// 1. Invite to space itself
await widget.space.invite(userId);
// 2. Invite to every joined child room
final client = widget.space.client;
for (final child in widget.space.spaceChildren) {
final roomId = child.roomId;
if (roomId == null) continue;
final room = client.getRoomById(roomId);
if (room == null || room.membership == Membership.leave) continue;
try {
await room.invite(userId);
} catch (_) {}
}
if (ctx.mounted) Navigator.pop(ctx);
} catch (e) {
setS(() { _inviteError = e.toString().split('\n').first; });
} finally {
setS(() => _inviting = false);
}
},
child: _inviting
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Einladen',
style: TextStyle(color: Colors.white)),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return IconButton(
icon: Icon(Icons.person_add_outlined, color: pt.accent, size: 20),
tooltip: 'Einladen',
onPressed: _showInvite,
);
}
}
class _MemberTile extends ConsumerStatefulWidget {
final User member;
final Room space;
final int ownPower;
final VoidCallback onChanged;
const _MemberTile({
required this.member,
required this.space,
required this.ownPower,
required this.onChanged,
});
@override
ConsumerState<_MemberTile> createState() => _MemberTileState();
}
class _MemberTileState extends ConsumerState<_MemberTile> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final member = widget.member;
final power = member.powerLevel;
final name = member.displayName ?? member.id;
final client = ref.watch(matrixClientProvider).valueOrNull;
final avatarUri = member.avatarUrl;
final isMe = member.id == widget.space.client.userID;
final canManage = widget.ownPower > power && !isMe;
Widget avatar;
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
if (client != null && avatarUri != null) {
avatar = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 32,
borderRadius: BorderRadius.circular(16),
placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 32),
);
} else {
avatar = _InitialAvatar(letter: letter, pt: pt, size: 32);
}
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: _hovered ? pt.bg2 : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: avatar,
title: Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
subtitle: Text(member.id,
style: TextStyle(color: pt.fgDim, fontSize: 11),
overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: _powerColor(power, pt).withAlpha(30),
borderRadius: BorderRadius.circular(6),
),
child: Text(_powerLabel(power),
style: TextStyle(
color: _powerColor(power, pt),
fontSize: 11,
fontWeight: FontWeight.w600)),
),
if (canManage) ...[
const SizedBox(width: 4),
_MemberActions(
member: member,
space: widget.space,
ownPower: widget.ownPower,
onChanged: widget.onChanged),
],
],
),
),
),
);
}
}
class _MemberActions extends ConsumerWidget {
final User member;
final Room space;
final int ownPower;
final VoidCallback onChanged;
const _MemberActions({
required this.member,
required this.space,
required this.ownPower,
required this.onChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
return PopupMenuButton<_MemberAction>(
icon: Icon(Icons.more_vert, color: pt.fgDim, size: 18),
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase)),
itemBuilder: (_) => [
if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerMod)
const PopupMenuItem(
value: _MemberAction.makeMod,
child: Text('Zum Moderator machen'),
),
if (ownPower >= _kPowerAdmin && member.powerLevel == _kPowerMod)
const PopupMenuItem(
value: _MemberAction.removeMod,
child: Text('Moderator-Rechte entziehen'),
),
if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerAdmin)
const PopupMenuItem(
value: _MemberAction.makeAdmin,
child: Text('Zum Admin machen'),
),
const PopupMenuItem(
value: _MemberAction.kick,
child: Text('Kicken', style: TextStyle(color: Color(0xFFF59E0B))),
),
const PopupMenuItem(
value: _MemberAction.ban,
child: Text('Bannen', style: TextStyle(color: Color(0xFFED4245))),
),
],
onSelected: (action) => _handleAction(context, action),
);
}
Future<void> _handleAction(BuildContext context, _MemberAction action) async {
switch (action) {
case _MemberAction.makeMod:
await _setPower(context, _kPowerMod);
case _MemberAction.removeMod:
await _setPower(context, _kPowerMember);
case _MemberAction.makeAdmin:
_confirmAdmin(context);
case _MemberAction.kick:
_confirmKick(context);
case _MemberAction.ban:
_confirmBan(context);
}
}
Future<void> _setPower(BuildContext context, int level) async {
try {
// NICHT space.setPower() — das merged nur mit dem lokalen Cache und
// kann die users-Map des Servers zerstören (Admin-Aussperrung).
await updatePowerLevelsSafely(space, (content) {
(content['users'] as Map<String, dynamic>)[member.id] = level;
});
onChanged();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())));
}
}
}
void _confirmAdmin(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Admin machen?'),
content: Text(
'${member.displayName ?? member.id} zum Admin befördern? '
'Admin-Rechte können nicht einfach entzogen werden.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
await _setPower(context, _kPowerAdmin);
},
child: const Text('Admin machen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
void _confirmKick(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Mitglied kicken?'),
content: Text('${member.displayName ?? member.id} aus dem Space entfernen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await space.kick(member.id);
onChanged();
} catch (_) {}
},
child: const Text('Kicken',
style: TextStyle(color: Color(0xFFF59E0B))),
),
],
),
);
}
void _confirmBan(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Mitglied bannen?'),
content: Text(
'${member.displayName ?? member.id} dauerhaft aus dem Space ausschließen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await space.ban(member.id);
onChanged();
} catch (_) {}
},
child: const Text('Bannen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
}
enum _MemberAction { makeMod, removeMod, makeAdmin, kick, ban }
@@ -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<void> _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<Uint8List>(
context: context,
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4),
);
if (cropped == null || !mounted) return;
setState(() => _pendingBanner = cropped);
}
Future<void> _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<void> _removeAvatar() async {
setState(() => _pendingAvatar = null);
try {
await widget.space.client.setRoomStateWithKey(
widget.space.id, EventTypes.RoomAvatar, '', {});
} catch (_) {}
}
Future<void> _removeBanner() async {
setState(() { _pendingBanner = null; _bannerMxcUri = null; });
try {
await widget.space.client.setRoomStateWithKey(
widget.space.id, _kSpaceBannerEvent, '', {});
} catch (_) {}
}
Future<void> _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<void> _setStateWithRepair(
Client client,
String type,
Map<String, Object?> 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<bool> _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 <String, Object?>{},
'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<void> _load() async {
try {
final joinRules = widget.space.joinRules;
setState(() {
_isPublic = joinRules == JoinRules.public;
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
Future<void> _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))),
),
],
),
);
}
}
@@ -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<String, int> _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<void> _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<int> 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<int>(
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)),
),
],
),
);
}
}
@@ -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<String> 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)),
),
);
}
}
File diff suppressed because it is too large Load Diff