diff --git a/CHANGES.md b/CHANGES.md index f3b945a..7af9d13 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1163,3 +1163,5 @@ 2026-07-03 10:56 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-03 10:58 [Edit] /home/steggi/pyramid/ROADMAP.md 2026-07-03 10:58 [Edit] /home/steggi/pyramid/PROGRESS.md +2026-07-03 11:00 [Edit] /home/steggi/pyramid/ROADMAP.md +2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md index 29b87fa..e4f1d90 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,6 +13,45 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: --- +## 2026-07-03 – M2: Gott-Datei `rooms_panel.dart` aufgeteilt (8 Dateien) + +**Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt: +`features/rooms/rooms_panel.dart` (2555 Zeilen, 37 Klassen/Enums + 4 lose +Top-Level-Helfer) aufgeteilt in 8 Dateien unter `features/rooms/panel/`: +`rooms_panel_core.dart` (`RoomsPanel`, `_RoomsPanelState` + die +Top-Level-Helfer `_kVoiceRoomType`/`_kSpaceBannerType`/`_isVoiceRoom`/ +`_compareSpaceChildren`, die von mehreren anderen Teilen genutzt werden – +deshalb bewusst NICHT in ein eigenes „shared"-File, sondern beim Hauptwidget +belassen, wo sie ursprünglich standen), `rooms_header.dart` (`_Header`, +`_MenuRow`, `_NotificationsButton`, `_FilterInput`, `_SpaceMenuItem`), +`rooms_space_invite.dart` (`_SpaceInviteView`+State), `rooms_space_list.dart` +(`_SpaceRoomsList`+State, `_JoinableRoom`+Item+State, `_CategoryHeader`+State), +`rooms_list.dart` (`_RoomsList`, `_SectionHeader`+State, `_RoomItem`+State, +`_DraggableRoomItem`+State), `rooms_voice.dart` (`_VoiceParticipantList`, +`_Initials`, `_VoiceBarBtn`+State), `rooms_invite_item.dart` +(`_InviteItem`+State), `rooms_shared.dart` (`_DmAvatar`+State, +`_InitialCircle`, `_RoomMenuBtn`+State, `_HoverAction`+State, `_UnreadBadge`). +`rooms_panel.dart` selbst: nur noch Bibliothekskopf (Imports + 8 +`part`-Direktiven, 31 Zeilen). + +Verifikation wie bei den beiden vorigen Umbauten: automatisierte +Klammer-Zählung für Blockgrenzen, alle 37 Blöcke + 4 Helfer wortwörtlich in +den neuen Dateien wiedergefunden, Anzahl `class`/`enum` (37) und +`Widget build(`-Methoden (22) vorher/nachher identisch, `flutter analyze` +unverändert bei 1 bekanntem Hinweis. `dart format` wieder bewusst nicht +gelaufen. + +**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Raumliste +prüfen – Space wechseln, Raum beitreten/verlassen, Voice-Channel-Teilnehmerliste +in der Raumliste, Einladung annehmen/ablehnen, Drag&Drop zum Umsortieren, +Ungelesen-Badge, DM-Avatare. Danach `space_admin_dialog.dart` (2278 Zeilen) +oder `chat_view.dart` (2116 Zeilen) nach demselben Muster – damit wären alle +in der Ist-Analyse gefundenen Gott-Dateien abgearbeitet. + +**Stolperfallen:** Keine neuen. + +--- + ## 2026-07-03 – M2: Gott-Datei `message_group.dart` aufgeteilt (7 Dateien) **Erledigt:** Gleiche Technik wie beim `settings_modal.dart`-Umbau direkt diff --git a/ROADMAP.md b/ROADMAP.md index cd23e71..0ade17f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,9 +82,11 @@ lassen, ohne dass andere Module angefasst werden müssen. `part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md 2026-07-03 „Settings-Datei aufgeteilt"; `features/chat/message_group.dart` (2775 Zeilen) ebenso in 7 `part`-Dateien unter `features/chat/message/` - aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt") – - restliche Gott-Dateien `rooms_panel.dart`, `space_admin_dialog.dart`, - `chat_view.dart` aus der Ist-Analyse noch offen) + aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt"); + `features/rooms/rooms_panel.dart` (2555 Zeilen) ebenso in 8 + `part`-Dateien unter `features/rooms/panel/` aufgeteilt (PROGRESS.md + 2026-07-03 „rooms_panel.dart aufgeteilt") – restliche Gott-Dateien + `space_admin_dialog.dart`, `chat_view.dart` aus der Ist-Analyse noch offen) - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit ## M3 – Calls: Logik, UI & Audio (Discord als Referenz) diff --git a/lib/features/rooms/panel/rooms_header.dart b/lib/features/rooms/panel/rooms_header.dart new file mode 100644 index 0000000..f9022a1 --- /dev/null +++ b/lib/features/rooms/panel/rooms_header.dart @@ -0,0 +1,501 @@ +part of '../rooms_panel.dart'; + +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)), + ]); +} + +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), + ), + ), + ), + ], + ), + ), + ); + } +} + +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, + ), + ], + ), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_invite_item.dart b/lib/features/rooms/panel/rooms_invite_item.dart new file mode 100644 index 0000000..5ee17a5 --- /dev/null +++ b/lib/features/rooms/panel/rooms_invite_item.dart @@ -0,0 +1,113 @@ +part of '../rooms_panel.dart'; + +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)), + ), + ), + ]), + ]), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_list.dart b/lib/features/rooms/panel/rooms_list.dart new file mode 100644 index 0000000..a204167 --- /dev/null +++ b/lib/features/rooms/panel/rooms_list.dart @@ -0,0 +1,608 @@ +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) => _RoomItem( + room: room, + isActive: activeRoomId == room.id, + pt: pt, + onTap: () { + ref.read(activeRoomIdProvider.notifier).state = room.id; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + )), + ], + ); + } +} + +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; + } +} diff --git a/lib/features/rooms/panel/rooms_panel_core.dart b/lib/features/rooms/panel/rooms_panel_core.dart new file mode 100644 index 0000000..a333be3 --- /dev/null +++ b/lib/features/rooms/panel/rooms_panel_core.dart @@ -0,0 +1,146 @@ +part of '../rooms_panel.dart'; + +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); +} + +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()], + ); + }), + ], + ), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_shared.dart b/lib/features/rooms/panel/rooms_shared.dart new file mode 100644 index 0000000..43d47c4 --- /dev/null +++ b/lib/features/rooms/panel/rooms_shared.dart @@ -0,0 +1,279 @@ +part of '../rooms_panel.dart'; + +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))), + ); + } +} + +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)), + ), + ), + ); + } +} + +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))), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_space_invite.dart b/lib/features/rooms/panel/rooms_space_invite.dart new file mode 100644 index 0000000..71a7a0d --- /dev/null +++ b/lib/features/rooms/panel/rooms_space_invite.dart @@ -0,0 +1,159 @@ +part of '../rooms_panel.dart'; + +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'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_space_list.dart b/lib/features/rooms/panel/rooms_space_list.dart new file mode 100644 index 0000000..102cd72 --- /dev/null +++ b/lib/features/rooms/panel/rooms_space_list.dart @@ -0,0 +1,569 @@ +part of '../rooms_panel.dart'; + +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))), + ), + ], + ); + } +} + +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), + ), + ), + ], + ), + ), + ), + ); + } +} + +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, + ), + ), + ]), + ), + ), + ); + } +} diff --git a/lib/features/rooms/panel/rooms_voice.dart b/lib/features/rooms/panel/rooms_voice.dart new file mode 100644 index 0000000..4401112 --- /dev/null +++ b/lib/features/rooms/panel/rooms_voice.dart @@ -0,0 +1,132 @@ +part of '../rooms_panel.dart'; + +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)), + ), + ), + ); + } +} diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart index cb18e3c..ae9f69f 100644 --- a/lib/features/rooms/rooms_panel.dart +++ b/lib/features/rooms/rooms_panel.dart @@ -20,2536 +20,12 @@ 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))), - ); - } -} +part 'panel/rooms_panel_core.dart'; +part 'panel/rooms_header.dart'; +part 'panel/rooms_space_invite.dart'; +part 'panel/rooms_space_list.dart'; +part 'panel/rooms_list.dart'; +part 'panel/rooms_voice.dart'; +part 'panel/rooms_invite_item.dart'; +part 'panel/rooms_shared.dart';