Files
Client/lib/view/pages/talk/widgets/highlighted_linkify.dart
T

173 lines
4.5 KiB
Dart

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> {
// Cached per link text — search rebuilds keystroke-by-keystroke
// would otherwise churn allocate/dispose. Pruned via [_seenLinkKeys].
final Map<String, TapGestureRecognizer> _recognizers = {};
final Set<String> _seenLinkKeys = {};
@override
void dispose() {
for (final r in _recognizers.values) {
r.dispose();
}
_recognizers.clear();
super.dispose();
}
TapGestureRecognizer _recognizerFor(LinkableElement el) {
final key = el.text;
final existing = _recognizers[key];
if (existing != null) {
// Refresh onTap so a parent rebuild's new closure is picked up.
existing.onTap = () => widget.onOpen?.call(el);
return existing;
}
final created = TapGestureRecognizer()
..onTap = () => widget.onOpen?.call(el);
_recognizers[key] = created;
return created;
}
void _pruneUnseen() {
final stale = _recognizers.keys
.where((k) => !_seenLinkKeys.contains(k))
.toList(growable: false);
for (final k in stale) {
_recognizers.remove(k)?.dispose();
}
}
@override
Widget build(BuildContext context) {
_seenLinkKeys.clear();
final defaultStyle = widget.style ??
Theme.of(context).textTheme.bodyMedium ??
DefaultTextStyle.of(context).style;
// Default first, link style on top — reversing the merge silently
// drops link color/underline because TextStyle.merge treats explicit
// nulls in the overlay as "leave unchanged".
final linkStyle = defaultStyle.merge(
widget.linkStyle ??
const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
);
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) {
_seenLinkKeys.add(el.text);
final recognizer = _recognizerFor(el);
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,
),
);
}
}
_pruneUnseen();
return Text.rich(TextSpan(children: spans));
}
}