import 'dart:async'; import 'package:flutter/material.dart' hide Visibility; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; // SpaceChild wird vom matrix-Paket nicht öffentlich exportiert. // ignore: implementation_imports import 'package:matrix/src/utils/space_child.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart'; import 'package:pyramid/features/call/mini_call_widget.dart'; import 'package:pyramid/features/rooms/user_panel.dart'; import 'package:pyramid/features/spaces/space_admin_dialog.dart'; import 'package:pyramid/widgets/create_join_dialog.dart'; import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/new_dm_dialog.dart'; // ─── Helpers ────────────────────────────────────────────────────────────────── const _kVoiceRoomType = 'io.pyramid.voice_channel'; const _kSpaceBannerType = 'io.pyramid.space.banner'; bool _isVoiceRoom(Room room) => room.getState(EventTypes.RoomCreate)?.content['type'] == _kVoiceRoomType; int _compareSpaceChildren(SpaceChild a, SpaceChild b) { final ao = a.order; final bo = b.order; if (ao.isEmpty && bo.isEmpty) return 0; if (ao.isEmpty) return 1; if (bo.isEmpty) return -1; return ao.compareTo(bo); } // ─── Panel shell ────────────────────────────────────────────────────────────── class RoomsPanel extends ConsumerStatefulWidget { const RoomsPanel({super.key}); @override ConsumerState createState() => _RoomsPanelState(); } class _RoomsPanelState extends ConsumerState { final _filterCtrl = TextEditingController(); String _filter = ''; final Map _collapsed = {}; @override void dispose() { _filterCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final roomsAsync = ref.watch(roomListProvider); final activeSpaceId = ref.watch(activeSpaceIdProvider); final call = ref.watch(callStateProvider); final voip = ref.watch(voipStateProvider); final isRealSpace = activeSpaceId != null && activeSpaceId != 'dms' && activeSpaceId != 'rooms'; final client = ref.watch(matrixClientProvider).valueOrNull; final forceJoined = ref.watch(forceJoinedSpacesProvider); final activeSpaceRoom = isRealSpace ? client?.getRoomById(activeSpaceId) : null; final isInvitedSpace = activeSpaceRoom?.membership == Membership.invite && !forceJoined.contains(activeSpaceId); return Container( color: pt.bg1, child: Column( children: [ _Header(pt: pt, spaceId: activeSpaceId), if (!isInvitedSpace) _FilterInput( controller: _filterCtrl, pt: pt, onChanged: (v) => setState(() => _filter = v), ), Expanded( child: isInvitedSpace ? _SpaceInviteView(space: activeSpaceRoom!, pt: pt) : isRealSpace ? _SpaceRoomsList( spaceId: activeSpaceId, filter: _filter, collapsed: _collapsed, pt: pt, onToggleSection: (id) => setState(() { _collapsed[id] = !(_collapsed[id] ?? false); }), ) : roomsAsync.when( loading: () => Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: pt.accent), ), ), error: (e, _) => Center( child: Text('Error', style: TextStyle(color: pt.fgDim, fontSize: 12)), ), data: (rooms) => _RoomsList( rooms: rooms, filter: _filter, spaceId: activeSpaceId, collapsed: _collapsed, pt: pt, onToggleSection: (id) => setState(() { _collapsed[id] = !(_collapsed[id] ?? false); }), ), ), ), // Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden. // Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und // Konto-Balken nebeneinander statt übereinander. Builder(builder: (context) { final hasVoipCall = voip.currentCall != null && voip.currentCall!.state != CallState.kEnded; final hasCallDock = call.isActive || hasVoipCall; final isLandscapeCompact = MediaQuery.sizeOf(context).height < 500; final callDock = AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, alignment: Alignment.bottomCenter, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (call.isActive) MiniCallWidget(call: call), if (hasVoipCall) MiniCallWidget(call: voip), ], ), ); if (isLandscapeCompact && hasCallDock) { return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded(child: callDock), const Expanded(child: UserPanel()), ], ); } return Column( mainAxisSize: MainAxisSize.min, children: [callDock, const UserPanel()], ); }), ], ), ); } } // ─── Space invite view ───────────────────────────────────────────────────────── class _SpaceInviteView extends ConsumerStatefulWidget { final Room space; final PyramidTheme pt; const _SpaceInviteView({required this.space, required this.pt}); @override ConsumerState<_SpaceInviteView> createState() => _SpaceInviteViewState(); } class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> { bool _accepting = false; bool _declining = false; Future _accept() async { setState(() => _accepting = true); final client = widget.space.client; final id = widget.space.id; try { await client.joinRoomById(id).timeout(const Duration(seconds: 20)); // Server confirmed the join. Switch to the channel list optimistically — // the local sync membership may lag behind, so don't wait for it. ref.read(forceJoinedSpacesProvider.notifier).add(id); if (mounted) setState(() => _accepting = false); } catch (e) { if (mounted) { setState(() => _accepting = false); ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( 'Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'), backgroundColor: widget.pt.danger, )); } } } Future _decline() async { setState(() => _declining = true); try { await widget.space.leave(); if (mounted) { ref.read(activeSpaceIdProvider.notifier).state = 'rooms'; } } catch (_) { if (mounted) setState(() => _declining = false); } } @override Widget build(BuildContext context) { final pt = widget.pt; final client = ref.watch(matrixClientProvider).valueOrNull; final name = widget.space.getLocalizedDisplayname(); final busy = _accepting || _declining; return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 72, height: 72, decoration: BoxDecoration( color: pt.accent.withAlpha(40), borderRadius: BorderRadius.circular(20), ), child: client != null && widget.space.avatar != null ? ClipRRect( borderRadius: BorderRadius.circular(20), child: MxcAvatar( mxcUri: widget.space.avatar, client: client, size: 72, borderRadius: BorderRadius.circular(20), placeholder: (_) => Center( child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( color: pt.accent, fontSize: 28, fontWeight: FontWeight.w700), ), ), ), ) : Center( child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( color: pt.accent, fontSize: 28, fontWeight: FontWeight.w700), ), ), ), const SizedBox(height: 16), Text('Einladung zum Space', style: TextStyle(color: pt.fgDim, fontSize: 13)), const SizedBox(height: 4), Text( name, textAlign: TextAlign.center, style: TextStyle( color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700), ), const SizedBox(height: 24), Row( children: [ Expanded( child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(100)), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), ), onPressed: busy ? null : _decline, child: _declining ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator.adaptive( strokeWidth: 2)) : const Text('Ablehnen'), ), ), const SizedBox(width: 12), Expanded( 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: busy ? null : _accept, child: _accepting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator.adaptive( strokeWidth: 2)) : const Text('Beitreten'), ), ), ], ), ], ), ), ); } } // ─── Header ─────────────────────────────────────────────────────────────────── enum _SpaceMenuItem { newDm, createGroup, createRoom, joinRoom, discoverRooms, editSpace, adminSpace, leaveSpace, } class _Header extends ConsumerWidget { final PyramidTheme pt; final String? spaceId; const _Header({required this.pt, required this.spaceId}); @override Widget build(BuildContext context, WidgetRef ref) { final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms'; final size = MediaQuery.sizeOf(context); final isMobile = size.width < 900 || size.height < 500; final spaces = ref.watch(spacesProvider).valueOrNull ?? []; Room? activeSpace; if (isRealSpace) { try { activeSpace = spaces.firstWhere((s) => s.id == spaceId); } catch (_) {} } final client = ref.watch(matrixClientProvider).valueOrNull; final isAdmin = activeSpace != null && activeSpace.ownPowerLevel >= 50; final name = isRealSpace ? (activeSpace?.getLocalizedDisplayname() ?? 'Space') : (spaceId == 'dms' ? 'Chats' : 'Rooms'); void handleMenu(_SpaceMenuItem item) { switch (item) { case _SpaceMenuItem.newDm: showDialog(context: context, builder: (_) => const NewDmDialog()); case _SpaceMenuItem.createGroup: showDialog( context: context, builder: (_) => const CreateJoinDialog()); case _SpaceMenuItem.createRoom: showDialog( context: context, builder: (_) => const CreateJoinDialog()); case _SpaceMenuItem.joinRoom: showDialog( context: context, builder: (_) => const CreateJoinDialog(initialTab: CreateJoinTab.join)); case _SpaceMenuItem.discoverRooms: showDialog( context: context, builder: (_) => const CreateJoinDialog(initialTab: CreateJoinTab.discover)); case _SpaceMenuItem.editSpace: if (activeSpace != null) { showDialog( context: context, builder: (_) => SpaceEditDialog(space: activeSpace!)); } case _SpaceMenuItem.adminSpace: if (activeSpace != null) { showDialog( context: context, builder: (_) => SpaceAdminDialog(space: activeSpace!)); } case _SpaceMenuItem.leaveSpace: if (activeSpace != null) { final space = activeSpace; 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 "${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'), ), ], ), ).then((confirmed) { if (confirmed == true) { space.leave().then((_) { ref.read(activeSpaceIdProvider.notifier).state = 'rooms'; }); } }); } } } List> buildMenuItems() { if (spaceId == 'dms' || spaceId == null) { return [ PopupMenuItem( value: _SpaceMenuItem.newDm, child: _MenuRow( Icons.chat_bubble_outline_rounded, 'Neue Unterhaltung', pt)), PopupMenuItem( value: _SpaceMenuItem.createGroup, child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)), ]; } else if (spaceId == 'rooms') { return [ PopupMenuItem( value: _SpaceMenuItem.createRoom, child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)), PopupMenuItem( value: _SpaceMenuItem.joinRoom, child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)), PopupMenuItem( value: _SpaceMenuItem.discoverRooms, child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)), ]; } else { return [ if (isAdmin) ...[ PopupMenuItem( value: _SpaceMenuItem.adminSpace, child: _MenuRow( Icons.admin_panel_settings_rounded, 'Space verwalten', pt)), PopupMenuItem( value: _SpaceMenuItem.editSpace, child: _MenuRow(Icons.edit_outlined, 'Name/Beschreibung', pt)), const PopupMenuDivider(), ], PopupMenuItem( value: _SpaceMenuItem.leaveSpace, child: _MenuRow(Icons.exit_to_app_rounded, 'Space verlassen', pt, danger: true)), ]; } } // Check for banner — space banner for real spaces, server banner otherwise. final bannerOverrides = ref.watch(spaceBannerOverrideProvider); final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {}; String? bannerUrl; if (isRealSpace && activeSpace != null) { bannerUrl = bannerOverrides.containsKey(activeSpace.id) ? bannerOverrides[activeSpace.id] : activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?; } else if (client != null) { bannerUrl = serverBanners[client.homeserver.toString()]; } final hasBanner = bannerUrl != null && client != null; Widget controlsRow = Row( children: [ if (isMobile) PyrIconBtn( icon: const Icon(Icons.menu_rounded), tooltip: 'Spaces', onPressed: () => Scaffold.of(context).openDrawer(), ) else ...[ PyrIconBtn( icon: const Icon(Icons.menu_rounded), tooltip: 'Toggle sidebar', onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s), ), const SizedBox(width: 4), ], // Space avatar (only when no banner) if (isRealSpace && !hasBanner && client != null && activeSpace != null) ...[ Container( width: 28, height: 28, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: pt.accent.withAlpha(40), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: activeSpace.avatar != null ? MxcAvatar( mxcUri: activeSpace.avatar, client: client, size: 28, borderRadius: BorderRadius.circular(8), placeholder: (_) => Center( child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( color: pt.accent, fontSize: 13, fontWeight: FontWeight.w700), ), ), ) : Center( child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( color: pt.accent, fontSize: 13, fontWeight: FontWeight.w700), ), ), ), ), const SizedBox(width: 8), ], Expanded( child: Text( name, style: TextStyle( color: hasBanner ? Colors.white : pt.fg, fontSize: 15, fontWeight: isRealSpace ? FontWeight.w700 : FontWeight.w600, shadows: hasBanner ? [const Shadow(blurRadius: 4, color: Colors.black45)] : null, ), overflow: TextOverflow.ellipsis, ), ), if (isRealSpace && isAdmin) PyrIconBtn( icon: Icon(Icons.admin_panel_settings_rounded, color: hasBanner ? Colors.white70 : null), tooltip: 'Space verwalten', onPressed: () => handleMenu(_SpaceMenuItem.adminSpace), ), if (!isRealSpace) _NotificationsButton(pt: pt, hasBanner: hasBanner), PopupMenuButton<_SpaceMenuItem>( tooltip: 'Erstellen', color: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide(color: pt.border)), itemBuilder: (_) => buildMenuItems(), onSelected: handleMenu, child: Padding( padding: const EdgeInsets.all(8), child: Icon(Icons.add_rounded, color: hasBanner ? Colors.white70 : pt.fgDim, size: 18), ), ), ], ); if (hasBanner) { return SizedBox( height: 110, child: Stack( fit: StackFit.expand, children: [ // Banner image MxcImage( mxcUri: bannerUrl, client: client, fit: BoxFit.cover, placeholder: Container(color: pt.bg0), ), // Gradient overlay — bottom-heavy so text is readable DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: [0.0, 0.4, 1.0], colors: [ Color(0x00000000), Color(0x55000000), Color(0xCC000000), ], ), ), ), // Controls at the bottom Positioned( left: 0, right: 0, bottom: 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), child: controlsRow, ), ), // Bottom border Positioned( left: 0, right: 0, bottom: 0, child: Container(height: 1, color: pt.border.withAlpha(80)), ), ], ), ); } return Container( height: isRealSpace ? 56 : 52, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: isRealSpace ? pt.bg0 : pt.bg1, border: Border(bottom: BorderSide(color: pt.border)), ), child: controlsRow, ); } } class _MenuRow extends StatelessWidget { final IconData icon; final String label; final PyramidTheme pt; final bool danger; const _MenuRow(this.icon, this.label, this.pt, {this.danger = false}); @override Widget build(BuildContext context) => Row(children: [ Icon(icon, size: 14, color: danger ? pt.danger : pt.fgMuted), const SizedBox(width: 8), Text(label, style: TextStyle( color: danger ? pt.danger : pt.fg, fontSize: 13)), ]); } // ─── Notifications button ────────────────────────────────────────────────────── /// Glocke im Header: Popup mit allen Räumen, die ungelesene Nachrichten oder /// Mentions haben. Tippen öffnet den jeweiligen Raum. class _NotificationsButton extends ConsumerWidget { final PyramidTheme pt; final bool hasBanner; const _NotificationsButton({required this.pt, required this.hasBanner}); @override Widget build(BuildContext context, WidgetRef ref) { final rooms = ref.watch(roomListProvider).valueOrNull ?? []; final unread = rooms .where((r) => r.membership == Membership.join && (r.notificationCount > 0 || r.highlightCount > 0)) .toList() ..sort((a, b) { final hl = b.highlightCount.compareTo(a.highlightCount); if (hl != 0) return hl; return b.notificationCount.compareTo(a.notificationCount); }); return PopupMenuButton( tooltip: 'Benachrichtigungen', color: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide(color: pt.border)), offset: const Offset(0, 36), onSelected: (roomId) { ref.read(activeRoomIdProvider.notifier).state = roomId; ref.read(viewModeProvider.notifier).state = ViewMode.chat; }, itemBuilder: (_) { if (unread.isEmpty) { return [ PopupMenuItem( enabled: false, child: Text('Keine neuen Benachrichtigungen', style: TextStyle(color: pt.fgDim, fontSize: 13)), ), ]; } return unread .map((r) => PopupMenuItem( value: r.id, child: Row( children: [ Icon( r.highlightCount > 0 ? Icons.alternate_email_rounded : (r.isDirectChat ? Icons.chat_bubble_outline_rounded : Icons.tag_rounded), size: 14, color: r.highlightCount > 0 ? pt.danger : pt.fgMuted, ), const SizedBox(width: 8), Expanded( child: Text( r.getLocalizedDisplayname(), style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 1), decoration: BoxDecoration( color: r.highlightCount > 0 ? pt.danger : pt.accent, borderRadius: BorderRadius.circular(999), ), child: Text( '${r.notificationCount > 0 ? r.notificationCount : r.highlightCount}', style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700), ), ), ], ), )) .toList(); }, child: Padding( padding: const EdgeInsets.all(8), child: Stack( clipBehavior: Clip.none, children: [ Icon(Icons.notifications_none_rounded, size: 18, color: hasBanner ? Colors.white70 : pt.fgDim), if (unread.isNotEmpty) Positioned( top: -1, right: -1, child: Container( width: 8, height: 8, decoration: BoxDecoration( color: pt.danger, shape: BoxShape.circle, border: Border.all(color: pt.bg1, width: 1.5), ), ), ), ], ), ), ); } } // ─── Filter input ────────────────────────────────────────────────────────────── class _FilterInput extends StatelessWidget { final TextEditingController controller; final PyramidTheme pt; final ValueChanged onChanged; const _FilterInput( {required this.controller, required this.pt, required this.onChanged}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), child: Stack( alignment: Alignment.centerLeft, children: [ Padding( padding: const EdgeInsets.only(left: 8), child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim), ), TextField( controller: controller, onChanged: onChanged, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( hintText: 'Filter rooms', hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7), filled: true, fillColor: pt.bg2, border: OutlineInputBorder( borderRadius: BorderRadius.circular(pt.rSm), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(pt.rSm), borderSide: const BorderSide(color: Colors.transparent)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(pt.rSm), borderSide: BorderSide(color: pt.accent, width: 1)), isDense: true, ), cursorColor: pt.accent, ), ], ), ); } } // ─── Space rooms list ────────────────────────────────────────────────────────── class _SpaceRoomsList extends ConsumerStatefulWidget { final String spaceId; final String filter; final Map collapsed; final PyramidTheme pt; final ValueChanged onToggleSection; const _SpaceRoomsList({ required this.spaceId, required this.filter, required this.collapsed, required this.pt, required this.onToggleSection, }); @override ConsumerState<_SpaceRoomsList> createState() => _SpaceRoomsListState(); } class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { // Local order override after drag-and-drop (room IDs in order per category key) final Map> _localOrder = {}; // Joinable channels discovered via the space hierarchy (not yet a member). List<_JoinableRoom> _joinable = []; final Set _joining = {}; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { final client = ref.read(matrixClientProvider).valueOrNull; if (client != null) _loadHierarchy(client); }); } @override void didUpdateWidget(covariant _SpaceRoomsList old) { super.didUpdateWidget(old); if (old.spaceId != widget.spaceId) { setState(() => _joinable = []); final client = ref.read(matrixClientProvider).valueOrNull; if (client != null) _loadHierarchy(client); } } /// Queries the server-side space hierarchy and keeps only rooms the user is /// not yet a member of — these are the channels they can still join. Future _loadHierarchy(Client client) async { try { final resp = await client.getSpaceHierarchy( widget.spaceId, maxDepth: 3, suggestedOnly: false, ); final joinable = <_JoinableRoom>[]; for (final chunk in resp.rooms) { if (chunk.roomId == widget.spaceId) continue; if (chunk.roomType == 'm.space') continue; // skip sub-spaces // Keep every channel the hierarchy reports. The build method filters out // the ones already rendered in the joined sections — relying on local // membership here is unreliable because Continuwuity's sync lags behind. joinable.add(_JoinableRoom( roomId: chunk.roomId, name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId, isVoice: chunk.roomType == _kVoiceRoomType, memberCount: chunk.numJoinedMembers, )); } if (mounted) setState(() => _joinable = joinable); } catch (_) { // Hierarchy endpoint unavailable / no permission — silently ignore. } } Future _joinAndEnter(Client client, _JoinableRoom jr) async { setState(() => _joining.add(jr.roomId)); try { await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20)); // Give the sync a brief chance to deliver the room object, but don't block // on it — the local membership may lag behind the server. Room? room = client.getRoomById(jr.roomId); if (room == null) { try { await client.onSync.stream .firstWhere((_) => client.getRoomById(jr.roomId) != null) .timeout(const Duration(seconds: 4)); } catch (_) {} room = client.getRoomById(jr.roomId); } if (!mounted) return; setState(() => _joining.remove(jr.roomId)); if (jr.isVoice) { // LiveKit only needs the room id + a display name, so connect even if the // matrix room object hasn't synced locally yet. final call = ref.read(callStateProvider); if (call.isActive) await call.hangUp(); ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId; await call.startCall( roomName: jr.roomId, roomDisplayName: room?.getLocalizedDisplayname() ?? jr.name, identity: client.userID ?? '', audioOnly: true, voiceChannel: true, matrixClient: client, matrixRoomId: jr.roomId, ); } else if (room != null) { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; } } catch (e) { if (mounted) { setState(() => _joining.remove(jr.roomId)); ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'), backgroundColor: widget.pt.danger, )); } } } Future _applyReorder( Room space, String categoryKey, List newOrder) async { try { for (var i = 0; i < newOrder.length; i++) { final child = newOrder[i]; if (child.roomId == null) continue; final newOrd = (i + 1).toString().padLeft(4, '0'); if (child.order != newOrd) { await space.setSpaceChild(child.roomId!, via: child.via.isEmpty ? null : child.via, order: newOrd); } } } catch (_) { } } @override Widget build(BuildContext context) { // Rebuild when rooms list changes (sync) ref.watch(roomListProvider); final client = ref.watch(matrixClientProvider).valueOrNull; final activeRoomId = ref.watch(activeRoomIdProvider); if (client == null) { return Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: widget.pt.accent))); } final space = client.getRoomById(widget.spaceId); if (space == null) { return Center( child: Text('Space nicht gefunden', style: TextStyle(color: widget.pt.fgDim, fontSize: 12))); } final isAdmin = space.ownPowerLevel >= 50; final allChildren = space.spaceChildren; // Separate sub-spaces (categories) from direct rooms final subSpaceEntries = []; final directEntries = []; for (final c in allChildren) { if (c.roomId == null) continue; final room = client.getRoomById(c.roomId!); if (room == null || room.membership == Membership.leave) continue; if (room.isSpace) { subSpaceEntries.add(c); } else { directEntries.add(c); } } subSpaceEntries.sort(_compareSpaceChildren); directEntries.sort(_compareSpaceChildren); // Collect invited rooms final invites = allChildren .where((c) => c.roomId != null) .map((c) => client.getRoomById(c.roomId!)) .whereType() .where((r) => r.membership == Membership.invite) .toList(); // IDs already rendered in the joined/invited sections — used to avoid showing // them again under "available channels" from the hierarchy. final shownIds = {...invites.map((r) => r.id)}; void selectRoom(Room room) { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; } void joinVoiceChannel(Room room) async { final call = ref.read(callStateProvider); final activeVoice = ref.read(activeVoiceRoomIdProvider); // Toggle: leave if already in this channel 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; final userId = client.userID ?? ''; await call.startCall( roomName: room.id, roomDisplayName: room.getLocalizedDisplayname(), identity: userId, audioOnly: true, voiceChannel: true, matrixClient: client, matrixRoomId: room.id, ); } // Build category sections for sub-spaces List categoryWidgets = []; for (final catEntry in subSpaceEntries) { final subSpace = client.getRoomById(catEntry.roomId!)!; final catId = subSpace.id; final catName = subSpace.getLocalizedDisplayname(); final isCollapsed = widget.collapsed[catId] ?? false; final catChildren = subSpace.spaceChildren .where((c) => c.roomId != null) .map((c) => client.getRoomById(c.roomId!)) .whereType() .where((r) => r.membership != Membership.leave && !r.isSpace) .toList(); shownIds.addAll(catChildren.map((r) => r.id)); final filtered = catChildren .where((r) => widget.filter.isEmpty || r.getLocalizedDisplayname() .toLowerCase() .contains(widget.filter.toLowerCase())) .toList(); categoryWidgets.add(_CategoryHeader( name: catName.toUpperCase(), isCollapsed: isCollapsed, pt: widget.pt, onTap: () => widget.onToggleSection(catId), )); if (!isCollapsed) { for (final room in filtered) { final isVoice = _isVoiceRoom(room); categoryWidgets.add(_RoomItem( key: ValueKey('cat_${room.id}'), room: room, isActive: isVoice ? false : activeRoomId == room.id, isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == room.id, pt: widget.pt, onTap: () => isVoice ? joinVoiceChannel(room) : selectRoom(room), )); } } } // Build direct rooms section (reorderable for admin) final directRooms = directEntries .map((c) => client.getRoomById(c.roomId!)) .whereType() .toList(); shownIds.addAll(directRooms.map((r) => r.id)); // Apply local order override if present final localOrd = _localOrder['__direct__']; if (localOrd != null) { directRooms.sort((a, b) { final ai = localOrd.indexOf(a.id); final bi = localOrd.indexOf(b.id); if (ai == -1 && bi == -1) return 0; if (ai == -1) return 1; if (bi == -1) return -1; return ai.compareTo(bi); }); } final filteredDirect = directRooms .where((r) => widget.filter.isEmpty || r.getLocalizedDisplayname() .toLowerCase() .contains(widget.filter.toLowerCase())) .toList(); // Joinable channels from the hierarchy that aren't already listed above. final visibleJoinable = _joinable .where((jr) => !shownIds.contains(jr.roomId) && (widget.filter.isEmpty || jr.name.toLowerCase().contains(widget.filter.toLowerCase()))) .toList(); // Build the complete list return ListView( padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), children: [ // Invite cards if (invites.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 2), child: Text('EINLADUNGEN', style: TextStyle( color: widget.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: widget.pt))), const SizedBox(height: 4), ], // Sub-space categories ...categoryWidgets, // Direct rooms if (filteredDirect.isNotEmpty) ...[ if (subSpaceEntries.isNotEmpty) _CategoryHeader( name: 'KANÄLE', isCollapsed: widget.collapsed['__direct__'] ?? false, pt: widget.pt, onTap: () => widget.onToggleSection('__direct__'), ), if (!(widget.collapsed['__direct__'] ?? false)) if (isAdmin) // Reorderable list for admins ReorderableListView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, onReorderItem: (oldIdx, newIdx) { final ids = filteredDirect.map((r) => r.id).toList(); final moved = ids.removeAt(oldIdx); ids.insert(newIdx, moved); setState(() => _localOrder['__direct__'] = ids); // Apply to Matrix final newOrder = ids .map((id) => directEntries.firstWhere((c) => c.roomId == id, orElse: () => directEntries.first)) .toList(); _applyReorder(space, '__direct__', newOrder); }, children: [ for (var i = 0; i < filteredDirect.length; i++) _DraggableRoomItem( key: ValueKey(filteredDirect[i].id), room: filteredDirect[i], index: i, isActive: _isVoiceRoom(filteredDirect[i]) ? false : activeRoomId == filteredDirect[i].id, isInVoice: _isVoiceRoom(filteredDirect[i]) && ref.read(activeVoiceRoomIdProvider) == filteredDirect[i].id, pt: widget.pt, onTap: () => _isVoiceRoom(filteredDirect[i]) ? joinVoiceChannel(filteredDirect[i]) : selectRoom(filteredDirect[i]), ), ], ) else ...filteredDirect.map((r) { final isVoice = _isVoiceRoom(r); return _RoomItem( key: ValueKey(r.id), room: r, isActive: isVoice ? false : activeRoomId == r.id, isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == r.id, pt: widget.pt, onTap: () => isVoice ? joinVoiceChannel(r) : selectRoom(r), ); }), ], // Joinable channels from the space hierarchy (not yet a member) if (visibleJoinable.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(8, 12, 8, 2), child: Text('VERFÜGBARE KANÄLE', style: TextStyle( color: widget.pt.fgDim, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.6)), ), ...visibleJoinable.map((jr) => _JoinableRoomItem( jr: jr, joining: _joining.contains(jr.roomId), pt: widget.pt, onJoin: () => _joinAndEnter(client, jr), )), ], if (filteredDirect.isEmpty && categoryWidgets.isEmpty && invites.isEmpty && visibleJoinable.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Center( child: Text('Keine Kanäle', style: TextStyle(color: widget.pt.fgDim, fontSize: 13))), ), ], ); } } // ─── Joinable room (from space hierarchy) ───────────────────────────────────── class _JoinableRoom { final String roomId; final String name; final bool isVoice; final int memberCount; const _JoinableRoom({ required this.roomId, required this.name, required this.isVoice, required this.memberCount, }); } class _JoinableRoomItem extends StatefulWidget { final _JoinableRoom jr; final bool joining; final PyramidTheme pt; final VoidCallback onJoin; const _JoinableRoomItem({ required this.jr, required this.joining, required this.pt, required this.onJoin, }); @override State<_JoinableRoomItem> createState() => _JoinableRoomItemState(); } class _JoinableRoomItemState extends State<_JoinableRoomItem> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; final jr = widget.jr; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.joining ? null : widget.onJoin, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.symmetric(vertical: 1), padding: EdgeInsets.symmetric( horizontal: pt.rowPadX, vertical: pt.rowPadY), decoration: BoxDecoration( color: _hovered ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( children: [ SizedBox( width: 16, height: 16, child: Icon( jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded, size: 14, color: pt.fgDim, ), ), const SizedBox(width: 8), Expanded( child: Text( jr.name, style: TextStyle( color: pt.fgMuted, fontSize: 14, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis, ), ), if (widget.joining) const SizedBox( width: 14, height: 14, child: CircularProgressIndicator.adaptive(strokeWidth: 2), ) else Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: _hovered ? pt.accent : pt.bg3, borderRadius: BorderRadius.circular(6), ), child: Text( 'Beitreten', style: TextStyle( color: _hovered ? pt.accentFg : pt.fgMuted, fontSize: 11, fontWeight: FontWeight.w600), ), ), ], ), ), ), ); } } // ─── Category header ─────────────────────────────────────────────────────────── class _CategoryHeader extends StatefulWidget { final String name; final bool isCollapsed; final PyramidTheme pt; final VoidCallback onTap; const _CategoryHeader( {required this.name, required this.isCollapsed, required this.pt, required this.onTap}); @override State<_CategoryHeader> createState() => _CategoryHeaderState(); } class _CategoryHeaderState extends State<_CategoryHeader> { 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: Padding( padding: const EdgeInsets.fromLTRB(8, 10, 8, 2), child: Row(children: [ AnimatedRotation( turns: widget.isCollapsed ? -0.25 : 0, duration: const Duration(milliseconds: 180), child: Icon(Icons.keyboard_arrow_down_rounded, size: 12, color: _hovered ? pt.fgMuted : pt.fgDim), ), const SizedBox(width: 4), Text( widget.name, style: TextStyle( color: _hovered ? pt.fgMuted : pt.fgDim, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.5, ), ), ]), ), ), ); } } // ─── Draggable room item (admin only) ───────────────────────────────────────── 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, ), ), ]), ); } } // ─── Regular rooms list (DMs + all-rooms) ───────────────────────────────────── 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) => _RoomItem( room: room, isActive: activeRoomId == room.id, pt: pt, onTap: () { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; }, )), ], ); } } // ─── Section header ──────────────────────────────────────────────────────────── 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), ]), ), ); } } // ─── Room item ───────────────────────────────────────────────────────────────── 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; } } // ─── Voice participant list (Discord-style named rows) ──────────────────────── class _VoiceParticipantList extends StatelessWidget { final List> participants; final Client? client; final PyramidTheme pt; const _VoiceParticipantList( {required this.participants, required this.client, required this.pt}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: participants.map((p) { final avatarUrl = p['avatarUrl'] as String?; final name = (p['displayName'] as String? ?? '?'); final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; return Padding( padding: const EdgeInsets.only(top: 3), child: Row( children: [ SizedBox( width: 16, height: 16, child: client != null && avatarUrl != null ? MxcAvatar( mxcUri: Uri.tryParse(avatarUrl), client: client!, size: 16, borderRadius: BorderRadius.circular(8), placeholder: (_) => _Initials(letter: letter, pt: pt, size: 16), ) : _Initials(letter: letter, pt: pt, size: 16), ), const SizedBox(width: 6), Expanded( child: Text( name, style: TextStyle( color: const Color(0xFF57F287).withAlpha(200), fontSize: 12, fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), ), Icon(Icons.mic_rounded, size: 11, color: const Color(0xFF57F287).withAlpha(140)), ], ), ); }).toList(), ); } } class _Initials extends StatelessWidget { final String letter; final PyramidTheme pt; final double size; const _Initials({required this.letter, required this.pt, this.size = 18}); @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(size / 2), ), child: Center( child: Text(letter, style: TextStyle( color: pt.fg, fontSize: size * 0.5, fontWeight: FontWeight.w700)), ), ); } } class _VoiceBarBtn extends StatefulWidget { final IconData icon; final bool active; final Color activeColor; final Color inactiveColor; final PyramidTheme pt; final VoidCallback onTap; const _VoiceBarBtn({ required this.icon, required this.active, required this.activeColor, required this.inactiveColor, required this.pt, required this.onTap, }); @override State<_VoiceBarBtn> createState() => _VoiceBarBtnState(); } class _VoiceBarBtnState extends State<_VoiceBarBtn> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; final color = widget.active ? widget.activeColor : widget.inactiveColor; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: 28, height: 28, decoration: BoxDecoration( color: _hovered ? pt.bg3 : Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Center(child: Icon(widget.icon, size: 16, color: color)), ), ), ); } } // ─── Invite item ─────────────────────────────────────────────────────────────── class _InviteItem extends ConsumerStatefulWidget { final Room room; final PyramidTheme pt; const _InviteItem({required this.room, required this.pt}); @override ConsumerState<_InviteItem> createState() => _InviteItemState(); } class _InviteItemState extends ConsumerState<_InviteItem> { bool _accepting = false; bool _declining = false; Future _accept() async { setState(() => _accepting = true); try { await widget.room.join(); if (mounted) { ref.read(activeRoomIdProvider.notifier).state = widget.room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; } } catch (_) { if (mounted) setState(() => _accepting = false); } } Future _decline() async { setState(() => _declining = true); try { await widget.room.leave(); } catch (_) { if (mounted) setState(() => _declining = false); } } @override Widget build(BuildContext context) { final pt = widget.pt; final name = widget.room.getLocalizedDisplayname(); final isDm = widget.room.isDirectChat; return Container( margin: const EdgeInsets.symmetric(vertical: 1), padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), decoration: BoxDecoration( color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm), border: Border.all(color: pt.accent.withAlpha(50)), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(isDm ? Icons.person_rounded : Icons.tag_rounded, size: 12, color: pt.accent), const SizedBox(width: 6), Expanded( child: Text(name, style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), ), ]), const SizedBox(height: 6), Row(children: [ Expanded( child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(80)), padding: const EdgeInsets.symmetric(vertical: 4), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6)), ), onPressed: (_declining || _accepting) ? null : _decline, child: _declining ? const SizedBox( width: 12, height: 12, child: CircularProgressIndicator.adaptive(strokeWidth: 1.5)) : const Text('Ablehnen', style: TextStyle(fontSize: 12)), ), ), const SizedBox(width: 6), Expanded( child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 4), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6)), ), onPressed: (_accepting || _declining) ? null : _accept, child: _accepting ? const SizedBox( width: 12, height: 12, child: CircularProgressIndicator.adaptive(strokeWidth: 1.5)) : const Text('Annehmen', style: TextStyle(fontSize: 12)), ), ), ]), ]), ); } } // ─── DM avatar ───────────────────────────────────────────────────────────────── class _DmAvatar extends ConsumerStatefulWidget { final Room room; final PyramidTheme pt; const _DmAvatar({required this.room, required this.pt}); @override ConsumerState<_DmAvatar> createState() => _DmAvatarState(); } class _DmAvatarState extends ConsumerState<_DmAvatar> { CachedPresence? _presence; StreamSubscription? _sub; @override void initState() { super.initState(); _loadPresence(); } @override void dispose() { _sub?.cancel(); super.dispose(); } Future _loadPresence() async { final client = ref.read(matrixClientProvider).valueOrNull; if (client == null) return; final userId = widget.room.directChatMatrixID; if (userId == null) return; try { final p = await client.fetchCurrentPresence(userId); if (mounted) setState(() => _presence = p); } catch (_) {} _sub = client.onPresenceChanged.stream .where((p) => p.userid == userId) .listen((p) { if (mounted) setState(() => _presence = p); }); } Color? _dotColor() { final p = _presence; if (p == null) return null; // Server-Presence kann veraltet sein (z.B. nie als offline gemeldet). // Nur als online werten, wenn die letzte Aktivität wirklich frisch ist. final last = p.lastActiveTimestamp; final fresh = last != null && DateTime.now().difference(last) < const Duration(minutes: 5); if (p.presence == PresenceType.online && (p.currentlyActive == true || fresh)) { return PyramidColors.online; } if (p.presence == PresenceType.unavailable) return PyramidColors.away; return null; } @override Widget build(BuildContext context) { final name = widget.room.getLocalizedDisplayname(); final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; final color = _colorFromName(name); final client = ref.watch(matrixClientProvider).valueOrNull; Widget avatar; if (client != null && widget.room.avatar != null) { avatar = MxcAvatar( mxcUri: widget.room.avatar, client: client, size: 22, borderRadius: BorderRadius.circular(11), placeholder: (_) => _InitialCircle(initial: initial, color: color, size: 22), ); } else { avatar = _InitialCircle(initial: initial, color: color, size: 22); } final dot = _dotColor(); if (dot == null) return avatar; return Stack(clipBehavior: Clip.none, children: [ avatar, Positioned( right: -2, bottom: -2, child: Container( width: 9, height: 9, decoration: BoxDecoration( color: dot, shape: BoxShape.circle, border: Border.all(color: widget.pt.bg1, width: 1.5), ), ), ), ]); } Color _colorFromName(String name) { const colors = [ Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981), Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B), ]; if (name.isEmpty) return colors[0]; return colors[name.codeUnitAt(0) % colors.length]; } } class _InitialCircle extends StatelessWidget { final String initial; final Color color; final double size; const _InitialCircle( {required this.initial, required this.color, required this.size}); @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration(color: color, shape: BoxShape.circle), child: Center( child: Text(initial, style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600))), ); } } // ─── Hover action ────────────────────────────────────────────────────────────── class _RoomMenuBtn extends StatefulWidget { final PyramidTheme pt; final VoidCallback onInvite; final VoidCallback onSettings; final VoidCallback onLeave; const _RoomMenuBtn({required this.pt, required this.onInvite, required this.onSettings, required this.onLeave}); @override State<_RoomMenuBtn> createState() => _RoomMenuBtnState(); } class _RoomMenuBtnState extends State<_RoomMenuBtn> { bool _hovered = false; @override Widget build(BuildContext context) { return PopupMenuButton( color: widget.pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide(color: widget.pt.border)), offset: const Offset(-100, 24), tooltip: '', onSelected: (v) { if (v == 'invite') widget.onInvite(); if (v == 'settings') widget.onSettings(); if (v == 'leave') widget.onLeave(); }, itemBuilder: (_) => [ PopupMenuItem( value: 'invite', height: 36, child: Row(children: [ Icon(Icons.person_add_outlined, size: 14, color: widget.pt.fgMuted), const SizedBox(width: 8), Text('Nutzer einladen', style: TextStyle(color: widget.pt.fg, fontSize: 13)), ]), ), PopupMenuItem( value: 'settings', height: 36, child: Row(children: [ Icon(Icons.settings_rounded, size: 14, color: widget.pt.fgMuted), const SizedBox(width: 8), Text('Einstellungen', style: TextStyle(color: widget.pt.fg, fontSize: 13)), ]), ), PopupMenuDivider(height: 1), PopupMenuItem( value: 'leave', height: 36, child: Row(children: [ Icon(Icons.exit_to_app_rounded, size: 14, color: widget.pt.danger), const SizedBox(width: 8), Text('Raum verlassen', style: TextStyle(color: widget.pt.danger, fontSize: 13)), ]), ), ], child: MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: 20, height: 20, decoration: BoxDecoration( color: _hovered ? widget.pt.bgActive : Colors.transparent, borderRadius: BorderRadius.circular(4), ), child: Center( child: Icon(Icons.more_horiz_rounded, size: 12, color: _hovered ? widget.pt.fg : widget.pt.fgDim)), ), ), ); } } class _HoverAction extends StatefulWidget { final IconData icon; final PyramidTheme pt; final VoidCallback? onTap; const _HoverAction({required this.icon, required this.pt, this.onTap}); @override State<_HoverAction> createState() => _HoverActionState(); } class _HoverActionState extends State<_HoverAction> { bool _hovered = false; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.onTap, child: MouseRegion( cursor: widget.onTap != null ? SystemMouseCursors.click : MouseCursor.defer, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: 20, height: 20, decoration: BoxDecoration( color: _hovered ? widget.pt.bgActive : Colors.transparent, borderRadius: BorderRadius.circular(4), ), child: Center( child: Icon(widget.icon, size: 12, color: _hovered ? widget.pt.fg : widget.pt.fgDim)), ), ), ); } } // ─── Unread badge ────────────────────────────────────────────────────────────── class _UnreadBadge extends StatelessWidget { final int count; final bool isMention; final PyramidTheme pt; const _UnreadBadge( {required this.count, required this.isMention, required this.pt}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 5), constraints: const BoxConstraints(minWidth: 18, minHeight: 18), decoration: BoxDecoration( color: isMention ? pt.danger : pt.accent, borderRadius: BorderRadius.circular(999), ), child: Center( child: Text(count > 99 ? '99+' : '$count', style: TextStyle( color: isMention ? Colors.white : pt.accentFg, fontSize: 10, fontWeight: FontWeight.w700))), ); } }