Files

324 lines
8.4 KiB
Dart

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 = <InlineSpan>[];
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 = <InlineSpan>[];
final tags = <String>[];
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<void> _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);
}
}