feat/fix/refactor: адекватный UX чата существует
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,11 +8,13 @@ import 'package:komet/models/attachment.dart';
|
||||
const int _me = 1;
|
||||
const int _peer = 7;
|
||||
const double _photoWidth = 180;
|
||||
const double _maxBubbleWidth = 324;
|
||||
|
||||
CachedMessage _message({
|
||||
required String text,
|
||||
bool withReply = false,
|
||||
int senderId = _peer,
|
||||
String replyText = 'Пётр Синицын написал очень длинный ответ',
|
||||
}) => CachedMessage(
|
||||
id: '1',
|
||||
accountId: _me,
|
||||
@@ -28,7 +30,7 @@ CachedMessage _message({
|
||||
'message': {
|
||||
'id': '9',
|
||||
'sender': _me,
|
||||
'text': 'Пётр Синицын написал очень длинный ответ',
|
||||
'text': replyText,
|
||||
'time': 0,
|
||||
'attaches': [],
|
||||
},
|
||||
@@ -111,13 +113,17 @@ Future<void> _pumpColumn(
|
||||
Rect _bubbleRect(WidgetTester tester, int index, String text) {
|
||||
final label = find.descendant(
|
||||
of: find.byKey(ValueKey('bubble$index')),
|
||||
matching: find.text(text),
|
||||
matching: find.textContaining(text, findRichText: true),
|
||||
);
|
||||
final box = find.ancestor(of: label, matching: find.byType(Container)).first;
|
||||
return tester.getTopLeft(box) & tester.getSize(box);
|
||||
}
|
||||
|
||||
Future<void> _pumpBubble(WidgetTester tester, CachedMessage message) async {
|
||||
Future<void> _pumpBubble(
|
||||
WidgetTester tester,
|
||||
CachedMessage message, {
|
||||
String chatType = 'CHAT',
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
@@ -134,7 +140,7 @@ Future<void> _pumpBubble(WidgetTester tester, CachedMessage message) async {
|
||||
message: message,
|
||||
isMe: false,
|
||||
myId: _me,
|
||||
chatType: 'CHAT',
|
||||
chatType: chatType,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -149,6 +155,16 @@ Rect _rectOf(WidgetTester tester, Finder finder) {
|
||||
return topLeft & size;
|
||||
}
|
||||
|
||||
Rect _clockRect(WidgetTester tester) {
|
||||
var rect = Rect.zero;
|
||||
for (final element in find.textContaining('05:46').evaluate()) {
|
||||
final box = element.renderObject! as RenderBox;
|
||||
final candidate = box.localToGlobal(Offset.zero) & box.size;
|
||||
if (candidate.right > rect.right) rect = candidate;
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => ContactCache.put(_peer, 'Пётр Синицын'));
|
||||
|
||||
@@ -158,17 +174,23 @@ void main() {
|
||||
await _pumpBubble(tester, _message(text: 'нет'));
|
||||
|
||||
final header = _rectOf(tester, find.text('Пётр Синицын'));
|
||||
final clock = _rectOf(tester, find.textContaining('05:46'));
|
||||
final body = _rectOf(tester, find.text('нет'));
|
||||
final clock = _clockRect(tester);
|
||||
final body = _rectOf(
|
||||
tester,
|
||||
find.textContaining('нет', findRichText: true),
|
||||
);
|
||||
|
||||
expect(header.width, greaterThan(body.width + clock.width));
|
||||
expect(header.width, greaterThan(body.width));
|
||||
expect(clock.right, closeTo(header.right, 1));
|
||||
});
|
||||
|
||||
testWidgets('the reply quote fills the width the sender name opened up', (
|
||||
testWidgets('a short reply quote fills the width the sender name opened up', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(tester, _message(text: 'нет', withReply: true));
|
||||
await _pumpBubble(
|
||||
tester,
|
||||
_message(text: 'нет', withReply: true, replyText: 'ок'),
|
||||
);
|
||||
|
||||
final header = _rectOf(tester, find.text('Пётр Синицын'));
|
||||
final label = _rectOf(tester, find.text('Вы'));
|
||||
@@ -178,13 +200,36 @@ void main() {
|
||||
.ancestor(of: find.text('Вы'), matching: find.byType(Container))
|
||||
.first,
|
||||
);
|
||||
final clock = _rectOf(tester, find.textContaining('05:46'));
|
||||
final clock = _clockRect(tester);
|
||||
|
||||
expect(quote.right, greaterThan(label.right));
|
||||
expect(quote.right, closeTo(header.right, 1));
|
||||
expect(clock.right, closeTo(header.right, 1));
|
||||
});
|
||||
|
||||
testWidgets('a long reply quote widens the bubble past its own text', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(tester, _message(text: 'т'));
|
||||
final withoutReply = _clockRect(tester).right;
|
||||
|
||||
await _pumpBubble(tester, _message(text: 'т', withReply: true));
|
||||
|
||||
final header = _rectOf(tester, find.text('Пётр Синицын'));
|
||||
final quote = _rectOf(
|
||||
tester,
|
||||
find
|
||||
.ancestor(of: find.text('Вы'), matching: find.byType(Container))
|
||||
.first,
|
||||
);
|
||||
final clock = _clockRect(tester);
|
||||
|
||||
expect(clock.right, greaterThan(withoutReply + 40));
|
||||
expect(quote.right, greaterThan(header.right));
|
||||
expect(clock.right, closeTo(quote.right, 1));
|
||||
expect(quote.width, lessThanOrEqualTo(_maxBubbleWidth * 0.75 + 1));
|
||||
});
|
||||
|
||||
testWidgets('grouped bubbles keep the same gap with and without avatars', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -226,7 +271,10 @@ void main() {
|
||||
.ancestor(of: find.text('Вы'), matching: find.byType(Container))
|
||||
.first,
|
||||
);
|
||||
final caption = _rectOf(tester, find.text('Вот те раз, не может быть'));
|
||||
final caption = _rectOf(
|
||||
tester,
|
||||
find.textContaining('Вот те раз', findRichText: true),
|
||||
);
|
||||
|
||||
expect(quote.width, closeTo(_photoWidth - 16, 1));
|
||||
expect(quote.left, greaterThan(0));
|
||||
@@ -238,9 +286,29 @@ void main() {
|
||||
) async {
|
||||
await _pumpBubble(tester, _message(text: 'нет', senderId: 404));
|
||||
|
||||
final clock = _rectOf(tester, find.textContaining('05:46'));
|
||||
final body = _rectOf(tester, find.text('нет'));
|
||||
final clock = _clockRect(tester);
|
||||
final body = _rectOf(
|
||||
tester,
|
||||
find.textContaining('нет', findRichText: true),
|
||||
);
|
||||
|
||||
expect(clock.left, closeTo(body.right + 8, 1));
|
||||
expect(clock.center.dy, closeTo(body.center.dy, 4));
|
||||
});
|
||||
|
||||
testWidgets('the clock drops below wrapped text instead of widening it', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(
|
||||
tester,
|
||||
_message(text: '${'ф' * 14}\n${'ф' * 14}', senderId: 404),
|
||||
chatType: 'DIALOG',
|
||||
);
|
||||
|
||||
final clock = _clockRect(tester);
|
||||
final body = _rectOf(tester, find.textContaining('ф', findRichText: true));
|
||||
|
||||
expect(clock.top, greaterThanOrEqualTo(body.bottom - 2));
|
||||
expect(clock.right, lessThanOrEqualTo(body.right + 1));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user