import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/widgets/mxc_image.dart'; class ProfilePopover extends StatefulWidget { final String userId; final Room room; final Offset globalPos; final VoidCallback onClose; const ProfilePopover({ super.key, required this.userId, required this.room, required this.globalPos, required this.onClose, }); @override State createState() => _ProfilePopoverState(); } class _ProfilePopoverState extends State { Profile? _profile; PresenceType? _presence; String? _bannerMxcUri; @override void initState() { super.initState(); _load(); } Future _load() async { try { final p = await widget.room.client.getProfileFromUserId(widget.userId); if (mounted) setState(() => _profile = p); } catch (_) {} try { final p = await widget.room.client.fetchCurrentPresence(widget.userId); // Veraltete "online"-Presence herabstufen, wenn die letzte Aktivität // nicht frisch ist (Server meldet sonst dauerhaft online). final last = p.lastActiveTimestamp; final fresh = p.currentlyActive == true || (last != null && DateTime.now().difference(last) < const Duration(minutes: 5)); final effective = (p.presence == PresenceType.online && !fresh) ? PresenceType.offline : p.presence; if (mounted) setState(() => _presence = effective); } catch (_) {} // Load banner from Matrix profile custom field (works for any user) try { final client = widget.room.client; final apiUri = client.homeserver!.replace( path: '/_matrix/client/v3/profile/${Uri.encodeComponent(widget.userId)}', ); final resp = await client.httpClient.get( apiUri, headers: {'Authorization': 'Bearer ${client.accessToken}'}, ); if (resp.statusCode == 200) { final data = jsonDecode(resp.body) as Map; final url = data['io.pyramid.profile.banner_url'] as String?; if (url != null && mounted) setState(() => _bannerMxcUri = url); } } catch (_) {} // Legacy fallback: account data (only own user) if (_bannerMxcUri == null && widget.userId == widget.room.client.userID) { try { final bannerData = widget.room.client.accountData['com.pyramid.profile.banner']; final url = bannerData?.content['url'] as String?; if (url != null && mounted) setState(() => _bannerMxcUri = url); } catch (_) {} } } @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); final member = widget.room.unsafeGetUserFromMemoryOrFallback(widget.userId); final displayName = _profile?.displayName ?? member.displayName ?? widget.userId.split(':').first.replaceAll('@', ''); final avatarUrl = _profile?.avatarUrl ?? member.avatarUrl; final handle = widget.userId; final initials = displayName.isNotEmpty ? displayName[0].toUpperCase() : '?'; final color = _colorForName(displayName); final powerLevel = widget.room.getPowerLevelByUserId(widget.userId); final joinDate = _formatJoinDate(widget.room, widget.userId); const popoverW = 300.0; const popoverH = 380.0; final left = (widget.globalPos.dx + 16).clamp(8.0, size.width - popoverW - 8); final top = (widget.globalPos.dy - 80).clamp(8.0, size.height - popoverH - 8); return Positioned( left: left, top: top, child: Material( color: Colors.transparent, child: Container( width: popoverW, decoration: BoxDecoration( color: PyramidColors.bg0, borderRadius: BorderRadius.circular(12), border: Border.all(color: PyramidColors.border), boxShadow: const [ BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8)) ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Banner — real image for own profile, color gradient for others SizedBox( height: 64, child: _bannerMxcUri != null ? MxcImage( mxcUri: _bannerMxcUri!, client: widget.room.client, fit: BoxFit.cover, width: double.infinity, height: 64, placeholder: Container( decoration: BoxDecoration( gradient: LinearGradient( colors: [ color.withValues(alpha: 0.6), color.withValues(alpha: 0.3) ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), ), ) : Container( decoration: BoxDecoration( gradient: LinearGradient( colors: [ color.withValues(alpha: 0.6), color.withValues(alpha: 0.3) ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Avatar row Transform.translate( offset: const Offset(0, -28), child: Row( children: [ Stack( clipBehavior: Clip.none, children: [ Container( width: 56, height: 56, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(16), border: Border.all(color: PyramidColors.bg0, width: 3), ), clipBehavior: Clip.antiAlias, child: avatarUrl != null ? MxcImage( mxcUri: avatarUrl.toString(), client: widget.room.client, width: 56, height: 56, placeholder: Center( child: Text(initials, style: const TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700)), ), ) : Center( child: Text(initials, style: const TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700))), ), Positioned( right: -2, bottom: -2, child: _PresenceDot(presence: _presence, size: 14), ), ], ), ], ), ), Transform.translate( offset: const Offset(0, -20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(displayName, style: const TextStyle( color: PyramidColors.fg, fontSize: 17, fontWeight: FontWeight.w700)), const SizedBox(height: 2), Text(handle, style: const TextStyle( color: PyramidColors.fgMuted, fontSize: 12)), const SizedBox(height: 12), // Action buttons Row( children: [ _ProfileBtn( label: 'Nachricht', icon: Icons.send_outlined, primary: true, onTap: () { widget.onClose(); // TODO: open DM }, ), const SizedBox(width: 8), _ProfileBtn( label: 'Anrufen', icon: Icons.call_outlined, onTap: () {}, ), const SizedBox(width: 8), _ProfileBtn( icon: Icons.more_vert, onTap: () {}, ), ], ), if (powerLevel > 0) ...[ const SizedBox(height: 14), const Text('Rollen', style: TextStyle( color: PyramidColors.fgDim, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.8)), const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 4, children: [ _RoleBadge( label: powerLevel >= 100 ? 'Admin' : 'Moderator', color: powerLevel >= 100 ? const Color(0xFFF59E0B) : const Color(0xFF06B6D4), ), ], ), ], if (joinDate != null) ...[ const SizedBox(height: 14), const Text('Mitglied seit', style: TextStyle( color: PyramidColors.fgDim, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.8)), const SizedBox(height: 4), Text(joinDate, style: const TextStyle( color: PyramidColors.fgMuted, fontSize: 12)), ], ], ), ), ], ), ), ], ), ), ), ), ); } String? _formatJoinDate(Room room, String userId) { try { final member = room.unsafeGetUserFromMemoryOrFallback(userId); // User joined if membership is join if (member.membership != Membership.join) return null; // No timestamp available from StrippedStateEvent; show server domain hint final server = userId.contains(':') ? userId.split(':').last : null; return server != null ? 'via $server' : null; } catch (_) { return null; } } Color _colorForName(String name) { const colors = [ Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFFFF6B9D), Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFF3B82F6), Color(0xFFF43F5E), ]; final hash = name.codeUnits.fold(0, (a, b) => a + b); return colors[hash % colors.length]; } } class _PresenceDot extends StatelessWidget { final PresenceType? presence; final double size; const _PresenceDot({required this.presence, required this.size}); @override Widget build(BuildContext context) { final color = switch (presence) { PresenceType.online => const Color(0xFF23A55A), PresenceType.unavailable => const Color(0xFFF0B232), _ => const Color(0xFF7C7C8A), }; return Container( width: size, height: size, decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: Border.all(color: PyramidColors.bg0, width: 2), ), ); } } class _ProfileBtn extends StatefulWidget { final String? label; final IconData icon; final bool primary; final VoidCallback onTap; const _ProfileBtn({ required this.icon, required this.onTap, this.label, this.primary = false, }); @override State<_ProfileBtn> createState() => _ProfileBtnState(); } class _ProfileBtnState extends State<_ProfileBtn> { bool _hover = false; @override Widget build(BuildContext context) { final hasLabel = widget.label != null; return MouseRegion( onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 120), padding: EdgeInsets.symmetric( horizontal: hasLabel ? 12 : 9, vertical: 7), decoration: BoxDecoration( color: widget.primary ? (_hover ? PyramidTheme.of(context).accent.withAlpha(200) : PyramidTheme.of(context).accent) : (_hover ? PyramidColors.bgHover : PyramidColors.bg2), borderRadius: BorderRadius.circular(8), border: Border.all( color: widget.primary ? Colors.transparent : PyramidColors.border, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(widget.icon, size: 14, color: widget.primary ? PyramidColors.accentFg : PyramidColors.fg), if (hasLabel) ...[ const SizedBox(width: 6), Text(widget.label!, style: TextStyle( color: widget.primary ? PyramidColors.accentFg : PyramidColors.fg, fontSize: 13, fontWeight: FontWeight.w500)), ], ], ), ), ), ); } } class _RoleBadge extends StatelessWidget { final String label; final Color color; const _RoleBadge({required this.label, required this.color}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(999), border: Border.all(color: color.withValues(alpha: 0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 5), Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w500)), ], ), ); } }