/ app / lib / widgets / linkified_text.dart
linkified_text.dart
 1  import 'package:flutter/gestures.dart';
 2  import 'package:flutter/material.dart';
 3  import 'package:url_launcher/url_launcher.dart';
 4  
 5  /// A widget that renders text with tappable URLs highlighted in blue.
 6  class LinkifiedText extends StatelessWidget {
 7    final String text;
 8    final TextStyle? style;
 9  
10    const LinkifiedText(this.text, {super.key, this.style});
11  
12    static final _urlRegex = RegExp(r'https?://[^\s<>")\]]+');
13  
14    @override
15    Widget build(BuildContext context) {
16      final matches = _urlRegex.allMatches(text).toList();
17      if (matches.isEmpty) {
18        return Text(text, style: style);
19      }
20  
21      final spans = <InlineSpan>[];
22      int lastEnd = 0;
23  
24      for (final match in matches) {
25        // Add text before the URL
26        if (match.start > lastEnd) {
27          spans.add(TextSpan(text: text.substring(lastEnd, match.start), style: style));
28        }
29  
30        // Strip trailing punctuation that's likely not part of the URL
31        var url = match.group(0)!;
32        var trailingPunct = '';
33        while (url.isNotEmpty && '.,:;!?)'.contains(url[url.length - 1])) {
34          trailingPunct = url[url.length - 1] + trailingPunct;
35          url = url.substring(0, url.length - 1);
36        }
37  
38        // Add the URL span
39        spans.add(TextSpan(
40          text: url,
41          style: style?.copyWith(
42            color: Colors.blue,
43            decoration: TextDecoration.underline,
44            decorationColor: Colors.blue.withAlpha(120),
45          ) ?? const TextStyle(
46            color: Colors.blue,
47            decoration: TextDecoration.underline,
48          ),
49          recognizer: TapGestureRecognizer()
50            ..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
51        ));
52  
53        // Add trailing punctuation as plain text
54        if (trailingPunct.isNotEmpty) {
55          spans.add(TextSpan(text: trailingPunct, style: style));
56        }
57  
58        lastEnd = match.end;
59      }
60  
61      // Add remaining text after last URL
62      if (lastEnd < text.length) {
63        spans.add(TextSpan(text: text.substring(lastEnd), style: style));
64      }
65  
66      return RichText(text: TextSpan(children: spans));
67    }
68  }