import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart' hide Visibility; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; 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)), ), ); } }