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 '../../../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 '../../../theming/app_theme.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 { final ItemScrollController _itemScrollController = ItemScrollController(); final TextEditingController _searchTextController = TextEditingController(); final Map _matchIndices = {}; bool _searchActive = false; String _searchQuery = ''; List _matches = const []; int _activeMatchIndex = 0; GetChatResponse? _matchesComputedFor; String? _matchesComputedQuery; @override void dispose() { _searchTextController.dispose(); super.dispose(); } @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 messages = []; final chronologicalMatchIndex = {}; var lastDate = DateTime.now(); for (final element in response.sortByTimestamp()) { final elementDate = DateTime.fromMillisecondsSinceEpoch( element.timestamp * 1000, ); if (element.systemMessage.contains('reaction')) continue; if (element.systemMessage.contains('poll_voted')) continue; if (element.systemMessage.contains('message_deleted')) continue; final commonRead = int.parse( response.headers?['x-chat-last-common-read'] ?? '0', ); if (!elementDate.isSameDay(lastDate)) { lastDate = elementDate; messages.add( ChatBubble( context: context, 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( context: context, 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, ), ); } if (response.data.length >= 200) { messages.insert( 0, ChatBubble( context: context, isSender: false, bubbleData: GetChatResponseObject.getTextDummy( 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', ), chatData: widget.room, refetch: ({bool renew = false}) => _refresh(), ), ); chronologicalMatchIndex.updateAll((_, v) => v + 1); } 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) => 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: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( image: const AssetImage('assets/background/chat.png'), scale: 1.5, opacity: 1, repeat: ImageRepeat.repeat, invertColors: AppTheme.isDarkMode(context), ), ), child: Column( children: [ Expanded( child: LoadableStateConsumer( isReady: (state) => state.chatResponse != null && state.currentToken == widget.room.token, child: (state, _) { final items = _buildMessages(state.chatResponse!).reversed.toList(); return ScrollablePositionedList.builder( reverse: true, itemScrollController: _itemScrollController, 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) : SafeArea( child: ChatTextfield( widget.room.token, selfId: widget.selfId, ), ), ), ], ), ), ); }