From 4c190de479c08ed225ce65a2c99160dd809cae67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 22:21:36 +0200 Subject: [PATCH] implemented in-chat search with text highlighting, added search navigation UI, and integrated scrollable list for message jumping --- lib/view/pages/talk/chat_view.dart | 218 ++++++++++++++++-- lib/view/pages/talk/data/chat_message.dart | 11 +- .../talk/data/chat_search_controller.dart | 49 ++++ lib/view/pages/talk/widgets/bubble.dart | 16 +- lib/view/pages/talk/widgets/chat_bubble.dart | 68 +++++- .../talk/widgets/chat_search_app_bar.dart | 67 ++++++ .../talk/widgets/highlighted_linkify.dart | 140 +++++++++++ pubspec.yaml | 2 + .../talk/chat_search_controller_test.dart | 163 +++++++++++++ 9 files changed, 698 insertions(+), 36 deletions(-) create mode 100644 lib/view/pages/talk/data/chat_search_controller.dart create mode 100644 lib/view/pages/talk/widgets/chat_search_app_bar.dart create mode 100644 lib/view/pages/talk/widgets/highlighted_linkify.dart create mode 100644 test/view/talk/chat_search_controller_test.dart diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index 2c81c48..ccf3a07 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -1,5 +1,8 @@ +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'; @@ -10,9 +13,11 @@ 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 { @@ -32,14 +37,139 @@ class ChatView extends StatefulWidget { } class _ChatViewState extends State { - final ScrollController _listController = ScrollController(); + 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( @@ -65,6 +195,15 @@ class _ChatViewState extends State { ); } + 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, @@ -76,6 +215,8 @@ class _ChatViewState extends State { refetch: ({bool renew = false}) => _refresh(), isRead: element.id <= commonRead, selfId: widget.selfId, + highlightQuery: highlightQuery, + matchHighlight: highlight, ), ); } @@ -94,32 +235,60 @@ class _ChatViewState extends State { 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: 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, + 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( @@ -137,11 +306,16 @@ class _ChatViewState extends State { isReady: (state) => state.chatResponse != null && state.currentToken == widget.room.token, - child: (state, _) => ListView( - reverse: true, - controller: _listController, - children: _buildMessages(state.chatResponse!).reversed.toList(), - ), + 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( diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 30ec493..bba5ae8 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -1,12 +1,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../utils/url_opener.dart'; +import '../widgets/highlighted_linkify.dart'; class ChatMessage { String originalMessage; @@ -27,8 +27,13 @@ class ChatMessage { ); } - Widget getWidget() { - var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen); + Widget getWidget({String? highlightQuery, TextStyle? style}) { + var contentWidget = HighlightedLinkify( + text: content, + onOpen: UrlOpener.onOpen, + highlight: highlightQuery, + style: style, + ); if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { return ListTile( diff --git a/lib/view/pages/talk/data/chat_search_controller.dart b/lib/view/pages/talk/data/chat_search_controller.dart new file mode 100644 index 0000000..7d9e54b --- /dev/null +++ b/lib/view/pages/talk/data/chat_search_controller.dart @@ -0,0 +1,49 @@ +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; + +class ChatSearchMatch { + final int messageId; + final int timestamp; + + const ChatSearchMatch({required this.messageId, required this.timestamp}); +} + +class ChatSearchController { + static List findMatches( + GetChatResponse response, + String query, + ) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return const []; + + final matches = []; + for (final element in response.sortByTimestamp()) { + if (element.systemMessage.contains('reaction')) continue; + if (element.systemMessage.contains('poll_voted')) continue; + + final haystackText = RichObjectStringProcessor.parseToString( + element.message, + element.messageParameters, + ).toLowerCase(); + + var matched = haystackText.contains(q); + if (!matched && + element.messageType != GetRoomResponseObjectMessageType.system) { + matched = element.actorDisplayName.toLowerCase().contains(q); + } + + if (matched) { + matches.add( + ChatSearchMatch( + messageId: element.id, + timestamp: element.timestamp, + ), + ); + } + } + + matches.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return matches; + } +} diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart index 30d1fac..436fa05 100644 --- a/lib/view/pages/talk/widgets/bubble.dart +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -27,6 +27,7 @@ class BubbleStyle { const BubbleStyle({ this.color, this.borderWidth = 0, + this.borderColor, this.elevation = 0, this.margin = const BubbleEdges.only(), this.padding = const BubbleEdges.all(8), @@ -37,12 +38,25 @@ class BubbleStyle { final Color? color; final double borderWidth; + final Color? borderColor; final double elevation; final BubbleEdges margin; final BubbleEdges padding; final Alignment alignment; final BubbleNip nip; final double borderRadius; + + BubbleStyle copyWith({double? borderWidth, Color? borderColor}) => BubbleStyle( + color: color, + borderWidth: borderWidth ?? this.borderWidth, + borderColor: borderColor ?? this.borderColor, + elevation: elevation, + margin: margin, + padding: padding, + alignment: alignment, + nip: nip, + borderRadius: borderRadius, + ); } /// The "nip" is faked by flattening one corner so the bubble anchors to @@ -88,7 +102,7 @@ class Bubble extends StatelessWidget { borderRadius: radius, border: style.borderWidth > 0 ? Border.all( - color: Theme.of(context).dividerColor, + color: style.borderColor ?? Theme.of(context).dividerColor, width: style.borderWidth, ) : null, diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 9e5079f..a100a20 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -18,6 +18,9 @@ import 'bubble.dart'; import 'chat_bubble_poll.dart'; import 'chat_bubble_reactions.dart'; import 'chat_message_options_dialog.dart'; +import 'highlighted_linkify.dart'; + +enum SearchHighlight { none, secondary, active } class ChatBubble extends StatefulWidget { final BuildContext context; @@ -33,6 +36,9 @@ class ChatBubble extends StatefulWidget { final void Function({bool renew}) refetch; + final String? highlightQuery; + final SearchHighlight matchHighlight; + const ChatBubble({ required this.context, required this.isSender, @@ -41,6 +47,8 @@ class ChatBubble extends StatefulWidget { required this.refetch, this.isRead = false, this.selfId, + this.highlightQuery, + this.matchHighlight = SearchHighlight.none, super.key, }); @@ -142,13 +150,29 @@ class _ChatBubbleState extends State BubbleStyle _getStyle() { final styles = ChatBubbleStyles(context); + final BubbleStyle base; if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) { - return styles.getSystemStyle(); + base = styles.getSystemStyle(); + } else { + base = widget.isSender + ? styles.getSelfStyle(false) + : styles.getRemoteStyle(false); + } + switch (widget.matchHighlight) { + case SearchHighlight.none: + return base; + case SearchHighlight.secondary: + return base.copyWith( + borderWidth: 1.5, + borderColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.45), + ); + case SearchHighlight.active: + return base.copyWith( + borderWidth: 3, + borderColor: Theme.of(context).colorScheme.primary, + ); } - return widget.isSender - ? styles.getSelfStyle(false) - : styles.getRemoteStyle(false); } void _showOptionsDialog() => showChatMessageOptionsDialog( @@ -196,15 +220,29 @@ class _ChatBubbleState extends State GetRoomResponseObjectMessageType.deletedComment; final parent = widget.bubbleData.parent; + final actorBaseStyle = TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ); final actorText = Text( widget.bubbleData.actorDisplayName, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - ), + style: actorBaseStyle, ); + final actorWidget = (widget.highlightQuery?.trim().isNotEmpty ?? false) + ? Text.rich( + TextSpan( + children: buildHighlightedSpans( + text: widget.bubbleData.actorDisplayName, + query: widget.highlightQuery, + baseStyle: actorBaseStyle, + ), + ), + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ) + : actorText; final timeText = Text( DateTime.fromMillisecondsSinceEpoch( @@ -252,8 +290,16 @@ class _ChatBubbleState extends State style: _getStyle(), child: _BubbleContent( actorText: actorText, + actorWidget: actorWidget, timeText: timeText, - messageWidget: message.getWidget(), + messageWidget: message.getWidget( + highlightQuery: widget.highlightQuery, + style: + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.system + ? Theme.of(context).textTheme.bodySmall + : null, + ), parent: parent, bubbleData: widget.bubbleData, isSender: widget.isSender, @@ -282,6 +328,7 @@ class _ChatBubbleState extends State class _BubbleContent extends StatelessWidget { final Text actorText; + final Widget actorWidget; final Text timeText; final Widget messageWidget; final GetChatResponseObject? parent; @@ -298,6 +345,7 @@ class _BubbleContent extends StatelessWidget { const _BubbleContent({ required this.actorText, + required this.actorWidget, required this.timeText, required this.messageWidget, required this.parent, @@ -323,7 +371,7 @@ class _BubbleContent extends StatelessWidget { ), child: Stack( children: [ - if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText), + if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget), Padding( padding: EdgeInsets.only( bottom: showBubbleTime ? 18 : 0, diff --git a/lib/view/pages/talk/widgets/chat_search_app_bar.dart b/lib/view/pages/talk/widgets/chat_search_app_bar.dart new file mode 100644 index 0000000..0b1c9f7 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_search_app_bar.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget { + final TextEditingController controller; + final int matchCount; + final int activeIndex; + final ValueChanged onChanged; + final VoidCallback onClose; + final VoidCallback? onPrevious; + final VoidCallback? onNext; + + const ChatSearchAppBar({ + required this.controller, + required this.matchCount, + required this.activeIndex, + required this.onChanged, + required this.onClose, + required this.onPrevious, + required this.onNext, + super.key, + }); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + final counterText = matchCount == 0 + ? '0/0' + : '${activeIndex + 1}/$matchCount'; + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onClose, + ), + title: TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.search, + decoration: const InputDecoration( + hintText: 'In Chat suchen…', + border: InputBorder.none, + ), + onChanged: onChanged, + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + counterText, + style: const TextStyle(fontSize: 12), + ), + ), + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_up), + onPressed: onPrevious, + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: onNext, + ), + ], + ); + } +} diff --git a/lib/view/pages/talk/widgets/highlighted_linkify.dart b/lib/view/pages/talk/widgets/highlighted_linkify.dart new file mode 100644 index 0000000..95281ac --- /dev/null +++ b/lib/view/pages/talk/widgets/highlighted_linkify.dart @@ -0,0 +1,140 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:linkify/linkify.dart' as linkify_pkg; + +const TextStyle kSearchHighlightStyle = TextStyle( + backgroundColor: Color(0xFFFFD54F), + color: Colors.black, +); + +List buildHighlightedSpans({ + required String text, + required String? query, + required TextStyle baseStyle, + TextStyle highlightStyle = kSearchHighlightStyle, + GestureRecognizer? recognizer, +}) { + final q = query?.trim().toLowerCase(); + if (q == null || q.isEmpty) { + return [TextSpan(text: text, style: baseStyle, recognizer: recognizer)]; + } + + final spans = []; + final lower = text.toLowerCase(); + var cursor = 0; + while (cursor < text.length) { + final hit = lower.indexOf(q, cursor); + if (hit < 0) { + spans.add( + TextSpan( + text: text.substring(cursor), + style: baseStyle, + recognizer: recognizer, + ), + ); + break; + } + if (hit > cursor) { + spans.add( + TextSpan( + text: text.substring(cursor, hit), + style: baseStyle, + recognizer: recognizer, + ), + ); + } + final end = hit + q.length; + spans.add( + TextSpan( + text: text.substring(hit, end), + style: baseStyle.merge(highlightStyle), + recognizer: recognizer, + ), + ); + cursor = end; + } + return spans; +} + +class HighlightedLinkify extends StatefulWidget { + final String text; + final String? highlight; + final LinkCallback? onOpen; + final TextStyle? style; + final TextStyle? linkStyle; + + const HighlightedLinkify({ + required this.text, + this.highlight, + this.onOpen, + this.style, + this.linkStyle, + super.key, + }); + + @override + State createState() => _HighlightedLinkifyState(); +} + +class _HighlightedLinkifyState extends State { + final List _recognizers = []; + + @override + void dispose() { + for (final r in _recognizers) { + r.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + + final defaultStyle = widget.style ?? + Theme.of(context).textTheme.bodyMedium ?? + DefaultTextStyle.of(context).style; + final linkStyle = (widget.linkStyle ?? + const TextStyle(color: Colors.blue, decoration: TextDecoration.underline)) + .merge(defaultStyle.copyWith(color: null, decoration: null)); + const linkHighlight = TextStyle( + backgroundColor: Color(0xFFFFD54F), + color: Colors.black, + decoration: TextDecoration.underline, + ); + + final elements = linkify_pkg.linkify(widget.text); + final spans = []; + + for (final el in elements) { + if (el is LinkableElement) { + final recognizer = TapGestureRecognizer() + ..onTap = () => widget.onOpen?.call(el); + _recognizers.add(recognizer); + spans.addAll( + buildHighlightedSpans( + text: el.text, + query: widget.highlight, + baseStyle: linkStyle, + highlightStyle: linkHighlight, + recognizer: recognizer, + ), + ); + } else { + spans.addAll( + buildHighlightedSpans( + text: el.text, + query: widget.highlight, + baseStyle: defaultStyle, + ), + ); + } + } + + return Text.rich(TextSpan(children: spans)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 98ddb1e..34428e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,9 @@ dependencies: workmanager: ^0.9.0+3 intl: ^0.20.2 flutter_linkify: ^6.0.0 + linkify: ^5.0.0 flutter_local_notifications: ^21.0.0 + scrollable_positioned_list: ^0.3.8 flutter_split_view: ^0.1.2 flutter_svg: ^2.0.10 freezed_annotation: ^3.1.0 diff --git a/test/view/talk/chat_search_controller_test.dart b/test/view/talk/chat_search_controller_test.dart new file mode 100644 index 0000000..47dd0a7 --- /dev/null +++ b/test/view/talk/chat_search_controller_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart'; +import 'package:marianum_mobile/view/pages/talk/data/chat_search_controller.dart'; + +GetChatResponseObject _msg({ + required int id, + required int timestamp, + String actorDisplayName = 'Anyone', + String message = '', + String systemMessage = '', + GetRoomResponseObjectMessageType type = + GetRoomResponseObjectMessageType.comment, + Map? params, +}) => + GetChatResponseObject( + id, + 'token', + GetRoomResponseObjectMessageActorType.user, + 'actor-id', + actorDisplayName, + timestamp, + systemMessage, + type, + true, + '', + message, + params, + null, + null, + null, + ); + +GetChatResponse _response(List messages) => + GetChatResponse(messages.toSet()); + +void main() { + group('ChatSearchController.findMatches', () { + test('empty query returns no matches', () { + final response = _response([ + _msg(id: 1, timestamp: 100, message: 'Hallo'), + ]); + expect(ChatSearchController.findMatches(response, ''), isEmpty); + expect(ChatSearchController.findMatches(response, ' '), isEmpty); + }); + + test('matches message text case-insensitively', () { + final response = _response([ + _msg(id: 1, timestamp: 100, message: 'Hallo Welt'), + _msg(id: 2, timestamp: 200, message: 'nichts hier'), + ]); + final matches = ChatSearchController.findMatches(response, 'WELT'); + expect(matches.length, 1); + expect(matches.first.messageId, 1); + }); + + test('matches actor display name', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + actorDisplayName: 'Lisa Maier', + message: 'irgendwas', + ), + _msg( + id: 2, + timestamp: 200, + actorDisplayName: 'Tom Weber', + message: 'auch was', + ), + ]); + final matches = ChatSearchController.findMatches(response, 'lisa'); + expect(matches.length, 1); + expect(matches.first.messageId, 1); + }); + + test('system messages match on text but not on actor', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + actorDisplayName: 'Lisa', + message: 'Lisa ist beigetreten', + type: GetRoomResponseObjectMessageType.system, + ), + ]); + // Match on text content + expect( + ChatSearchController.findMatches(response, 'beigetreten').length, + 1, + ); + // Actor name alone (not in text) should not match for system messages + final actorOnlyResponse = _response([ + _msg( + id: 1, + timestamp: 100, + actorDisplayName: 'Lisa', + message: 'jemand ist beigetreten', + type: GetRoomResponseObjectMessageType.system, + ), + ]); + expect( + ChatSearchController.findMatches(actorOnlyResponse, 'lisa'), + isEmpty, + ); + }); + + test('reaction and poll_voted system messages are filtered out', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + message: 'Treffer', + systemMessage: 'reaction', + type: GetRoomResponseObjectMessageType.system, + ), + _msg( + id: 2, + timestamp: 200, + message: 'Treffer', + systemMessage: 'poll_voted', + type: GetRoomResponseObjectMessageType.system, + ), + _msg(id: 3, timestamp: 300, message: 'Treffer'), + ]); + final matches = ChatSearchController.findMatches(response, 'Treffer'); + expect(matches.length, 1); + expect(matches.first.messageId, 3); + }); + + test('rich object parameters are searchable (e.g. file names)', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + message: '{file}', + params: { + 'file': RichObjectString( + RichObjectStringObjectType.file, + '42', + 'hausaufgaben.pdf', + null, + null, + ), + }, + ), + ]); + final matches = ChatSearchController.findMatches(response, 'hausaufgab'); + expect(matches.length, 1); + expect(matches.first.messageId, 1); + }); + + test('matches are sorted newest first', () { + final response = _response([ + _msg(id: 1, timestamp: 100, message: 'X'), + _msg(id: 2, timestamp: 300, message: 'X'), + _msg(id: 3, timestamp: 200, message: 'X'), + ]); + final matches = ChatSearchController.findMatches(response, 'x'); + expect(matches.map((m) => m.messageId).toList(), [2, 3, 1]); + }); + }); +}