import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/widgets/pyramid_logo.dart'; class MembersPanel extends ConsumerWidget { final String roomId; /// When set, a close button is shown in the header (used on mobile/overlay). final VoidCallback? onClose; /// Panel width. Defaults to 280 (desktop side panel). final double width; const MembersPanel({super.key, required this.roomId, this.onClose, this.width = 280}); @override Widget build(BuildContext context, WidgetRef ref) { final pt = PyramidTheme.of(context); final client = ref.watch(matrixClientProvider).valueOrNull; final room = client?.getRoomById(roomId); final members = room?.getParticipants() ?? []; // Show all members; presence info may not be available final online = members.toList(); final offline = []; return Container( width: width, decoration: BoxDecoration( color: pt.bg1, border: Border(left: BorderSide(color: pt.border)), ), child: Column( children: [ Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: pt.border)), ), child: Row( children: [ Icon(Icons.group_rounded, size: 16, color: pt.fg), const SizedBox(width: 8), Text( 'Members', style: TextStyle( color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 6), Text( '${members.length}', style: TextStyle(color: pt.fgDim, fontSize: 14), ), if (onClose != null) ...[ const Spacer(), GestureDetector( onTap: onClose, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(4), child: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted), ), ), ], ], ), ), Expanded( child: ListView( padding: const EdgeInsets.all(8), children: [ if (online.isNotEmpty) ...[ _SectionLabel('Online — ${online.length}', pt), ...online.map((m) => _MemberItem(member: m, pt: pt)), ], if (offline.isNotEmpty) ...[ const SizedBox(height: 14), _SectionLabel('Offline — ${offline.length}', pt), ...offline.map((m) => _MemberItem( member: m, pt: pt, isOffline: true, )), ], if (members.isEmpty) ...[ // Show placeholder members when room has none loaded _SectionLabel('Online — 0', pt), _SectionLabel('', pt), Center( child: Padding( padding: const EdgeInsets.only(top: 24), child: Text( 'No members loaded', style: TextStyle(color: pt.fgDim, fontSize: 12), ), ), ), ], ], ), ), ], ), ); } } class _SectionLabel extends StatelessWidget { final String text; final PyramidTheme pt; const _SectionLabel(this.text, this.pt); @override Widget build(BuildContext context) { if (text.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), child: Text( text.toUpperCase(), style: TextStyle( color: pt.fgDim, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.04 * 11, ), ), ); } } class _MemberItem extends StatefulWidget { final User member; final PyramidTheme pt; final bool isOffline; const _MemberItem({ required this.member, required this.pt, this.isOffline = false, }); @override State<_MemberItem> createState() => _MemberItemState(); } class _MemberItemState extends State<_MemberItem> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; final member = widget.member; final name = member.calcDisplayname(); final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; final color = _colorFromName(name); final status = widget.isOffline ? 'offline' : _memberStatus(member); return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), decoration: BoxDecoration( color: _hovered ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( children: [ Opacity( opacity: widget.isOffline ? 0.5 : 1, child: Stack( children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(pt.rSm), ), child: Center( child: Text( initial, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600, ), ), ), ), if (!widget.isOffline) Positioned( bottom: -2, right: -2, child: PresenceDot( status: status, size: 9, borderColor: pt.bgHover, ), ), ], ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, style: TextStyle( color: widget.isOffline ? pt.fgMuted : pt.fg, fontSize: 13, fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), ); } String _memberStatus(User m) { try { // ignore: deprecated_member_use final p = m.room.client.presences[m.id]; if (p == null) return 'offline'; // Veraltete "online"-Presence vom Server nicht blind übernehmen: // nur online zeigen, wenn die letzte Aktivität frisch ist. final last = p.lastActiveTimestamp; final fresh = last != null && DateTime.now().difference(last) < const Duration(minutes: 5); return switch (p.presence) { PresenceType.online when p.currentlyActive == true || fresh => 'online', PresenceType.online => 'offline', PresenceType.unavailable => 'away', _ => 'offline', }; } catch (_) { return 'offline'; } } 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]; } }