144 lines
4.0 KiB
Dart
144 lines
4.0 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/utils/link_opener.dart';
|
|
import '../../core/utils/text_format.dart';
|
|
import 'link_text.dart';
|
|
|
|
class FormattedMessageText extends StatefulWidget {
|
|
final String text;
|
|
final List<FormatRange> ranges;
|
|
final TextStyle style;
|
|
final TextAlign textAlign;
|
|
|
|
const FormattedMessageText({
|
|
super.key,
|
|
required this.text,
|
|
required this.ranges,
|
|
required this.style,
|
|
this.textAlign = TextAlign.start,
|
|
});
|
|
|
|
static bool isFormatted(String? text, List<FormatRange> ranges) =>
|
|
text != null &&
|
|
text.isNotEmpty &&
|
|
(ranges.isNotEmpty || LinkText.hasLinks(text));
|
|
|
|
static TextSpan buildInlineSpan(
|
|
String text,
|
|
List<FormatRange> ranges,
|
|
TextStyle style,
|
|
) {
|
|
final quoteColor = style.color?.withValues(alpha: 0.85);
|
|
final segments = segmentizeFormats(text, ranges);
|
|
return TextSpan(
|
|
style: style,
|
|
children: [
|
|
for (final segment in segments)
|
|
TextSpan(
|
|
text: text.substring(segment.start, segment.end),
|
|
style: applyTextFormats(
|
|
style,
|
|
segment.formats,
|
|
quoteColor: quoteColor,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<FormattedMessageText> createState() => _FormattedMessageTextState();
|
|
}
|
|
|
|
class _FormattedMessageTextState extends State<FormattedMessageText> {
|
|
final List<TapGestureRecognizer> _recognizers = [];
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposeRecognizers();
|
|
super.dispose();
|
|
}
|
|
|
|
void _disposeRecognizers() {
|
|
for (final recognizer in _recognizers) {
|
|
recognizer.dispose();
|
|
}
|
|
_recognizers.clear();
|
|
}
|
|
|
|
List<FormatRange> _withAutoLinks() {
|
|
final ranges = List<FormatRange>.from(widget.ranges);
|
|
final hasExplicitLink = ranges.any((r) => r.format == TextFormat.link);
|
|
if (hasExplicitLink) return ranges;
|
|
for (final match in linkPattern.allMatches(widget.text)) {
|
|
final raw = match.group(0)!;
|
|
final target = raw.startsWith('www.') ? 'https://$raw' : raw;
|
|
ranges.add(
|
|
FormatRange(
|
|
format: TextFormat.link,
|
|
start: match.start,
|
|
length: match.end - match.start,
|
|
attributes: {'url': target},
|
|
),
|
|
);
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
_disposeRecognizers();
|
|
final segments = segmentizeFormats(widget.text, _withAutoLinks());
|
|
final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface;
|
|
final barColor = baseColor.withValues(alpha: 0.4);
|
|
final quoteColor = baseColor.withValues(alpha: 0.85);
|
|
|
|
final spans = <InlineSpan>[];
|
|
var prevQuote = false;
|
|
for (final segment in segments) {
|
|
final isQuote = segment.formats.contains(TextFormat.quote);
|
|
if (isQuote && !prevQuote) {
|
|
spans.add(
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: Container(
|
|
width: 3,
|
|
height: (widget.style.fontSize ?? 16) * 1.15,
|
|
margin: const EdgeInsets.only(right: 6, left: 1),
|
|
decoration: BoxDecoration(
|
|
color: barColor,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
prevQuote = isQuote;
|
|
|
|
final style = applyTextFormats(
|
|
widget.style,
|
|
segment.formats,
|
|
quoteColor: quoteColor,
|
|
);
|
|
final content = widget.text.substring(segment.start, segment.end);
|
|
if (segment.url != null) {
|
|
final url = segment.url!;
|
|
final recognizer = TapGestureRecognizer()
|
|
..onTap = () => openExternalUrl(context, url);
|
|
_recognizers.add(recognizer);
|
|
spans.add(
|
|
TextSpan(text: content, style: style, recognizer: recognizer),
|
|
);
|
|
} else {
|
|
spans.add(TextSpan(text: content, style: style));
|
|
}
|
|
}
|
|
|
|
return Text.rich(
|
|
TextSpan(style: widget.style, children: spans),
|
|
textAlign: widget.textAlign,
|
|
);
|
|
}
|
|
}
|