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 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 = []; 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 createState() => _HighlightedLinkifyState(); } class _HighlightedLinkifyState extends State { // Cached per link text — search rebuilds keystroke-by-keystroke // would otherwise churn allocate/dispose. Pruned via [_seenLinkKeys]. final Map _recognizers = {}; final Set _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 = []; 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)); } }