173 lines
4.5 KiB
Dart
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));
|
|
}
|
|
}
|