part of '../rooms_panel.dart'; class _DraggableRoomItem extends StatefulWidget { final Room room; final int index; final bool isActive; final bool isInVoice; final PyramidTheme pt; final VoidCallback onTap; const _DraggableRoomItem({ super.key, required this.room, required this.index, required this.isActive, this.isInVoice = false, required this.pt, required this.onTap, }); @override State<_DraggableRoomItem> createState() => _DraggableRoomItemState(); } class _DraggableRoomItemState extends State<_DraggableRoomItem> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Row(children: [ ReorderableDragStartListener( index: widget.index, child: MouseRegion( cursor: SystemMouseCursors.grab, child: AnimatedOpacity( opacity: _hovered ? 1.0 : 0.0, duration: const Duration(milliseconds: 120), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 3), child: Icon(Icons.drag_indicator_rounded, size: 12, color: widget.pt.fgDim), ), ), ), ), Expanded( child: _RoomItem( room: widget.room, isActive: widget.isActive, isInVoice: widget.isInVoice, pt: widget.pt, onTap: widget.onTap, ), ), ]), ); } } class _RoomsList extends ConsumerWidget { final List rooms; final String filter; final String? spaceId; final Map collapsed; final PyramidTheme pt; final ValueChanged onToggleSection; const _RoomsList({ required this.rooms, required this.filter, required this.spaceId, required this.collapsed, required this.pt, required this.onToggleSection, }); @override Widget build(BuildContext context, WidgetRef ref) { final activeRoomId = ref.watch(activeRoomIdProvider); final isDms = spaceId == 'dms'; final invites = rooms .where((r) => r.membership == Membership.invite && isDms == r.isDirectChat) .toList(); final filtered = rooms.where((r) { if (r.membership == Membership.invite) return false; if (isDms != r.isDirectChat) return false; if (filter.isEmpty) return true; return r.getLocalizedDisplayname() .toLowerCase() .contains(filter.toLowerCase()); }).toList(); if (filtered.isEmpty && invites.isEmpty) { return Center( child: Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13))); } final sectionTitle = isDms ? 'Direct Messages' : 'Rooms'; final sectionId = isDms ? 'dms' : 'rooms'; final isCollapsed = collapsed[sectionId] ?? false; return ListView( padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), children: [ if (invites.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 2), child: Text('EINLADUNGEN', style: TextStyle( color: pt.accent, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.6)), ), ...invites.map((r) => Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: _InviteItem(room: r, pt: pt))), const SizedBox(height: 4), ], _SectionHeader( title: sectionTitle, count: filtered.length, isCollapsed: isCollapsed, pt: pt, onToggle: () => onToggleSection(sectionId), onAddTap: isDms ? () => showDialog( context: context, builder: (_) => const NewDmDialog()) : () => showDialog( context: context, builder: (_) => const CreateJoinDialog()), ), if (!isCollapsed) ...filtered.map((room) { final isVoice = _isVoiceRoom(room); return _RoomItem( room: room, isActive: isVoice ? false : activeRoomId == room.id, isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == room.id, pt: pt, // Voice-Räume treten dem Kanal bei (wie in der Space-Liste) – // vorher öffnete der Klick hier nur die Chat-Ansicht, ein // Standalone-Voice-Channel war per Klick gar nicht beitretbar. onTap: () { if (isVoice) { _toggleVoiceChannel(ref, room); } else { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; } }, ); }), ], ); } } /// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das /// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim /// aktiven Kanal, Wechsel legt vorher auf). Future _toggleVoiceChannel(WidgetRef ref, Room room) async { final call = ref.read(voiceChannelProvider); final activeVoice = ref.read(activeVoiceRoomIdProvider); if (call.isVoiceChannel && activeVoice == room.id) { ref.read(activeVoiceRoomIdProvider.notifier).state = null; await call.hangUp(); return; } if (call.isActive) await call.hangUp(); ref.read(activeVoiceRoomIdProvider.notifier).state = room.id; await call.startCall( roomName: room.id, roomDisplayName: room.getLocalizedDisplayname(), identity: room.client.userID ?? '', audioOnly: true, voiceChannel: true, matrixClient: room.client, matrixRoomId: room.id, ); } class _SectionHeader extends StatefulWidget { final String title; final int count; final bool isCollapsed; final PyramidTheme pt; final VoidCallback onToggle; final VoidCallback? onAddTap; const _SectionHeader({ required this.title, required this.count, required this.isCollapsed, required this.pt, required this.onToggle, this.onAddTap, }); @override State<_SectionHeader> createState() => _SectionHeaderState(); } class _SectionHeaderState extends State<_SectionHeader> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Container( padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), child: Row(children: [ Expanded( child: GestureDetector( onTap: widget.onToggle, child: Row(children: [ AnimatedRotation( turns: widget.isCollapsed ? -0.25 : 0, duration: const Duration(milliseconds: 200), curve: Curves.easeOutBack, child: Icon(Icons.keyboard_arrow_down_rounded, size: 14, color: _hovered ? pt.fgMuted : pt.fgDim), ), const SizedBox(width: 4), Text(widget.title.toUpperCase(), style: TextStyle( color: _hovered ? pt.fgMuted : pt.fgDim, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.04 * 11)), const Spacer(), Text('${widget.count}', style: TextStyle(color: pt.fgDim, fontSize: 11)), ]), ), ), if (_hovered && widget.onAddTap != null) ...[ const SizedBox(width: 4), GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.onAddTap, child: SizedBox( width: 18, height: 18, child: Center( child: Icon(Icons.add, size: 12, color: pt.fgMuted)), ), ), ] else const SizedBox(width: 22), ]), ), ); } } class _RoomItem extends ConsumerStatefulWidget { final Room room; final bool isActive; final bool isInVoice; final PyramidTheme pt; final VoidCallback onTap; const _RoomItem({ super.key, required this.room, required this.isActive, this.isInVoice = false, required this.pt, required this.onTap, }); @override ConsumerState<_RoomItem> createState() => _RoomItemState(); } class _RoomItemState extends ConsumerState<_RoomItem> { bool _hovered = false; Future _leaveRoom() async { final pt = widget.pt; 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 id = widget.room.id; await widget.room.leave(); if (mounted && ref.read(activeRoomIdProvider) == id) { ref.read(activeRoomIdProvider.notifier).state = null; } } catch (_) {} } Future _leaveDm() async { final pt = widget.pt; 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('Chat verlassen?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text( 'Unterhaltung mit "${widget.room.getLocalizedDisplayname()}" schließen?', 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 id = widget.room.id; await widget.room.leave(); if (mounted) { if (ref.read(activeRoomIdProvider) == id) { ref.read(activeRoomIdProvider.notifier).state = null; } } } catch (_) {} } Future _showRoomSettings() async { await showDialog( context: context, builder: (_) => RoomSettingsDialog(room: widget.room), ); } Future _inviteUser() async { final pt = widget.pt; final ctrl = TextEditingController(); final userId = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Nutzer einladen', style: TextStyle( color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: TextField( controller: ctrl, autofocus: true, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( hintText: '@nutzer:server', hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), filled: true, fillColor: pt.bg3, isDense: true, 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, onSubmitted: (v) => Navigator.pop(ctx, v.trim()), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), TextButton( onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), child: Text('Einladen', style: TextStyle(color: pt.accent))), ], ), ); ctrl.dispose(); if (userId == null || userId.isEmpty || !mounted) return; try { await widget.room.invite(userId); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Fehler: ${e.toString().split('\n').first}'), backgroundColor: widget.pt.danger, )); } } } /// Touch-friendly action menu — opened via long-press (the hover menu is not /// reachable on touch devices). void _showContextMenu() { final pt = widget.pt; final room = widget.room; final isDm = room.isDirectChat; Widget action(IconData icon, String label, VoidCallback onTap, {bool danger = false}) { return ListTile( leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted), title: Text(label, style: TextStyle( color: danger ? pt.danger : pt.fg, fontSize: 14)), onTap: () { Navigator.pop(context); onTap(); }, ); } showModalBottomSheet( context: context, backgroundColor: pt.bg2, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Row(children: [ Expanded( child: Text(room.getLocalizedDisplayname(), style: TextStyle( color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700), overflow: TextOverflow.ellipsis), ), ]), ), Divider(height: 1, color: pt.border), if (!isDm) ...[ action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser), action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings), ], action( Icons.exit_to_app_rounded, isDm ? 'Chat verlassen' : 'Raum verlassen', isDm ? _leaveDm : _leaveRoom, danger: true, ), const SizedBox(height: 8), ], ), ), ); } @override Widget build(BuildContext context) { final pt = widget.pt; final room = widget.room; final active = widget.isActive; final inVoice = widget.isInVoice; final name = room.getLocalizedDisplayname(); final unread = room.notificationCount; final isDm = room.isDirectChat; final isVoice = _isVoiceRoom(room); final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : >[]; final client = ref.watch(matrixClientProvider).valueOrNull; Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, onLongPress: _showContextMenu, child: Stack( children: [ if (active) Positioned( left: 0, top: 6, bottom: 6, child: Container( width: 3, decoration: BoxDecoration( color: pt.accent, borderRadius: const BorderRadius.only( topRight: Radius.circular(2), bottomRight: Radius.circular(2), ), ), ), ), AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeOutCubic, margin: const EdgeInsets.symmetric(vertical: 1), padding: EdgeInsets.symmetric( horizontal: pt.rowPadX, vertical: pt.rowPadY), decoration: BoxDecoration( color: active ? pt.accentSoft : inVoice ? const Color(0xFF57F287).withAlpha(25) : _hovered ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ if (isDm) ...[ _DmAvatar(room: room, pt: pt), ] else ...[ SizedBox( width: 16, height: 16, child: Icon( isVoice ? Icons.mic_rounded : _roomIcon(room), size: 14, color: isVoice ? (inVoice ? const Color(0xFF57F287) : _hovered ? const Color(0xFF57F287) : const Color(0xFF57F287).withAlpha(180)) : (active ? pt.accent : _hovered ? pt.fg : pt.fgDim), ), ), ], const SizedBox(width: 8), Expanded( child: Text( name, style: TextStyle( color: textColor, fontSize: 14, fontWeight: unread > 0 || active || inVoice ? FontWeight.w600 : FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), ), if (isVoice && voiceParticipants.isNotEmpty) Padding( padding: const EdgeInsets.only(right: 4), child: Text( '${voiceParticipants.length}', style: TextStyle( color: const Color(0xFF57F287).withAlpha(200), fontSize: 11, fontWeight: FontWeight.w600), ), ), if (!isVoice && _hovered) ...[ if (isDm) _HoverAction(icon: Icons.close, pt: pt, onTap: _leaveDm) else _RoomMenuBtn( pt: pt, onInvite: _inviteUser, onSettings: _showRoomSettings, onLeave: _leaveRoom, ), ] else if (!isVoice && unread > 0) ...[ _UnreadBadge( count: unread, isMention: room.highlightCount > 0, pt: pt), ], ], ), // Voice participants (Discord-style named rows) if (isVoice && voiceParticipants.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2, left: 24), child: _VoiceParticipantList( participants: voiceParticipants, client: client, pt: pt, ), ), ], ), ), ], ), ), ); } IconData _roomIcon(Room room) { if (room.encrypted) return Icons.lock_outline_rounded; return Icons.tag_rounded; } }