import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/link_trust_service.dart'; import '../services/ts3_server_link.dart'; final _tagRe = RegExp( r'\[(\/?(?:b|i|u|s' r'|color(?:=[^\]]*)?' r'|size(?:=\d+)?' r'|url(?:=[^\]]*)?' r'|img(?:=[^\]]*)?' r'|list|\*|quote|code|center|left|right' r'))\]', caseSensitive: false, ); final _urlRe = RegExp(r'url=(.+)', caseSensitive: false); final _imgRe = RegExp(r'img=(.+)'); final _urlAutoRe = RegExp( r'(?:\[url\])?((?:https?|ts3server)://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false, ); final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false); const _linkStyle = TextStyle(color: Colors.blue); int? _findCloseUrl(String src, int from) { final m = _closeUrlRe.matchAsPrefix(src, from); if (m != null) return from; final idx = src.indexOf('[/url]', from); if (idx >= 0) return idx; final idxu = src.indexOf('[/URL]', from); if (idxu >= 0) return idxu; return null; } Color? _parseColor(String hex) { try { var h = hex.replaceFirst('#', ''); if (h.length == 6) h = 'FF$h'; if (h.length == 8) { return Color(int.parse(h, radix: 16)); } } catch (_) {} return null; } class BbCodeText extends StatelessWidget { const BbCodeText( this.text, { super.key, required this.linkTrust, this.onTs3ServerLink, }); final String text; final LinkTrustService linkTrust; final Ts3ServerLinkHandler? onTs3ServerLink; @override Widget build(BuildContext context) { if (!text.contains('[') || !text.contains(']')) { return _plainWithAutoLinks(context, text); } return _render(context, text); } Widget _plainWithAutoLinks(BuildContext context, String src) { final parts = []; int last = 0; for (final m in _urlAutoRe.allMatches(src)) { if (m.start > last) { parts.add(TextSpan(text: src.substring(last, m.start))); } final url = m.group(1)!; parts.add( WidgetSpan( alignment: PlaceholderAlignment.middle, child: _LinkTap( url: url, linkTrust: linkTrust, onTs3ServerLink: onTs3ServerLink, child: Text(url, style: _linkStyle), ), ), ); last = m.end; } if (last < src.length) { parts.add(TextSpan(text: src.substring(last))); } if (parts.isEmpty) return Text(src); return Text.rich(TextSpan(children: parts)); } Widget _render(BuildContext context, String src) { final spans = []; final tags = []; void flush(StringBuffer buf) { if (buf.isEmpty) return; var t = buf.toString(); buf.clear(); bool bold = false; bool italic = false; bool underline = false; bool strikethrough = false; Color? color; double? size; for (final tag in tags) { if (tag == 'b') { bold = true; } else if (tag == 'i') { italic = true; } else if (tag == 'u') { underline = true; } else if (tag == 's') { strikethrough = true; } else if (tag.startsWith('color=')) { color = _parseColor(tag.substring(6)); } else if (tag.startsWith('size=')) { size = double.tryParse(tag.substring(5)); } } spans.add( TextSpan( text: t, style: TextStyle( fontWeight: bold ? FontWeight.bold : null, fontStyle: italic ? FontStyle.italic : null, decoration: TextDecoration.combine([ if (underline) TextDecoration.underline, if (strikethrough) TextDecoration.lineThrough, ]), color: color, fontSize: size, ), ), ); } final buf = StringBuffer(); int i = 0; while (i < src.length) { if (src[i] != '[') { buf.write(src[i]); i++; continue; } final m = _tagRe.matchAsPrefix(src, i); if (m == null) { buf.write(src[i]); i++; continue; } flush(buf); final rawTag = m.group(1)!; final raw = rawTag.toLowerCase(); i = m.end; if (raw.startsWith('/')) { final closeTag = raw.substring(1); if (closeTag == 'url' || closeTag == 'img') { continue; } tags.remove(closeTag); continue; } switch (raw) { case 'b': case 'i': case 'u': case 's': case 'list': case 'quote': case 'code': case 'center': case 'left': case 'right': tags.add(raw); break; case '*': spans.add(const TextSpan(text: '\n \u2022 ')); break; default: if (raw.startsWith('color=') || raw.startsWith('size=')) { tags.add(raw); } else if (raw.startsWith('url=')) { final url = _urlRe.firstMatch(rawTag)?.group(1) ?? ''; final closeIdx = _findCloseUrl(src, i); String inner; if (closeIdx != null) { inner = src.substring(i, closeIdx); i = closeIdx + 6; } else { inner = url; } spans.add( WidgetSpan( alignment: PlaceholderAlignment.middle, child: _LinkTap( url: url.isNotEmpty ? url : inner, linkTrust: linkTrust, onTs3ServerLink: onTs3ServerLink, child: Text(inner, style: _linkStyle), ), ), ); } else if (raw.startsWith('img=')) { final src2 = _imgRe.firstMatch(raw)?.group(1) ?? ''; if (src2.isNotEmpty) { spans.add( WidgetSpan( child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( Uri.tryParse(src2)?.toString() ?? src2, fit: BoxFit.scaleDown, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), ), ); } } else if (raw == 'url') { final closeIdx = _findCloseUrl(src, i); if (closeIdx != null) { final url = src.substring(i, closeIdx).trim(); i = closeIdx + 6; spans.add( WidgetSpan( alignment: PlaceholderAlignment.middle, child: _LinkTap( url: url, linkTrust: linkTrust, onTs3ServerLink: onTs3ServerLink, child: Text(url, style: _linkStyle), ), ), ); } } break; } } flush(buf); if (spans.isEmpty) return Text(src); return Text.rich(TextSpan(children: spans)); } } class _LinkTap extends StatefulWidget { const _LinkTap({ required this.url, required this.child, required this.linkTrust, this.onTs3ServerLink, }); final String url; final Widget child; final LinkTrustService linkTrust; final Ts3ServerLinkHandler? onTs3ServerLink; @override State<_LinkTap> createState() => _LinkTapState(); } class _LinkTapState extends State<_LinkTap> { @override void initState() { super.initState(); widget.linkTrust.addListener(_onChanged); } @override void dispose() { widget.linkTrust.removeListener(_onChanged); super.dispose(); } void _onChanged() => mounted ? setState(() {}) : null; Future _open() async { final ts3Link = parseTs3ServerLink(widget.url); if (ts3Link != null) { await widget.onTs3ServerLink?.call(ts3Link); return; } final uri = Uri.tryParse(widget.url); if (uri == null) return; final host = uri.host; if (host.isEmpty) return; if (widget.linkTrust.isTrusted(host)) { await launchUrl(uri, mode: LaunchMode.externalApplication); return; } if (!mounted) return; final trust = await showLinkTrustDialog(context, host); if (trust == null) return; if (trust) { await widget.linkTrust.addTrustedDomain(host); } await launchUrl(uri, mode: LaunchMode.externalApplication); } @override Widget build(BuildContext context) { return GestureDetector(onTap: _open, child: widget.child); } }