implemented in-chat search with text highlighting, added search navigation UI, and integrated scrollable list for message jumping

This commit is contained in:
2026-05-09 22:21:36 +02:00
parent b36d1e02f5
commit 4c190de479
9 changed files with 698 additions and 36 deletions
+181 -7
View File
@@ -1,5 +1,8 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/chat/get_chat_response.dart';
import '../../../api/marianumcloud/talk/room/get_room_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 '../../../theming/app_theme.dart';
import '../../../widget/clickable_app_bar.dart'; import '../../../widget/clickable_app_bar.dart';
import '../../../widget/user_avatar.dart'; import '../../../widget/user_avatar.dart';
import 'data/chat_search_controller.dart';
import 'details/chat_info.dart'; import 'details/chat_info.dart';
import 'talk_navigator.dart'; import 'talk_navigator.dart';
import 'widgets/chat_bubble.dart'; import 'widgets/chat_bubble.dart';
import 'widgets/chat_search_app_bar.dart';
import 'widgets/chat_textfield.dart'; import 'widgets/chat_textfield.dart';
class ChatView extends StatefulWidget { class ChatView extends StatefulWidget {
@@ -32,14 +37,139 @@ class ChatView extends StatefulWidget {
} }
class _ChatViewState extends State<ChatView> { class _ChatViewState extends State<ChatView> {
final ScrollController _listController = ScrollController(); final ItemScrollController _itemScrollController = ItemScrollController();
final TextEditingController _searchTextController = TextEditingController();
final Map<int, int> _matchIndices = {};
bool _searchActive = false;
String _searchQuery = '';
List<ChatSearchMatch> _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() { void _refresh() {
context.read<ChatBloc>().setToken(widget.room.token); context.read<ChatBloc>().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<ChatBloc>().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<Widget> _buildMessages(GetChatResponse response) { List<Widget> _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 = <Widget>[]; final messages = <Widget>[];
final chronologicalMatchIndex = <int, int>{};
var lastDate = DateTime.now(); var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) { for (final element in response.sortByTimestamp()) {
final elementDate = DateTime.fromMillisecondsSinceEpoch( final elementDate = DateTime.fromMillisecondsSinceEpoch(
@@ -65,6 +195,15 @@ class _ChatViewState extends State<ChatView> {
); );
} }
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( messages.add(
ChatBubble( ChatBubble(
context: context, context: context,
@@ -76,6 +215,8 @@ class _ChatViewState extends State<ChatView> {
refetch: ({bool renew = false}) => _refresh(), refetch: ({bool renew = false}) => _refresh(),
isRead: element.id <= commonRead, isRead: element.id <= commonRead,
selfId: widget.selfId, selfId: widget.selfId,
highlightQuery: highlightQuery,
matchHighlight: highlight,
), ),
); );
} }
@@ -94,16 +235,37 @@ class _ChatViewState extends State<ChatView> {
refetch: ({bool renew = false}) => _refresh(), 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; return messages;
} }
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xffefeae2), backgroundColor: const Color(0xffefeae2),
appBar: ClickableAppBar( appBar: _searchActive
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), ? 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( appBar: AppBar(
title: Row( title: Row(
children: [ children: [
@@ -118,6 +280,13 @@ class _ChatViewState extends State<ChatView> {
), ),
], ],
), ),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'In Chat suchen',
onPressed: _enterSearchMode,
),
],
), ),
), ),
body: DecoratedBox( body: DecoratedBox(
@@ -137,11 +306,16 @@ class _ChatViewState extends State<ChatView> {
isReady: (state) => isReady: (state) =>
state.chatResponse != null && state.chatResponse != null &&
state.currentToken == widget.room.token, state.currentToken == widget.room.token,
child: (state, _) => ListView( child: (state, _) {
final items =
_buildMessages(state.chatResponse!).reversed.toList();
return ScrollablePositionedList.builder(
reverse: true, reverse: true,
controller: _listController, itemScrollController: _itemScrollController,
children: _buildMessages(state.chatResponse!).reversed.toList(), itemCount: items.length,
), itemBuilder: (ctx, idx) => items[idx],
);
},
), ),
), ),
ColoredBox( ColoredBox(
+8 -3
View File
@@ -1,12 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../model/account_data.dart'; import '../../../../model/account_data.dart';
import '../../../../model/endpoint_data.dart'; import '../../../../model/endpoint_data.dart';
import '../../../../utils/url_opener.dart'; import '../../../../utils/url_opener.dart';
import '../widgets/highlighted_linkify.dart';
class ChatMessage { class ChatMessage {
String originalMessage; String originalMessage;
@@ -27,8 +27,13 @@ class ChatMessage {
); );
} }
Widget getWidget() { Widget getWidget({String? highlightQuery, TextStyle? style}) {
var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen); var contentWidget = HighlightedLinkify(
text: content,
onOpen: UrlOpener.onOpen,
highlight: highlightQuery,
style: style,
);
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
return ListTile( return ListTile(
@@ -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<ChatSearchMatch> findMatches(
GetChatResponse response,
String query,
) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return const [];
final matches = <ChatSearchMatch>[];
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;
}
}
+15 -1
View File
@@ -27,6 +27,7 @@ class BubbleStyle {
const BubbleStyle({ const BubbleStyle({
this.color, this.color,
this.borderWidth = 0, this.borderWidth = 0,
this.borderColor,
this.elevation = 0, this.elevation = 0,
this.margin = const BubbleEdges.only(), this.margin = const BubbleEdges.only(),
this.padding = const BubbleEdges.all(8), this.padding = const BubbleEdges.all(8),
@@ -37,12 +38,25 @@ class BubbleStyle {
final Color? color; final Color? color;
final double borderWidth; final double borderWidth;
final Color? borderColor;
final double elevation; final double elevation;
final BubbleEdges margin; final BubbleEdges margin;
final BubbleEdges padding; final BubbleEdges padding;
final Alignment alignment; final Alignment alignment;
final BubbleNip nip; final BubbleNip nip;
final double borderRadius; 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 /// The "nip" is faked by flattening one corner so the bubble anchors to
@@ -88,7 +102,7 @@ class Bubble extends StatelessWidget {
borderRadius: radius, borderRadius: radius,
border: style.borderWidth > 0 border: style.borderWidth > 0
? Border.all( ? Border.all(
color: Theme.of(context).dividerColor, color: style.borderColor ?? Theme.of(context).dividerColor,
width: style.borderWidth, width: style.borderWidth,
) )
: null, : null,
+57 -9
View File
@@ -18,6 +18,9 @@ import 'bubble.dart';
import 'chat_bubble_poll.dart'; import 'chat_bubble_poll.dart';
import 'chat_bubble_reactions.dart'; import 'chat_bubble_reactions.dart';
import 'chat_message_options_dialog.dart'; import 'chat_message_options_dialog.dart';
import 'highlighted_linkify.dart';
enum SearchHighlight { none, secondary, active }
class ChatBubble extends StatefulWidget { class ChatBubble extends StatefulWidget {
final BuildContext context; final BuildContext context;
@@ -33,6 +36,9 @@ class ChatBubble extends StatefulWidget {
final void Function({bool renew}) refetch; final void Function({bool renew}) refetch;
final String? highlightQuery;
final SearchHighlight matchHighlight;
const ChatBubble({ const ChatBubble({
required this.context, required this.context,
required this.isSender, required this.isSender,
@@ -41,6 +47,8 @@ class ChatBubble extends StatefulWidget {
required this.refetch, required this.refetch,
this.isRead = false, this.isRead = false,
this.selfId, this.selfId,
this.highlightQuery,
this.matchHighlight = SearchHighlight.none,
super.key, super.key,
}); });
@@ -142,14 +150,30 @@ class _ChatBubbleState extends State<ChatBubble>
BubbleStyle _getStyle() { BubbleStyle _getStyle() {
final styles = ChatBubbleStyles(context); final styles = ChatBubbleStyles(context);
final BubbleStyle base;
if (widget.bubbleData.messageType != if (widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.comment) { GetRoomResponseObjectMessageType.comment) {
return styles.getSystemStyle(); base = styles.getSystemStyle();
} } else {
return widget.isSender base = widget.isSender
? styles.getSelfStyle(false) ? styles.getSelfStyle(false)
: styles.getRemoteStyle(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,
);
}
}
void _showOptionsDialog() => showChatMessageOptionsDialog( void _showOptionsDialog() => showChatMessageOptionsDialog(
context, context,
@@ -196,15 +220,29 @@ class _ChatBubbleState extends State<ChatBubble>
GetRoomResponseObjectMessageType.deletedComment; GetRoomResponseObjectMessageType.deletedComment;
final parent = widget.bubbleData.parent; final parent = widget.bubbleData.parent;
final actorBaseStyle = TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
);
final actorText = Text( final actorText = Text(
widget.bubbleData.actorDisplayName, widget.bubbleData.actorDisplayName,
textAlign: TextAlign.start, textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: actorBaseStyle,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
); );
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( final timeText = Text(
DateTime.fromMillisecondsSinceEpoch( DateTime.fromMillisecondsSinceEpoch(
@@ -252,8 +290,16 @@ class _ChatBubbleState extends State<ChatBubble>
style: _getStyle(), style: _getStyle(),
child: _BubbleContent( child: _BubbleContent(
actorText: actorText, actorText: actorText,
actorWidget: actorWidget,
timeText: timeText, 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, parent: parent,
bubbleData: widget.bubbleData, bubbleData: widget.bubbleData,
isSender: widget.isSender, isSender: widget.isSender,
@@ -282,6 +328,7 @@ class _ChatBubbleState extends State<ChatBubble>
class _BubbleContent extends StatelessWidget { class _BubbleContent extends StatelessWidget {
final Text actorText; final Text actorText;
final Widget actorWidget;
final Text timeText; final Text timeText;
final Widget messageWidget; final Widget messageWidget;
final GetChatResponseObject? parent; final GetChatResponseObject? parent;
@@ -298,6 +345,7 @@ class _BubbleContent extends StatelessWidget {
const _BubbleContent({ const _BubbleContent({
required this.actorText, required this.actorText,
required this.actorWidget,
required this.timeText, required this.timeText,
required this.messageWidget, required this.messageWidget,
required this.parent, required this.parent,
@@ -323,7 +371,7 @@ class _BubbleContent extends StatelessWidget {
), ),
child: Stack( child: Stack(
children: [ children: [
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText), if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget),
Padding( Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: showBubbleTime ? 18 : 0, bottom: showBubbleTime ? 18 : 0,
@@ -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<String> 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,
),
],
);
}
}
@@ -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<TextSpan> 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 = <TextSpan>[];
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<HighlightedLinkify> createState() => _HighlightedLinkifyState();
}
class _HighlightedLinkifyState extends State<HighlightedLinkify> {
final List<TapGestureRecognizer> _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 = <InlineSpan>[];
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));
}
}
+2
View File
@@ -38,7 +38,9 @@ dependencies:
workmanager: ^0.9.0+3 workmanager: ^0.9.0+3
intl: ^0.20.2 intl: ^0.20.2
flutter_linkify: ^6.0.0 flutter_linkify: ^6.0.0
linkify: ^5.0.0
flutter_local_notifications: ^21.0.0 flutter_local_notifications: ^21.0.0
scrollable_positioned_list: ^0.3.8
flutter_split_view: ^0.1.2 flutter_split_view: ^0.1.2
flutter_svg: ^2.0.10 flutter_svg: ^2.0.10
freezed_annotation: ^3.1.0 freezed_annotation: ^3.1.0
@@ -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<String, RichObjectString>? params,
}) =>
GetChatResponseObject(
id,
'token',
GetRoomResponseObjectMessageActorType.user,
'actor-id',
actorDisplayName,
timestamp,
systemMessage,
type,
true,
'',
message,
params,
null,
null,
null,
);
GetChatResponse _response(List<GetChatResponseObject> 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]);
});
});
}