part of '../space_admin_dialog.dart'; class _OverviewTab extends ConsumerStatefulWidget { final Room space; const _OverviewTab({required this.space}); @override ConsumerState<_OverviewTab> createState() => _OverviewTabState(); } class _OverviewTabState extends ConsumerState<_OverviewTab> { late TextEditingController _nameCtrl; late TextEditingController _topicCtrl; bool _saving = false; String? _error; String? _success; Uint8List? _pendingBanner; String? _bannerMxcUri; Uint8List? _pendingAvatar; @override void initState() { super.initState(); _nameCtrl = TextEditingController( text: widget.space.getLocalizedDisplayname()); _topicCtrl = TextEditingController(text: widget.space.topic); // Prefer local override (set on last save) over room state event final overrides = ref.read(spaceBannerOverrideProvider); _bannerMxcUri = overrides.containsKey(widget.space.id) ? overrides[widget.space.id] : widget.space.getState(_kSpaceBannerEvent)?.content['url'] as String?; } @override void dispose() { _nameCtrl.dispose(); _topicCtrl.dispose(); super.dispose(); } Future _pickBanner() async { final result = await FilePicker.platform .pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final raw = file.bytes ?? await file.xFile.readAsBytes(); if (!mounted) return; final cropped = await showDialog( context: context, builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), ); if (cropped == null || !mounted) return; setState(() => _pendingBanner = cropped); } Future _pickAvatar() async { final result = await FilePicker.platform .pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final raw = file.bytes ?? await file.xFile.readAsBytes(); if (!mounted) return; setState(() => _pendingAvatar = raw); } Future _removeAvatar() async { setState(() => _pendingAvatar = null); try { await widget.space.client.setRoomStateWithKey( widget.space.id, EventTypes.RoomAvatar, '', {}); } catch (_) {} } Future _removeBanner() async { setState(() { _pendingBanner = null; _bannerMxcUri = null; }); try { await widget.space.client.setRoomStateWithKey( widget.space.id, _kSpaceBannerEvent, '', {}); } catch (_) {} } Future _save() async { setState(() { _saving = true; _error = null; _success = null; }); // Welcher Schritt gerade läuft — für präzise Fehlermeldungen statt eines // pauschalen "keine Berechtigung" (das auch von Upload-Fehlern kam). var step = 'Speichern'; try { final client = widget.space.client; final newName = _nameCtrl.text.trim(); final newTopic = _topicCtrl.text.trim(); if (newName.isNotEmpty && newName != widget.space.getLocalizedDisplayname()) { step = 'Name ändern'; await widget.space.setName(newName); } if (newTopic != widget.space.topic) { step = 'Beschreibung ändern'; await widget.space.setDescription(newTopic); } if (_pendingAvatar != null) { step = 'Avatar hochladen'; final uri = await client.uploadContent( _pendingAvatar!, filename: 'space_avatar.jpg', contentType: 'image/jpeg', ); step = 'Avatar setzen'; await _setStateWithRepair( client, EventTypes.RoomAvatar, {'url': uri.toString()}, ); setState(() => _pendingAvatar = null); } if (_pendingBanner != null) { step = 'Banner hochladen'; final uri = await client.uploadContent( _pendingBanner!, filename: 'space_banner.jpg', contentType: 'image/jpeg', ); step = 'Banner setzen'; await _setStateWithRepair( client, _kSpaceBannerEvent, {'url': uri.toString()}, ); final uriStr = uri.toString(); // Update local override immediately so header updates before next sync ref.read(spaceBannerOverrideProvider.notifier).update( (m) => {...m, widget.space.id: uriStr}, ); setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; }); } setState(() { _success = 'Gespeichert'; }); } catch (e) { // Server-Fehlertext anzeigen (MatrixException liefert lesbare Meldung) — // nicht pauschal auf "Admin-Level erforderlich" mappen. final raw = e is MatrixException ? '${e.errcode}: ${e.errorMessage}' : e.toString().split('\n').first; var detail = ''; if (e is MatrixException && e.errcode == 'M_FORBIDDEN') { detail = '\n${_permDiagnostics()}'; // Zusätzlich die SERVER-Sicht der Power-Levels holen — die lokale // Kopie kann von der des Servers abweichen. try { final pl = await widget.space.client.getRoomStateWithKey( widget.space.id, EventTypes.RoomPowerLevels, ''); detail += '\nServer-PL: users=${pl['users']}, state_default=${pl['state_default']}, events=${pl['events']}'; } on MatrixException catch (se) { detail += '\nServer-PL: ${se.errcode} (Event fehlt auch serverseitig)'; } catch (_) {} } setState(() { _error = '$step fehlgeschlagen – $raw$detail'; }); } finally { setState(() { _saving = false; }); } } /// Sendet ein State-Event; bei M_FORBIDDEN in einem Raum OHNE /// power_levels-Event (Continuwuity-Sonderfall: Server verweigert dann /// Custom-State trotz Creator-Rechten) werden einmalig Standard-Power-Levels /// mit uns als Admin angelegt und der Vorgang wiederholt. Future _setStateWithRepair( Client client, String type, Map content, ) async { try { await client.setRoomStateWithKey(widget.space.id, type, '', content); } on MatrixException catch (e) { if (e.errcode != 'M_FORBIDDEN') rethrow; final repaired = await _ensurePowerLevels(client); if (!repaired) rethrow; await client.setRoomStateWithKey(widget.space.id, type, '', content); } } /// Legt Standard-Power-Levels an, falls der Raum keine hat. /// Gibt true zurück, wenn das Event neu angelegt wurde. Future _ensurePowerLevels(Client client) async { try { await client.getRoomStateWithKey( widget.space.id, EventTypes.RoomPowerLevels, ''); return false; // existiert bereits — Ablehnung hat andere Ursache } catch (_) { // fehlt (M_NOT_FOUND) → anlegen } try { await client.setRoomStateWithKey( widget.space.id, EventTypes.RoomPowerLevels, '', { 'users': {client.userID!: 100}, 'users_default': 0, 'events': const {}, 'events_default': 0, 'state_default': 50, 'ban': 50, 'kick': 50, 'redact': 50, 'invite': 0, 'notifications': {'room': 50}, }, ); return true; } catch (_) { return false; } } /// Kompakte Berechtigungs-Diagnose für M_FORBIDDEN-Fehler: zeigt, wie der /// Server die Lage vermutlich sieht (Raumversion, Creator, Power-Level). String _permDiagnostics() { try { final space = widget.space; final create = space.getState(EventTypes.RoomCreate); final version = create?.content['room_version'] ?? '1'; final creator = create?.senderId ?? '?'; final own = space.ownPowerLevel; final needed = space.powerForChangingStateEvent(_kSpaceBannerEvent); final hasPl = space.getState(EventTypes.RoomPowerLevels) != null; return 'Diagnose: dein Level $own, benötigt $needed, ' 'Raumversion $version, Creator $creator' '${hasPl ? '' : ', kein power_levels-Event vorhanden!'}'; } catch (_) { return ''; } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final client = ref.watch(matrixClientProvider).valueOrNull; // SDK-Berechtigungsprüfung (berücksichtigt events-Overrides und // state_default) statt hartcodiertem Admin-Level 100. final canEdit = widget.space.canChangeStateEvent(EventTypes.RoomName) || widget.space.ownPowerLevel >= _kPowerAdmin; return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Banner ──────────────────────────────────────────────────────── _SectionLabel('Banner', pt), const SizedBox(height: 6), GestureDetector( onTap: canEdit ? _pickBanner : null, child: Stack( children: [ AspectRatio( aspectRatio: 2.4, child: Container( width: double.infinity, decoration: BoxDecoration( color: pt.bg0, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: _pendingBanner != null ? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity) : (_bannerMxcUri != null && client != null ? MxcImage( mxcUri: _bannerMxcUri!, client: client, fit: BoxFit.cover, width: double.infinity, placeholder: Container(color: pt.bg0), ) : Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim), const SizedBox(height: 4), Text('Banner hinzufügen', style: TextStyle( color: pt.fgDim, fontSize: 12)), ], ), )), ), // Container ), // AspectRatio if (canEdit) Positioned( right: 6, bottom: 6, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (_bannerMxcUri != null || _pendingBanner != null) GestureDetector( onTap: _removeBanner, child: Container( margin: const EdgeInsets.only(right: 4), padding: const EdgeInsets.symmetric( horizontal: 7, vertical: 3), decoration: BoxDecoration( color: const Color(0xFFED4245).withAlpha(200), borderRadius: BorderRadius.circular(5), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.delete_outline, size: 11, color: Colors.white), SizedBox(width: 3), Text('Entfernen', style: TextStyle( color: Colors.white, fontSize: 11)), ], ), ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 7, vertical: 3), decoration: BoxDecoration( color: pt.bg0.withAlpha(200), borderRadius: BorderRadius.circular(5), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), const SizedBox(width: 3), Text('Banner ändern', style: TextStyle( color: pt.fgMuted, fontSize: 11)), ], ), ), ], ), ), ], ), ), const SizedBox(height: 16), // ── Avatar ─────────────────────────────────────────────────────── _SectionLabel('Serverbild', pt), const SizedBox(height: 8), _AvatarEditor( space: widget.space, pendingAvatar: _pendingAvatar, canEdit: canEdit, onPick: _pickAvatar, onRemove: _removeAvatar, pt: pt, client: client, ), const SizedBox(height: 16), // ── Name / Topic ───────────────────────────────────────────────── _SectionLabel('Name', pt), const SizedBox(height: 6), _Field(controller: _nameCtrl, hint: 'Space-Name', pt: pt), const SizedBox(height: 16), _SectionLabel('Beschreibung', pt), const SizedBox(height: 6), _Field( controller: _topicCtrl, hint: 'Worum geht es in diesem Space?', maxLines: 4, pt: pt), const SizedBox(height: 8), _SpaceVisibilityToggle(space: widget.space), const SizedBox(height: 20), if (_error != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(_error!, style: const TextStyle(color: Color(0xFFED4245), fontSize: 12)), ), if (_success != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(_success!, style: const TextStyle(color: Color(0xFF57F287), fontSize: 12)), ), SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, style: FilledButton.styleFrom( backgroundColor: pt.accent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rBase)), ), child: _saving ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: pt.bg0)) : const Text('Speichern', style: TextStyle( color: Colors.white, fontWeight: FontWeight.w600)), ), ), const SizedBox(height: 24), _DangerZone(space: widget.space), ], ), ); } } class _SpaceVisibilityToggle extends ConsumerStatefulWidget { final Room space; const _SpaceVisibilityToggle({required this.space}); @override ConsumerState<_SpaceVisibilityToggle> createState() => _SpaceVisibilityToggleState(); } class _SpaceVisibilityToggleState extends ConsumerState<_SpaceVisibilityToggle> { bool? _isPublic; bool _loading = true; @override void initState() { super.initState(); _load(); } Future _load() async { try { final joinRules = widget.space.joinRules; setState(() { _isPublic = joinRules == JoinRules.public; _loading = false; }); } catch (_) { setState(() => _loading = false); } } Future _toggle(bool value) async { setState(() => _isPublic = value); try { await widget.space.setJoinRules( value ? JoinRules.public : JoinRules.invite); } catch (_) { setState(() => _isPublic = !value); } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); if (_loading) return const SizedBox.shrink(); return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Öffentlicher Space', style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600)), Text('Jeder kann diesem Space beitreten', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Switch( value: _isPublic ?? false, onChanged: _toggle, activeThumbColor: pt.accent, ), ], ); } } class _DangerZone extends ConsumerWidget { final Room space; const _DangerZone({required this.space}); @override Widget build(BuildContext context, WidgetRef ref) { final pt = PyramidTheme.of(context); return Container( decoration: BoxDecoration( border: Border.all(color: const Color(0xFFED4245).withAlpha(80)), borderRadius: BorderRadius.circular(pt.rBase), ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Gefahrenzone', style: TextStyle( color: const Color(0xFFED4245), fontSize: 13, fontWeight: FontWeight.w700)), const SizedBox(height: 12), Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Space verlassen', style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600)), Text('Du verlässt diesen Space', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), OutlinedButton( onPressed: () => _confirmLeave(context, ref), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFFED4245), side: const BorderSide(color: Color(0xFFED4245)), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), ), child: const Text('Verlassen'), ), ], ), ], ), ); } void _confirmLeave(BuildContext context, WidgetRef ref) { showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Space verlassen?'), content: Text( 'Möchtest du "${space.getLocalizedDisplayname()}" wirklich verlassen?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen')), TextButton( onPressed: () async { Navigator.pop(context); Navigator.pop(context); await space.leave(); }, child: const Text('Verlassen', style: TextStyle(color: Color(0xFFED4245))), ), ], ), ); } }