import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../extensions/date_time.dart'; import '../../../notification/notification_tasks.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../widget/chat_background.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; import 'data/chat_message_matcher.dart'; import 'details/chat_info.dart'; import 'search/chat_search_controller.dart'; import 'search/chat_search_delegate.dart'; import 'talk_navigator.dart'; import 'widgets/chat_bubble.dart'; import 'widgets/chat_textfield.dart'; import 'widgets/history_jump_banner.dart'; class ChatView extends StatefulWidget { final GetRoomResponseObject room; final String selfId; final UserAvatar avatar; const ChatView({ super.key, required this.room, required this.selfId, required this.avatar, }); @override State createState() => _ChatViewState(); } class _ChatViewState extends State with RouteAware { final ItemScrollController _itemScrollController = ItemScrollController(); final ItemPositionsListener _positionsListener = ItemPositionsListener.create(); // Number of rows currently rendered; kept in sync in build so the scroll // listener can tell when the oldest row (highest index, reverse list) nears. int _itemCount = 0; // Search hit the chat jumped to; stays marked until the next search. int? _highlightId; String? _highlightQuery; int? _highlightIndex; // Running jump towards a search hit that is not loaded yet. ChatSearchHit? _jumpTarget; DateTime _jumpStartedAt = DateTime.now(); DateTime? _jumpLoadedUntil; int _jumpEpoch = 0; // Captured in initState because the framework has unmounted us by the // time dispose runs. late final ChatBloc _chatBloc; late final ChatListBloc _chatListBloc; PageRoute? _subscribedRoute; @override void initState() { super.initState(); _chatBloc = context.read(); _chatListBloc = context.read(); _positionsListener.itemPositions.addListener(_onScrollPositions); NotificationTasks.clearNotificationsForChat(widget.room.token); } /// Loads the next older block once the top of the list comes into view. void _onScrollPositions() { final positions = _positionsListener.itemPositions.value; if (positions.isEmpty) return; // reverse:true → the highest index is the oldest message (top of screen). // Prefetch ~a screenful ahead so the next block is usually already merged // before the user reaches the top — the load stays invisible. final maxIndex = positions.map((p) => p.index).reduce(math.max); if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return; _chatBloc.loadOlder(); } @override void didChangeDependencies() { super.didChangeDependencies(); final route = ModalRoute.of(context); if (route is PageRoute && route != _subscribedRoute) { if (_subscribedRoute != null) { AppRoutes.chatRouteObserver.unsubscribe(this); } AppRoutes.chatRouteObserver.subscribe(this, route); _subscribedRoute = route; } } @override void didPopNext() { super.didPopNext(); // A stacked chat above us was just popped (typical: notification tap // opened another chat). The global ChatBloc currently points at that // other chat's token, so our isReady predicate fails until we re-claim. _chatBloc.setToken(widget.room.token); } @override void dispose() { if (_subscribedRoute != null) { AppRoutes.chatRouteObserver.unsubscribe(this); } _positionsListener.itemPositions.removeListener(_onScrollPositions); _markAsReadFinal(); _chatBloc.leaveChat(widget.room.token); super.dispose(); } /// Defensive final mark-as-read so a back-out before the long-poll /// could fire doesn't leave the room as unread. Skipped when the bloc /// has already moved on to another chat — the response data there /// belongs to a different room, and writing its max-id as our marker /// would regress our server cursor. void _markAsReadFinal() { final state = _chatBloc.state.data; if (state == null) return; if (state.currentToken != widget.room.token) return; final response = state.chatResponse; if (response == null) return; final maxId = response.data.map((m) => m.id).fold(0, math.max); if (maxId == 0) return; _chatListBloc.markRoomAsRead(widget.room.token, maxId); unawaited(_chatBloc.sendServerReadMarker(widget.room.token, maxId)); } @override void didUpdateWidget(covariant ChatView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.room.token != oldWidget.room.token) _clearHighlight(); } void _refetch() => _chatBloc.setToken(widget.room.token); // The built rows only depend on the chat data and the search state; the list // itself rebuilds far more often (loading flips, keyboard, parent rebuilds), // and re-sorting plus re-creating every bubble each time is wasted work. // The top marker is cached separately so a loading-older flip doesn't // rebuild every message row. Object? _messagesKey; List _messages = const []; Object? _itemsKey; List _items = const []; List _itemsFor(ChatState state) { final messagesKey = ( state.chatResponse, _highlightId, _highlightQuery, widget.room, ); if (messagesKey != _messagesKey) { _messagesKey = messagesKey; _messages = _buildMessages(state.chatResponse!).reversed.toList(); } final key = (_messages, state.isLoadingOlder, state.hasMoreOld); if (key == _itemsKey) return _items; _itemsKey = key; final items = [..._messages]; // reverse:true renders index 0 at the bottom, so the top marker // (spinner / start-of-chat) goes at the end. if (state.isLoadingOlder) { items.add(const _LoadingOlderIndicator()); } else if (!state.hasMoreOld) { items.add( ChatBubble( key: const ValueKey('chat-start'), isSender: false, bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'), chatData: widget.room, refetch: _refetch, ), ); } return _items = items; } Future _openSearch() async { final delegate = ChatSearchDelegate( ChatSearchController( chatToken: widget.room.token, loadedMessages: () { final state = _chatBloc.state.data; return state?.currentToken == widget.room.token ? state?.chatResponse : null; }, ), ); final hit = await showSearch( context: context, delegate: delegate, ); final query = delegate.query; delegate.disposeController(); if (hit == null || !mounted) return; await _jumpToMessage(hit, query); } /// Older hits from the server search are usually not loaded yet; the /// history is paged in until the message is reached. Future _jumpToMessage(ChatSearchHit hit, String query) async { final epoch = ++_jumpEpoch; _clearHighlight(); setState(() { _jumpTarget = hit; _jumpStartedAt = DateTime.now(); _jumpLoadedUntil = null; }); final found = await _chatBloc.loadOlderUntil( hit.messageId, isCancelled: () => !mounted || epoch != _jumpEpoch, onProgress: (oldest) { if (mounted && epoch == _jumpEpoch) { setState(() => _jumpLoadedUntil = oldest); } }, ); if (!mounted || epoch != _jumpEpoch) return; setState(() { _jumpTarget = null; if (found) { _highlightId = hit.messageId; _highlightQuery = query; } }); if (!found) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Nachricht nicht gefunden.')), ); return; } WidgetsBinding.instance.addPostFrameCallback((_) { final idx = _highlightIndex; if (!mounted || idx == null || !_itemScrollController.isAttached) return; _itemScrollController.scrollTo( index: idx, alignment: 0.4, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, ); }); } void _cancelJump() { _jumpEpoch++; setState(() => _jumpTarget = null); } void _clearHighlight() { _highlightId = null; _highlightQuery = null; } List _buildMessages(GetChatResponse response) { final commonRead = int.parse( response.headers?['x-chat-last-common-read'] ?? '0', ); final messages = []; int? highlightPosition; var lastDate = DateTime.now(); for (final element in response.sortByTimestamp()) { if (ChatMessageMatcher.isHiddenSystemMessage(element)) continue; final elementDate = element.sentAt; if (!elementDate.isSameDay(lastDate)) { lastDate = elementDate; messages.add( ChatBubble( isSender: false, key: ValueKey( 'day-${elementDate.year}-${elementDate.month}-' '${elementDate.day}', ), bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), chatData: widget.room, refetch: _refetch, ), ); } final isHighlighted = element.id == _highlightId; if (isHighlighted) highlightPosition = messages.length; messages.add( ChatBubble( key: ValueKey(element.id), isSender: element.actorId == widget.selfId && (element.messageType == GetRoomResponseObjectMessageType.comment || element.messageType == GetRoomResponseObjectMessageType.deletedComment), bubbleData: element, chatData: widget.room, refetch: _refetch, isRead: element.id <= commonRead, selfId: widget.selfId, highlightQuery: isHighlighted ? _highlightQuery : null, matchHighlight: isHighlighted ? SearchHighlight.active : SearchHighlight.none, ), ); } // The list is rendered reversed (newest at index 0). _highlightIndex = highlightPosition == null ? null : messages.length - 1 - highlightPosition; return messages; } @override Widget build(BuildContext context) { final input = ChatTextfield( widget.room.token, selfId: widget.selfId, roomType: widget.room.type, ); return _KeyboardDismissPopScope( child: Scaffold( backgroundColor: const Color(0xffefeae2), appBar: ClickableAppBar( onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), appBar: AppBar( title: Row( children: [ widget.avatar, const SizedBox(width: 10), Expanded( child: Text( widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1, ), ), ], ), actions: [ IconButton( icon: const Icon(Icons.search), tooltip: 'In Chat suchen', onPressed: _openSearch, ), ], ), ), body: ChatBackground( child: Column( children: [ if (_jumpTarget case final target?) HistoryJumpBanner( startedAt: _jumpStartedAt, target: target.sentAt, loadedUntil: _jumpLoadedUntil, onCancel: _cancelJump, ), Expanded( child: LoadableStateConsumer( isReady: (state) => state.chatResponse != null && state.currentToken == widget.room.token, enablePullToRefresh: false, child: (state, _) { final items = _itemsFor(state); _itemCount = items.length; return ScrollablePositionedList.builder( reverse: true, itemScrollController: _itemScrollController, itemPositionsListener: _positionsListener, itemCount: items.length, itemBuilder: (ctx, idx) => items[idx], ); }, ), ), ColoredBox( color: Theme.of(context).colorScheme.surface, child: TalkNavigator.isSecondaryVisible(context) ? input : SafeArea(child: input), ), ], ), ), ), ); } } /// How many rows before the oldest one the older-history prefetch kicks in. const _kLoadOlderPrefetchRows = 15; class _LoadingOlderIndicator extends StatelessWidget { const _LoadingOlderIndicator(); @override Widget build(BuildContext context) => const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } /// Swallows the first back gesture while the keyboard is visible so it /// dismisses the IME instead of popping the chat — matches platform UX /// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits /// mid-typing. A separate widget so the per-frame inset changes during the /// keyboard animation only rebuild this scope, not the whole chat. class _KeyboardDismissPopScope extends StatelessWidget { final Widget child; const _KeyboardDismissPopScope({required this.child}); @override Widget build(BuildContext context) { final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; return PopScope( canPop: !keyboardOpen, onPopInvokedWithResult: (didPop, _) { if (didPop) return; FocusManager.instance.primaryFocus?.unfocus(); }, child: child, ); } }