import 'dart:async'; import 'package:flutter/material.dart' hide Visibility; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/space_child.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart'; import 'package:pyramid/features/call/mini_call_widget.dart'; import 'package:pyramid/features/rooms/user_panel.dart'; import 'package:pyramid/features/spaces/space_admin_dialog.dart'; import 'package:pyramid/widgets/create_join_dialog.dart'; import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/new_dm_dialog.dart'; // ─── Helpers ────────────────────────────────────────────────────────────────── const _kVoiceRoomType = 'io.pyramid.voice_channel'; const _kSpaceBannerType = 'io.pyramid.space.banner'; bool _isVoiceRoom(Room room) => room.getState(EventTypes.RoomCreate)?.content['type'] == _kVoiceRoomType; int _compareSpaceChildren(SpaceChild a, SpaceChild b) { final ao = a.order; final bo = b.order; if (ao.isEmpty && bo.isEmpty) return 0; if (ao.isEmpty) return 1; if (bo.isEmpty) return -1; return ao.compareTo(bo); } // ─── Panel shell ────────────────────────────────────────────────────────────── class RoomsPanel extends ConsumerStatefulWidget { const RoomsPanel({super.key}); @override ConsumerState createState() => _RoomsPanelState(); } class _RoomsPanelState extends ConsumerState { final _filterCtrl = TextEditingController(); String _filter = ''; final Map _collapsed = {}; @override void dispose() { _filterCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final roomsAsync = ref.watch(roomListProvider); final activeSpaceId = ref.watch(activeSpaceIdProvider); final call = ref.watch(callStateProvider); final voip = ref.watch(voipStateProvider); final isRealSpace = activeSpaceId != null && activeSpaceId != 'dms' && activeSpaceId != 'rooms'; return Container( color: pt.bg1, child: Column( children: [ _Header(pt: pt, spaceId: activeSpaceId), _FilterInput( controller: _filterCtrl, pt: pt, onChanged: (v) => setState(() => _filter = v), ), Expanded( child: 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); }), ), ), ), if (call.isActive && !call.isVoiceChannel) MiniCallWidget(call: call), if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) MiniCallWidget(call: voip), const UserPanel(), ], ), ); } } // ─── Header ─────────────────────────────────────────────────────────────────── enum _SpaceMenuItem { newDm, 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.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)), ]; } 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 space banner — prefer local override (set immediately on save) // over the state event (only available after next Matrix sync) final bannerOverrides = ref.watch(spaceBannerOverrideProvider); final bannerUrl = isRealSpace && activeSpace != null ? (bannerOverrides.containsKey(activeSpace.id) ? bannerOverrides[activeSpace.id] : activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?) : null; 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) PyrIconBtn( icon: const Icon(Icons.notifications_none_rounded), tooltip: 'Notifications', onPressed: () {}, ), PopupMenuButton<_SpaceMenuItem>( tooltip: 'Optionen', 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.keyboard_arrow_down_rounded, color: hasBanner ? Colors.white70 : pt.fgDim, size: 16), ), ), ], ); 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)), ]); } // ─── 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 = {}; 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(); 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(); 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(); // 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(); // 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, onReorder: (oldIdx, newIdx) { if (newIdx > 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), ); }), ], if (filteredDirect.isEmpty && categoryWidgets.isEmpty && invites.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Center( child: Text('Keine Kanäle', style: TextStyle(color: widget.pt.fgDim, fontSize: 13))), ), ], ); } } // ─── 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, )); } } } @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, 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; if (p.currentlyActive == true) 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))), ); } }