part of '../chat_view.dart'; class _ChatHeader extends ConsumerWidget { final Room? room; final String roomId; final PyramidTheme pt; final bool membersOpen; final bool pinnedOpen; final VoidCallback onToggleMembers; final VoidCallback onOpenSearch; final VoidCallback onTogglePinned; const _ChatHeader({ required this.room, required this.roomId, required this.pt, required this.membersOpen, required this.pinnedOpen, required this.onToggleMembers, required this.onOpenSearch, required this.onTogglePinned, }); @override Widget build(BuildContext context, WidgetRef ref) { final name = room?.getLocalizedDisplayname() ?? roomId; final isDm = room?.isDirectChat ?? false; final isEncrypted = room?.encrypted ?? false; final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name'); final client = ref.watch(matrixClientProvider).valueOrNull; final size = MediaQuery.sizeOf(context); final isMobile = size.width < 900 || size.height < 500; return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: pt.bg1, border: Border(bottom: BorderSide(color: pt.border)), ), child: Row( children: [ // Back button on mobile if (isMobile) ...[ GestureDetector( onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), ), const SizedBox(width: 12), ], // Room icon / avatar if (isDm && client != null) ...[ _DmHeaderAvatar(room: room, name: name, client: client), ] else if (!isDm) ...[ Icon( isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded, size: 16, color: pt.fgDim, ), ], const SizedBox(width: 8), // Name + presence for DMs if (isDm) Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, style: TextStyle( color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis, ), if (room != null && client != null) _PresenceLine( userId: room!.directChatMatrixID, client: client, pt: pt, ), ], ), ) else Text( name, style: TextStyle( color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), ), const SizedBox(width: 12), if (!isDm && topic.isNotEmpty) ...[ Container(width: 1, height: 18, color: pt.border), const SizedBox(width: 12), Expanded( child: Text( topic, style: TextStyle(color: pt.fgMuted, fontSize: 13), overflow: TextOverflow.ellipsis, ), ), ] else if (!isDm) const Spacer(), // Right actions Row( children: [ if (isDm) ...[ PyrIconBtn( size: 28, icon: const Icon(Icons.phone_rounded), tooltip: 'Voice call', onPressed: () => _startCall(ref, room, cam: false), ), PyrIconBtn( size: 28, icon: const Icon(Icons.videocam_rounded), tooltip: 'Video call', onPressed: () => _startCall(ref, room, cam: true), ), if (size.width > 700) Container( width: 1, height: 20, margin: const EdgeInsets.symmetric(horizontal: 4), color: pt.border, ), ], if (size.width > 800) ...[ PyrIconBtn( size: 28, icon: const Icon(Icons.inbox_rounded), tooltip: 'Threads', onPressed: () {}, ), PyrIconBtn( size: 28, icon: const Icon(Icons.push_pin_rounded), tooltip: 'Angepinnte Nachrichten', active: pinnedOpen, onPressed: onTogglePinned, ), ], PyrIconBtn( size: 28, icon: const Icon(Icons.search_rounded), tooltip: 'Search', onPressed: onOpenSearch, ), PyrIconBtn( size: 28, icon: const Icon(Icons.group_rounded), tooltip: 'Members', active: membersOpen, onPressed: onToggleMembers, ), ], ), ], ), ); } void _startCall(WidgetRef ref, Room? room, {required bool cam}) async { if (room == null) return; if (room.isDirectChat) { // Use native Matrix VoIP for 1:1 DMs ref.read(viewModeProvider.notifier).state = ViewMode.voice; await PyramidVoipManager.instance.startCall(room.id, video: cam); return; } // Use LiveKit for group voice rooms ref.read(viewModeProvider.notifier).state = ViewMode.voice; await ref.read(callStateProvider).startCall( roomName: roomId, roomDisplayName: room.getLocalizedDisplayname(), identity: 'user_${DateTime.now().millisecondsSinceEpoch}', audioOnly: !cam, ); } } class _DmExpandingHeader extends ConsumerStatefulWidget { final Room room; final String roomId; final PyramidTheme pt; final bool expanded; final VoidCallback onToggle; final bool membersOpen; final bool pinnedOpen; final VoidCallback onToggleMembers; final VoidCallback onOpenSearch; final VoidCallback onTogglePinned; const _DmExpandingHeader({ super.key, required this.room, required this.roomId, required this.pt, required this.expanded, required this.onToggle, required this.membersOpen, required this.pinnedOpen, required this.onToggleMembers, required this.onOpenSearch, required this.onTogglePinned, }); @override ConsumerState<_DmExpandingHeader> createState() => _DmExpandingHeaderState(); } class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { static const _animDuration = Duration(milliseconds: 280); static const _animCurve = Curves.easeOutCubic; Profile? _profile; String? _bannerMxcUri; String? _statusMsg; bool _online = false; String? get _partnerId => widget.room.directChatMatrixID; @override void initState() { super.initState(); // Direkt laden, damit das Banner beim ersten Aufklappen schon da ist. _load(); } @override void didUpdateWidget(_DmExpandingHeader old) { super.didUpdateWidget(old); if (old.room.id != widget.room.id) { _profile = null; _bannerMxcUri = null; _statusMsg = null; _online = false; _load(); } } Future _load() async { final userId = _partnerId; if (userId == null) return; final client = widget.room.client; // Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des // VORHERIGEN Partners das Profil des aktuellen nicht überschreiben. bool stillCurrent() => mounted && _partnerId == userId; try { final p = await client.getProfileFromUserId(userId); if (stillCurrent()) setState(() => _profile = p); } catch (_) {} try { final p = await client.fetchCurrentPresence(userId); final last = p.lastActiveTimestamp; final fresh = p.currentlyActive == true || (last != null && DateTime.now().difference(last) < const Duration(minutes: 5)); if (stillCurrent()) { setState(() { _online = p.presence == PresenceType.online && fresh; _statusMsg = p.statusMsg; }); } } catch (_) {} // Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url). try { final apiUri = client.homeserver!.replace( path: '/_matrix/client/v3/profile/${Uri.encodeComponent(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 && stillCurrent()) { setState(() => _bannerMxcUri = url); } } } catch (_) {} } @override Widget build(BuildContext context) { final pt = widget.pt; final expanded = widget.expanded; final size = MediaQuery.sizeOf(context); final isMobile = size.width < 900 || size.height < 500; final client = ref.watch(matrixClientProvider).valueOrNull; final userId = _partnerId ?? ''; final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId); final name = _profile?.displayName ?? member.displayName ?? widget.room.getLocalizedDisplayname(); final avatarUrl = _profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar; final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; // Im Querformat (wenig Höhe) kompakter aufklappen. final expandedHeight = size.height < 500 ? 140.0 : 190.0; final avatarSize = expanded ? (size.height < 500 ? 72.0 : 96.0) : 32.0; return AnimatedContainer( duration: _animDuration, curve: _animCurve, height: expanded ? expandedHeight : 52, decoration: BoxDecoration( color: pt.bg1, border: Border(bottom: BorderSide(color: pt.border)), ), clipBehavior: Clip.hardEdge, child: Stack( fit: StackFit.expand, children: [ // Banner als Hintergrund — blendet beim Aufklappen ein. if (_bannerMxcUri != null && client != null) Positioned.fill( child: AnimatedOpacity( opacity: expanded ? 1 : 0, duration: _animDuration, curve: Curves.easeOut, child: MxcImage( mxcUri: _bannerMxcUri!, client: client, fit: BoxFit.cover, placeholder: const SizedBox.shrink(), ), ), ), // Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup). if (_bannerMxcUri != null) Positioned.fill( child: AnimatedOpacity( opacity: expanded ? 1 : 0, duration: _animDuration, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ pt.bg1, pt.bg1.withAlpha(210), pt.bg1.withAlpha(0), ], stops: const [0.0, 0.32, 0.72], ), ), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ if (isMobile) ...[ GestureDetector( onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), ), const SizedBox(width: 12), ], // Klickbarer Profilbereich: Avatar + Name + Details Expanded( child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.onToggle, child: Row( children: [ // Avatar — skaliert mit (rund, wie im Mockup) AnimatedContainer( duration: _animDuration, curve: _animCurve, width: avatarSize, height: avatarSize, decoration: BoxDecoration( shape: BoxShape.circle, color: pt.bg3, ), clipBehavior: Clip.antiAlias, child: avatarUrl != null && client != null ? MxcImage( mxcUri: avatarUrl.toString(), client: client, fit: BoxFit.cover, placeholder: _initialBox(initial, pt), ) : _initialBox(initial, pt), ), AnimatedContainer( duration: _animDuration, curve: _animCurve, width: expanded ? 20 : 10, ), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Name skaliert mit; Status blendet daneben ein Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Flexible( child: AnimatedDefaultTextStyle( duration: _animDuration, curve: _animCurve, style: TextStyle( color: pt.fg, fontSize: expanded ? 24 : 15, fontWeight: expanded ? FontWeight.w700 : FontWeight.w600, ), child: Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), AnimatedOpacity( opacity: expanded ? 1 : 0, duration: _animDuration, child: Padding( padding: const EdgeInsets.only(left: 8), child: Text( _online ? 'online' : 'offline', style: TextStyle( color: _online ? pt.online : pt.fgMuted, fontSize: 13, fontWeight: FontWeight.w500, ), ), ), ), ], ), // Eingeklappt: "zuletzt online vor…", // aufgeklappt: Handle + Biographie. AnimatedCrossFade( duration: _animDuration, sizeCurve: _animCurve, crossFadeState: expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, firstChild: client != null ? _PresenceLine( userId: _partnerId, client: client, pt: pt, ) : const SizedBox.shrink(), secondChild: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), Text( userId, style: TextStyle( color: pt.fgMuted, fontSize: 13), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (_statusMsg != null && _statusMsg!.isNotEmpty) ...[ const SizedBox(height: 8), Text( _statusMsg!, style: TextStyle( color: pt.fgMuted, fontSize: 13, height: 1.35), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ], ), ), ], ), ), ], ), ), ), ), const SizedBox(width: 12), // Aktionen — bleiben immer sichtbar Row( children: [ PyrIconBtn( size: 28, icon: const Icon(Icons.phone_rounded), tooltip: 'Voice call', onPressed: () => _startCall(cam: false), ), PyrIconBtn( size: 28, icon: const Icon(Icons.videocam_rounded), tooltip: 'Video call', onPressed: () => _startCall(cam: true), ), if (size.width > 700) Container( width: 1, height: 20, margin: const EdgeInsets.symmetric(horizontal: 4), color: pt.border, ), if (size.width > 800) PyrIconBtn( size: 28, icon: const Icon(Icons.push_pin_rounded), tooltip: 'Angepinnte Nachrichten', active: widget.pinnedOpen, onPressed: widget.onTogglePinned, ), PyrIconBtn( size: 28, icon: const Icon(Icons.search_rounded), tooltip: 'Search', onPressed: widget.onOpenSearch, ), PyrIconBtn( size: 28, icon: const Icon(Icons.group_rounded), tooltip: 'Members', active: widget.membersOpen, onPressed: widget.onToggleMembers, ), ], ), ], ), ), ], ), ); } Widget _initialBox(String initial, PyramidTheme pt) => Center( child: FittedBox( fit: BoxFit.scaleDown, child: Padding( padding: const EdgeInsets.all(6), child: Text( initial, style: TextStyle( color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), ), ), ), ); void _startCall({required bool cam}) async { ref.read(viewModeProvider.notifier).state = ViewMode.voice; await PyramidVoipManager.instance.startCall(widget.room.id, video: cam); } } class _DmHeaderAvatar extends StatelessWidget { final Room? room; final String name; final Client client; const _DmHeaderAvatar({required this.room, required this.name, required this.client}); @override Widget build(BuildContext context) { final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; const size = 32.0; return MxcAvatar( mxcUri: room?.avatar, client: client, size: size, borderRadius: BorderRadius.circular(size / 2), placeholder: (_) => _InitialCircle(initial: initial, size: size), ); } } class _InitialCircle extends StatelessWidget { final String initial; final double size; const _InitialCircle({required this.initial, required this.size}); @override Widget build(BuildContext context) { const color = Color(0xFF06B6D4); return Container( width: size, height: size, decoration: const BoxDecoration(color: color, shape: BoxShape.circle), child: Center( child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)), ), ); } } class _PresenceLine extends StatefulWidget { final String? userId; final Client client; final PyramidTheme pt; const _PresenceLine({required this.userId, required this.client, required this.pt}); @override State<_PresenceLine> createState() => _PresenceLineState(); } class _PresenceLineState extends State<_PresenceLine> { CachedPresence? _presence; StreamSubscription? _sub; // Debounce timer: delay "online" updates 4s to filter out background-sync // false positives (push notifications briefly wake the recipient's client). Timer? _onlineDebounce; @override void initState() { super.initState(); _load(widget.userId); } @override void didUpdateWidget(_PresenceLine oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.userId != widget.userId) { _sub?.cancel(); _sub = null; _onlineDebounce?.cancel(); setState(() => _presence = null); _load(widget.userId); } } @override void dispose() { _sub?.cancel(); _onlineDebounce?.cancel(); super.dispose(); } // "online" only when the server's presence enum says online AND the last // activity is recent. Using currentlyActive (the old logic) reported online // long after the user left, because that flag lingers. static bool _isOnline(CachedPresence p) { if (p.presence != PresenceType.online) return false; final last = p.lastActiveTimestamp; return last == null || DateTime.now().difference(last).inSeconds < 90; } void _applyPresence(CachedPresence p) { _onlineDebounce?.cancel(); if (_isOnline(p)) { // Wait 4 seconds before committing "online" to filter single-sync spikes // (a backgrounded client briefly reports online when a push wakes it). _onlineDebounce = Timer(const Duration(seconds: 4), () { if (mounted) setState(() => _presence = p); }); } else { if (mounted) setState(() => _presence = p); } } Future _load(String? userId) async { if (userId == null) return; // Never show our own presence in a DM header if (userId == widget.client.userID) return; try { final p = await widget.client.fetchCurrentPresence(userId); if (mounted) _applyPresence(p); } catch (_) {} _sub = widget.client.onPresenceChanged.stream .where((p) => p.userid == userId) .listen((p) => _applyPresence(p)); } @override Widget build(BuildContext context) { final p = _presence; if (p == null) return const SizedBox.shrink(); String text; Color color = widget.pt.fgDim; if (_isOnline(p)) { text = 'online'; color = const Color(0xFF3BA55D); } else if (p.lastActiveTimestamp != null) { text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}'; } else { return const SizedBox.shrink(); } return Text( text, style: TextStyle(color: color, fontSize: 11), maxLines: 1, overflow: TextOverflow.ellipsis, ); } String _relativeTime(DateTime t) { final diff = DateTime.now().difference(t); if (diff.inMinutes < 1) return 'gerade eben'; if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min'; if (diff.inHours < 24) return 'vor ${diff.inHours}h'; if (diff.inDays < 7) return 'vor ${diff.inDays}d'; if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w'; return 'vor ${(diff.inDays / 365).floor()}j'; } }