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, ), ), ]), ); } } /// Filter der gemeinsamen Chatliste (Chips über der Liste). enum _ChatFilter { all, unread, people, groups } final _chatFilterProvider = StateProvider<_ChatFilter>((ref) => _ChatFilter.all); /// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität /// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server. class _RoomsList extends ConsumerWidget { final List rooms; final String filter; final PyramidTheme pt; const _RoomsList({ required this.rooms, required this.filter, required this.pt, }); @override Widget build(BuildContext context, WidgetRef ref) { final activeRoomId = ref.watch(activeRoomIdProvider); final channels = ref.watch(spaceChannelIdsProvider); final chatFilter = ref.watch(_chatFilterProvider); final query = filter.toLowerCase(); final invites = rooms.where((r) => r.membership == Membership.invite).toList(); bool matches(Room r) { switch (chatFilter) { case _ChatFilter.all: break; case _ChatFilter.unread: if (!r.isUnread) return false; case _ChatFilter.people: if (!r.isDirectChat) return false; case _ChatFilter.groups: if (r.isDirectChat) return false; } if (query.isEmpty) return true; return r.getLocalizedDisplayname().toLowerCase().contains(query); } final chats = rooms .where((r) => r.membership == Membership.join && isChatListRoom(r, channels)) .toList(); final filtered = chats.where(matches).toList(); final unreadCount = chats.where((r) => r.isUnread).length; return ListView( padding: const EdgeInsets.fromLTRB(8, 0, 8, 12), children: [ _ChatFilterChips( value: chatFilter, unreadCount: unreadCount, pt: pt, onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f, ), 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), ], if (filtered.isEmpty) _ChatListEmpty( filter: chatFilter, searching: query.isNotEmpty, hasInvites: invites.isNotEmpty, pt: pt, ) else ...filtered.map((room) { final isVoice = _isVoiceRoom(room); return _RoomItem( key: ValueKey('chat:${room.id}'), 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; } }, ); }), ], ); } } class _ChatFilterChips extends StatelessWidget { final _ChatFilter value; final int unreadCount; final PyramidTheme pt; final ValueChanged<_ChatFilter> onChanged; const _ChatFilterChips({ required this.value, required this.unreadCount, required this.pt, required this.onChanged, }); @override Widget build(BuildContext context) { Widget chip(_ChatFilter f, String label) { final selected = value == f; return Padding( padding: const EdgeInsets.only(right: 6), child: Semantics( button: true, selected: selected, child: GestureDetector( onTap: () => onChanged(selected ? _ChatFilter.all : f), child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: selected ? pt.accentSoft : pt.bg2, borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? pt.accent.withAlpha(120) : pt.border), ), child: Text(label, style: TextStyle( color: selected ? pt.accent : pt.fgMuted, fontSize: 12.5, fontWeight: selected ? FontWeight.w700 : FontWeight.w500)), ), ), ), ); } final chips = [ chip(_ChatFilter.all, 'Alle'), chip(_ChatFilter.unread, unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'), chip(_ChatFilter.people, 'Personen'), chip(_ChatFilter.groups, 'Gruppen'), ]; // Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt // abschneiden – mit der Maus wischt niemand seitwärts. if (!isMobileLayout(MediaQuery.sizeOf(context))) { return Padding( padding: const EdgeInsets.fromLTRB(4, 2, 4, 6), child: Wrap(runSpacing: 6, children: chips), ); } return SizedBox( height: 40, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB(4, 2, 4, 6), children: chips, ), ); } } /// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“. class _ChatListEmpty extends StatelessWidget { final _ChatFilter filter; final bool searching; final bool hasInvites; final PyramidTheme pt; const _ChatListEmpty({ required this.filter, required this.searching, required this.hasInvites, required this.pt, }); @override Widget build(BuildContext context) { if (hasInvites && filter == _ChatFilter.all && !searching) { return const SizedBox.shrink(); } final (IconData icon, String text) = searching ? (Icons.search_off_rounded, 'Nichts gefunden') : switch (filter) { _ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'), _ChatFilter.people => ( Icons.person_outline_rounded, 'Noch keine Unterhaltungen' ), _ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'), _ChatFilter.all => ( Icons.chat_bubble_outline_rounded, 'Noch keine Chats' ), }; final showAction = !searching && filter != _ChatFilter.unread; return Padding( padding: const EdgeInsets.fromLTRB(16, 40, 16, 16), child: Column( children: [ Icon(icon, size: 34, color: pt.fgDim), const SizedBox(height: 10), Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)), if (showAction) ...[ const SizedBox(height: 14), FilledButton.tonalIcon( style: FilledButton.styleFrom( backgroundColor: pt.accentSoft, foregroundColor: pt.accent, ), icon: Icon(filter == _ChatFilter.groups ? Icons.group_add_rounded : Icons.edit_rounded, size: 18), label: Text(filter == _ChatFilter.groups ? 'Gruppe erstellen' : 'Neue Unterhaltung'), onPressed: () => showDialog( context: context, builder: (_) => filter == _ChatFilter.groups ? const CreateJoinDialog() : const NewDmDialog(), ), ), ], ], ), ); } } /// 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 _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; // Handy: Sprachkanal als Kachel mit Teilnehmern (rooms_mobile_tile.dart). if (isVoice && isMobileLayout(MediaQuery.sizeOf(context))) { return _MobileVoiceTile( room: room, inVoice: inVoice, participants: voiceParticipants, client: client, pt: pt, onTap: widget.onTap, onLongPress: _showContextMenu, ); } // Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart). if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) { return _MobileRoomTile( room: room, isActive: active, pt: pt, myUserId: client?.userID, onTap: widget.onTap, onLongPress: _showContextMenu, ); } 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 if (!isVoice && !ref .watch(spaceChannelIdsProvider) .contains(room.id)) ...[ _RoomTileAvatar(room: room, pt: pt, size: 22), ] 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; } }