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
+15 -1
View File
@@ -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,
+58 -10
View File
@@ -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));
}
}