feat/fix/refactor: адекватный UX чата существует

This commit is contained in:
Jganenokk
2026-08-15 20:44:13 +07:00
parent cf9297b0ab
commit d91759a1cf
11 changed files with 963 additions and 364 deletions
+5 -5
View File
@@ -235,6 +235,7 @@ TextStyle applyTextFormats(
Set<TextFormat> formats, {
Color? quoteColor,
Color? mentionColor,
Paint? quoteBackground,
}) {
if (formats.isEmpty) return base;
@@ -247,9 +248,9 @@ TextStyle applyTextFormats(
decorations.add(TextDecoration.lineThrough);
}
final isItalic = formats.contains(TextFormat.emphasized) ||
formats.contains(TextFormat.quote);
final isItalic = formats.contains(TextFormat.emphasized);
final isQuote = formats.contains(TextFormat.quote);
final isMention = formats.contains(TextFormat.userMention);
final isHeading = formats.contains(TextFormat.heading);
@@ -260,9 +261,8 @@ TextStyle applyTextFormats(
: null,
fontStyle: isItalic ? FontStyle.italic : null,
fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null,
color: isMention
? mentionColor
: (formats.contains(TextFormat.quote) ? quoteColor : null),
background: isQuote ? quoteBackground : null,
color: isMention ? mentionColor : (isQuote ? quoteColor : null),
decoration: decorations.isEmpty
? null
: TextDecoration.combine(decorations),
+27 -11
View File
@@ -635,6 +635,7 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<bool> _animojiHold = ValueNotifier(true);
final ValueNotifier<Set<String>> _selectedIds = ValueNotifier(const {});
final ValueNotifier<Offset?> _textSelectionDrag = ValueNotifier(null);
final ValueNotifier<({String id, Offset pos})?> _textSelection =
ValueNotifier(null);
late final AnimationController _selectionAnim;
@@ -683,7 +684,6 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.addListener(_maybeLoadMoreHistory);
_scrollController.addListener(_recordScrollPixels);
_scrollController.addListener(_scheduleReadMarker);
_scrollController.addListener(_exitTextSelectionOnScroll);
_scrollController.addListener(_updateScrollDownVisible);
MediaPlayback.instance.enterChat(widget.chatId);
AppVisualStyle.current.addListener(_onVisualStyleChanged);
@@ -2107,7 +2107,6 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.removeListener(_maybeLoadMoreHistory);
_scrollController.removeListener(_recordScrollPixels);
_scrollController.removeListener(_scheduleReadMarker);
_scrollController.removeListener(_exitTextSelectionOnScroll);
_scrollController.removeListener(_updateScrollDownVisible);
_readMarkTimer?.cancel();
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
@@ -2172,6 +2171,7 @@ class _ChatScreenState extends State<ChatScreen>
_search.dispose();
_selectedIds.dispose();
_textSelection.dispose();
_textSelectionDrag.dispose();
_messageController.dispose();
_messageFocusNode.dispose();
_stickers.dispose();
@@ -2352,6 +2352,7 @@ class _ChatScreenState extends State<ChatScreen>
void _startTextSelection(CachedMessage message, Offset globalPosition) {
if (message.isControl || message.selectableText == null) return;
_textSelectionDrag.value = null;
_textSelection.value = (id: message.id, pos: globalPosition);
}
@@ -2362,15 +2363,6 @@ class _ChatScreenState extends State<ChatScreen>
_textSelection.value = null;
}
void _exitTextSelectionOnScroll() {
if (_textSelection.value == null) return;
if (!_scrollController.hasClients) return;
if (_scrollController.position.userScrollDirection !=
ScrollDirection.idle) {
_exitTextSelection();
}
}
void _syncSelectionAnim() {
if (_selectedIds.value.isEmpty) {
_selectionAnim.reverse();
@@ -5754,6 +5746,7 @@ class _ChatScreenState extends State<ChatScreen>
? widget.imageUrl
: null,
textSelection: _textSelection,
textSelectionDrag: _textSelectionDrag,
onExitTextSelection: _exitTextSelection,
commentsLabel: isChannelPost
? _commentsLabelFor(message.id)
@@ -5780,6 +5773,8 @@ class _ChatScreenState extends State<ChatScreen>
_enterSelection(message),
onStartTextSelection: (pos) =>
_startTextSelection(message, pos),
onDragTextSelection: (pos) =>
_textSelectionDrag.value = pos,
onDelete: () =>
_confirmDeleteMessage(message.id, isMe),
onEdit: _canEditMessage(message)
@@ -7103,6 +7098,7 @@ class _SelectableMessageRow extends StatefulWidget {
final VoidCallback onToggleSelection;
final VoidCallback onEnterSelection;
final void Function(Offset globalPosition) onStartTextSelection;
final void Function(Offset? globalPosition) onDragTextSelection;
final VoidCallback onDelete;
final VoidCallback? onEdit;
final VoidCallback? onReply;
@@ -7128,6 +7124,7 @@ class _SelectableMessageRow extends StatefulWidget {
required this.onToggleSelection,
required this.onEnterSelection,
required this.onStartTextSelection,
required this.onDragTextSelection,
required this.onDelete,
this.onEdit,
this.onReply,
@@ -7299,7 +7296,21 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
});
}
bool _textSelectionPress = false;
void _handleLongPressMove(Offset globalPosition) {
if (!_textSelectionPress) return;
widget.onDragTextSelection(globalPosition);
}
void _handleLongPressEnd() {
if (!_textSelectionPress) return;
_textSelectionPress = false;
widget.onDragTextSelection(null);
}
void _handleLongPressStart(Offset globalPosition) {
_textSelectionPress = false;
if (!widget.isSelectionActive()) {
widget.onEnterSelection();
return;
@@ -7307,6 +7318,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
final selected = widget.selectedIds.value.contains(widget.message.id);
final hasText = widget.message.selectableText != null;
if (selected && hasText && !widget.message.isControl) {
_textSelectionPress = true;
widget.onStartTextSelection(globalPosition);
} else {
widget.onToggleSelection();
@@ -7355,6 +7367,10 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
onTapDown: (d) => _lastTapDown = d.globalPosition,
onTap: _handleTap,
onLongPressStart: (d) => _handleLongPressStart(d.globalPosition),
onLongPressMoveUpdate: (d) =>
_handleLongPressMove(d.globalPosition),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressEnd,
onSecondaryTapDown: active ? null : _onSecondaryTapDown,
child: ColoredBox(
color: isSelected
@@ -92,6 +92,8 @@ class BubbleContext {
final void Function(StickerAttachment sticker)? onStickerTap;
final ForwardedSourceTap? onForwardedSourceTap;
final BubblePresentation? presentation;
final bool metaInFooter;
final Widget Function(Widget)? selectable;
BubbleContext({
required this.context,
@@ -115,6 +117,8 @@ class BubbleContext {
this.onForwardedSourceTap,
this.reactionInfo,
this.presentation,
this.metaInFooter = false,
this.selectable,
}) : dim = text.withValues(alpha: 0.7);
String? get contentText =>
@@ -149,6 +153,8 @@ class BubbleContext {
onForwardedSourceTap: onForwardedSourceTap,
reactionInfo: reactionInfo,
presentation: value,
metaInFooter: metaInFooter,
selectable: selectable,
);
String get clockText {
@@ -169,17 +175,25 @@ class BubbleContext {
final style = TextStyle(color: text, fontSize: 16, height: 1.3);
final captionText = contentText;
final ranges = contentFormatRanges;
final Widget body;
if (FormattedMessageText.isFormatted(captionText, ranges)) {
return FormattedMessageText(
body = FormattedMessageText(
text: captionText!,
ranges: ranges,
style: style,
);
} else {
body = Text(captionText ?? '', style: style);
}
return Text(captionText ?? '', style: style);
final wrap = selectable;
return wrap == null ? body : wrap(body);
}
Widget meta() {
Widget meta() => metaInFooter ? const SizedBox.shrink() : _metaRow();
Widget footerMeta() => _metaRow();
Widget _metaRow() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
@@ -195,6 +209,8 @@ class BubbleContext {
}
Widget compactTime() {
if (metaInFooter) return const SizedBox.shrink();
final bgColor = isMe
? Colors.black.withValues(alpha: 0.4)
: Colors.black.withValues(alpha: 0.5);
@@ -90,7 +90,7 @@ class FileBubble extends StatelessWidget {
),
),
const SizedBox(width: 10),
Flexible(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
+39 -11
View File
@@ -30,6 +30,7 @@ void showChatMenu({
required List<ChatMenuItem> items,
Widget? header,
Widget? footer,
bool compact = false,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
late OverlayEntry entry;
@@ -39,6 +40,7 @@ void showChatMenu({
items: items,
header: header,
footer: footer,
compact: compact,
onDismiss: () {
if (entry.mounted) entry.remove();
},
@@ -53,6 +55,7 @@ class _ChatMenuLayer extends StatefulWidget {
final List<ChatMenuItem> items;
final Widget? header;
final Widget? footer;
final bool compact;
final VoidCallback onDismiss;
const _ChatMenuLayer({
@@ -61,6 +64,7 @@ class _ChatMenuLayer extends StatefulWidget {
required this.onDismiss,
this.header,
this.footer,
this.compact = false,
});
@override
@@ -69,17 +73,26 @@ class _ChatMenuLayer extends StatefulWidget {
class _MenuLayout extends SingleChildLayoutDelegate {
static const double menuWidth = 290.0;
static const double compactMenuWidth = 226.0;
static const double margin = 8.0;
static const double gap = 6.0;
final Rect anchor;
final EdgeInsets safeArea;
final bool compact;
const _MenuLayout({required this.anchor, required this.safeArea});
const _MenuLayout({
required this.anchor,
required this.safeArea,
this.compact = false,
});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final width = math.min(menuWidth, constraints.maxWidth - margin * 2);
final width = math.min(
compact ? compactMenuWidth : menuWidth,
constraints.maxWidth - margin * 2,
);
final available =
constraints.maxHeight - safeArea.top - safeArea.bottom - margin * 2;
return BoxConstraints(
@@ -112,7 +125,9 @@ class _MenuLayout extends SingleChildLayoutDelegate {
@override
bool shouldRelayout(_MenuLayout oldDelegate) =>
oldDelegate.anchor != anchor || oldDelegate.safeArea != safeArea;
oldDelegate.anchor != anchor ||
oldDelegate.safeArea != safeArea ||
oldDelegate.compact != compact;
}
class _ChatMenuLayerState extends State<_ChatMenuLayer>
@@ -154,6 +169,7 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
delegate: _MenuLayout(
anchor: widget.anchorRect,
safeArea: safeArea,
compact: widget.compact,
),
child: Opacity(
opacity: t,
@@ -187,9 +203,13 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
color: cs.onSurface.withValues(alpha: 0.07),
),
],
const SizedBox(height: 6),
SizedBox(height: widget.compact ? 4 : 6),
for (final item in widget.items) ...[
_ChatMenuRow(item: item, onTap: () => _onItemTap(item)),
_ChatMenuRow(
item: item,
compact: widget.compact,
onTap: () => _onItemTap(item),
),
if (item.dividerAfter)
Divider(
height: 1,
@@ -197,7 +217,7 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
color: cs.onSurface.withValues(alpha: 0.07),
),
],
const SizedBox(height: 6),
SizedBox(height: widget.compact ? 4 : 6),
if (widget.footer != null) ...[
Divider(
height: 1,
@@ -217,8 +237,13 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer>
class _ChatMenuRow extends StatelessWidget {
final ChatMenuItem item;
final VoidCallback onTap;
final bool compact;
const _ChatMenuRow({required this.item, required this.onTap});
const _ChatMenuRow({
required this.item,
required this.onTap,
this.compact = false,
});
@override
Widget build(BuildContext context) {
@@ -227,11 +252,14 @@ class _ChatMenuRow extends StatelessWidget {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15),
padding: EdgeInsets.symmetric(
horizontal: compact ? 14 : 18,
vertical: compact ? 10 : 15,
),
child: Row(
children: [
Icon(item.icon, size: 24, weight: 350, color: fg),
const SizedBox(width: 18),
Icon(item.icon, size: compact ? 20 : 24, weight: 350, color: fg),
SizedBox(width: compact ? 12 : 18),
Expanded(
child: Text(
item.label,
@@ -239,7 +267,7 @@ class _ChatMenuRow extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontSize: 16,
fontSize: compact ? 14 : 16,
fontWeight: FontWeight.w500,
),
),
+125 -24
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart' show ContactCache;
import '../../core/utils/link_opener.dart';
@@ -251,31 +252,27 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
final segments = segmentizeFormats(widget.text, ranges);
final cs = Theme.of(context).colorScheme;
final baseColor = widget.style.color ?? cs.onSurface;
final barColor = baseColor.withValues(alpha: 0.4);
final quoteColor = baseColor.withValues(alpha: 0.85);
final mentionColor = mentionTextColor(cs);
final spans = <InlineSpan>[];
var prevQuote = false;
final blocks = <_TextBlock>[];
var spans = <InlineSpan>[];
var blockIsQuote = false;
void closeBlock() {
_trimBlockEdges(spans);
if (spans.isNotEmpty) {
blocks.add(_TextBlock(quote: blockIsQuote, spans: spans));
}
spans = <InlineSpan>[];
}
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),
),
),
),
);
if (isQuote != blockIsQuote) {
closeBlock();
blockIsQuote = isQuote;
}
prevQuote = isQuote;
final style = applyTextFormats(
widget.style,
@@ -375,11 +372,115 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
}
}
return Text.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
maxLines: widget.maxLines,
overflow: widget.overflow ?? TextOverflow.clip,
closeBlock();
if (blocks.length == 1 && !blocks.first.quote) {
return _paragraph(blocks.first.spans);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < blocks.length; i++) ...[
if (i > 0) const SizedBox(height: 4),
blocks[i].quote
? _quoteBlock(blocks[i].spans, baseColor)
: _paragraph(blocks[i].spans),
],
],
);
}
Widget _paragraph(List<InlineSpan> spans) => Text.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
maxLines: widget.maxLines,
overflow: widget.overflow ?? TextOverflow.clip,
);
Widget _quoteBlock(List<InlineSpan> spans, Color baseColor) {
final glyphSize = (widget.style.fontSize ?? 16) * 0.85;
return Container(
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.fromLTRB(9, 5, 9, 6),
child: Stack(
children: [
Padding(
padding: EdgeInsets.only(right: glyphSize + 2),
child: _paragraph(spans),
),
Positioned(
top: 0,
right: 0,
child: Icon(
Symbols.format_quote,
size: glyphSize,
fill: 1,
color: baseColor.withValues(alpha: 0.55),
),
),
],
),
);
}
}
class _TextBlock {
final bool quote;
final List<InlineSpan> spans;
const _TextBlock({required this.quote, required this.spans});
}
void _trimBlockEdges(List<InlineSpan> spans) {
while (spans.isNotEmpty) {
final trimmed = _withText(
spans.first,
(t) => t.replaceFirst(_leadingNewlines, ''),
);
if (trimmed == null) break;
if (_isEmptyText(trimmed)) {
spans.removeAt(0);
continue;
}
spans[0] = trimmed;
break;
}
while (spans.isNotEmpty) {
final trimmed = _withText(
spans.last,
(t) => t.replaceFirst(_trailingNewlines, ''),
);
if (trimmed == null) break;
if (_isEmptyText(trimmed)) {
spans.removeLast();
continue;
}
spans[spans.length - 1] = trimmed;
break;
}
}
final RegExp _leadingNewlines = RegExp(r'^\n+');
final RegExp _trailingNewlines = RegExp(r'\n+$');
InlineSpan? _withText(InlineSpan span, String Function(String) transform) {
if (span is! TextSpan) return null;
final text = span.text;
if (text == null) return null;
final next = transform(text);
if (next == text) return span;
return TextSpan(
text: next,
style: span.style,
recognizer: span.recognizer,
children: span.children,
);
}
bool _isEmptyText(InlineSpan span) =>
span is TextSpan && (span.text?.isEmpty ?? false) && span.children == null;
+316 -115
View File
@@ -21,6 +21,7 @@ import '../../core/utils/webview_support.dart';
import '../../core/config/app_link_preview.dart';
import 'custom_notification.dart';
import 'formatted_message_text.dart';
import 'text_entity_actions.dart';
import 'sending_clock_icon.dart';
import 'photo_viewer.dart';
import 'selectable_message_text.dart';
@@ -58,20 +59,173 @@ class ReactionAnimationEvent {
typedef ReactionAnimojiResolver = Animoji? Function(String emoji);
class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget {
const _ZeroIntrinsicWidth({required Widget super.child});
class _TextWithMeta extends MultiChildRenderObjectWidget {
_TextWithMeta({required Widget text, required Widget meta})
: super(children: [text, meta]);
@override
RenderObject createRenderObject(BuildContext context) =>
_RenderZeroIntrinsicWidth();
_RenderTextWithMeta();
}
class _RenderZeroIntrinsicWidth extends RenderProxyBox {
@override
double computeMinIntrinsicWidth(double height) => 0;
class _TextWithMetaParentData extends ContainerBoxParentData<RenderBox> {}
class _RenderTextWithMeta extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _TextWithMetaParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _TextWithMetaParentData> {
static const double _gap = 8;
static const double _baselineNudge = 2;
RenderBox get _text => firstChild!;
RenderBox get _meta => lastChild!;
@override
double computeMaxIntrinsicWidth(double height) => 0;
void setupParentData(RenderBox child) {
if (child.parentData is! _TextWithMetaParentData) {
child.parentData = _TextWithMetaParentData();
}
}
RenderParagraph? _soleParagraph() {
RenderParagraph? found;
var seen = 0;
void visit(RenderObject node) {
if (node is RenderParagraph) {
found = node;
seen++;
return;
}
node.visitChildren(visit);
}
_text.visitChildren(visit);
if (_text is RenderParagraph) {
found = _text as RenderParagraph;
seen = 1;
}
return seen == 1 ? found : null;
}
@override
double computeMinIntrinsicWidth(double height) =>
_text.getMinIntrinsicWidth(height);
@override
double computeMaxIntrinsicWidth(double height) =>
_text.getMaxIntrinsicWidth(height) +
_gap +
_meta.getMaxIntrinsicWidth(height);
@override
double computeMinIntrinsicHeight(double width) =>
_text.getMinIntrinsicHeight(width);
@override
double computeMaxIntrinsicHeight(double width) =>
_text.getMaxIntrinsicHeight(width) + _meta.getMaxIntrinsicHeight(width);
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) =>
BaselineOffset(_text.getDistanceToActualBaseline(baseline)).offset;
@override
void performLayout() {
_meta.layout(const BoxConstraints(), parentUsesSize: true);
final metaSize = _meta.size;
_text.layout(constraints.loosen(), parentUsesSize: true);
final textSize = _text.size;
final paragraph = _soleParagraph();
final needed = _gap + metaSize.width;
double width;
double height;
var metaOnOwnLine = false;
if (paragraph != null) {
final length = paragraph.text.toPlainText().length;
final caret = paragraph.getOffsetForCaret(
TextPosition(offset: length),
Rect.zero,
);
final lastLine = caret.dx;
final singleLine = caret.dy < 0.5;
if (lastLine + needed <= textSize.width) {
width = textSize.width;
height = textSize.height;
} else if (singleLine) {
width = lastLine + needed;
height = textSize.height;
} else {
width = textSize.width;
height = textSize.height + metaSize.height;
metaOnOwnLine = true;
}
} else {
width = math.max(textSize.width, metaSize.width);
height = textSize.height + metaSize.height;
metaOnOwnLine = true;
}
size = constraints.constrain(Size(width, height));
(_text.parentData! as _TextWithMetaParentData).offset = Offset.zero;
(_meta.parentData! as _TextWithMetaParentData).offset = Offset(
math.max(0, size.width - metaSize.width),
metaOnOwnLine
? size.height - metaSize.height
: size.height - metaSize.height - _baselineNudge,
);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
class _CapIntrinsicWidth extends SingleChildRenderObjectWidget {
final double cap;
const _CapIntrinsicWidth({required this.cap, required Widget super.child});
@override
RenderObject createRenderObject(BuildContext context) =>
_RenderCapIntrinsicWidth(cap);
@override
void updateRenderObject(
BuildContext context,
_RenderCapIntrinsicWidth renderObject,
) {
renderObject.cap = cap;
}
}
class _RenderCapIntrinsicWidth extends RenderProxyBox {
_RenderCapIntrinsicWidth(this._cap);
double _cap;
set cap(double value) {
if (value == _cap) return;
_cap = value;
markNeedsLayout();
}
@override
double computeMinIntrinsicWidth(double height) =>
math.min(super.computeMinIntrinsicWidth(height), _cap);
@override
double computeMaxIntrinsicWidth(double height) =>
math.min(super.computeMaxIntrinsicWidth(height), _cap);
}
class _HeaderAboveMatchWidth extends MultiChildRenderObjectWidget {
@@ -83,7 +237,8 @@ class _HeaderAboveMatchWidth extends MultiChildRenderObjectWidget {
_RenderHeaderAboveMatchWidth();
}
class _HeaderAboveMatchWidthParentData extends ContainerBoxParentData<RenderBox> {}
class _HeaderAboveMatchWidthParentData
extends ContainerBoxParentData<RenderBox> {}
class _RenderHeaderAboveMatchWidth extends RenderBox
with
@@ -493,6 +648,7 @@ class MessageBubble extends StatelessWidget {
final String? senderNameOverride;
final String? senderAvatarOverride;
final ValueListenable<({String id, Offset pos})?>? textSelection;
final ValueListenable<Offset?>? textSelectionDrag;
final VoidCallback? onExitTextSelection;
final String? commentsLabel;
final VoidCallback? onCommentsTap;
@@ -523,6 +679,7 @@ class MessageBubble extends StatelessWidget {
this.senderNameOverride,
this.senderAvatarOverride,
this.textSelection,
this.textSelectionDrag,
this.onExitTextSelection,
this.commentsLabel,
this.onCommentsTap,
@@ -574,12 +731,9 @@ class MessageBubble extends StatelessWidget {
}
bool get _showsSenderName =>
!isMe &&
chatType == "CHAT" &&
prevMessage?.senderId != message.senderId;
!isMe && chatType == "CHAT" && prevMessage?.senderId != message.senderId;
bool get _stretchesTextRow =>
message.replyInfo != null || _showsSenderName;
bool get _stretchesTextRow => message.replyInfo != null || _showsSenderName;
BubbleShape _computeShape() {
if (message.isControl) return BubbleShape.singleMiddle;
@@ -749,6 +903,8 @@ class MessageBubble extends StatelessWidget {
);
}
static const double _replyWidthShare = 0.75;
static const List<Color> _senderPalette = [
Color(0xFFE57373),
Color(0xFF64B5F6),
@@ -794,7 +950,8 @@ class MessageBubble extends StatelessWidget {
Widget _buildLeadingAvatar(ColorScheme cs) {
final senderAvatar =
senderAvatarOverride ?? ContactCache.getAvatar(message.senderId);
final displaySender = senderNameOverride ?? ContactCache.get(message.senderId);
final displaySender =
senderNameOverride ?? ContactCache.get(message.senderId);
final Widget avatar;
if (senderAvatar != null && senderAvatar.isNotEmpty) {
avatar = CircleAvatar(
@@ -887,15 +1044,17 @@ class MessageBubble extends StatelessWidget {
final maxBubbleWidth = isVideoNote
? math.min(screenWidth - 24, 560.0)
: math.min(screenWidth * 0.75, 560.0);
final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null;
final noBubbleBackground =
isVideoNote || _isSticker || jumboAnimoji != null;
final bubbleColor = noBubbleBackground
? Colors.transparent
: (isMe ? cs.primaryContainer : cs.surfaceContainerHighest);
BubbleContext makeCtx() => BubbleContext(
BubbleContext makeCtx({bool metaInFooter = false}) => BubbleContext(
context: context,
cs: cs,
text: textColor,
metaInFooter: metaInFooter,
shape: shape,
contentType: contentType,
hasPhotoWithCaption: hasPhotoCap,
@@ -913,16 +1072,9 @@ class MessageBubble extends StatelessWidget {
onStickerTap: onStickerTap,
onForwardedSourceTap: onForwardedSourceTap,
reactionInfo: _resolveReactionInfo(),
selectable: _wrapSelectable,
);
final Widget bubbleContent =
reactionsListenable != null && contentType == MessageType.text
? ValueListenableBuilder<Map<String, dynamic>?>(
valueListenable: reactionsListenable!,
builder: (context, _, _) => _buildContent(makeCtx()),
)
: _buildContent(makeCtx());
final reactionsInside = contentType != MessageType.text;
final reply = message.replyInfo;
@@ -933,20 +1085,19 @@ class MessageBubble extends StatelessWidget {
: padding;
final Widget contentWithReactions = reactionsInside
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
bubbleContent,
_reactionsBar(
cs,
inset: padding == EdgeInsets.zero
? const EdgeInsets.fromLTRB(8, 4, 8, 6)
: const EdgeInsets.only(top: 4),
),
],
? _contentWithReactionsFooter(
cs,
makeCtx,
inset: padding == EdgeInsets.zero
? const EdgeInsets.fromLTRB(8, 4, 8, 6)
: const EdgeInsets.only(top: 4),
)
: bubbleContent;
: reactionsListenable != null
? ValueListenableBuilder<Map<String, dynamic>?>(
valueListenable: reactionsListenable!,
builder: (context, _, _) => _buildContent(makeCtx()),
)
: _buildContent(makeCtx());
final Widget? senderHeader = showSenderName
? _buildSenderHeader(cs, padding == EdgeInsets.zero)
@@ -967,7 +1118,8 @@ class MessageBubble extends StatelessWidget {
child: senderHeader,
),
if (reply != null) ...[
_ZeroIntrinsicWidth(
_CapIntrinsicWidth(
cap: maxBubbleWidth * _replyWidthShare,
child: _buildReplyQuote(context, cs, textColor, reply),
),
const SizedBox(height: 4),
@@ -1084,10 +1236,7 @@ class MessageBubble extends StatelessWidget {
color: cs.onSurfaceVariant.withValues(alpha: 0.18),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 11,
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Icon(Symbols.mode_comment, size: 19, color: accent),
@@ -1162,37 +1311,53 @@ class MessageBubble extends StatelessWidget {
'OPEN_APP' => Symbols.chevron_right,
_ => null,
};
final isClipboard = button.type == 'CLIPBOARD';
return Material(
color: cs.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _onInlineButtonTap(context, keyboard, button),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
button.text,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.primary,
fontSize: 14,
fontWeight: FontWeight.w600,
child: Stack(
children: [
Padding(
padding: EdgeInsets.fromLTRB(isClipboard ? 26 : 12, 10, 12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
button.text,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.primary,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
if (trailingIcon != null) ...[
const SizedBox(width: 4),
Icon(trailingIcon, size: 16, color: cs.primary),
],
],
),
),
if (isClipboard)
Positioned(
top: 6,
right: 8,
child: Icon(
Symbols.content_copy,
size: 15,
weight: 500,
color: cs.primary.withValues(alpha: 0.85),
),
),
if (trailingIcon != null) ...[
const SizedBox(width: 4),
Icon(trailingIcon, size: 16, color: cs.primary),
],
],
),
],
),
),
);
@@ -1213,6 +1378,11 @@ class MessageBubble extends StatelessWidget {
case 'OPEN_APP':
await _openMiniApp(context, button);
return;
case 'CLIPBOARD':
final payload = button.payload;
if (payload == null || payload.isEmpty) return;
await copyTextEntity(context, payload, 'Скопировано');
return;
default:
final callbackId = keyboard.callbackId;
if (callbackId == null || callbackId.isEmpty) {
@@ -1300,17 +1470,70 @@ class MessageBubble extends StatelessWidget {
return info != null && info.counters.isNotEmpty;
}
Widget _reactionsBar(ColorScheme cs, {required EdgeInsets inset}) {
Widget _contentWithReactionsFooter(
ColorScheme cs,
BubbleContext Function({bool metaInFooter}) makeCtx, {
required EdgeInsets inset,
}) {
final listenable = reactionsListenable;
if (listenable != null) {
return ValueListenableBuilder<Map<String, dynamic>?>(
valueListenable: listenable,
builder: (context, info, _) =>
_buildReactionsBarFor(cs, info, inset: inset),
_reactionsFooterLayout(cs, makeCtx, info, inset: inset),
);
}
final info = message.payload?['reactionInfo'];
return _buildReactionsBarFor(cs, info is Map ? info : null, inset: inset);
return _reactionsFooterLayout(
cs,
makeCtx,
info is Map ? info : null,
inset: inset,
);
}
Widget _reactionsFooterLayout(
ColorScheme cs,
BubbleContext Function({bool metaInFooter}) makeCtx,
Map? info, {
required EdgeInsets inset,
}) {
final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info));
if (chips.isEmpty) return _buildContent(makeCtx());
final carriesMeta =
_contentType == MessageType.attachment ||
_contentType == MessageType.voice;
final ctx = makeCtx(metaInFooter: carriesMeta);
return IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildContent(ctx),
Padding(
padding: inset,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: _ReactionsWrap(
spacing: 4,
runSpacing: 4,
children: chips,
),
),
if (carriesMeta) ...[
const SizedBox(width: 8),
ctx.footerMeta(),
],
],
),
),
],
),
);
}
Widget _buildContent(BubbleContext ctx) {
@@ -1541,6 +1764,7 @@ class MessageBubble extends StatelessWidget {
if (req == null || req.id != message.id) return child!;
return SelectableMessageText(
initialGlobalPosition: req.pos,
dragPosition: textSelectionDrag,
onExit: onExitTextSelection ?? () {},
child: child!,
);
@@ -1578,8 +1802,9 @@ class MessageBubble extends StatelessWidget {
);
final hasReactions = reactionChips.isNotEmpty;
final activeFontFamily =
Theme.of(ctx.context).textTheme.bodyLarge?.fontFamily;
final activeFontFamily = Theme.of(
ctx.context,
).textTheme.bodyLarge?.fontFamily;
final textStyle = TextStyle(
color: ctx.text,
fontSize: 16,
@@ -1591,6 +1816,24 @@ class MessageBubble extends StatelessWidget {
);
final ranges = message.formatRanges;
final decryptedText = decryption?.plaintext;
final metaRow = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (decryption?.isDecrypted ?? false) ...[
Icon(Symbols.lock, size: 11, weight: 700, fill: 1, color: ctx.dim),
const SizedBox(width: 3),
],
Text(
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
style: TextStyle(color: ctx.dim, fontSize: 10),
),
if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()],
if (message.deleted) ...[const SizedBox(width: 4), ctx.deletedIcon()],
],
);
final Widget textWidget;
if (decryption?.state == MessageDecryptionState.wrongKey) {
textWidget = _wrapSelectable(
@@ -1618,20 +1861,6 @@ class MessageBubble extends StatelessWidget {
textWidget = _wrapSelectable(Text(message.text ?? '', style: textStyle));
}
final metaWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (decryption?.isDecrypted ?? false) ...[
Icon(Symbols.lock, size: 11, weight: 700, fill: 1, color: ctx.dim),
const SizedBox(width: 3),
],
Text(
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
style: TextStyle(color: ctx.dim, fontSize: 10),
),
],
);
if (hasReactions) {
return IntrinsicWidth(
child: Column(
@@ -1652,13 +1881,8 @@ class MessageBubble extends StatelessWidget {
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: metaWidget,
child: metaRow,
),
if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()],
if (message.deleted) ...[
const SizedBox(width: 4),
ctx.deletedIcon(),
],
],
),
],
@@ -1666,30 +1890,7 @@ class MessageBubble extends StatelessWidget {
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_stretchesTextRow
? Expanded(child: textWidget)
: Flexible(child: textWidget),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: metaWidget,
),
if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()],
if (message.deleted) ...[
const SizedBox(width: 4),
ctx.deletedIcon(),
],
],
),
],
);
return _TextWithMeta(text: textWidget, meta: metaRow);
}
Widget _buildReplyQuote(
@@ -1698,7 +1899,7 @@ class MessageBubble extends StatelessWidget {
Color textColor,
ReplyInfo reply,
) {
final accent = isMe ? cs.onPrimaryContainer : cs.primary;
final accent = _senderColor(reply.senderId);
final name = reply.senderId == myId
? 'Вы'
: (ContactCache.get(reply.senderId) ?? 'Сообщение');
@@ -1806,7 +2007,7 @@ class MessageBubble extends StatelessWidget {
),
if (hasOrigText) ...[
const SizedBox(height: 2),
_wrapSelectable(forwardedCtx.caption()),
forwardedCtx.caption(),
] else ...[
const SizedBox(height: 2),
_wrapSelectable(
@@ -109,7 +109,8 @@ class RichMessageController extends TextEditingController {
return (text: src, elements: elementsForSend());
}
final entities = [..._animoji]..sort((a, b) => a.offset.compareTo(b.offset));
final entities = [..._animoji]
..sort((a, b) => a.offset.compareTo(b.offset));
final sb = StringBuffer();
var last = 0;
@@ -386,6 +387,9 @@ class RichMessageController extends TextEditingController {
final ranges = _toFormatRanges();
final baseColor = baseStyle.color;
final quoteColor = baseColor?.withValues(alpha: 0.85);
final quoteBackground = baseColor == null
? null
: (Paint()..color = baseColor.withValues(alpha: 0.12));
final mentionColor = mentionTextColor(Theme.of(context).colorScheme);
final segments = segmentizeFormats(content, ranges);
final entityByOffset = {for (final e in _animoji) e.offset: e};
@@ -398,6 +402,7 @@ class RichMessageController extends TextEditingController {
segment.formats,
quoteColor: quoteColor,
mentionColor: mentionColor,
quoteBackground: quoteBackground,
);
var runStart = segment.start;
var i = segment.start;
+337 -175
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -10,34 +11,64 @@ import '../../core/utils/haptics.dart';
import '../../l10n/app_localizations.dart';
import 'custom_notification.dart';
RenderParagraph? _findParagraph(RenderObject? ro) {
if (ro == null) return null;
if (ro is RenderParagraph) return ro;
RenderParagraph? found;
ro.visitChildren((child) {
found ??= _findParagraph(child);
});
return found;
void _collectParagraphs(RenderObject ro, List<RenderParagraph> out) {
if (ro is RenderParagraph) {
out.add(ro);
return;
}
ro.visitChildren((child) => _collectParagraphs(child, out));
}
bool _isSpace(int c) =>
c == 0x20 ||
c == 0x09 ||
c == 0x0A ||
c == 0x0D ||
c == 0x0C ||
c == 0xA0;
c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D || c == 0x0C || c == 0xA0;
bool _isGlyphOnly(String text) {
if (text.isEmpty) return true;
for (final rune in text.runes) {
final private =
(rune >= 0xE000 && rune <= 0xF8FF) ||
(rune >= 0xF0000 && rune <= 0xFFFFD) ||
(rune >= 0x100000 && rune <= 0x10FFFD);
if (!private) return false;
}
return true;
}
class _ParaSlice {
final RenderParagraph rp;
final int start;
final String text;
const _ParaSlice({required this.rp, required this.start, required this.text});
int get end => start + text.length;
bool get usable => rp.attached && rp.hasSize;
Offset get origin => rp.localToGlobal(Offset.zero);
Rect get globalRect => origin & rp.size;
TextSelection? clip(TextSelection selection) {
final s = selection.start.clamp(start, end) - start;
final e = selection.end.clamp(start, end) - start;
if (e <= s) return null;
return TextSelection(baseOffset: s, extentOffset: e);
}
}
class SelectableMessageText extends StatefulWidget {
final Widget child;
final Offset initialGlobalPosition;
final VoidCallback onExit;
final ValueListenable<Offset?>? dragPosition;
const SelectableMessageText({
super.key,
required this.child,
required this.initialGlobalPosition,
required this.onExit,
this.dragPosition,
});
@override
@@ -53,30 +84,111 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
static const double _hitBelow = 42.0;
final GlobalKey _textKey = GlobalKey();
final LayerLink _link = LayerLink();
final ValueNotifier<bool> _toolbarVisible = ValueNotifier(false);
late final AnimationController _entrance;
OverlayEntry? _overlay;
Timer? _settle;
TextSelection _selection = const TextSelection.collapsed(offset: 0);
String? _cachedText;
bool _dragging = false;
bool _exiting = false;
double? _dragStartLocalY;
double? _dragStartGlobalY;
double _dragAnchorY = 0;
double _dragLineHeight = 0;
RenderParagraph? _cachedParagraph;
List<_ParaSlice>? _cachedSlices;
String _cachedJoined = '';
ScrollPosition? _scrollPosition;
TextSelection? _anchor;
RenderParagraph? get _paragraph {
final cached = _cachedParagraph;
if (cached != null && cached.attached) return cached;
return _cachedParagraph = _findParagraph(
_textKey.currentContext?.findRenderObject(),
);
List<_ParaSlice> get _slices {
final cached = _cachedSlices;
if (cached != null && cached.isNotEmpty && cached.every((s) => s.usable)) {
return cached;
}
final root = _textKey.currentContext?.findRenderObject();
final paragraphs = <RenderParagraph>[];
if (root != null) _collectParagraphs(root, paragraphs);
final slices = <_ParaSlice>[];
var offset = 0;
for (final rp in paragraphs) {
if (!rp.attached || !rp.hasSize) continue;
final text = rp.text.toPlainText();
if (_isGlyphOnly(text)) continue;
slices.add(_ParaSlice(rp: rp, start: offset, text: text));
offset += text.length + 1;
}
_cachedJoined = slices.map((s) => s.text).join('\n');
return _cachedSlices = slices;
}
String _text(RenderParagraph rp) => _cachedText ??= rp.text.toPlainText();
String get _joined {
_slices;
return _cachedJoined;
}
_ParaSlice? _sliceAt(Offset globalPos) {
final slices = _slices;
if (slices.isEmpty) return null;
_ParaSlice? nearest;
var best = double.infinity;
for (final slice in slices) {
final rect = slice.globalRect;
if (rect.contains(globalPos)) return slice;
final dy = globalPos.dy < rect.top
? rect.top - globalPos.dy
: globalPos.dy - rect.bottom;
final distance = math.max(0.0, dy);
if (distance < best) {
best = distance;
nearest = slice;
}
}
return nearest;
}
int? _offsetAt(Offset globalPos) {
final slice = _sliceAt(globalPos);
if (slice == null) return null;
final local = slice.rp.globalToLocal(globalPos);
final off = slice.rp
.getPositionForOffset(local)
.offset
.clamp(0, slice.text.length);
return slice.start + off;
}
RenderBox? get _rootBox {
final ro = _textKey.currentContext?.findRenderObject();
if (ro is! RenderBox || !ro.attached || !ro.hasSize) return null;
return ro;
}
List<Rect> _localBoxes(TextSelection selection) {
if (!selection.isValid || selection.isCollapsed) return const [];
final root = _rootBox;
if (root == null) return const [];
final rootOrigin = root.localToGlobal(Offset.zero);
final out = <Rect>[];
for (final slice in _slices) {
final local = slice.clip(selection);
if (local == null) continue;
final delta = slice.origin - rootOrigin;
for (final box in slice.rp.getBoxesForSelection(local)) {
out.add(box.toRect().shift(delta));
}
}
return out;
}
List<Rect> _globalBoxes(TextSelection selection) {
final root = _rootBox;
if (root == null) return const [];
final rootOrigin = root.localToGlobal(Offset.zero);
return [for (final rect in _localBoxes(selection)) rect.shift(rootOrigin)];
}
@override
void initState() {
@@ -85,12 +197,57 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
vsync: this,
duration: const Duration(milliseconds: 260),
)..addListener(() => _overlay?.markNeedsBuild());
widget.dragPosition?.addListener(_onDragPosition);
WidgetsBinding.instance.addPostFrameCallback((_) => _init(4));
}
@override
void didUpdateWidget(SelectableMessageText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.dragPosition == widget.dragPosition) return;
oldWidget.dragPosition?.removeListener(_onDragPosition);
widget.dragPosition?.addListener(_onDragPosition);
}
void _onDragPosition() {
if (!mounted) return;
final pos = widget.dragPosition?.value;
if (pos == null) {
if (_anchor != null) _toolbarVisible.value = true;
return;
}
final anchor = _anchor;
if (anchor == null) return;
final off = _offsetAt(pos);
if (off == null) return;
_toolbarVisible.value = false;
if (off > anchor.end) {
_applySelection(
TextSelection(baseOffset: anchor.start, extentOffset: off),
);
} else if (off < anchor.start) {
_applySelection(TextSelection(baseOffset: off, extentOffset: anchor.end));
} else {
_applySelection(anchor);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final position = Scrollable.maybeOf(context)?.position;
if (identical(position, _scrollPosition)) return;
_scrollPosition?.removeListener(_onScroll);
_scrollPosition = position?..addListener(_onScroll);
}
void _onScroll() => _overlay?.markNeedsBuild();
@override
void dispose() {
_settle?.cancel();
widget.dragPosition?.removeListener(_onDragPosition);
_scrollPosition?.removeListener(_onScroll);
_entrance.dispose();
_overlay?.remove();
_overlay = null;
@@ -100,8 +257,7 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
void _init(int retries) {
if (!mounted) return;
final rp = _paragraph;
if (rp == null || !rp.hasSize) {
if (_slices.isEmpty) {
if (retries > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1));
} else {
@@ -109,7 +265,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
}
return;
}
_selectWordAt(widget.initialGlobalPosition, rp);
_selectWordAt(widget.initialGlobalPosition);
_anchor = _selection;
Haptics.selection();
_ensureOverlay();
_toolbarVisible.value = true;
@@ -130,10 +287,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
});
}
TextSelection _normalize(int a, int b) => TextSelection(
baseOffset: math.min(a, b),
extentOffset: math.max(a, b),
);
TextSelection _normalize(int a, int b) =>
TextSelection(baseOffset: math.min(a, b), extentOffset: math.max(a, b));
void _applySelection(TextSelection sel, {bool animate = false}) {
_selection = sel;
@@ -142,12 +297,11 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
_overlay?.markNeedsBuild();
}
TextRange _wordRange(RenderParagraph rp, Offset globalPos) {
final text = _text(rp);
TextRange _wordRange(Offset globalPos) {
final text = _joined;
final len = text.length;
if (len == 0) return const TextRange.collapsed(0);
final local = rp.globalToLocal(globalPos);
var off = rp.getPositionForOffset(local).offset.clamp(0, len);
var off = (_offsetAt(globalPos) ?? 0).clamp(0, len);
bool ws(int i) => i < 0 || i >= len || _isSpace(text.codeUnitAt(i));
if (ws(off) && off > 0 && !ws(off - 1)) off -= 1;
if (ws(off)) return TextRange.collapsed(off);
@@ -162,72 +316,59 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
return TextRange(start: s, end: e);
}
void _selectWordAt(Offset globalPos, RenderParagraph rp) {
final range = _wordRange(rp, globalPos);
void _selectWordAt(Offset globalPos) {
final range = _wordRange(globalPos);
if (range.isCollapsed) {
_applySelection(_normalize(0, _text(rp).length), animate: true);
_applySelection(_normalize(0, _joined.length), animate: true);
} else {
_applySelection(_normalize(range.start, range.end), animate: true);
}
}
void _onBackgroundTap(Offset globalPos) {
final rp = _paragraph;
if (rp == null || !rp.hasSize) {
final slices = _slices;
if (slices.isEmpty) {
_requestExit();
return;
}
final local = rp.globalToLocal(globalPos);
if (rp.size.contains(local)) {
final inside = slices.any((s) => s.globalRect.contains(globalPos));
if (inside) {
_dragging = false;
_settle?.cancel();
_selectWordAt(globalPos, rp);
_toolbarVisible.value = true;
} else {
_requestExit();
}
}
ui.TextBox? _edgeBox(RenderParagraph rp, bool isStart) {
if (!_selection.isValid || _selection.isCollapsed) return null;
final boxes = rp.getBoxesForSelection(_selection);
if (boxes.isEmpty) return null;
return isStart ? boxes.first : boxes.last;
}
void _onHandleDragStart(Offset globalPos, bool isStart) {
_dragging = true;
_settle?.cancel();
_entrance.value = 1.0;
_toolbarVisible.value = false;
_dragStartLocalY = null;
final rp = _paragraph;
if (rp == null || !rp.hasSize) return;
final box = _edgeBox(rp, isStart);
if (box == null) return;
final rect = box.toRect();
_dragStartLocalY = rp.globalToLocal(globalPos).dy;
_dragStartGlobalY = null;
final boxes = _globalBoxes(_selection);
if (boxes.isEmpty) return;
final rect = isStart ? boxes.first : boxes.last;
_dragStartGlobalY = globalPos.dy;
_dragAnchorY = rect.center.dy;
_dragLineHeight = rect.height;
}
double _lineSnappedY(double fingerLocalY) {
final startY = _dragStartLocalY;
if (startY == null || _dragLineHeight <= 0) return fingerLocalY;
final dragged = fingerLocalY - startY;
double _lineSnappedY(double fingerGlobalY) {
final startY = _dragStartGlobalY;
if (startY == null || _dragLineHeight <= 0) return fingerGlobalY;
final dragged = fingerGlobalY - startY;
final direction = dragged < 0 ? -1 : 1;
final lines = direction * (dragged.abs() / _dragLineHeight).floor();
return _dragAnchorY + lines * _dragLineHeight;
}
void _onHandleDrag(Offset globalPos, bool isStart) {
final rp = _paragraph;
if (rp == null || !rp.hasSize) return;
final len = _text(rp).length;
final local = rp.globalToLocal(globalPos);
final off = rp
.getPositionForOffset(Offset(local.dx, _lineSnappedY(local.dy)))
.offset;
final len = _joined.length;
if (len == 0) return;
final off = _offsetAt(Offset(globalPos.dx, _lineSnappedY(globalPos.dy)));
if (off == null) return;
if (isStart) {
final ns = off.clamp(0, math.max(0, _selection.end - 1)).toInt();
_applySelection(
@@ -243,7 +384,7 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
void _onHandleDragEnd() {
_dragging = false;
_dragStartLocalY = null;
_dragStartGlobalY = null;
_settle?.cancel();
_settle = Timer(const Duration(milliseconds: 140), () {
if (!mounted || _dragging) return;
@@ -253,9 +394,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
}
void _copy() {
final rp = _paragraph;
if (rp != null && _selection.isValid && !_selection.isCollapsed) {
final text = _text(rp);
if (_selection.isValid && !_selection.isCollapsed) {
final text = _joined;
final sub = text.substring(
_selection.start.clamp(0, text.length),
_selection.end.clamp(0, text.length),
@@ -273,48 +413,53 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
}
void _selectAll() {
final rp = _paragraph;
if (rp == null) return;
if (_slices.isEmpty) return;
Haptics.tap();
_applySelection(_normalize(0, _text(rp).length), animate: true);
_applySelection(_normalize(0, _joined.length), animate: true);
_toolbarVisible.value = true;
}
Widget _buildOverlay(BuildContext ctx) {
final rp = _paragraph;
if (rp == null || !rp.hasSize || !rp.attached) {
return const SizedBox.shrink();
}
if (_slices.isEmpty) return const SizedBox.shrink();
final cs = Theme.of(ctx).colorScheme;
final List<ui.TextBox> boxes =
(_selection.isValid && !_selection.isCollapsed)
? rp.getBoxesForSelection(_selection)
: const [];
final rects = _localBoxes(_selection);
final children = <Widget>[
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
behavior: HitTestBehavior.translucent,
onTapUp: (d) => _onBackgroundTap(d.globalPosition),
),
),
];
if (boxes.isNotEmpty) {
final first = boxes.first.toRect();
final last = boxes.last.toRect();
final startBottom = rp.localToGlobal(Offset(first.left, first.bottom));
final endBottom = rp.localToGlobal(Offset(last.right, last.bottom));
if (rects.isNotEmpty) {
final first = rects.first;
final last = rects.last;
children.add(_handle(cs, startBottom, isStart: true));
children.add(_handle(cs, endBottom, isStart: false));
children.add(_toolbar(ctx, rp, first, last));
children.add(
_handle(cs, Offset(first.left, first.bottom), isStart: true),
);
children.add(
_handle(cs, Offset(last.right, last.bottom), isStart: false),
);
children.add(_toolbar(ctx, first, last));
}
return Stack(children: children);
}
Widget _follow(Offset offset, Widget child) => Positioned(
left: 0,
top: 0,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
offset: offset,
child: child,
),
);
Widget _handle(
ColorScheme cs,
Offset lineBottomGlobal, {
@@ -325,41 +470,42 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
lineBottomGlobal.dy + _ballRadius,
);
final leftInset = isStart ? _hitOuter : _hitInner;
return Positioned(
left: center.dx - leftInset,
top: center.dy - _hitAbove,
width: _hitInner + _hitOuter,
height: _hitAbove + _hitBelow,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart),
onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart),
onPanEnd: (_) => _onHandleDragEnd(),
onPanCancel: _onHandleDragEnd,
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: EdgeInsets.only(
left: leftInset - _ballRadius,
top: _hitAbove - _ballRadius,
),
child: Transform.scale(
scale: Curves.easeOutBack.transform(
_entrance.value.clamp(0.0, 1.0),
return _follow(
Offset(center.dx - leftInset, center.dy - _hitAbove),
SizedBox(
width: _hitInner + _hitOuter,
height: _hitAbove + _hitBelow,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart),
onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart),
onPanEnd: (_) => _onHandleDragEnd(),
onPanCancel: _onHandleDragEnd,
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: EdgeInsets.only(
left: leftInset - _ballRadius,
top: _hitAbove - _ballRadius,
),
child: Container(
width: _ballRadius * 2,
height: _ballRadius * 2,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
child: Transform.scale(
scale: Curves.easeOutBack.transform(
_entrance.value.clamp(0.0, 1.0),
),
child: Container(
width: _ballRadius * 2,
height: _ballRadius * 2,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
),
@@ -369,34 +515,36 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
);
}
Widget _toolbar(BuildContext ctx, RenderParagraph rp, Rect first, Rect last) {
Widget _toolbar(BuildContext ctx, Rect first, Rect last) {
final media = MediaQuery.of(ctx);
final size = media.size;
final rootOrigin = _rootBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final safeTop = media.padding.top + 8;
final safeBottom = size.height - media.padding.bottom - 8;
const height = 48.0;
const gap = 10.0;
final topGlobal = rp.localToGlobal(first.topLeft).dy;
final bottomGlobal = rp.localToGlobal(Offset(last.right, last.bottom)).dy;
double top = topGlobal - gap - height;
if (top < safeTop) top = bottomGlobal + gap;
double top = rootOrigin.dy + first.top - gap - height;
if (top < safeTop) top = rootOrigin.dy + last.bottom + gap;
top = top.clamp(safeTop, math.max(safeTop, safeBottom - height));
return Positioned(
left: 12,
right: 12,
top: top,
child: ValueListenableBuilder<bool>(
valueListenable: _toolbarVisible,
builder: (ctx, visible, _) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Center(child: _pill(ctx)),
return _follow(
Offset(-rootOrigin.dx, top - rootOrigin.dy),
SizedBox(
width: size.width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ValueListenableBuilder<bool>(
valueListenable: _toolbarVisible,
builder: (ctx, visible, _) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Center(child: _pill(ctx)),
),
),
),
),
),
@@ -447,28 +595,34 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return CustomPaint(
painter: _HighlightPainter(
paragraph: _paragraph,
selection: _selection,
animation: _entrance,
fill: cs.primary.withValues(alpha: 0.28),
stem: cs.primary,
return CompositedTransformTarget(
link: _link,
child: CustomPaint(
painter: _HighlightPainter(
rootKey: _textKey,
slices: _slices,
selection: _selection,
animation: _entrance,
fill: cs.primary.withValues(alpha: 0.28),
stem: cs.primary,
),
child: KeyedSubtree(key: _textKey, child: widget.child),
),
child: KeyedSubtree(key: _textKey, child: widget.child),
);
}
}
class _HighlightPainter extends CustomPainter {
final RenderParagraph? paragraph;
final GlobalKey rootKey;
final List<_ParaSlice> slices;
final TextSelection selection;
final Animation<double> animation;
final Color fill;
final Color stem;
_HighlightPainter({
required this.paragraph,
required this.rootKey,
required this.slices,
required this.selection,
required this.animation,
required this.fill,
@@ -478,28 +632,36 @@ class _HighlightPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
if (!selection.isValid || selection.isCollapsed) return;
final rp = paragraph;
if (rp == null || !rp.hasSize || !rp.attached) return;
final boxes = rp.getBoxesForSelection(selection);
if (boxes.isEmpty) return;
final root = rootKey.currentContext?.findRenderObject();
if (root is! RenderBox || !root.attached || !root.hasSize) return;
final rootOrigin = root.localToGlobal(Offset.zero);
final rects = <Rect>[];
for (final slice in slices) {
if (!slice.usable) continue;
final local = slice.clip(selection);
if (local == null) continue;
final delta = slice.origin - rootOrigin;
for (final box in slice.rp.getBoxesForSelection(local)) {
rects.add(box.toRect().shift(delta));
}
}
if (rects.isEmpty) return;
final t = animation.value.clamp(0.0, 1.0);
final eased = Curves.easeOut.transform(t);
final grow = 0.72 + 0.28 * eased;
final fillPaint = Paint()..color = fill.withValues(alpha: fill.a * eased);
for (final box in boxes) {
final rect = box.toRect().inflate(0.5);
final cy = rect.center.dy;
final h = rect.height * grow;
final animRect = Rect.fromLTRB(
rect.left,
cy - h / 2,
rect.right,
cy + h / 2,
);
for (final rect in rects) {
final inflated = rect.inflate(0.5);
final cy = inflated.center.dy;
final h = inflated.height * grow;
canvas.drawRRect(
RRect.fromRectAndRadius(animRect, const Radius.circular(3)),
RRect.fromRectAndRadius(
Rect.fromLTRB(inflated.left, cy - h / 2, inflated.right, cy + h / 2),
const Radius.circular(3),
),
fillPaint,
);
}
@@ -508,8 +670,8 @@ class _HighlightPainter extends CustomPainter {
..color = stem.withValues(alpha: stem.a * eased)
..strokeWidth = 2.5
..strokeCap = StrokeCap.round;
final first = boxes.first.toRect();
final last = boxes.last.toRect();
final first = rects.first;
final last = rects.last;
canvas.drawLine(
Offset(first.left, first.top),
Offset(first.left, first.bottom),
@@ -525,7 +687,7 @@ class _HighlightPainter extends CustomPainter {
@override
bool shouldRepaint(_HighlightPainter old) =>
old.selection != selection ||
old.paragraph != paragraph ||
old.slices.length != slices.length ||
old.fill != fill ||
old.stem != stem;
}
@@ -37,6 +37,7 @@ void showPhoneEntityMenu(
showChatMenu(
context: context,
anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0),
compact: true,
header: _PhoneOwnerHeader(phone: phone),
items: [
ChatMenuItem(
@@ -62,6 +63,7 @@ void showCardEntityMenu(
showChatMenu(
context: context,
anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0),
compact: true,
items: [
ChatMenuItem(
icon: Symbols.content_copy,
@@ -95,19 +97,19 @@ class _CardFooter extends StatelessWidget {
final cs = Theme.of(context).colorScheme;
final title = cardBrandTitle(digits);
return Padding(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 11),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cardMask(digits),
style: TextStyle(color: cs.onSurface, fontSize: 15),
style: TextStyle(color: cs.onSurface, fontSize: 14),
),
if (title != null) ...[
const SizedBox(height: 2),
Text(
title,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
],
],
@@ -160,7 +162,7 @@ class _PhoneOwnerHeaderState extends State<_PhoneOwnerHeader> {
: _OwnerRow(found: found, phone: widget.phone);
}
return Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
padding: const EdgeInsets.fromLTRB(14, 11, 14, 10),
child: content,
);
},
@@ -199,7 +201,7 @@ class _OwnerRow extends StatelessWidget {
),
Text(
phone,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
],
),