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_search_controller.dart'; import 'details/chat_info.dart'; import 'talk_navigator.dart'; import 'widgets/chat_bubble.dart'; import 'widgets/chat_search_app_bar.dart'; import 'widgets/chat_textfield.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(); final TextEditingController _searchTextController = TextEditingController(); final Map _matchIndices = {}; // 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; bool _searchActive = false; String _searchQuery = ''; List _matches = const []; int _activeMatchIndex = 0; GetChatResponse? _matchesComputedFor; String? _matchesComputedQuery; // Captured in initState because the framework has unmounted us by the // time dispose runs. ChatBloc? _chatBlocRef; ChatListBloc? _chatListBlocRef; PageRoute? _subscribedRoute; @override void initState() { super.initState(); _chatBlocRef = context.read(); _chatListBlocRef = 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; final data = _chatBlocRef?.state.data; if (data == null || !data.hasMoreOld || data.isLoadingOlder) return; _chatBlocRef?.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. _chatBlocRef?.setToken(widget.room.token); } @override void dispose() { if (_subscribedRoute != null) { AppRoutes.chatRouteObserver.unsubscribe(this); } _positionsListener.itemPositions.removeListener(_onScrollPositions); _markAsReadFinal(); _chatBlocRef?.leaveChat(widget.room.token); _searchTextController.dispose(); 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 = _chatBlocRef?.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; _chatListBlocRef?.markRoomAsRead(widget.room.token, maxId); unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId)); } @override void didUpdateWidget(covariant ChatView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.room.token != oldWidget.room.token && _searchActive) { _exitSearchMode(); } } void _refresh() { context.read().setToken(widget.room.token); } void _enterSearchMode() { setState(() { _searchActive = true; _searchQuery = ''; _matches = const []; _activeMatchIndex = 0; _matchesComputedFor = null; _matchesComputedQuery = null; _searchTextController.clear(); }); } void _exitSearchMode() { setState(() { _searchActive = false; _searchQuery = ''; _matches = const []; _activeMatchIndex = 0; _matchIndices.clear(); _matchesComputedFor = null; _matchesComputedQuery = null; _searchTextController.clear(); }); } void _onSearchChanged(String q) { final chatResponse = context.read().state.data?.chatResponse; setState(() { _searchQuery = q; _activeMatchIndex = 0; if (chatResponse != null) { _recomputeMatches(chatResponse); } else { _matches = const []; _matchesComputedFor = null; _matchesComputedQuery = null; } }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _matches.isNotEmpty) _scrollToActiveMatch(); }); } void _recomputeMatches(GetChatResponse response) { _matches = ChatSearchController.findMatches(response, _searchQuery); _activeMatchIndex = _activeMatchIndex.clamp( 0, math.max(0, _matches.length - 1), ); _matchesComputedFor = response; _matchesComputedQuery = _searchQuery; } void _goToPreviousMatch() { if (_matches.isEmpty) return; setState(() { _activeMatchIndex = (_activeMatchIndex + 1) % _matches.length; }); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch()); } void _goToNextMatch() { if (_matches.isEmpty) return; setState(() { _activeMatchIndex = (_activeMatchIndex - 1 + _matches.length) % _matches.length; }); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch()); } void _scrollToActiveMatch() { if (_matches.isEmpty) return; if (!_itemScrollController.isAttached) return; final id = _matches[_activeMatchIndex].messageId; final idx = _matchIndices[id]; if (idx == null) return; _itemScrollController.scrollTo( index: idx, alignment: 0.4, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, ); } List _buildMessages(GetChatResponse response) { if (_searchActive && (response != _matchesComputedFor || _searchQuery != _matchesComputedQuery)) { _recomputeMatches(response); } final matchIds = _matches.map((m) => m.messageId).toSet(); final activeId = _matches.isNotEmpty ? _matches[_activeMatchIndex].messageId : null; final highlightQuery = _searchActive && _searchQuery.trim().isNotEmpty ? _searchQuery : null; final commonRead = int.parse( response.headers?['x-chat-last-common-read'] ?? '0', ); final messages = []; final chronologicalMatchIndex = {}; var lastDate = DateTime.now(); for (final element in response.sortByTimestamp()) { if (ChatSearchController.isHiddenSystemMessage(element)) continue; final elementDate = DateTime.fromMillisecondsSinceEpoch( element.timestamp * 1000, ); if (!elementDate.isSameDay(lastDate)) { lastDate = elementDate; messages.add( ChatBubble( isSender: false, bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), chatData: widget.room, refetch: ({bool renew = false}) => _refresh(), ), ); } final isMatch = matchIds.contains(element.id); final highlight = isMatch ? (element.id == activeId ? SearchHighlight.active : SearchHighlight.secondary) : SearchHighlight.none; if (isMatch) chronologicalMatchIndex[element.id] = messages.length; messages.add( ChatBubble( isSender: element.actorId == widget.selfId && (element.messageType == GetRoomResponseObjectMessageType.comment || element.messageType == GetRoomResponseObjectMessageType.deletedComment), bubbleData: element, chatData: widget.room, refetch: ({bool renew = false}) => _refresh(), isRead: element.id <= commonRead, selfId: widget.selfId, highlightQuery: highlightQuery, matchHighlight: highlight, ), ); } final total = messages.length; _matchIndices ..clear() ..addEntries( chronologicalMatchIndex.entries.map( (e) => MapEntry(e.key, (total - 1) - e.value), ), ); return messages; } @override Widget build(BuildContext context) { // Swallow 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. final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; return PopScope( canPop: !keyboardOpen, onPopInvokedWithResult: (didPop, _) { if (didPop) return; FocusManager.instance.primaryFocus?.unfocus(); }, child: Scaffold( backgroundColor: const Color(0xffefeae2), appBar: _searchActive ? ChatSearchAppBar( controller: _searchTextController, matchCount: _matches.length, activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex, onChanged: _onSearchChanged, onClose: _exitSearchMode, onPrevious: _matches.isEmpty ? null : _goToPreviousMatch, onNext: _matches.isEmpty ? null : _goToNextMatch, ) : 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: _enterSearchMode, ), ], ), ), body: ChatBackground( child: Column( children: [ Expanded( child: LoadableStateConsumer( isReady: (state) => state.chatResponse != null && state.currentToken == widget.room.token, enablePullToRefresh: false, child: (state, _) { final items = _buildMessages( state.chatResponse!, ).reversed.toList(); // 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( isSender: false, bubbleData: GetChatResponseObject.getTextDummy( 'Anfang des Chats', ), chatData: widget.room, refetch: ({bool renew = false}) => _refresh(), ), ); } _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) ? ChatTextfield( widget.room.token, selfId: widget.selfId, roomType: widget.room.type, ) : SafeArea( child: ChatTextfield( widget.room.token, selfId: widget.selfId, roomType: widget.room.type, ), ), ), ], ), ), ), ); } } /// 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), ), ), ); }