import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/rendering.dart' show ScrollCacheExtent; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/chat/attachment_dialog.dart'; import 'package:pyramid/features/chat/chat_composer.dart'; import 'package:pyramid/features/chat/chat_provider.dart'; import 'package:pyramid/features/chat/message_group.dart'; import 'package:pyramid/features/chat/pinned_messages_panel.dart'; import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/core/notification_service.dart'; import 'package:pyramid/core/voip_manager.dart'; class ChatView extends ConsumerStatefulWidget { final String roomId; const ChatView({super.key, required this.roomId}); @override ConsumerState createState() => _ChatViewState(); } class _ChatViewState extends ConsumerState { final _scrollCtrl = ScrollController(); Event? _replyTo; bool _loadingHistory = false; bool _historyExhausted = false; bool _draggingOver = false; bool _pinnedOpen = false; bool _profileExpanded = false; bool _showScrollDown = false; final Set _selectedIds = {}; // Unread state captured at session open (before marking as read) String? _sessionFullyReadId; bool _sessionHasUnread = false; bool _sessionCaptured = false; bool _didScrollToUnread = false; bool _loadingFuture = false; // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. bool _unreadFading = false; Timer? _unreadFadeTimer; // Jump-to-message (from search): scroll to + briefly highlight a target event. final _jumpKey = GlobalKey(); String? _jumpEventId; String? _highlightEventId; bool _highlightOn = false; bool _jumpInProgress = false; Timer? _highlightTimer; void _startJump(String eventId) { _highlightTimer?.cancel(); setState(() { _jumpEventId = eventId; _highlightEventId = eventId; _highlightOn = true; _jumpInProgress = true; }); // Consume the request so it doesn't re-fire. ref.read(pendingJumpEventProvider.notifier).state = null; WidgetsBinding.instance.addPostFrameCallback((_) => _attemptJump(0)); } void _attemptJump(int attempt) { if (!mounted || _jumpEventId == null) return; final ctx = _jumpKey.currentContext; if (ctx != null) { Scrollable.ensureVisible( ctx, alignment: 0.4, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); _jumpInProgress = false; // Hold the highlight briefly, then fade it out smoothly. _highlightTimer?.cancel(); _highlightTimer = Timer(const Duration(milliseconds: 1300), () { if (!mounted) return; setState(() => _highlightOn = false); // triggers the AnimatedOpacity fade _highlightTimer = Timer(const Duration(milliseconds: 950), () { if (mounted) setState(() { _highlightEventId = null; _jumpEventId = null; }); }); }); return; } if (attempt >= 40) { // Couldn't reach the event — give up gracefully (room is at least open). setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); return; } // Not built yet — load older history toward the target every few tries. final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) { timeline.requestHistory(historyCount: 100).catchError((_) {}); } WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); }); } // GlobalKey for the "Neue Nachrichten" divider — used by ensureVisible to // scroll precisely to the divider on chat open. final _unreadDividerKey = GlobalKey(); bool get _inSelectionMode => _selectedIds.isNotEmpty; void _toggleSelect(String eventId) { setState(() { if (_selectedIds.contains(eventId)) { _selectedIds.remove(eventId); } else { _selectedIds.add(eventId); } }); } void _clearSelection() => setState(() => _selectedIds.clear()); @override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); } @override void didUpdateWidget(ChatView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.roomId != widget.roomId) { _profileExpanded = false; _sessionFullyReadId = null; _sessionHasUnread = false; _sessionCaptured = false; _didScrollToUnread = false; _loadingFuture = false; _historyExhausted = false; _unreadFading = false; _unreadFadeTimer?.cancel(); _jumpEventId = null; _highlightEventId = null; _highlightOn = false; _jumpInProgress = false; _highlightTimer?.cancel(); // The GlobalKey stays alive — it'll simply have no context until the // new room's divider (if any) is built. } } @override void dispose() { _unreadFadeTimer?.cancel(); _highlightTimer?.cancel(); _scrollCtrl.dispose(); super.dispose(); } void _captureAndMarkRead(Timeline timeline) { if (!_sessionCaptured) { final room = timeline.room; final fullyRead = room.fullyRead; // "" if never set final newFullyReadId = fullyRead.isNotEmpty ? fullyRead : null; // Show the divider only if there's a genuinely unread VISIBLE message // FROM SOMEONE ELSE after the read marker. Own messages and hidden events // (reactions, receipts, edits) don't count — otherwise the divider ends // up below all messages (e.g. right after one's own latest message). final me = room.client.userID; final events = timeline.events; // newest-first final frIndex = fullyRead.isEmpty ? -1 : events.indexWhere((e) => e.eventId == fullyRead); // Indices [0, upTo) are newer than the read marker. final upTo = frIndex >= 0 ? frIndex : events.length; var newHasUnread = false; for (var i = 0; i < upTo; i++) { final e = events[i]; if (_MessageList._isVisible(e) && e.senderId != me) { newHasUnread = true; break; } } _sessionCaptured = true; // setState so _MessageList rebuilds with the captured unread marker. if (mounted) { setState(() { _sessionFullyReadId = newFullyReadId; _sessionHasUnread = newHasUnread; }); if (newHasUnread) { WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline)); } } } _markRead(timeline); } void _scrollToUnread(Timeline timeline) { if (_didScrollToUnread) return; _attemptScrollToUnread(0); } // The "Neue Nachrichten" divider may not be laid out yet on first open (lazy // reverse ListView only builds visible + cacheExtent items). Retry until its // context exists, nudging the scroll upward periodically so the list builds // toward the divider when there are many unread messages. void _attemptScrollToUnread(int attempt) { if (_didScrollToUnread || !mounted) return; final ctx = _unreadDividerKey.currentContext; if (ctx != null) { _didScrollToUnread = true; // alignment 0.85: in a reverse list this lands the divider near the top // of the viewport with a little read context above it and the unread // messages filling the screen below. Scrollable.ensureVisible( ctx, alignment: 0.85, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ); return; } if (attempt >= 24) { _didScrollToUnread = true; // give up — stay at the newest messages return; } if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) { final pos = _scrollCtrl.position; final target = (pos.pixels + 1500).clamp(0.0, pos.maxScrollExtent); if (target > pos.pixels) _scrollCtrl.jumpTo(target); } WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 70), () => _attemptScrollToUnread(attempt + 1)); }); } void _markRead([Timeline? tl]) { // Cancel any pending notification for this room (user is now reading it). cancelNotificationForRoom(widget.roomId); final sendReceipts = ref.read(privacyReadReceiptsProvider); if (!sendReceipts) return; final room = ref.read(roomProvider(widget.roomId)); if (room == null) return; final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; final latest = timeline?.events.firstOrNull; if (latest != null && latest.status.isSynced) { room.setReadMarker(latest.eventId, mRead: latest.eventId) .catchError((_) {}); } else { room.markUnread(false).catchError((_) {}); } } Future _onScroll() async { if (!_scrollCtrl.hasClients) return; final pixels = _scrollCtrl.position.pixels; final maxExtent = _scrollCtrl.position.maxScrollExtent; if (pixels < 60) { _markRead(null); // Reached the newest messages → the "Neue Nachrichten" divider is no // longer useful. Fade it out, then remove it. if (_sessionHasUnread && !_unreadFading) { setState(() => _unreadFading = true); _unreadFadeTimer?.cancel(); _unreadFadeTimer = Timer(const Duration(milliseconds: 900), () { if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; }); }); } } final shouldShow = pixels > 400; if (shouldShow != _showScrollDown) { setState(() => _showScrollDown = shouldShow); } final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; if (timeline == null) return; // Backward pagination — older events near the top. We keep requesting until // a request genuinely returns nothing new (real start of room), tracked via // _historyExhausted, so we neither stop early nor hammer the server. if (!_loadingHistory && !_historyExhausted && pixels >= maxExtent - 600) { _loadingHistory = true; final oldestBefore = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; final lenBefore = timeline.events.length; try { await timeline.requestHistory(historyCount: 60); } catch (_) {} final oldestAfter = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; // Nothing older arrived → we've reached the beginning of the room. if (timeline.events.length == lenBefore && oldestBefore == oldestAfter) { _historyExhausted = true; } await Future.delayed(const Duration(milliseconds: 200)); _loadingHistory = false; } // Forward pagination — only relevant after jumping to a historical anchor // (e.g. from search). At the live edge canRequestFuture is false, so this // won't fire during normal reading and can't pull the view back down. if (!_loadingFuture && timeline.canRequestFuture && pixels < 200) { _loadingFuture = true; try { await timeline.requestFuture(historyCount: 60); } catch (_) {} await Future.delayed(const Duration(milliseconds: 250)); _loadingFuture = false; } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final room = ref.watch(roomProvider(widget.roomId)); final timelineAsync = ref.watch(timelineProvider(widget.roomId)); final membersOpen = ref.watch(membersPanelOpenProvider); // A search result requested a jump to a specific message. Using watch (not // listen) so it also fires when this ChatView is freshly mounted for a // DIFFERENT room — listen would miss a value set before registration. final pendingJump = ref.watch(pendingJumpEventProvider); if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_jumpInProgress && ref.read(pendingJumpEventProvider) == pendingJump) { _startJump(pendingJump); } }); } final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; final body = Stack( fit: StackFit.expand, children: [ Row( children: [ Expanded( child: Container( color: pt.bg1, child: Column( children: [ if (_inSelectionMode) _SelectionHeader( pt: pt, count: _selectedIds.length, selectedIds: Set.from(_selectedIds), room: room, timeline: ref.read(timelineProvider(widget.roomId)).valueOrNull, currentUserId: room?.client.userID ?? '', onClear: _clearSelection, onReply: () { final tl = ref.read(timelineProvider(widget.roomId)).valueOrNull; if (tl == null) return; try { final event = tl.events.firstWhere((e) => e.eventId == _selectedIds.first); setState(() { _replyTo = event; _selectedIds.clear(); }); } catch (_) {} }, ) else if (room != null && room.isDirectChat) // DM: aufklappbarer Banner-Header — Avatar und Name // skalieren mit, Banner und Bio blenden ein. // ValueKey: pro Raum ein frischer State, damit beim // schnellen Kanalwechsel keinerlei Daten (Avatar, // Banner, laufende Futures) vom Vorgänger übrigbleiben. _DmExpandingHeader( key: ValueKey('dmheader:${room.id}'), room: room, roomId: widget.roomId, pt: pt, expanded: _profileExpanded, onToggle: () => setState( () => _profileExpanded = !_profileExpanded), membersOpen: membersOpen, pinnedOpen: _pinnedOpen, onToggleMembers: () => ref .read(membersPanelOpenProvider.notifier) .update((s) => !s), onOpenSearch: () => ref.read(activeModalProvider.notifier).state = ModalKind.search, onTogglePinned: () => setState(() => _pinnedOpen = !_pinnedOpen), ) else _ChatHeader( room: room, roomId: widget.roomId, pt: pt, membersOpen: membersOpen, pinnedOpen: _pinnedOpen, onToggleMembers: () => ref .read(membersPanelOpenProvider.notifier) .update((s) => !s), onOpenSearch: () => ref.read(activeModalProvider.notifier).state = ModalKind.search, onTogglePinned: () => setState(() => _pinnedOpen = !_pinnedOpen), ), const _ConnectionBanner(), Expanded( child: Stack( children: [ timelineAsync.when( loading: () => Center( child: CircularProgressIndicator( strokeWidth: 2, color: pt.accent, ), ), error: (e, _) => Center( child: Text( 'Could not load messages', style: TextStyle(color: pt.fgDim, fontSize: 14), ), ), data: (timeline) { WidgetsBinding.instance.addPostFrameCallback( (_) => _captureAndMarkRead(timeline)); return _MessageList( timeline: timeline, scrollCtrl: _scrollCtrl, currentUserId: room?.client.userID ?? '', pt: pt, onReply: (event) => setState(() => _replyTo = event), selectedIds: _selectedIds, onToggleSelect: _toggleSelect, sessionFullyReadId: _sessionFullyReadId, sessionHasUnread: _sessionHasUnread, unreadFading: _unreadFading, unreadDividerKey: _sessionHasUnread ? _unreadDividerKey : null, jumpEventId: _jumpEventId, jumpKey: _jumpKey, highlightEventId: _highlightEventId, highlightOn: _highlightOn, ); }, ), if (_showScrollDown) Positioned( right: 16, bottom: 12, child: AnimatedOpacity( opacity: _showScrollDown ? 1 : 0, duration: const Duration(milliseconds: 150), child: GestureDetector( onTap: () => _scrollCtrl.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ), child: Container( width: 36, height: 36, decoration: BoxDecoration( color: pt.bg0, borderRadius: BorderRadius.circular(18), border: Border.all(color: pt.border), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(80), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted), ), ), ), ), ], ), ), if (room != null) _TypingIndicator(room: room, pt: pt), if (room != null) ChatComposer( room: room, replyTo: _replyTo, onClearReply: () => setState(() => _replyTo = null), ), ], ), ), ), if (_pinnedOpen) PinnedMessagesPanel( roomId: widget.roomId, onClose: () => setState(() => _pinnedOpen = false), ), ], ), // Drag-and-drop overlay — bleibt gemountet und blendet animiert // ein/aus, damit der Übergang flüssig statt abrupt ist. Positioned.fill( child: IgnorePointer( child: AnimatedOpacity( opacity: _draggingOver ? 1 : 0, duration: const Duration(milliseconds: 160), curve: Curves.easeOutCubic, child: _DragOverlay(pt: pt, active: _draggingOver), ), ), ), ], ); if (!isDesktop) return body; return DropTarget( onDragEntered: (_) => setState(() => _draggingOver = true), onDragExited: (_) => setState(() => _draggingOver = false), onDragDone: (details) async { setState(() => _draggingOver = false); if (room == null) return; final files = details.files.map((xf) => File(xf.path)).toList(); if (files.isEmpty) return; if (!mounted) return; await AttachmentDialog.show(context, files, room, replyTo: _replyTo); if (mounted) setState(() => _replyTo = null); }, child: body, ); } } 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, ); } } /// DM-Header, der sich per Klick aufklappt: Das Banner blendet als Hintergrund /// ein, Avatar und Name skalieren mit, Handle + Bio erscheinen darunter /// (Design nach Nutzer-Mockup, Twitter/Discord-artig). 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'; } } // Shows a slim banner when the client loses its connection to the homeserver. // Debounced so brief transient sync errors don't flicker a banner. class _ConnectionBanner extends ConsumerStatefulWidget { const _ConnectionBanner(); @override ConsumerState<_ConnectionBanner> createState() => _ConnectionBannerState(); } class _ConnectionBannerState extends ConsumerState<_ConnectionBanner> { StreamSubscription? _sub; Timer? _debounce; bool _lost = false; @override void initState() { super.initState(); _attach(); } Future _attach() async { try { final client = await ref.read(matrixClientProvider.future); _sub = client.onSyncStatus.stream.listen((s) { if (s.status == SyncStatus.error) { // Only surface after the error persists a few seconds. _debounce ??= Timer(const Duration(seconds: 4), () { _debounce = null; if (mounted) setState(() => _lost = true); }); } else if (s.status == SyncStatus.finished || s.status == SyncStatus.processing) { _debounce?.cancel(); _debounce = null; if (_lost && mounted) setState(() => _lost = false); } }); } catch (_) {} } @override void dispose() { _sub?.cancel(); _debounce?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, alignment: Alignment.topCenter, child: _lost ? Container( width: double.infinity, color: pt.danger.withAlpha(36), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.cloud_off_rounded, size: 14, color: pt.danger), const SizedBox(width: 8), Flexible( child: Text( 'Keine Verbindung zum Server – Wiederverbindung läuft…', style: TextStyle(color: pt.danger, fontSize: 12, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ) : const SizedBox.shrink(), ); } } class _MessageList extends StatelessWidget { final Timeline timeline; final ScrollController scrollCtrl; final String currentUserId; final PyramidTheme pt; final ValueChanged onReply; final Set selectedIds; final ValueChanged onToggleSelect; final String? sessionFullyReadId; final bool sessionHasUnread; final bool unreadFading; /// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it. final GlobalKey? unreadDividerKey; /// Jump-to-message target (from search): scroll to + highlight this event. final String? jumpEventId; final GlobalKey? jumpKey; final String? highlightEventId; final bool highlightOn; const _MessageList({ required this.timeline, required this.scrollCtrl, required this.currentUserId, required this.pt, required this.onReply, required this.selectedIds, required this.onToggleSelect, this.sessionFullyReadId, this.sessionHasUnread = false, this.unreadFading = false, this.unreadDividerKey, this.jumpEventId, this.jumpKey, this.highlightEventId, this.highlightOn = false, }); @override Widget build(BuildContext context) { // Dedupe by eventId — during concurrent forward/backward pagination the // timeline can transiently contain the same event twice, which would // produce duplicate ListView keys and crash the sliver (indexOf assertion). final seenIds = {}; final events = timeline.events .where(_isVisible) .where((e) => seenIds.add(e.eventId)) .toList(); if (events.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.forum_outlined, size: 48, color: pt.fgDim), const SizedBox(height: 16), Text( 'No messages yet', style: TextStyle(color: pt.fgMuted, fontSize: 14), ), ], ), ); } final groups = _buildGroups(events); final hasUnread = sessionHasUnread; // Resolve the effective fullyReadId: if the captured ID points to a // non-visible event (reaction, edit, state) walk the timeline forward // (toward older events) until we find the nearest visible one. String? fullyReadId = sessionFullyReadId; if (fullyReadId != null) { final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId)); if (!inGroups) { final tlEvents = timeline.events; // newest-first final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId); if (idx >= 0) { fullyReadId = null; for (int i = idx; i < tlEvents.length; i++) { if (_isVisible(tlEvents[i])) { fullyReadId = tlEvents[i].eventId; break; } } } else { fullyReadId = null; // event not even in loaded timeline } } } // Read-receipt display — matching Element behaviour: // • The newest unread own group shows ✓ (sent but not read) // • The newest own group AT or BEFORE the partner's receipt shows ✓✓ // • If those are the same group → just ✓✓ final room = timeline.room; // Read receipts (Element-style): // • ✓✓ at the single newest event another member has read (any sender) — // moves next to the partner's own reply when they reply/read everything. // • grey ✓ only on my newest message while it's still unread by them. // timeline.events is newest-first → lower index = newer. // // Receipts können je nach Client in `global` ODER `mainThread` stehen // (threaded read receipts) — beide zusammenführen, neuere ts gewinnt. final mergedReceipts = {}; for (final source in [ room.receiptState.global.otherUsers, room.receiptState.mainThread?.otherUsers ?? const {}, ]) { source.forEach((userId, data) { final existing = mergedReceipts[userId]; if (existing == null || data.ts > existing.ts) { mergedReceipts[userId] = data; } }); } int readIndex = -1; int latestReceiptTs = 0; mergedReceipts.forEach((userId, data) { if (userId == currentUserId) return; if (data.ts > latestReceiptTs) latestReceiptTs = data.ts; var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId); // Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) — // zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker // verloren. while (ri >= 0 && ri < timeline.events.length && !_isVisible(timeline.events[ri])) { ri++; } if (ri >= timeline.events.length) ri = -1; if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri; }); final readEventIds = readIndex >= 0 ? {timeline.events[readIndex].eventId} : {}; // Newest own visible message, and whether it's still unread (newer than the // partner's read position, or no read position yet). final newestOwnIndex = timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId); var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex); // Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters // (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als // meine neueste Nachricht, gilt sie trotzdem als gelesen. if (ownUnread && readIndex < 0 && latestReceiptTs >= timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) { readEventIds.add(timeline.events[newestOwnIndex].eventId); ownUnread = false; } final lastOwnGroupKey = (ownUnread) ? () { for (final g in groups) { if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}'; } return null; }() : null; // Flatten groups + date dividers + unread divider into independent list items. // itemFullyReadIds[i] is non-null only for a group item that contains the // fullyRead event at a non-last position — MessageGroup renders an inline // divider after that event. final itemKeys = []; final itemGroups = ?>[]; final itemDates = []; final itemUnread = []; final itemFullyReadIds = []; bool unreadInserted = false; for (var i = 0; i < groups.length; i++) { final group = groups[i]; // Use the newest event (group.last after reversal) for date comparison. final groupNewest = group.last.originServerTs; final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null; final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest); // Check if this group contains the effective fullyRead event. final groupContainsFullyRead = fullyReadId != null && group.any((e) => e.eventId == fullyReadId); if (!unreadInserted && hasUnread && groupContainsFullyRead) { if (group.last.eventId == fullyReadId) { // fullyRead is the NEWEST event in the group → the whole group is // read. Insert a group-level divider between this group and the // newer (lower-index) groups. itemKeys.add('unread'); itemGroups.add(null); itemDates.add(null); itemUnread.add(true); itemFullyReadIds.add(null); } // Whether group-level or inline, we've now handled the marker. unreadInserted = true; } // Key based on oldest event (group.first) — stable identifier. itemKeys.add('g:${group.first.eventId}'); itemGroups.add(group); itemDates.add(null); itemUnread.add(false); // Pass fullyReadId to the group only when the event is mid-group // (not the last event) so MessageGroup can render an inline divider. // groupContainsFullyRead already guarantees fullyReadId != null. final passedFullyReadId = (groupContainsFullyRead && group.last.eventId != fullyReadId) ? fullyReadId : null; itemFullyReadIds.add(passedFullyReadId); if (isLastOfDay) { final d = groupNewest; itemKeys.add('d:${d.year}-${d.month}-${d.day}'); itemGroups.add(null); itemDates.add(d); itemUnread.add(false); itemFullyReadIds.add(null); } } // Fallback: fullyRead event is older than all loaded events — all visible // messages are unread. Insert divider above the oldest loaded group (top of list). if (!unreadInserted && hasUnread) { itemKeys.add('unread'); itemGroups.add(null); itemDates.add(null); itemUnread.add(true); itemFullyReadIds.add(null); } final keyIndex = { for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i, }; return ListView.builder( controller: scrollCtrl, reverse: true, physics: const ClampingScrollPhysics(), padding: const EdgeInsets.only(bottom: 8), scrollCacheExtent: const ScrollCacheExtent.pixels(2500), itemCount: itemKeys.length, findChildIndexCallback: (Key key) { // Only return an index when the key still maps to that exact slot — // returning a stale/duplicate index trips the sliver indexOf assertion. if (key is ValueKey) { final idx = keyIndex[key.value]; if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) { return idx; } } return null; }, itemBuilder: (context, i) { final key = ValueKey(itemKeys[i]); if (itemUnread[i]) { // Wrap in SizedBox so the list can use the ValueKey for optimisation // while _UnreadDivider carries the GlobalKey for ensureVisible. return SizedBox( key: key, child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading), ); } final date = itemDates[i]; if (date != null) return DateDivider(key: key, date: date); final group = itemGroups[i]!; final groupKey = itemKeys[i]; // Show the sending/sent indicator on the newest own group. final showStatus = groupKey == lastOwnGroupKey; return MessageGroup( key: key, events: group, currentUserId: currentUserId, room: room, timeline: timeline, onReply: () => onReply(group.last), selectedIds: selectedIds, onToggleSelect: onToggleSelect, showStatusIndicator: showStatus, readEventIds: readEventIds, fullyReadId: itemFullyReadIds[i], jumpEventId: jumpEventId, jumpKey: jumpKey, highlightEventId: highlightEventId, highlightOn: highlightOn, ); }, ); } List> _buildGroups(List events) { final groups = >[]; List? current; for (final event in events) { if (current == null || current.first.senderId != event.senderId || event.originServerTs.difference(current.last.originServerTs).abs() > const Duration(minutes: 5)) { current = [event]; groups.add(current); } else { current.add(event); } } // The timeline is newest-first, so each group is [newest, ..., oldest]. // Reverse each group so messages render oldest-to-newest (top to bottom). return groups.map((g) => g.reversed.toList()).toList(); } bool _sameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; static bool _isVisible(Event e) { // NOTE: error-status events are kept visible so a failed send shows a // "not sent" state with retry/delete instead of silently disappearing. if (!{ EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted, }.contains(e.type)) { return false; } if ({ RelationshipTypes.edit, RelationshipTypes.reaction, }.contains(e.relationshipType)) { return false; } if (e.type == EventTypes.Redaction) { return false; } return true; } } class _SelectionHeader extends StatelessWidget { final PyramidTheme pt; final int count; final Set selectedIds; final Room? room; final Timeline? timeline; final String currentUserId; final VoidCallback onClear; final VoidCallback onReply; const _SelectionHeader({ required this.pt, required this.count, required this.selectedIds, required this.room, required this.timeline, required this.currentUserId, required this.onClear, required this.onReply, }); List _resolveEvents() { if (timeline == null) return []; return selectedIds .map((id) { try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; } }) .whereType() .toList(); } void _copyAll() { final texts = _resolveEvents().map((e) => e.body).join('\n'); Clipboard.setData(ClipboardData(text: texts)); } void _deleteAll(BuildContext context) { for (final e in _resolveEvents()) { if (e.senderId == currentUserId) { e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null); } } onClear(); } void _pinAll() { if (room == null) return; final pinned = List.from( room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], ); for (final e in _resolveEvents()) { if (!pinned.contains(e.eventId)) pinned.add(e.eventId); } room!.setPinnedEvents(pinned).catchError((_) => ''); onClear(); } @override Widget build(BuildContext context) { final events = _resolveEvents(); final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId); final isTextOnly = events.every((e) => e.messageType == 'm.text'); return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: pt.accentSoft, border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))), ), child: Row( children: [ IconButton( onPressed: onClear, icon: Icon(Icons.close_rounded, size: 20, color: pt.accent), tooltip: 'Auswahl aufheben', constraints: const BoxConstraints(minWidth: 36, minHeight: 36), padding: EdgeInsets.zero, ), const SizedBox(width: 4), Text( '$count ausgewählt', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), ), const Spacer(), if (count == 1) _SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply), if (isTextOnly) _SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll), _SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll), if (canDelete) _SelBtn( icon: Icons.delete_outline_rounded, tooltip: 'Löschen', pt: pt, color: pt.danger, onTap: () => _deleteAll(context), ), ], ), ); } } class _SelBtn extends StatelessWidget { final IconData icon; final String tooltip; final PyramidTheme pt; final Color? color; final VoidCallback onTap; const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color}); @override Widget build(BuildContext context) { return IconButton( onPressed: onTap, icon: Icon(icon, size: 20, color: color ?? pt.fg), tooltip: tooltip, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), padding: EdgeInsets.zero, ); } } class _UnreadDivider extends StatelessWidget { final PyramidTheme pt; final bool fading; const _UnreadDivider({super.key, required this.pt, this.fading = false}); @override Widget build(BuildContext context) { return AnimatedOpacity( opacity: fading ? 0.0 : 1.0, duration: const Duration(milliseconds: 700), curve: Curves.easeOut, child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), child: Row( children: [ Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( 'Neue Nachrichten', style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600), ), ), Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), ], ), ), ); } } // ─── Typing indicator ───────────────────────────────────────────────────────── class _TypingIndicator extends StatefulWidget { final Room room; final PyramidTheme pt; const _TypingIndicator({required this.room, required this.pt}); @override State<_TypingIndicator> createState() => _TypingIndicatorState(); } class _TypingIndicatorState extends State<_TypingIndicator> { StreamSubscription? _sub; List _typing = []; StreamSubscription _listenTyping(Room room) { // client.onSync fires after every sync — cheap to filter for typing events. return room.client.onSync.stream.listen((_) { if (!mounted) return; final typing = room.typingUsers .where((u) => u.id != room.client.userID) .toList(); if (typing.length != _typing.length || !typing.every((u) => _typing.any((t) => t.id == u.id))) { setState(() => _typing = typing); } }); } @override void initState() { super.initState(); _sub = _listenTyping(widget.room); } @override void didUpdateWidget(_TypingIndicator oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.room.id != widget.room.id) { _sub?.cancel(); setState(() => _typing = []); _sub = _listenTyping(widget.room); } } @override void dispose() { _sub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { if (_typing.isEmpty) return const SizedBox.shrink(); final pt = widget.pt; final names = _typing.map((u) => u.calcDisplayname()).toList(); final label = names.length == 1 ? '${names[0]} tippt…' : names.length == 2 ? '${names[0]} und ${names[1]} tippen…' : '${names[0]} und ${names.length - 1} weitere tippen…'; return Padding( padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), child: Row( children: [ _TypingDots(color: pt.fgDim), const SizedBox(width: 8), Flexible( child: Text( label, style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ); } } class _TypingDots extends StatefulWidget { final Color color; const _TypingDots({required this.color}); @override State<_TypingDots> createState() => _TypingDotsState(); } class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), )..repeat(); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _ctrl, builder: (context, child) { return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (i) { // Each dot peaks at a different phase. final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0); final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0); return Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: Opacity( opacity: opacity, child: Container( width: 4, height: 4, decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), ), ), ); }), ); }, ); } } // ─── Drag-and-drop overlay ──────────────────────────────────────────────────── class _DragOverlay extends StatefulWidget { final PyramidTheme pt; final bool active; const _DragOverlay({required this.pt, required this.active}); @override State<_DragOverlay> createState() => _DragOverlayState(); } class _DragOverlayState extends State<_DragOverlay> with SingleTickerProviderStateMixin { // Sanftes Auf-und-ab des Icons, solange das Overlay aktiv ist. late final AnimationController _bob = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), ); @override void initState() { super.initState(); if (widget.active) _bob.repeat(reverse: true); } @override void didUpdateWidget(_DragOverlay old) { super.didUpdateWidget(old); if (widget.active && !old.active) { _bob.repeat(reverse: true); } else if (!widget.active && old.active) { _bob.stop(); } } @override void dispose() { _bob.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pt = widget.pt; return Container( color: pt.bg0.withAlpha(200), child: Center( child: AnimatedScale( scale: widget.active ? 1.0 : 0.88, duration: const Duration(milliseconds: 220), curve: Curves.easeOutBack, child: Container( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(20), border: Border.all(color: pt.accent, width: 2), boxShadow: [ BoxShadow( color: pt.accent.withAlpha(60), blurRadius: 32, spreadRadius: 4, ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _bob, builder: (_, child) => Transform.translate( offset: Offset( 0, -7 * Curves.easeInOut.transform(_bob.value)), child: child, ), child: Icon(Icons.upload_file_rounded, size: 56, color: pt.accent), ), const SizedBox(height: 16), Text( 'Datei hier ablegen', style: TextStyle( color: pt.fg, fontSize: 20, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Text( 'Loslassen zum Hochladen', style: TextStyle(color: pt.fgMuted, fontSize: 14), ), ], ), ), ), ), ); } } // Placeholder shown when no room is selected class NoChatSelected extends StatelessWidget { const NoChatSelected({super.key}); @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return Container( color: pt.bg1, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), const SizedBox(height: 16), Text( 'Select a room to start chatting', style: TextStyle(color: pt.fgMuted, fontSize: 16), ), const SizedBox(height: 8), Text( 'Choose from the sidebar on the left', style: TextStyle(color: pt.fgDim, fontSize: 13), ), ], ), ), ); } }