Created
January 22, 2025 19:32
-
-
Save kit-g/32550338e385d795b5f0772e0e09a990 to your computer and use it in GitHub Desktop.
Simplified variant of the [Text] widget which accepts both a primary string for the raw text body, and a secondary `sentAt` string which annotates the former with a timestamp, similar to how popular chat apps like WhatsApp render individual messages.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:linkify/linkify.dart'; | |
/// Simplified variant of the [Text] widget which accepts both a primary string | |
/// for the raw text body, and a secondary `sentAt` string which annotates the | |
/// former with a timestamp, similar to how popular chat apps like WhatsApp | |
/// render individual messages. | |
/// | |
/// The [TimestampedChatBubble] extends [LeafRenderObjectWidget], which means | |
/// it has no children and instead creates a [TimestampedChatMessageRenderObject], | |
/// which handles all layout and painting itself. | |
class TimestampedChatBubble extends LeafRenderObjectWidget { | |
const TimestampedChatBubble({ | |
super.key, | |
required this.text, | |
required this.sentAt, | |
required this.textStyle, | |
required this.linkStyle, | |
required this.sentAtStyle, | |
this.editedStatus, | |
this.editedStyle, | |
this.deliveryStatusIcon, | |
this.onLink, | |
this.counterText, | |
this.counterHeaderText, | |
this.counterStyle, | |
this.counterHeaderStyle, | |
this.counterPadding = 4.0, | |
required this.counterBackgroundColor, | |
this.counterRadius = const Radius.circular(4), | |
required this.counterMarkerColor, | |
this.onTapCounter, | |
this.gestureOffset, | |
}); | |
final String text; | |
final String sentAt; | |
final String? counterText; | |
final String? counterHeaderText; | |
final TextStyle? counterStyle; | |
final TextStyle? counterHeaderStyle; | |
final double counterPadding; | |
final Color counterBackgroundColor; | |
final Radius counterRadius; | |
final Color counterMarkerColor; | |
final String? editedStatus; | |
final TextStyle textStyle; | |
final TextStyle linkStyle; | |
final TextStyle sentAtStyle; | |
final TextStyle? editedStyle; | |
final Icon? deliveryStatusIcon; | |
final void Function(LinkifyElement)? onLink; | |
final VoidCallback? onTapCounter; | |
final double? gestureOffset; | |
@override | |
RenderObject createRenderObject(BuildContext context) { | |
return TimestampedChatMessageRenderObject( | |
text: text, | |
sentAt: sentAt, | |
textDirection: Directionality.of(context), | |
textStyle: textStyle, | |
sentAtStyle: sentAtStyle, | |
linkStyle: linkStyle, | |
onLink: onLink, | |
deliveryStatusIcon: _deliveryStatusIcon, | |
deliveryStatusIconStyle: _deliveryStatusIconStyle, | |
editedStatus: editedStatus, | |
editedStyle: editedStyle, | |
counterText: counterText, | |
counterStyle: counterStyle, | |
counterHeaderText: counterHeaderText, | |
counterHeaderStyle: counterHeaderStyle, | |
counterBackgroundColor: counterBackgroundColor, | |
counterPadding: counterPadding, | |
counterRadius: counterRadius, | |
counterMarkerColor: counterMarkerColor, | |
onTapCounter: onTapCounter, | |
gestureOffset: gestureOffset, | |
); | |
} | |
@override | |
void updateRenderObject(BuildContext context, TimestampedChatMessageRenderObject renderObject) { | |
renderObject | |
..text = text | |
..textStyle = textStyle | |
..counter = counterText | |
..counterStyle = counterStyle | |
..counterBackgroundColor = counterBackgroundColor | |
..counterHeader = counterHeaderText | |
..sentAt = sentAt | |
..sentAtStyle = sentAtStyle | |
..textDirection = Directionality.of(context); | |
if (deliveryStatusIcon != null) { | |
renderObject | |
..deliveryStatusIcon = _deliveryStatusIcon! | |
..deliveryStatusIconStyle = _deliveryStatusIconStyle!; | |
} | |
if ((editedStatus, editedStyle) case (String status, TextStyle style)) { | |
renderObject | |
..editedStatus = status | |
..editedStyle = style; | |
} | |
} | |
String? get _deliveryStatusIcon { | |
return switch (deliveryStatusIcon) { | |
Icon(:IconData icon) => String.fromCharCode(icon.codePoint), | |
_ => null, | |
}; | |
} | |
TextStyle? get _deliveryStatusIconStyle { | |
return switch (deliveryStatusIcon) { | |
Icon icon => TextStyle( | |
color: icon.color, | |
shadows: icon.shadows, | |
fontSize: icon.size ?? 12, | |
fontFamily: icon.icon?.fontFamily, | |
package: icon.icon?.fontPackage, | |
), | |
_ => null, | |
}; | |
} | |
} | |
/// Simplified variant of [RenderParagraph] which supports the | |
/// [TimestampedChatBubble] widget. | |
/// | |
/// Like the [Text] widget and its inner [RenderParagraph], the | |
/// [TimestampedChatMessageRenderObject] makes heavy use of the [TextPainter] | |
/// class. | |
class TimestampedChatMessageRenderObject extends RenderBox { | |
final void Function(LinkifyElement)? onLink; | |
late TextDirection _textDirection; | |
late String _text; | |
late String? _counterText; | |
late String? _counterHeaderText; | |
late String _sentAt; | |
late String? _edited; | |
late TextPainter _textPainter; | |
late TextPainter _sentAtTextPainter; | |
late TextPainter? _counterTextPainter; | |
late TextPainter? _counterHeaderTextPainter; | |
late TextPainter? _deliveryStatusTextPainter; | |
late TextPainter? _editedPainter; | |
late String? _deliveryStatusShareCode; | |
late TextStyle _sentAtStyle; | |
late TextStyle _textStyle; | |
late TextStyle? _counterStyle; | |
late TextStyle? _counterHeaderStyle; | |
late TextStyle? _deliveryStatusStyle; | |
late Color _counterBackgroundColor; | |
final Color counterMarkerColor; | |
final double counterPadding; | |
final Radius counterRadius; | |
late TextStyle? _editedStyle; | |
late TextStyle _linkStyle; | |
late bool _sentAtFitsOnLastLine; | |
late double _lastMessageLineWidth; | |
double _longestLineWidth = 0; | |
double _longestCounterLine = 0; | |
double _iconLineWidth = 0; | |
double _editedLineWidth = 0; | |
late double _sentAtLineWidth; | |
late double _sentAtLineHeight; | |
TapGestureRecognizer? _tapGestureRecognizer; | |
Rect? _counterFrame; | |
final double? gestureOffset; | |
TimestampedChatMessageRenderObject({ | |
required String sentAt, | |
required String text, | |
required TextStyle sentAtStyle, | |
required TextStyle textStyle, | |
required TextStyle linkStyle, | |
required TextDirection textDirection, | |
String? deliveryStatusIcon, | |
String? counterText, | |
String? counterHeaderText, | |
TextStyle? deliveryStatusIconStyle, | |
TextStyle? counterStyle, | |
TextStyle? counterHeaderStyle, | |
required Color counterBackgroundColor, | |
required this.counterPadding, | |
String? editedStatus, | |
TextStyle? editedStyle, | |
required this.counterRadius, | |
required this.counterMarkerColor, | |
this.onLink, | |
VoidCallback? onTapCounter, | |
this.gestureOffset, | |
}) { | |
_text = text; | |
_sentAt = sentAt; | |
_edited = editedStatus; | |
_textStyle = textStyle; | |
_linkStyle = linkStyle; | |
_editedStyle = editedStyle; | |
_sentAtStyle = sentAtStyle; | |
_textDirection = textDirection; | |
_deliveryStatusShareCode = deliveryStatusIcon; | |
_deliveryStatusStyle = deliveryStatusIconStyle; | |
_counterText = counterText; | |
_counterHeaderText = counterHeaderText; | |
_counterStyle = counterStyle; | |
_counterHeaderStyle = counterHeaderStyle; | |
_counterBackgroundColor = counterBackgroundColor; | |
_textPainter = TextPainter( | |
text: textTextSpan, | |
textDirection: _textDirection, | |
); | |
_sentAtTextPainter = TextPainter( | |
text: sentAtTextSpan, | |
textDirection: _textDirection, | |
); | |
_counterTextPainter = switch (_counterText) { | |
String() => TextPainter( | |
text: counterSpan, | |
textDirection: _textDirection, | |
maxLines: 2, | |
// ellipsis: '...', | |
), | |
null => null, | |
}; | |
_counterHeaderTextPainter = switch (_counterHeaderText) { | |
String() => TextPainter( | |
text: counterHeaderSpan, | |
textDirection: _textDirection, | |
maxLines: 1, | |
// ellipsis: '...', | |
), | |
null => null, | |
}; | |
_deliveryStatusTextPainter = switch (_deliveryStatusShareCode) { | |
String() => TextPainter( | |
text: deliveryStatusTextSpan, | |
textDirection: _textDirection, | |
), | |
null => null, | |
}; | |
_editedPainter = switch (_edited) { | |
String() => TextPainter( | |
text: editedSpan, | |
textDirection: _textDirection, | |
), | |
null => null, | |
}; | |
_tapGestureRecognizer = TapGestureRecognizer()..onTap = onTapCounter; | |
} | |
double get lastLineWithBadges => | |
_lastMessageLineWidth + (_sentAtLineWidth * 1.08) + (_iconLineWidth * 1.3) + (_editedLineWidth * 1.3); | |
@override | |
void dispose() { | |
_tapGestureRecognizer?.dispose(); | |
super.dispose(); | |
} | |
@override | |
bool hitTestSelf(Offset position) { | |
return true; | |
} | |
TextSpan get textTextSpan { | |
return TextSpan( | |
children: _textSpans.keys.toList(), | |
); | |
} | |
TextSpan? get counterSpan { | |
return switch (_counterText) { | |
String s => TextSpan( | |
text: s, | |
style: _counterStyle, | |
), | |
null => null, | |
}; | |
} | |
TextSpan? get counterHeaderSpan { | |
return switch (_counterHeaderText) { | |
String s => TextSpan( | |
text: s, | |
style: _counterHeaderStyle, | |
), | |
null => null, | |
}; | |
} | |
double get totalCounterPadding { | |
return switch (_counterTextPainter) { | |
TextPainter() => counterPadding * 2, | |
null => 0, | |
}; | |
} | |
TextSpan get sentAtTextSpan => TextSpan(text: _sentAt, style: _sentAtStyle); | |
TextSpan? get deliveryStatusTextSpan { | |
return switch (_deliveryStatusShareCode) { | |
String s => TextSpan(text: s, style: _deliveryStatusStyle), | |
null => null, | |
}; | |
} | |
TextSpan? get editedSpan { | |
return switch (_edited) { | |
String s => TextSpan(text: s, style: _editedStyle), | |
null => null, | |
}; | |
} | |
set sentAt(String v) { | |
if (v == _sentAt) return; | |
_sentAt = v; | |
_sentAtTextPainter.text = sentAtTextSpan; | |
markNeedsLayout(); | |
markNeedsSemanticsUpdate(); | |
} | |
set sentAtStyle(TextStyle val) { | |
if (val == _sentAtStyle) return; | |
_sentAtStyle = val; | |
_sentAtTextPainter.text = sentAtTextSpan; | |
markNeedsLayout(); | |
} | |
String get text => _text; | |
set text(String val) { | |
if (val == _text) return; | |
_text = val; | |
_textPainter.text = textTextSpan; | |
markNeedsLayout(); | |
markNeedsSemanticsUpdate(); | |
} | |
String? get counter => _counterText; | |
set counter(String? v) { | |
if (v == _counterText) return; | |
if (_counterText != null) { | |
if (_counterTextPainter == null) { | |
_counterTextPainter = TextPainter( | |
textDirection: _textDirection, | |
text: counterSpan, | |
); | |
} else { | |
_counterTextPainter?.text = counterSpan; | |
} | |
} | |
markNeedsLayout(); | |
markNeedsSemanticsUpdate(); | |
} | |
set counterStyle(TextStyle? v) { | |
if (v == _counterStyle) return; | |
_counterStyle = v; | |
if (_counterTextPainter == null) { | |
_counterTextPainter = TextPainter( | |
textDirection: _textDirection, | |
text: counterSpan, | |
); | |
} else { | |
_counterTextPainter?.text = counterSpan; | |
} | |
markNeedsLayout(); | |
} | |
String? get counterHeader => _counterHeaderText; | |
set counterHeader(String? v) { | |
if (v == _counterHeaderText) return; | |
if (_counterHeaderText != null) { | |
if (_counterHeaderTextPainter == null) { | |
_counterTextPainter = TextPainter( | |
textDirection: _textDirection, | |
text: counterHeaderSpan, | |
); | |
} else { | |
_counterHeaderTextPainter?.text = counterHeaderSpan; | |
} | |
} | |
markNeedsLayout(); | |
markNeedsSemanticsUpdate(); | |
} | |
set counterHeaderStyle(TextStyle? v) { | |
if (v == _counterHeaderStyle) return; | |
_counterHeaderStyle = v; | |
if (_counterHeaderTextPainter == null) { | |
_counterTextPainter = TextPainter( | |
textDirection: _textDirection, | |
text: counterHeaderSpan, | |
); | |
} else { | |
_counterHeaderTextPainter?.text = counterHeaderSpan; | |
} | |
markNeedsLayout(); | |
} | |
set counterBackgroundColor(Color v) { | |
if (v == _counterBackgroundColor) return; | |
_counterBackgroundColor = v; | |
markNeedsLayout(); | |
} | |
TextStyle get textStyle => _textStyle; | |
set textStyle(TextStyle v) { | |
if (v == _textStyle) return; | |
_textStyle = v; | |
_textPainter.text = textTextSpan; | |
markNeedsLayout(); | |
} | |
set textDirection(TextDirection v) { | |
if (_textDirection == v) return; | |
_textDirection = v; | |
_counterTextPainter?.textDirection = v; | |
_counterHeaderTextPainter?.textDirection = v; | |
_textPainter.textDirection = v; | |
_sentAtTextPainter.textDirection = v; | |
markNeedsSemanticsUpdate(); | |
markNeedsLayout(); | |
} | |
set deliveryStatusIcon(String iconSharCode) { | |
if (iconSharCode == _deliveryStatusShareCode) { | |
return; | |
} | |
_deliveryStatusShareCode = iconSharCode; | |
if (_deliveryStatusShareCode != null) { | |
if (_deliveryStatusTextPainter == null) { | |
_deliveryStatusTextPainter = TextPainter( | |
text: deliveryStatusTextSpan, | |
textDirection: _textDirection, | |
); | |
} else { | |
_deliveryStatusTextPainter!.text = deliveryStatusTextSpan; | |
} | |
} | |
markNeedsLayout(); | |
markNeedsSemanticsUpdate(); | |
} | |
set deliveryStatusIconStyle(TextStyle v) { | |
if (v == _deliveryStatusStyle) return; | |
_deliveryStatusStyle = v; | |
if (_deliveryStatusTextPainter != null) { | |
_deliveryStatusTextPainter!.text = deliveryStatusTextSpan; | |
} else if (_deliveryStatusShareCode != null) { | |
_deliveryStatusTextPainter = TextPainter( | |
text: deliveryStatusTextSpan, | |
textDirection: _textDirection, | |
); | |
} | |
markNeedsLayout(); | |
} | |
set editedStatus(String v) { | |
if (v == _edited) return; | |
_edited = v; | |
if (_edited != null) { | |
if (_editedPainter == null) { | |
_editedPainter = TextPainter( | |
text: editedSpan, | |
textDirection: _textDirection, | |
); | |
} else { | |
_editedPainter?.text = editedSpan; | |
} | |
} | |
markNeedsLayout(); | |
markNeedsSemanticsUpdate(); | |
} | |
set editedStyle(TextStyle v) { | |
if (_editedStyle == v) return; | |
_editedStyle = v; | |
if (_editedPainter == null) { | |
_editedPainter = TextPainter( | |
text: editedSpan, | |
textDirection: _textDirection, | |
); | |
} else { | |
_editedPainter?.text = editedSpan; | |
} | |
markNeedsLayout(); | |
} | |
@override | |
void handleEvent(PointerEvent event, BoxHitTestEntry entry) { | |
if (event is PointerDownEvent) { | |
if (isHittingCounter(event)) { | |
_tapGestureRecognizer?.addPointer(event); | |
} | |
} | |
super.handleEvent(event, entry); | |
} | |
@override | |
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { | |
final textPosition = _textPainter.getPositionForOffset(position); | |
final span = _textPainter.text?.getSpanForPosition(textPosition); | |
if (span case TextSpan(:GestureRecognizer recognizer)) { | |
recognizer.addPointer(PointerDownEvent(position: position)); | |
return true; | |
} | |
return false; | |
} | |
List<LinkifyElement> get _linkElements { | |
return linkify( | |
_text, | |
linkifiers: [ | |
...defaultLinkifiers, | |
const _HashtagLinkifier(), | |
], | |
options: const LinkifyOptions(humanize: false), | |
); | |
} | |
Map<InlineSpan, LinkifyElement> get _textSpans { | |
return Map.fromEntries( | |
_linkElements.map<MapEntry<InlineSpan, LinkifyElement>>( | |
(element) { | |
return MapEntry<InlineSpan, LinkifyElement>( | |
TextSpan( | |
text: element.text, | |
style: switch (element) { | |
LinkableElement() => _linkStyle, | |
_ => _textStyle, | |
}, | |
recognizer: TapGestureRecognizer() | |
..onTapDown = (_) { | |
onLink?.call(element); | |
}, | |
), | |
element, | |
); | |
}, | |
), | |
); | |
} | |
@override | |
void describeSemanticsConfiguration(SemanticsConfiguration config) { | |
super.describeSemanticsConfiguration(config); | |
config | |
..isSemanticBoundary = true | |
..label = '$_text, sent $_sentAt' | |
..textDirection = _textDirection; | |
} | |
@override | |
double computeMinIntrinsicWidth(double height) { | |
_layoutText(double.infinity); | |
return _longestLineWidth; | |
} | |
@override | |
double computeMinIntrinsicHeight(double width) => computeMaxIntrinsicHeight(width); | |
@override | |
double computeMaxIntrinsicHeight(double width) { | |
final computedSize = _layoutText(width); | |
return computedSize.height; | |
} | |
@override | |
void performLayout() { | |
final unconstrainedSize = _layoutText(constraints.maxWidth); | |
size = constraints.constrain( | |
Size(unconstrainedSize.width, unconstrainedSize.height), | |
); | |
} | |
/// Lays out the text within a given width constraint and returns its [Size]. | |
/// | |
/// Because [_layoutText] is called from multiple places with multiple concerns, | |
/// like intrinsics which could have different width parameters than a typical | |
/// layout, this logic is moved out of `performLayout` and into a dedicated | |
/// method which accepts and works with any width constraint. | |
Size _layoutText(double maxWidth) { | |
// Draw nothing (requiring no size) if the string doesn't exist. This is one | |
// of many opinionated choices we could make here if the text is empty. | |
if (_textPainter.text?.toPlainText() == '') return Size.zero; | |
assert( | |
maxWidth > 0, | |
'You must allocate SOME space to layout a TimestampedChatMessageRenderObject. Received a ' | |
'`maxWidth` value of $maxWidth.', | |
); | |
// Layout the raw message, which saves expected high-level sizing values | |
// to the painter itself. | |
_textPainter.layout(maxWidth: maxWidth); | |
final textLines = _textPainter.computeLineMetrics(); | |
if (_counterHeaderTextPainter case TextPainter(text: InlineSpan _)) { | |
_counterHeaderTextPainter?.layout(maxWidth: maxWidth); | |
} | |
if (_counterTextPainter case TextPainter(text: InlineSpan _)) { | |
_counterTextPainter?.layout(maxWidth: maxWidth); | |
} | |
try { | |
// looking for the longest line in the counter | |
_longestCounterLine = [ | |
...?_counterHeaderTextPainter?.computeLineMetrics(), | |
...?_counterTextPainter?.computeLineMetrics(), | |
].map((line) => line.width).reduce(max); | |
} on StateError { | |
_longestCounterLine = 0; | |
} | |
// Now make similar calculations for all other parts | |
if (_editedPainter case TextPainter(text: InlineSpan _)) { | |
_editedPainter?.layout(maxWidth: maxWidth); | |
_editedLineWidth = _editedPainter?.computeLineMetrics().first.width ?? 0; | |
} | |
_sentAtTextPainter.layout(maxWidth: maxWidth); | |
final sentAtLines = _sentAtTextPainter.computeLineMetrics(); | |
_sentAtLineWidth = sentAtLines.first.width; | |
_sentAtLineHeight = sentAtLines.first.height; | |
if (_deliveryStatusTextPainter case TextPainter(text: InlineSpan _)) { | |
_deliveryStatusTextPainter?.layout(maxWidth: maxWidth); | |
_iconLineWidth = _deliveryStatusTextPainter?.computeLineMetrics().first.width ?? 0; | |
} | |
// Reset cached values from the last frame if they're assumed to start at 0. | |
// (Because this is used in `max`, if it opens a new frame still holding the | |
// value from a previous frame, we could fail to accurately calculate the | |
// longest line.) | |
_longestLineWidth = textLines.map((line) => line.width).reduce(max); | |
// If the message is very short, it's possible that the longest line is | |
// is actually the date. | |
final iconWidth = _deliveryStatusTextPainter?.width ?? 0; | |
final editedWidth = _editedPainter?.width ?? 0; | |
_longestLineWidth = max(_longestLineWidth, _sentAtTextPainter.width + iconWidth + editedWidth); | |
// Because [_textPainter.width] can be the maximum width we passed to it, | |
// even if the longest line is shorter, we use this logic to determine its | |
// real size, for our purposes. | |
final sizeOfMessage = Size(_longestLineWidth, _textPainter.height); | |
// Cache additional variables used both in the rest of this method and in | |
// `paint` later on. | |
_lastMessageLineWidth = textLines.last.width; | |
// Determine whether the message's last line and the date can share a | |
// horizontal row together. | |
if (textLines.length == 1) { | |
_sentAtFitsOnLastLine = lastLineWithBadges < maxWidth; | |
} else { | |
_sentAtFitsOnLastLine = lastLineWithBadges < min(_longestLineWidth, maxWidth); | |
} | |
final sizeOfCounter = Size( | |
_longestCounterLine + totalCounterPadding + counterPadding, | |
(_counterTextPainter?.height ?? 0) + totalCounterPadding + (_counterHeaderTextPainter?.height ?? 0), | |
); | |
return switch (_sentAtFitsOnLastLine) { | |
false => Size( | |
// If `sentAt` does not fit on the longest line, then we know the | |
// message contains a long line, making this a safe value for `width`. | |
max(sizeOfMessage.width, sizeOfCounter.width), | |
// And similarly, if `sentAt` does not fit, we know to add its height | |
// to the overall size of just-the-message. | |
sizeOfMessage.height + _sentAtTextPainter.height + sizeOfCounter.height, | |
), | |
true => Size( | |
// the longest of the message lines, last line or the counter | |
[_longestLineWidth, lastLineWithBadges, sizeOfCounter.width].reduce(max), | |
sizeOfMessage.height + sizeOfCounter.height, | |
), | |
}; | |
} | |
@override | |
void paint(PaintingContext context, Offset offset) { | |
final canvas = context.canvas; | |
// Draw nothing (requiring no paint calls) if the string doesn't exist. This | |
// is one of many opinionated choices we could make here if the text is empty. | |
if (_textPainter.text?.toPlainText() == '') return; | |
Offset textOffset; | |
if (_counterTextPainter case TextPainter(text: InlineSpan s) when s.toPlainText().isNotEmpty) { | |
final counterSize = Size( | |
max((_counterTextPainter?.size.width ?? 0), (_counterHeaderTextPainter?.size.width ?? 0)), | |
(_counterTextPainter?.size.height ?? 0) + (_counterHeaderTextPainter?.size.height ?? 0), | |
); | |
// longest line + side padding + marker padding | |
final counterWidth = _longestCounterLine + totalCounterPadding + counterPadding; | |
final backgroundRect = RRect.fromRectAndRadius( | |
Rect.fromLTWH( | |
offset.dx, | |
offset.dy, | |
switch (_sentAtFitsOnLastLine) { | |
true => [counterWidth, _longestLineWidth, lastLineWithBadges].reduce(max), | |
false => max(counterWidth, _longestLineWidth), | |
}, | |
counterSize.height + totalCounterPadding, | |
), | |
const Radius.circular(4.0), | |
); | |
_counterFrame = backgroundRect.outerRect; | |
canvas.drawRRect(backgroundRect, Paint()..color = _counterBackgroundColor); | |
final markerRect = RRect.fromRectAndCorners( | |
Rect.fromLTWH( | |
offset.dx, | |
offset.dy, | |
4, | |
counterSize.height + totalCounterPadding, | |
), | |
topLeft: counterRadius, | |
bottomLeft: counterRadius, | |
); | |
canvas.drawRRect( | |
markerRect, | |
Paint() | |
..shader = const LinearGradient( | |
colors: [Color(0xFF0A79FB), Color(0xFF12CD83)], | |
begin: Alignment.topCenter, | |
end: Alignment.bottomCenter, | |
).createShader(markerRect.outerRect), | |
); | |
final counterHeaderOffset = Offset( | |
offset.dx + counterPadding + counterPadding, | |
offset.dy + counterPadding, | |
); | |
_counterHeaderTextPainter?.paint(canvas, counterHeaderOffset); | |
final counterTextOffset = Offset( | |
offset.dx + counterPadding + counterPadding, | |
offset.dy + ((_counterHeaderTextPainter?.height ?? 0) + counterPadding), | |
); | |
_counterTextPainter?.paint(context.canvas, counterTextOffset); | |
textOffset = Offset( | |
offset.dx, | |
counterTextOffset.dy + (_counterTextPainter?.height ?? 0) + counterPadding, | |
); | |
} else { | |
textOffset = offset; | |
} | |
// This line writes the actual message to the screen. Because we use the | |
// same offset we were passed, the text will appear in the upper-left corner | |
// of our available space. | |
_textPainter.paint(context.canvas, textOffset); | |
final sentAtOffset = switch (_sentAtFitsOnLastLine) { | |
true => Offset( | |
offset.dx + (size.width - (_sentAtLineWidth + _iconLineWidth * 1.1)), | |
textOffset.dy + _textPainter.height - _sentAtLineHeight, // Align with the last line | |
), | |
false => Offset( | |
offset.dx + (size.width - (_sentAtLineWidth + _iconLineWidth * 1.1)), | |
textOffset.dy + _textPainter.height, // Place below the text if it doesn't fit on the last line | |
) | |
}; | |
// Paint the `sentAt` value | |
_sentAtTextPainter.paint(context.canvas, sentAtOffset); | |
// Calculate and paint the edited badge, if present | |
if (_editedPainter != null) { | |
final editedOffset = Offset( | |
sentAtOffset.dx - (_editedLineWidth * 1.2), // Place to the left of the `sentAt` | |
sentAtOffset.dy, | |
); | |
_editedPainter?.paint(context.canvas, editedOffset); | |
} | |
// Paint the delivery status, if present | |
if (_deliveryStatusTextPainter != null) { | |
final iconOffset = Offset( | |
sentAtOffset.dx + (_sentAtLineWidth * 1.05), | |
sentAtOffset.dy + _sentAtLineHeight - _deliveryStatusTextPainter!.height, | |
); | |
_deliveryStatusTextPainter?.paint(context.canvas, iconOffset); | |
} | |
} | |
/// whether the touch pointer falls within the counter area | |
/// because [_counterFrame] is stored as a rect, | |
/// its sides are global and top and bottom are local, hence the math | |
bool isHittingCounter(PointerEvent event) { | |
if (_counterFrame case Rect(:var top, :var bottom, :var left, :var right)) { | |
var PointerEvent(:position, :localPosition) = event; | |
var correctedLocal = localPosition.dy + (gestureOffset ?? 0); | |
return left < position.dx && right > position.dx && top < correctedLocal && bottom > correctedLocal; | |
} | |
return false; | |
} | |
} | |
final _hashtagRegex = RegExp(r'(^|\s)(#[\p{L}\p{M}0-9_]+)', unicode: true); | |
class _HashtagLinkifier extends Linkifier { | |
const _HashtagLinkifier(); | |
@override | |
List<LinkifyElement> parse(List<LinkifyElement> elements, options) { | |
final list = <LinkifyElement>[]; | |
void parseRecursively(String text, int lastMatchEnd) { | |
var match = _hashtagRegex.firstMatch(text); | |
if (match == null) { | |
// No more matches, add the remaining text and return | |
if (lastMatchEnd < text.length) { | |
list.add(TextElement(text.substring(lastMatchEnd))); | |
} | |
return; | |
} | |
// Add the text before the match | |
if (match.start > lastMatchEnd) { | |
list.add(TextElement(text.substring(lastMatchEnd, match.start))); | |
} | |
// Add the matched hashtag | |
list.add(HashtagElement(match.group(0)!)); | |
// Recur with the remaining text after the match | |
parseRecursively(text.substring(match.end), 0); | |
} | |
for (var element in elements) { | |
if (element is TextElement) { | |
parseRecursively(element.text, 0); | |
} else { | |
list.add(element); | |
} | |
} | |
return list; | |
} | |
} | |
/// Represents an element containing a hashtag | |
class HashtagElement extends LinkableElement { | |
final String hashtag; | |
HashtagElement(this.hashtag) | |
: super( | |
hashtag, | |
switch (hashtag.trim()) { | |
String h when h.startsWith('#') => h.substring(1), | |
String h => h, | |
}, | |
); | |
@override | |
String toString() { | |
return "HashtagElement: '$hashtag'"; | |
} | |
@override | |
bool operator ==(other) => equals(other); | |
@override | |
int get hashCode => Object.hash(text, originText, url, hashtag); | |
@override | |
bool equals(other) => other is HashtagElement && hashtag == other.hashtag; | |
} | |
// fixme: a multiline message like 1\n2 has its last line rendered separately from the badges |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment