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_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<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() {
|
||||
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) {
|
||||
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 chronologicalMatchIndex = <int, int>{};
|
||||
var lastDate = DateTime.now();
|
||||
for (final element in response.sortByTimestamp()) {
|
||||
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(
|
||||
ChatBubble(
|
||||
context: context,
|
||||
@@ -76,6 +215,8 @@ class _ChatViewState extends State<ChatView> {
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
isRead: element.id <= commonRead,
|
||||
selfId: widget.selfId,
|
||||
highlightQuery: highlightQuery,
|
||||
matchHighlight: highlight,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -94,32 +235,60 @@ class _ChatViewState extends State<ChatView> {
|
||||
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<ChatView> {
|
||||
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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
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,
|
||||
|
||||
@@ -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<ChatBubble>
|
||||
|
||||
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<ChatBubble>
|
||||
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<ChatBubble>
|
||||
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<ChatBubble>
|
||||
|
||||
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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user