import 'dart:async'; 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/mxc_image.dart'; enum CreateJoinTab { create, join, discover } enum _CreateType { room, voice, space } class CreateJoinDialog extends ConsumerStatefulWidget { final CreateJoinTab initialTab; const CreateJoinDialog({super.key, this.initialTab = CreateJoinTab.create}); @override ConsumerState createState() => _CreateJoinDialogState(); } class _CreateJoinDialogState extends ConsumerState { late CreateJoinTab _tab; _CreateType _createType = _CreateType.room; // Create final _nameCtrl = TextEditingController(); final _topicCtrl = TextEditingController(); final _aliasCtrl = TextEditingController(); bool _isPublic = false; bool _isEncrypted = true; bool _creating = false; String? _createError; // Join final _joinCtrl = TextEditingController(); bool _joining = false; String? _joinError; // Discover final _discoverServerCtrl = TextEditingController(); final _discoverSearchCtrl = TextEditingController(); List _publicRooms = []; String? _nextBatch; bool _discoverLoading = false; bool _discoverLoadingMore = false; String? _discoverError; Timer? _searchDebounce; final Set _joiningRooms = {}; @override void initState() { super.initState(); _tab = widget.initialTab; WidgetsBinding.instance.addPostFrameCallback((_) { if (_tab == CreateJoinTab.discover) _discover(); }); } @override void dispose() { _nameCtrl.dispose(); _topicCtrl.dispose(); _aliasCtrl.dispose(); _joinCtrl.dispose(); _discoverServerCtrl.dispose(); _discoverSearchCtrl.dispose(); _searchDebounce?.cancel(); super.dispose(); } Future _create() async { final name = _nameCtrl.text.trim(); if (name.isEmpty) { setState(() => _createError = 'Bitte einen Namen eingeben.'); return; } setState(() { _creating = true; _createError = null; }); try { final client = await ref.read(matrixClientProvider.future); String roomId; if (_createType == _CreateType.space) { roomId = await client.createSpace( name: name, topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(), visibility: _isPublic ? Visibility.public : Visibility.private, spaceAliasName: _aliasCtrl.text.trim().isEmpty ? null : _aliasCtrl.text.trim(), ); } else if (_createType == _CreateType.voice) { roomId = await client.createRoom( name: name, topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(), visibility: Visibility.private, preset: CreateRoomPreset.privateChat, creationContent: {'type': 'io.pyramid.voice_channel'}, ); // Voice-Presence (io.pyramid.voice.presence) muss von JEDEM Mitglied // schreibbar sein (state_default ist 50, normale Mitglieder haben 0) — // sonst sieht man nur den Raum-Ersteller im Kanal. Nachträglich setzen, // weil Continuwuity power_level_content_override beim createRoom // ignoriert. try { final plRaw = await client.getRoomStateWithKey( roomId, EventTypes.RoomPowerLevels, ''); final pl = Map.from(plRaw); final events = Map.from(pl['events'] as Map? ?? {}); events['io.pyramid.voice.presence'] = 0; pl['events'] = events; await client.setRoomStateWithKey( roomId, EventTypes.RoomPowerLevels, '', pl); } catch (_) { // Nicht kritisch — Presence-Anzeige funktioniert dann nur für Admins. } } else { roomId = await client.createRoom( name: name, topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(), visibility: _isPublic ? Visibility.public : Visibility.private, roomAliasName: _aliasCtrl.text.trim().isEmpty ? null : _aliasCtrl.text.trim(), preset: _isPublic ? CreateRoomPreset.publicChat : CreateRoomPreset.privateChat, initialState: _isEncrypted && !_isPublic ? [StateEvent(content: {'algorithm': 'm.megolm.v1.aes-sha2'}, type: EventTypes.Encryption)] : null, ); } if (mounted) { ref.read(activeRoomIdProvider.notifier).state = roomId; ref.read(viewModeProvider.notifier).state = ViewMode.chat; Navigator.of(context).pop(); } } on MatrixException catch (e) { if (mounted) setState(() { _creating = false; _createError = e.errorMessage; }); } catch (e) { if (mounted) setState(() { _creating = false; _createError = e.toString().split('\n').first; }); } } Future _join() async { final addr = _joinCtrl.text.trim(); if (addr.isEmpty) { setState(() => _joinError = 'Bitte eine Raum-Adresse eingeben.'); return; } setState(() { _joining = true; _joinError = null; }); try { final client = await ref.read(matrixClientProvider.future); final roomId = await client.joinRoom(addr); if (mounted) { ref.read(activeRoomIdProvider.notifier).state = roomId; ref.read(viewModeProvider.notifier).state = ViewMode.chat; Navigator.of(context).pop(); } } on MatrixException catch (e) { if (mounted) setState(() { _joining = false; _joinError = e.errorMessage; }); } catch (e) { if (mounted) setState(() { _joining = false; _joinError = e.toString().split('\n').first; }); } } Future _discover({bool loadMore = false}) async { if (loadMore) { if (_nextBatch == null) return; setState(() => _discoverLoadingMore = true); } else { setState(() { _discoverLoading = true; _discoverError = null; }); } try { final client = await ref.read(matrixClientProvider.future); final server = _discoverServerCtrl.text.trim().isEmpty ? null : _discoverServerCtrl.text.trim(); final search = _discoverSearchCtrl.text.trim(); final res = await client.queryPublicRooms( server: server, limit: 30, since: loadMore ? _nextBatch : null, filter: search.isEmpty ? null : PublicRoomQueryFilter(genericSearchTerm: search), ); if (mounted) { setState(() { if (loadMore) { _publicRooms.addAll(res.chunk); } else { _publicRooms = res.chunk; } _nextBatch = res.nextBatch; _discoverLoading = false; _discoverLoadingMore = false; }); } } catch (e) { if (mounted) { setState(() { _discoverLoading = false; _discoverLoadingMore = false; _discoverError = e.toString().split('\n').first; }); } } } void _onSearchChanged(String _) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 500), _discover); } Future _joinPublicRoom(String roomId, List? via) async { setState(() => _joiningRooms.add(roomId)); try { final client = await ref.read(matrixClientProvider.future); final id = await client.joinRoom(roomId, via: via); if (mounted) { ref.read(activeRoomIdProvider.notifier).state = id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; Navigator.of(context).pop(); } } catch (_) { if (mounted) setState(() => _joiningRooms.remove(roomId)); } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final size = MediaQuery.sizeOf(context); final isNarrow = size.width < 600; return Dialog( backgroundColor: pt.bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), child: ConstrainedBox( constraints: BoxConstraints( maxWidth: isNarrow ? size.width * 0.97 : 580, maxHeight: size.height * 0.85, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildTabs(pt), Divider(color: pt.border, height: 1), Flexible(child: _buildBody(pt)), ], ), ), ); } Widget _buildTabs(PyramidTheme pt) { return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), child: Row( children: [ Expanded( child: Row( children: [ _Tab(label: 'Erstellen', icon: Icons.add_rounded, active: _tab == CreateJoinTab.create, pt: pt, onTap: () => setState(() => _tab = CreateJoinTab.create)), const SizedBox(width: 4), _Tab(label: 'Beitreten', icon: Icons.login_rounded, active: _tab == CreateJoinTab.join, pt: pt, onTap: () => setState(() => _tab = CreateJoinTab.join)), const SizedBox(width: 4), _Tab(label: 'Entdecken', icon: Icons.explore_rounded, active: _tab == CreateJoinTab.discover, pt: pt, onTap: () { setState(() => _tab = CreateJoinTab.discover); if (_publicRooms.isEmpty) _discover(); }), ], ), ), IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: () => Navigator.of(context).pop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ], ), ); } Widget _buildBody(PyramidTheme pt) { return switch (_tab) { CreateJoinTab.create => _buildCreate(pt), CreateJoinTab.join => _buildJoin(pt), CreateJoinTab.discover => _buildDiscover(pt), }; } Widget _buildCreate(PyramidTheme pt) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Type toggle Row(children: [ _TypeToggle( label: 'Raum', icon: Icons.tag_rounded, active: _createType == _CreateType.room, pt: pt, onTap: () => setState(() { _createType = _CreateType.room; _isPublic = false; }), ), const SizedBox(width: 8), _TypeToggle( label: 'Voice', icon: Icons.mic_rounded, active: _createType == _CreateType.voice, pt: pt, onTap: () => setState(() { _createType = _CreateType.voice; _isPublic = false; }), ), const SizedBox(width: 8), _TypeToggle( label: 'Space', icon: Icons.workspaces_rounded, active: _createType == _CreateType.space, pt: pt, onTap: () => setState(() => _createType = _CreateType.space), ), ]), const SizedBox(height: 16), _Field(ctrl: _nameCtrl, label: 'Name *', pt: pt, autofocus: true), const SizedBox(height: 10), _Field(ctrl: _topicCtrl, label: 'Thema / Beschreibung (optional)', pt: pt, maxLines: 2), const SizedBox(height: 14), // Options if (_createType == _CreateType.voice) Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Icon(Icons.info_outline, size: 14, color: pt.fgDim), const SizedBox(width: 6), Expanded( child: Text( 'Voice-Kanäle sind privat. Mitglieder können beitreten und direkt sprechen.', style: TextStyle(color: pt.fgDim, fontSize: 12), ), ), ], ), ), if (_createType != _CreateType.voice) ...[ _OptionRow( label: 'Öffentlich', desc: 'Im Verzeichnis sichtbar, jeder kann beitreten', pt: pt, value: _isPublic, onChanged: (v) => setState(() => _isPublic = v), ), if (_createType == _CreateType.room) ...[ const SizedBox(height: 8), _OptionRow( label: 'Verschlüsselt', desc: 'Ende-zu-Ende-Verschlüsselung aktivieren', pt: pt, value: _isEncrypted, onChanged: (v) => setState(() => _isEncrypted = v), ), ], if (_isPublic) ...[ const SizedBox(height: 10), _Field( ctrl: _aliasCtrl, label: 'Alias (optional)', pt: pt, hint: 'mein-raum → #mein-raum:server', ), ], ], if (_createError != null) ...[ const SizedBox(height: 10), _ErrorCard(text: _createError!, pt: pt), ], const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: _creating ? null : _create, child: _creating ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) : Text(_createType == _CreateType.space ? 'Space erstellen' : 'Raum erstellen'), ), ), ], ), ); } Widget _buildJoin(PyramidTheme pt) { return Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Raum-Adresse oder ID eingeben', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5)), const SizedBox(height: 12), _Field( ctrl: _joinCtrl, label: 'Adresse oder ID', hint: '#kanal:server.de oder !abc123:server.de', pt: pt, autofocus: true, onSubmitted: (_) => _join(), ), const SizedBox(height: 6), Text('Tipp: Matrix-Räume, Spaces und Gruppen werden alle unterstützt.', style: TextStyle(color: pt.fgDim, fontSize: 11)), if (_joinError != null) ...[ const SizedBox(height: 10), _ErrorCard(text: _joinError!, pt: pt), ], const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: _joining ? null : _join, child: _joining ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) : const Text('Beitreten'), ), ), ], ), ); } Widget _buildDiscover(PyramidTheme pt) { return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row(children: [ Expanded( child: _Field( ctrl: _discoverSearchCtrl, label: 'Suchen', pt: pt, onChanged: _onSearchChanged, prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim), ), ), const SizedBox(width: 8), SizedBox( width: 140, child: _Field( ctrl: _discoverServerCtrl, label: 'Server', hint: 'matrix.org', pt: pt, onSubmitted: (_) => _discover(), ), ), ]), ), Divider(color: pt.border, height: 1), Flexible( child: _discoverLoading ? const Center(child: CircularProgressIndicator.adaptive()) : _discoverError != null ? Center(child: _ErrorCard(text: _discoverError!, pt: pt)) : _publicRooms.isEmpty ? Center(child: Text('Keine Räume gefunden.', style: TextStyle(color: pt.fgMuted, fontSize: 13))) : ListView.builder( itemCount: _publicRooms.length + (_nextBatch != null ? 1 : 0), itemBuilder: (ctx, i) { if (i == _publicRooms.length) { return Padding( padding: const EdgeInsets.all(12), child: Center( child: _discoverLoadingMore ? const CircularProgressIndicator.adaptive() : TextButton( onPressed: () => _discover(loadMore: true), child: Text('Mehr laden', style: TextStyle(color: pt.accent)), ), ), ); } final room = _publicRooms[i]; return _PublicRoomItem( room: room, pt: pt, isJoining: _joiningRooms.contains(room.roomId), onJoin: () => _joinPublicRoom(room.roomId, null), ); }, ), ), ], ); } } // ── Space / Room editor dialog ───────────────────────────────────────────────── class SpaceEditDialog extends ConsumerStatefulWidget { final Room space; const SpaceEditDialog({super.key, required this.space}); @override ConsumerState createState() => _SpaceEditDialogState(); } class _SpaceEditDialogState extends ConsumerState { late TextEditingController _nameCtrl; late TextEditingController _topicCtrl; bool _saving = false; String? _error; @override void initState() { super.initState(); _nameCtrl = TextEditingController(text: widget.space.getLocalizedDisplayname()); _topicCtrl = TextEditingController(text: widget.space.topic); } @override void dispose() { _nameCtrl.dispose(); _topicCtrl.dispose(); super.dispose(); } Future _save() async { setState(() { _saving = true; _error = null; }); try { final name = _nameCtrl.text.trim(); final topic = _topicCtrl.text.trim(); if (name.isNotEmpty && name != widget.space.getLocalizedDisplayname()) { await widget.space.setName(name); } if (topic != widget.space.topic) { await widget.space.setDescription(topic); } if (mounted) setState(() { _saving = false; }); await Future.delayed(const Duration(seconds: 1)); if (mounted) Navigator.of(context).pop(true); } catch (e) { if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); } } Future _leave() async { final pt = PyramidTheme.of(context); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Space verlassen?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text('Du verlässt "${widget.space.getLocalizedDisplayname()}".', style: TextStyle(color: pt.fgMuted, fontSize: 13)), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(ctx, true), child: const Text('Verlassen'), ), ], ), ); if (confirmed != true || !mounted) return; try { await widget.space.leave(); if (mounted) { ref.read(activeSpaceIdProvider.notifier).state = 'rooms'; Navigator.of(context).pop(true); } } catch (e) { if (mounted) setState(() => _error = e.toString().split('\n').first); } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return Dialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.workspaces_rounded, size: 18, color: pt.accent), const SizedBox(width: 8), Expanded(child: Text('Space bearbeiten', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600))), IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: () => Navigator.of(context).pop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ]), const SizedBox(height: 16), _Field(ctrl: _nameCtrl, label: 'Name', pt: pt), const SizedBox(height: 10), _Field(ctrl: _topicCtrl, label: 'Beschreibung', pt: pt, maxLines: 2), if (_error != null) ...[ const SizedBox(height: 10), _ErrorCard(text: _error!, pt: pt), ], const SizedBox(height: 16), Row(children: [ Expanded(child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(80)), padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: _leave, child: const Text('Verlassen'), )), const SizedBox(width: 10), Expanded(child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: _saving ? null : _save, child: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) : const Text('Speichern'), )), ]), ], ), ), ), ); } } // ── Room settings dialog ─────────────────────────────────────────────────────── class RoomSettingsDialog extends ConsumerStatefulWidget { final Room room; const RoomSettingsDialog({super.key, required this.room}); @override ConsumerState createState() => _RoomSettingsDialogState(); } class _RoomSettingsDialogState extends ConsumerState { late TextEditingController _nameCtrl; late TextEditingController _topicCtrl; bool _saving = false; String? _error; @override void initState() { super.initState(); _nameCtrl = TextEditingController(text: widget.room.getLocalizedDisplayname()); _topicCtrl = TextEditingController(text: widget.room.topic); } @override void dispose() { _nameCtrl.dispose(); _topicCtrl.dispose(); super.dispose(); } Future _save() async { setState(() { _saving = true; _error = null; }); try { if (_nameCtrl.text.trim() != widget.room.getLocalizedDisplayname()) { await widget.room.setName(_nameCtrl.text.trim()); } final newTopic = _topicCtrl.text.trim(); if (newTopic != widget.room.topic) { await widget.room.setDescription(newTopic); } if (mounted) Navigator.of(context).pop(); } catch (e) { if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); } } Future _leaveRoom() async { final pt = PyramidTheme.of(context); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Raum verlassen?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text('"${widget.room.getLocalizedDisplayname()}" verlassen.', style: TextStyle(color: pt.fgMuted, fontSize: 13)), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(ctx, true), child: const Text('Verlassen'), ), ], ), ); if (confirmed != true || !mounted) return; try { final roomId = widget.room.id; await widget.room.leave(); if (mounted) { final current = ref.read(activeRoomIdProvider); if (current == roomId) ref.read(activeRoomIdProvider.notifier).state = null; Navigator.of(context).pop(); } } catch (e) { if (mounted) setState(() => _error = e.toString().split('\n').first); } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final canEdit = widget.room.canSendEvent(EventTypes.RoomName); return Dialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.settings_rounded, size: 18, color: pt.accent), const SizedBox(width: 8), Expanded(child: Text('Raum-Einstellungen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600))), IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: () => Navigator.of(context).pop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ]), const SizedBox(height: 6), SelectableText(widget.room.id, style: TextStyle(color: pt.fgDim, fontSize: 11, fontFamily: 'monospace')), const SizedBox(height: 16), if (canEdit) ...[ _Field(ctrl: _nameCtrl, label: 'Name', pt: pt), const SizedBox(height: 10), _Field(ctrl: _topicCtrl, label: 'Thema', pt: pt, maxLines: 2), ] else Text('Du hast keine Berechtigung, diesen Raum zu bearbeiten.', style: TextStyle(color: pt.fgMuted, fontSize: 13)), if (_error != null) ...[ const SizedBox(height: 10), _ErrorCard(text: _error!, pt: pt), ], const SizedBox(height: 16), Row(children: [ Expanded(child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(80)), padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: _leaveRoom, child: const Text('Verlassen'), )), if (canEdit) ...[ const SizedBox(width: 10), Expanded(child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: _saving ? null : _save, child: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) : const Text('Speichern'), )), ], ]), ], ), ), ), ); } } // ── Helper widgets ───────────────────────────────────────────────────────────── class _Tab extends StatefulWidget { final String label; final IconData icon; final bool active; final PyramidTheme pt; final VoidCallback onTap; const _Tab({required this.label, required this.icon, required this.active, required this.pt, required this.onTap}); @override State<_Tab> createState() => _TabState(); } class _TabState extends State<_Tab> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: widget.active ? pt.accentSoft : (_hovered ? pt.bgHover : Colors.transparent), borderRadius: BorderRadius.circular(6), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(widget.icon, size: 14, color: widget.active ? pt.accent : pt.fgMuted), const SizedBox(width: 6), Text(widget.label, style: TextStyle( color: widget.active ? pt.fg : pt.fgMuted, fontSize: 13, fontWeight: widget.active ? FontWeight.w600 : FontWeight.w400, )), ]), ), ), ); } } class _TypeToggle extends StatelessWidget { final String label; final IconData icon; final bool active; final PyramidTheme pt; final VoidCallback onTap; const _TypeToggle({required this.label, required this.icon, required this.active, required this.pt, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: active ? pt.accent : pt.bg3, borderRadius: BorderRadius.circular(8), border: Border.all(color: active ? pt.accent : pt.border), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: active ? pt.accentFg : pt.fgMuted), const SizedBox(width: 6), Text(label, style: TextStyle( color: active ? pt.accentFg : pt.fgMuted, fontSize: 13, fontWeight: FontWeight.w500, )), ]), ), ); } } class _OptionRow extends StatelessWidget { final String label; final String desc; final bool value; final ValueChanged onChanged; final PyramidTheme pt; const _OptionRow({required this.label, required this.desc, required this.value, required this.onChanged, required this.pt}); @override Widget build(BuildContext context) { return GestureDetector( onTap: () => onChanged(!value), child: Row(children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), Text(desc, style: TextStyle(color: pt.fgDim, fontSize: 11)), ])), Switch(value: value, onChanged: onChanged, activeColor: pt.accent), ]), ); } } class _Field extends StatelessWidget { final TextEditingController ctrl; final String label; final String? hint; final PyramidTheme pt; final bool autofocus; final int maxLines; final Widget? prefixIcon; final ValueChanged? onChanged; final ValueChanged? onSubmitted; const _Field({ required this.ctrl, required this.label, required this.pt, this.hint, this.autofocus = false, this.maxLines = 1, this.prefixIcon, this.onChanged, this.onSubmitted, }); @override Widget build(BuildContext context) => TextField( controller: ctrl, autofocus: autofocus, maxLines: maxLines, style: TextStyle(color: pt.fg, fontSize: 13), onChanged: onChanged, onSubmitted: onSubmitted, decoration: InputDecoration( labelText: label, hintText: hint, labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), prefixIcon: prefixIcon, filled: true, fillColor: pt.bg3, isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), ), cursorColor: pt.accent, ); } class _ErrorCard extends StatelessWidget { final String text; final PyramidTheme pt; const _ErrorCard({required this.text, required this.pt}); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: pt.danger.withAlpha(20), borderRadius: BorderRadius.circular(8), border: Border.all(color: pt.danger.withAlpha(60)), ), child: Row(children: [ Icon(Icons.error_outline_rounded, size: 14, color: pt.danger), const SizedBox(width: 8), Expanded(child: Text(text, style: TextStyle(color: pt.danger, fontSize: 12))), ]), ); } class _PublicRoomItem extends ConsumerWidget { final PublicRoomsChunk room; final PyramidTheme pt; final bool isJoining; final VoidCallback onJoin; const _PublicRoomItem({required this.room, required this.pt, required this.isJoining, required this.onJoin}); @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(matrixClientProvider).valueOrNull; final name = room.name ?? room.canonicalAlias ?? room.roomId; final avatarUrl = room.avatarUrl; final alreadyJoined = client?.getRoomById(room.roomId) != null; return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row(children: [ // Avatar Container( width: 36, height: 36, decoration: BoxDecoration(color: pt.bg3, borderRadius: BorderRadius.circular(8)), child: (client != null && avatarUrl != null) ? ClipRRect( borderRadius: BorderRadius.circular(8), child: MxcImage(mxcUri: avatarUrl.toString(), client: client, fit: BoxFit.cover), ) : Center(child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w700), )), ), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), if (room.topic != null) Text(room.topic!, style: TextStyle(color: pt.fgDim, fontSize: 11), overflow: TextOverflow.ellipsis, maxLines: 1), Text('${room.numJoinedMembers} Mitglieder', style: TextStyle(color: pt.fgDim, fontSize: 11)), ])), const SizedBox(width: 8), if (alreadyJoined) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(6)), child: Text('Beigetreten', style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600)), ) else if (isJoining) const SizedBox(width: 20, height: 20, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) else ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), onPressed: onJoin, child: const Text('Beitreten', style: TextStyle(fontSize: 12)), ), ]), ); } }