part of '../space_admin_dialog.dart'; const _kSpaceChild = 'm.space.child'; const _kSpaceParent = 'm.space.parent'; const _kVoiceChannelType = 'io.pyramid.voice_channel'; class _ChannelsTab extends ConsumerStatefulWidget { final Room space; const _ChannelsTab({required this.space}); @override ConsumerState<_ChannelsTab> createState() => _ChannelsTabState(); } class _ChannelsTabState extends ConsumerState<_ChannelsTab> { bool _busy = false; List<({String roomId, Room? room, bool isVoice})> _getChildren(Client client) { final childStates = widget.space.states[_kSpaceChild] ?? {}; final result = <({String roomId, Room? room, bool isVoice})>[]; for (final entry in childStates.entries) { final content = entry.value.content; if (content.isEmpty) continue; final childRoomId = entry.key; final room = client.getRoomById(childRoomId); final createContent = room?.getState('m.room.create')?.content; final isVoice = createContent?['type'] == _kVoiceChannelType; result.add((roomId: childRoomId, room: room, isVoice: isVoice)); } result.sort((a, b) { final nameA = a.room?.getLocalizedDisplayname() ?? a.roomId; final nameB = b.room?.getLocalizedDisplayname() ?? b.roomId; return nameA.compareTo(nameB); }); return result; } Future _removeChild(String roomId) async { final client = ref.read(matrixClientProvider).valueOrNull; if (client == null) return; setState(() => _busy = true); try { await client.setRoomStateWithKey(widget.space.id, _kSpaceChild, roomId, {}); if (mounted) setState(() {}); } catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) setState(() => _busy = false); } } Future _addChild(String roomId) async { final client = ref.read(matrixClientProvider).valueOrNull; if (client == null) return; final serverName = client.userID?.split(':').last ?? ''; try { await client.setRoomStateWithKey( widget.space.id, _kSpaceChild, roomId, {'via': [serverName], 'suggested': false}, ); // Best-effort: set parent in child room try { await client.setRoomStateWithKey( roomId, _kSpaceParent, widget.space.id, {'via': [serverName], 'canonical': true}, ); } catch (_) {} if (mounted) setState(() {}); } catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.toString()))); } } } Future _createAndAdd({ required String name, required bool isVoice, required Client client, }) async { setState(() => _busy = true); try { final serverName = client.userID?.split(':').last ?? ''; final roomId = await client.createRoom( name: name, visibility: Visibility.private, preset: CreateRoomPreset.privateChat, creationContent: isVoice ? {'type': _kVoiceChannelType} : null, initialState: [ StateEvent( type: 'm.room.join_rules', content: {'join_rule': 'restricted', 'allow': [ {'type': 'm.room_membership', 'room_id': widget.space.id} ]}, ), ], ); await client.setRoomStateWithKey( widget.space.id, _kSpaceChild, roomId, {'via': [serverName], 'suggested': false}, ); try { await client.setRoomStateWithKey( roomId, _kSpaceParent, widget.space.id, {'via': [serverName], 'canonical': true}, ); } catch (_) {} if (mounted) setState(() {}); } catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { if (mounted) setState(() => _busy = false); } } void _showCreateDialog(BuildContext context, Client client) { showDialog( context: context, builder: (_) => _CreateChannelDialog( onConfirm: (name, isVoice) => _createAndAdd(name: name, isVoice: isVoice, client: client), ), ); } void _showAddExistingDialog(BuildContext context, Client client) { final childStates = widget.space.states[_kSpaceChild] ?? {}; final existingIds = { for (final e in childStates.entries) if (e.value.content.isNotEmpty) e.key }; final available = client.rooms .where((r) => !r.isSpace && !existingIds.contains(r.id)) .toList() ..sort((a, b) => a.getLocalizedDisplayname() .compareTo(b.getLocalizedDisplayname())); showDialog( context: context, builder: (_) => _AddExistingDialog( rooms: available, client: client, onConfirm: (roomId) => _addChild(roomId), ), ); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final client = ref.watch(matrixClientProvider).valueOrNull; final ownPower = widget.space.ownPowerLevel; final canManage = ownPower >= _kPowerMod; if (client == null) { return Center( child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2)); } final children = _getChildren(client); return Column( children: [ if (canManage) Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( children: [ Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.add, size: 16), label: const Text('Kanal erstellen'), style: OutlinedButton.styleFrom( foregroundColor: pt.accent, side: BorderSide(color: pt.accent), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rSm)), ), onPressed: _busy ? null : () => _showCreateDialog(context, client), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.link, size: 16), label: const Text('Raum hinzufügen'), style: OutlinedButton.styleFrom( foregroundColor: pt.fgDim, side: BorderSide(color: pt.border), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rSm)), ), onPressed: _busy ? null : () => _showAddExistingDialog(context, client), ), ), ], ), ), if (children.isEmpty) Expanded( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.forum_outlined, color: pt.fgDim, size: 40), const SizedBox(height: 8), Text('Noch keine Kanäle', style: TextStyle(color: pt.fgDim, fontSize: 13)), ], ), ), ) else Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: children.length, itemBuilder: (context, i) { final child = children[i]; final name = child.room?.getLocalizedDisplayname() ?? child.roomId; return _ChannelItem( name: name, isVoice: child.isVoice, roomId: child.roomId, pt: pt, canRemove: canManage, onRemove: () => _removeChild(child.roomId), ); }, ), ), ], ); } } class _ChannelItem extends StatefulWidget { final String name; final bool isVoice; final String roomId; final PyramidTheme pt; final bool canRemove; final VoidCallback onRemove; const _ChannelItem({ required this.name, required this.isVoice, required this.roomId, required this.pt, required this.canRemove, required this.onRemove, }); @override State<_ChannelItem> createState() => _ChannelItemState(); } class _ChannelItemState extends State<_ChannelItem> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Container( margin: const EdgeInsets.symmetric(vertical: 1), decoration: BoxDecoration( color: _hovered ? pt.bg2 : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: ListTile( dense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0), leading: Icon( widget.isVoice ? Icons.volume_up_outlined : Icons.tag, color: pt.fgDim, size: 18, ), title: Text( widget.name, style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis, ), subtitle: Text( widget.isVoice ? 'Sprachkanal' : 'Textkanal', style: TextStyle(color: pt.fgDim, fontSize: 11), ), trailing: widget.canRemove && _hovered ? IconButton( icon: Icon(Icons.link_off, color: const Color(0xFFED4245), size: 18), tooltip: 'Aus Space entfernen', onPressed: widget.onRemove, ) : null, ), ), ); } } class _CreateChannelDialog extends StatefulWidget { final void Function(String name, bool isVoice) onConfirm; const _CreateChannelDialog({required this.onConfirm}); @override State<_CreateChannelDialog> createState() => _CreateChannelDialogState(); } class _CreateChannelDialogState extends State<_CreateChannelDialog> { final _ctrl = TextEditingController(); bool _isVoice = false; @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return AlertDialog( backgroundColor: pt.bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rLg)), title: Text('Neuen Kanal erstellen', style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionLabel('Name', pt), const SizedBox(height: 6), _Field(controller: _ctrl, hint: 'kanal-name', pt: pt), const SizedBox(height: 16), _SectionLabel('Typ', pt), const SizedBox(height: 8), Row( children: [ Expanded( child: GestureDetector( onTap: () => setState(() => _isVoice = false), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: !_isVoice ? pt.accent.withAlpha(40) : pt.bg0, border: Border.all( color: !_isVoice ? pt.accent : pt.border), borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.tag, color: !_isVoice ? pt.accent : pt.fgDim, size: 16), const SizedBox(width: 6), Text('Text', style: TextStyle( color: !_isVoice ? pt.accent : pt.fgDim, fontSize: 13, fontWeight: FontWeight.w600)), ], ), ), ), ), const SizedBox(width: 8), Expanded( child: GestureDetector( onTap: () => setState(() => _isVoice = true), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: _isVoice ? pt.accent.withAlpha(40) : pt.bg0, border: Border.all( color: _isVoice ? pt.accent : pt.border), borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.volume_up_outlined, color: _isVoice ? pt.accent : pt.fgDim, size: 16), const SizedBox(width: 6), Text('Sprache', style: TextStyle( color: _isVoice ? pt.accent : pt.fgDim, fontSize: 13, fontWeight: FontWeight.w600)), ], ), ), ), ), ], ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen')), FilledButton( style: FilledButton.styleFrom(backgroundColor: pt.accent), onPressed: () { final name = _ctrl.text.trim(); if (name.isEmpty) return; Navigator.pop(context); widget.onConfirm(name, _isVoice); }, child: const Text('Erstellen', style: TextStyle(color: Colors.white)), ), ], ); } } class _AddExistingDialog extends StatefulWidget { final List rooms; final Client client; final void Function(String roomId) onConfirm; const _AddExistingDialog({ required this.rooms, required this.client, required this.onConfirm, }); @override State<_AddExistingDialog> createState() => _AddExistingDialogState(); } class _AddExistingDialogState extends State<_AddExistingDialog> { String _filter = ''; final _ctrl = TextEditingController(); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final filtered = _filter.isEmpty ? widget.rooms : widget.rooms .where((r) => r .getLocalizedDisplayname() .toLowerCase() .contains(_filter.toLowerCase())) .toList(); return Dialog( backgroundColor: pt.bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rLg)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520), child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text('Raum hinzufügen', style: TextStyle( color: pt.fg, fontSize: 17, fontWeight: FontWeight.w700)), ), IconButton( icon: Icon(Icons.close, color: pt.fgDim, size: 20), onPressed: () => Navigator.pop(context), ), ], ), const SizedBox(height: 12), _SearchField( controller: _ctrl, hint: 'Raum suchen…', pt: pt, onChanged: (v) => setState(() => _filter = v), ), ], ), ), const Divider(height: 1), if (filtered.isEmpty) Expanded( child: Center( child: Text('Keine Räume gefunden', style: TextStyle(color: pt.fgDim, fontSize: 13)), ), ) else Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), itemCount: filtered.length, itemBuilder: (context, i) { final room = filtered[i]; final name = room.getLocalizedDisplayname(); final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; final avatarUri = room.avatar; final createContent = room.getState('m.room.create')?.content; final isVoice = createContent?['type'] == _kVoiceChannelType; return ListTile( dense: true, leading: avatarUri != null ? MxcAvatar( mxcUri: avatarUri, client: widget.client, size: 32, borderRadius: BorderRadius.circular(pt.rSm), placeholder: (_) => _InitialAvatar( letter: letter, pt: pt, size: 32), ) : _InitialAvatar(letter: letter, pt: pt, size: 32), title: Text(name, style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), subtitle: Text( isVoice ? 'Sprachkanal' : 'Textkanal', style: TextStyle(color: pt.fgDim, fontSize: 11), ), trailing: Icon(isVoice ? Icons.volume_up_outlined : Icons.tag, color: pt.fgDim, size: 16), onTap: () { Navigator.pop(context); widget.onConfirm(room.id); }, ); }, ), ), ], ), ), ); } }