implemented in-chat search with text highlighting, added search navigation UI, and integrated scrollable list for message jumping
This commit is contained in:
@@ -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,32 +235,60 @@ 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(
|
||||||
appBar: AppBar(
|
controller: _searchTextController,
|
||||||
title: Row(
|
matchCount: _matches.length,
|
||||||
children: [
|
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
|
||||||
widget.avatar,
|
onChanged: _onSearchChanged,
|
||||||
const SizedBox(width: 10),
|
onClose: _exitSearchMode,
|
||||||
Expanded(
|
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
|
||||||
child: Text(
|
onNext: _matches.isEmpty ? null : _goToNextMatch,
|
||||||
widget.room.displayName,
|
)
|
||||||
overflow: TextOverflow.ellipsis,
|
: ClickableAppBar(
|
||||||
maxLines: 1,
|
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(
|
body: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
@@ -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, _) {
|
||||||
reverse: true,
|
final items =
|
||||||
controller: _listController,
|
_buildMessages(state.chatResponse!).reversed.toList();
|
||||||
children: _buildMessages(state.chatResponse!).reversed.toList(),
|
return ScrollablePositionedList.builder(
|
||||||
),
|
reverse: true,
|
||||||
|
itemScrollController: _itemScrollController,
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (ctx, idx) => items[idx],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ColoredBox(
|
ColoredBox(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,13 +150,29 @@ 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 {
|
||||||
|
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(
|
void _showOptionsDialog() => showChatMessageOptionsDialog(
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user