implemented in-chat search with text highlighting, added search navigation UI, and integrated scrollable list for message jumping
This commit is contained in:
@@ -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