3398 lines
105 KiB
Dart
3398 lines
105 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:ogg_opus_player/ogg_opus_player.dart';
|
||
import 'package:video_player/video_player.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/rendering.dart';
|
||
import 'package:komet/main.dart';
|
||
import 'package:material_symbols_icons/symbols.dart';
|
||
import '../../backend/modules/messages.dart';
|
||
import '../screens/webapp/web_app_screen.dart';
|
||
import '../../core/config/app_bubble_behavior.dart';
|
||
import '../../core/config/app_bubble_shape.dart';
|
||
import '../../core/config/komet_settings.dart';
|
||
import '../../core/utils/bubble_radius.dart';
|
||
import '../../core/utils/format.dart';
|
||
import '../../core/utils/haptics.dart';
|
||
import '../../core/utils/file_download.dart';
|
||
import '../../core/utils/media_cache.dart';
|
||
import '../../core/utils/download_progress.dart';
|
||
import '../../core/utils/link_opener.dart';
|
||
import '../../core/utils/webview_support.dart';
|
||
import '../../core/config/app_link_preview.dart';
|
||
import 'custom_notification.dart';
|
||
import 'link_text.dart';
|
||
import '../../models/attachment.dart';
|
||
import 'poll_view.dart';
|
||
import 'photo_viewer.dart';
|
||
import 'video_player_screen.dart';
|
||
|
||
enum MessageType { text, attachment, voice, control }
|
||
|
||
enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle }
|
||
|
||
class _BubbleCtx {
|
||
final BuildContext context;
|
||
final ColorScheme cs;
|
||
final Color text;
|
||
final Color dim;
|
||
final BubbleShape shape;
|
||
final MessageType contentType;
|
||
final bool hasPhotoWithCaption;
|
||
final bool hasMultiplePhotosNoCaption;
|
||
final Map? reactionInfo;
|
||
|
||
_BubbleCtx({
|
||
required this.context,
|
||
required this.cs,
|
||
required this.text,
|
||
required this.shape,
|
||
required this.contentType,
|
||
required this.hasPhotoWithCaption,
|
||
required this.hasMultiplePhotosNoCaption,
|
||
this.reactionInfo,
|
||
}) : dim = text.withValues(alpha: 0.7);
|
||
}
|
||
|
||
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
|
||
final Expando<({bool full, String text})> _clockTextCache = Expando();
|
||
|
||
class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget {
|
||
const _ZeroIntrinsicWidth({required Widget super.child});
|
||
|
||
@override
|
||
RenderObject createRenderObject(BuildContext context) =>
|
||
_RenderZeroIntrinsicWidth();
|
||
}
|
||
|
||
class _RenderZeroIntrinsicWidth extends RenderProxyBox {
|
||
@override
|
||
double computeMinIntrinsicWidth(double height) => 0;
|
||
|
||
@override
|
||
double computeMaxIntrinsicWidth(double height) => 0;
|
||
}
|
||
|
||
class MessageBubble extends StatelessWidget {
|
||
static const double photoMaxSize = 280.0;
|
||
static const double photoMinSize = 100.0;
|
||
static const double photoBorderRadius = 12.0;
|
||
static const double bubbleBorderRadius = 20.0;
|
||
static const double captionPaddingHorizontal = 6.0;
|
||
static const double captionPaddingRight = 4.0;
|
||
static const double compactTimePadding = 8.0;
|
||
|
||
static const Radius _bigRadius = Radius.circular(bubbleBorderRadius);
|
||
static const Radius _smallRadius = Radius.circular(4);
|
||
static const Radius _photoRadius = Radius.circular(photoBorderRadius);
|
||
|
||
static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18);
|
||
static const BorderRadius _reactionChipRadius = BorderRadius.all(
|
||
Radius.circular(10),
|
||
);
|
||
|
||
static Color bubbleTextColor(BuildContext context) =>
|
||
Theme.of(context).brightness == Brightness.dark
|
||
? Colors.white
|
||
: Colors.black;
|
||
|
||
final CachedMessage message;
|
||
final bool isMe;
|
||
final int myId;
|
||
final CachedMessage? prevMessage;
|
||
final CachedMessage? nextMessage;
|
||
final String chatType;
|
||
final String? overrideStatus;
|
||
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
||
final ValueListenable<List<double>>? uploadProgress;
|
||
final void Function(String messageId)? onReplyTap;
|
||
final void Function(int senderId)? onAvatarTap;
|
||
final void Function(StickerAttachment sticker)? onStickerTap;
|
||
|
||
const MessageBubble({
|
||
super.key,
|
||
required this.message,
|
||
required this.isMe,
|
||
required this.myId,
|
||
this.prevMessage,
|
||
this.nextMessage,
|
||
required this.chatType,
|
||
this.overrideStatus,
|
||
this.reactionsListenable,
|
||
this.uploadProgress,
|
||
this.onReplyTap,
|
||
this.onAvatarTap,
|
||
this.onStickerTap,
|
||
});
|
||
|
||
bool _computeHasPhotoWithCaption() {
|
||
final attachments = message.attachments;
|
||
if (attachments == null || attachments.isEmpty) return false;
|
||
final hasPhoto = attachments.any((a) => a is PhotoAttachment);
|
||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||
return hasPhoto && hasCaption;
|
||
}
|
||
|
||
bool _computeHasMultiplePhotosNoCaption() {
|
||
final attachments = message.attachments;
|
||
if (attachments == null || attachments.isEmpty) return false;
|
||
final photoCount = attachments.whereType<PhotoAttachment>().length;
|
||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||
return photoCount >= 2 && !hasCaption;
|
||
}
|
||
|
||
BubbleShape _computeShape() {
|
||
if (message.isControl) return BubbleShape.singleMiddle;
|
||
|
||
final hasPrevFromMe =
|
||
prevMessage?.senderId == message.senderId && !prevMessage!.isControl;
|
||
final prevTimeDiff = hasPrevFromMe
|
||
? message.time - prevMessage!.time
|
||
: 999999999;
|
||
|
||
final hasNextFromMe =
|
||
nextMessage?.senderId == message.senderId && !nextMessage!.isControl;
|
||
final nextTimeDiff = hasNextFromMe
|
||
? nextMessage!.time - message.time
|
||
: 999999999;
|
||
|
||
final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000;
|
||
final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000;
|
||
|
||
if (!groupedWithPrev && !groupedWithNext) return BubbleShape.singleMiddle;
|
||
if (!groupedWithPrev && groupedWithNext) return BubbleShape.singleTop;
|
||
if (groupedWithPrev && !groupedWithNext) return BubbleShape.singleBottom;
|
||
return BubbleShape.groupedMiddle;
|
||
}
|
||
|
||
bool get _hasShareAttachment {
|
||
final a = message.attachments;
|
||
return a != null && a.isNotEmpty && a.first is ShareAttachment;
|
||
}
|
||
|
||
bool get _isVideoNote {
|
||
final a = message.attachments;
|
||
if (a == null || a.isEmpty) return false;
|
||
final first = a.first;
|
||
return first is VideoAttachment && first.isNote;
|
||
}
|
||
|
||
MessageType get _contentType {
|
||
if (_hasShareAttachment) return _computeContentType();
|
||
return _contentTypeCache[message] ??= _computeContentType();
|
||
}
|
||
|
||
String get _clockText {
|
||
final full = KometSettings.fullTimestamp.value;
|
||
final cached = _clockTextCache[message];
|
||
if (cached != null && cached.full == full) return cached.text;
|
||
final text = formatClock(
|
||
DateTime.fromMillisecondsSinceEpoch(message.time),
|
||
withSeconds: full,
|
||
);
|
||
_clockTextCache[message] = (full: full, text: text);
|
||
return text;
|
||
}
|
||
|
||
InlineKeyboardAttachment? get _inlineKeyboard {
|
||
final attachments = message.attachments;
|
||
if (attachments == null) return null;
|
||
for (final a in attachments) {
|
||
if (a is InlineKeyboardAttachment && !a.isEmpty) return a;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
MessageType _computeContentType() {
|
||
if (message.isControl) return MessageType.control;
|
||
final attachments = message.attachments
|
||
?.where((a) => a is! InlineKeyboardAttachment)
|
||
.toList();
|
||
if (attachments != null && attachments.isNotEmpty) {
|
||
final first = attachments.first;
|
||
if (first is ForwardedMessageAttachment) {
|
||
final fwd = first;
|
||
final hasContact = fwd.originalContact != null;
|
||
final hasPhoto =
|
||
fwd.originalAttachments != null &&
|
||
fwd.originalAttachments!.any((a) => a is PhotoAttachment);
|
||
final hasOther =
|
||
fwd.originalAttachments != null &&
|
||
fwd.originalAttachments!.isNotEmpty;
|
||
if (hasContact || hasPhoto || hasOther) return MessageType.attachment;
|
||
return MessageType.text;
|
||
}
|
||
if (first is ContactAttachment) return MessageType.attachment;
|
||
if (first is UnknownAttachment) return MessageType.text;
|
||
if (first.type == AttachmentType.audio) return MessageType.voice;
|
||
if (first is ShareAttachment) {
|
||
return AppLinkPreview.current.value
|
||
? MessageType.attachment
|
||
: MessageType.text;
|
||
}
|
||
return MessageType.attachment;
|
||
}
|
||
|
||
final payload = message.payload;
|
||
if (payload == null) return MessageType.text;
|
||
if (payload['voice'] != null) return MessageType.voice;
|
||
return MessageType.text;
|
||
}
|
||
|
||
EdgeInsets _paddingFor(MessageType contentType, BubbleShape shape) {
|
||
switch (contentType) {
|
||
case MessageType.text:
|
||
if (shape == BubbleShape.groupedMiddle) {
|
||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
||
}
|
||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||
case MessageType.attachment:
|
||
return EdgeInsets.zero;
|
||
case MessageType.voice:
|
||
if (shape == BubbleShape.singleTop ||
|
||
shape == BubbleShape.singleBottom) {
|
||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
||
}
|
||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
|
||
case MessageType.control:
|
||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
|
||
}
|
||
}
|
||
|
||
double _topMarginFor(MessageType contentType, BubbleShape shape) {
|
||
switch (contentType) {
|
||
case MessageType.text:
|
||
switch (shape) {
|
||
case BubbleShape.singleTop:
|
||
return 6;
|
||
case BubbleShape.singleBottom:
|
||
case BubbleShape.groupedMiddle:
|
||
return 1;
|
||
case BubbleShape.singleMiddle:
|
||
return 4;
|
||
}
|
||
case MessageType.attachment:
|
||
switch (shape) {
|
||
case BubbleShape.singleBottom:
|
||
return 6;
|
||
case BubbleShape.singleTop:
|
||
case BubbleShape.groupedMiddle:
|
||
return 1;
|
||
case BubbleShape.singleMiddle:
|
||
return 4;
|
||
}
|
||
case MessageType.voice:
|
||
return shape == BubbleShape.singleMiddle ? 4 : 1;
|
||
case MessageType.control:
|
||
return 4;
|
||
}
|
||
}
|
||
|
||
double _bottomMarginFor(MessageType contentType, BubbleShape shape) {
|
||
switch (contentType) {
|
||
case MessageType.text:
|
||
case MessageType.attachment:
|
||
case MessageType.voice:
|
||
return shape == BubbleShape.singleMiddle ? 4 : 1;
|
||
case MessageType.control:
|
||
return 4;
|
||
}
|
||
}
|
||
|
||
BorderRadius _borderRadiusFor(
|
||
BubbleStyle bubbleStyle,
|
||
BubbleBehavior bubbleBehavior,
|
||
BubbleShape shape,
|
||
bool hasPhotoWithCaption,
|
||
bool hasMultiplePhotosNoCaption,
|
||
) {
|
||
final isTop =
|
||
shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle;
|
||
final isBottom =
|
||
shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle;
|
||
return computeBubbleRadius(
|
||
isMe: isMe,
|
||
isTop: isTop,
|
||
isBottom: isBottom,
|
||
style: bubbleStyle,
|
||
behavior: bubbleBehavior,
|
||
hasPhotoWithCaption: hasPhotoWithCaption,
|
||
hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption,
|
||
);
|
||
}
|
||
|
||
static const List<Color> _senderPalette = [
|
||
Color(0xFFE57373),
|
||
Color(0xFF64B5F6),
|
||
Color(0xFF81C784),
|
||
Color(0xFFFFB74D),
|
||
Color(0xFFBA68C8),
|
||
Color(0xFF4DD0E1),
|
||
Color(0xFFF06292),
|
||
Color(0xFFA1887F),
|
||
];
|
||
|
||
Color _senderColor(int id) => _senderPalette[id.abs() % _senderPalette.length];
|
||
|
||
Widget _buildSenderHeader(ColorScheme cs, bool needsInset) {
|
||
final name = ContactCache.get(message.senderId);
|
||
if (name == null || name.isEmpty) return const SizedBox.shrink();
|
||
final header = Padding(
|
||
padding: needsInset
|
||
? const EdgeInsets.fromLTRB(12, 6, 12, 2)
|
||
: const EdgeInsets.only(bottom: 2),
|
||
child: Text(
|
||
name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: _senderColor(message.senderId),
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
|
||
final cb = onAvatarTap;
|
||
if (cb == null) return header;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => cb(message.senderId),
|
||
child: header,
|
||
);
|
||
}
|
||
|
||
Widget _buildLeadingAvatar(ColorScheme cs) {
|
||
final senderAvatar = ContactCache.getAvatar(message.senderId);
|
||
final displaySender = ContactCache.get(message.senderId);
|
||
final Widget avatar;
|
||
if (senderAvatar != null && senderAvatar.isNotEmpty) {
|
||
avatar = CircleAvatar(
|
||
radius: 15,
|
||
backgroundImage: CachedNetworkImageProvider(
|
||
senderAvatar,
|
||
maxWidth: 96,
|
||
maxHeight: 96,
|
||
),
|
||
backgroundColor: cs.primaryContainer,
|
||
);
|
||
} else {
|
||
avatar = CircleAvatar(
|
||
radius: 15,
|
||
backgroundColor: cs.primaryContainer,
|
||
child: Text(
|
||
displaySender != null && displaySender.isNotEmpty
|
||
? displaySender[0].toUpperCase()
|
||
: '?',
|
||
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
|
||
),
|
||
);
|
||
}
|
||
|
||
final cb = onAvatarTap;
|
||
if (cb == null) return avatar;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => cb(message.senderId),
|
||
child: avatar,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_hasShareAttachment) {
|
||
return ValueListenableBuilder<bool>(
|
||
valueListenable: AppLinkPreview.current,
|
||
builder: (context, _, _) => _buildBubble(context),
|
||
);
|
||
}
|
||
return _buildBubble(context);
|
||
}
|
||
|
||
Widget _buildBubble(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
final contentType = _contentType;
|
||
|
||
if (message.isControl) {
|
||
const controlShape = BubbleShape.singleMiddle;
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
top: _topMarginFor(contentType, controlShape),
|
||
bottom: _bottomMarginFor(contentType, controlShape),
|
||
),
|
||
child: Center(child: _buildControlContent(cs)),
|
||
);
|
||
}
|
||
|
||
final shape = _computeShape();
|
||
final hasPhotoCap = _computeHasPhotoWithCaption();
|
||
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
|
||
final textColor = bubbleTextColor(context);
|
||
|
||
final topMargin = _topMarginFor(contentType, shape);
|
||
final bottomMargin = _bottomMarginFor(contentType, shape);
|
||
final padding = _paddingFor(contentType, shape);
|
||
|
||
final showAvatarSlot = !isMe;
|
||
final showAvatar =
|
||
showAvatarSlot &&
|
||
chatType == "CHAT" &&
|
||
nextMessage?.senderId != message.senderId;
|
||
final showSenderName =
|
||
showAvatarSlot &&
|
||
chatType == "CHAT" &&
|
||
prevMessage?.senderId != message.senderId;
|
||
|
||
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
|
||
final keyboard = _inlineKeyboard;
|
||
final isVideoNote = _isVideoNote;
|
||
final bubbleColor = isVideoNote
|
||
? Colors.transparent
|
||
: (isMe ? cs.primaryContainer : cs.surfaceContainerHighest);
|
||
|
||
_BubbleCtx makeCtx() => _BubbleCtx(
|
||
context: context,
|
||
cs: cs,
|
||
text: textColor,
|
||
shape: shape,
|
||
contentType: contentType,
|
||
hasPhotoWithCaption: hasPhotoCap,
|
||
hasMultiplePhotosNoCaption: hasMultiPhotos,
|
||
reactionInfo: _resolveReactionInfo(),
|
||
);
|
||
|
||
final Widget bubbleContent =
|
||
reactionsListenable != null && contentType == MessageType.text
|
||
? ValueListenableBuilder<Map<String, dynamic>?>(
|
||
valueListenable: reactionsListenable!,
|
||
builder: (context, _, _) => _buildContent(makeCtx()),
|
||
)
|
||
: _buildContent(makeCtx());
|
||
|
||
final reactionsUnder = _reactionsUnderBubble(contentType);
|
||
final reactionsInside = contentType != MessageType.text && !reactionsUnder;
|
||
|
||
final reply = message.replyInfo;
|
||
Widget withReply(Widget content) {
|
||
if (reply == null) return content;
|
||
final quote = _buildReplyQuote(context, cs, textColor, reply);
|
||
if (contentType != MessageType.text) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [quote, const SizedBox(height: 4), content],
|
||
);
|
||
}
|
||
return IntrinsicWidth(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_ZeroIntrinsicWidth(child: quote),
|
||
const SizedBox(height: 4),
|
||
content,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: 12,
|
||
right: 12,
|
||
top: topMargin,
|
||
bottom: bottomMargin,
|
||
),
|
||
child: Align(
|
||
child: Row(
|
||
mainAxisAlignment: isMe
|
||
? MainAxisAlignment.end
|
||
: MainAxisAlignment.start,
|
||
spacing: 8,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
if (showAvatar)
|
||
_buildLeadingAvatar(cs)
|
||
else if (showAvatarSlot && chatType != "CHAT")
|
||
const SizedBox(width: 0)
|
||
else if (showAvatarSlot)
|
||
const CircleAvatar(
|
||
radius: 15,
|
||
backgroundColor: Color(0x00000000),
|
||
),
|
||
Column(
|
||
crossAxisAlignment: isMe
|
||
? CrossAxisAlignment.end
|
||
: CrossAxisAlignment.start,
|
||
children: [
|
||
ListenableBuilder(
|
||
listenable: Listenable.merge([
|
||
AppBubbleShape.current,
|
||
AppBubbleBehavior.current,
|
||
]),
|
||
builder: (context, child) => Container(
|
||
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
||
decoration: BoxDecoration(
|
||
color: bubbleColor,
|
||
borderRadius: isVideoNote
|
||
? null
|
||
: _borderRadiusFor(
|
||
AppBubbleShape.current.value,
|
||
AppBubbleBehavior.current.value,
|
||
shape,
|
||
hasPhotoCap,
|
||
hasMultiPhotos,
|
||
),
|
||
),
|
||
padding: padding,
|
||
child: child,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (showSenderName)
|
||
_buildSenderHeader(cs, padding == EdgeInsets.zero),
|
||
withReply(
|
||
reactionsInside
|
||
? Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [bubbleContent, _reactionsBar(cs)],
|
||
)
|
||
: bubbleContent,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (keyboard != null)
|
||
ConstrainedBox(
|
||
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
||
child: _buildInlineKeyboard(context, cs, keyboard),
|
||
),
|
||
if (reactionsUnder) _reactionsBar(cs),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInlineKeyboard(
|
||
BuildContext context,
|
||
ColorScheme cs,
|
||
InlineKeyboardAttachment keyboard,
|
||
) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
for (final row in keyboard.rows)
|
||
if (row.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Row(
|
||
children: [
|
||
for (var i = 0; i < row.length; i++) ...[
|
||
if (i > 0) const SizedBox(width: 4),
|
||
Expanded(
|
||
child: _buildInlineKeyboardButton(
|
||
context,
|
||
cs,
|
||
keyboard,
|
||
row[i],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInlineKeyboardButton(
|
||
BuildContext context,
|
||
ColorScheme cs,
|
||
InlineKeyboardAttachment keyboard,
|
||
InlineKeyboardButton button,
|
||
) {
|
||
final trailingIcon = switch (button.type) {
|
||
'LINK' => Symbols.open_in_new,
|
||
'OPEN_APP' => Symbols.chevron_right,
|
||
_ => null,
|
||
};
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
if (trailingIcon != null) ...[
|
||
const SizedBox(width: 4),
|
||
Icon(trailingIcon, size: 16, color: cs.primary),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _onInlineButtonTap(
|
||
BuildContext context,
|
||
InlineKeyboardAttachment keyboard,
|
||
InlineKeyboardButton button,
|
||
) async {
|
||
switch (button.type) {
|
||
case 'LINK':
|
||
final url = button.url;
|
||
if (url != null && url.isNotEmpty) {
|
||
await openExternalUrl(context, url);
|
||
}
|
||
return;
|
||
case 'OPEN_APP':
|
||
await _openMiniApp(context, button);
|
||
return;
|
||
default:
|
||
final callbackId = keyboard.callbackId;
|
||
if (callbackId == null || callbackId.isEmpty) {
|
||
showCustomNotification(context, 'Кнопка не поддерживается');
|
||
return;
|
||
}
|
||
final answer = await messagesModule.sendButtonCallback(
|
||
chatId: message.chatId,
|
||
messageId: message.id,
|
||
callbackId: callbackId,
|
||
payload: button.payload,
|
||
);
|
||
if (!context.mounted) return;
|
||
final url = answer?['url']?.toString();
|
||
if (url != null && url.isNotEmpty) {
|
||
await openExternalUrl(context, url);
|
||
return;
|
||
}
|
||
final text = answer?['text']?.toString();
|
||
if (text != null && text.isNotEmpty) {
|
||
showCustomNotification(context, text);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _openMiniApp(
|
||
BuildContext context,
|
||
InlineKeyboardButton button,
|
||
) async {
|
||
if (!webViewSupported) {
|
||
showCustomNotification(context, 'На вашей платформе это недоступно');
|
||
return;
|
||
}
|
||
|
||
final deeplink = button.webApp != null
|
||
? Uri.tryParse(button.webApp!)
|
||
: null;
|
||
final startParam =
|
||
button.payload ??
|
||
deeplink?.queryParameters['startapp'] ??
|
||
deeplink?.queryParameters['startApp'];
|
||
final chatId =
|
||
int.tryParse(deeplink?.queryParameters['chat_id'] ?? '') ??
|
||
message.chatId;
|
||
final botId = button.contactId;
|
||
|
||
if (botId == null) {
|
||
showCustomNotification(context, 'Не удалось открыть приложение');
|
||
return;
|
||
}
|
||
|
||
if (kDebugMode) {
|
||
debugPrint(
|
||
'[inline-kb] OPEN_APP botId=$botId chatId=$chatId startParam=$startParam',
|
||
);
|
||
}
|
||
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => WebAppScreen(
|
||
title: button.text,
|
||
loader: () => webAppModule.fetchLaunch(
|
||
botId,
|
||
startParam: startParam,
|
||
chatId: chatId,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Map? _resolveReactionInfo() {
|
||
if (reactionsListenable != null) {
|
||
final v = reactionsListenable!.value;
|
||
if (v != null) return v;
|
||
}
|
||
final info = message.payload?['reactionInfo'];
|
||
if (info is Map) return info;
|
||
return null;
|
||
}
|
||
|
||
bool _reactionsUnderBubble(MessageType contentType) {
|
||
if (contentType != MessageType.attachment) return false;
|
||
final attachments = message.attachments;
|
||
if (attachments == null || attachments.isEmpty) return false;
|
||
if (attachments.first is ForwardedMessageAttachment) return false;
|
||
if (attachments.any((a) => a is ContactAttachment)) return false;
|
||
if (attachments.whereType<PhotoAttachment>().length >= 2) return false;
|
||
return true;
|
||
}
|
||
|
||
Widget _reactionsBar(ColorScheme cs) {
|
||
final listenable = reactionsListenable;
|
||
if (listenable != null) {
|
||
return ValueListenableBuilder<Map<String, dynamic>?>(
|
||
valueListenable: listenable,
|
||
builder: (context, info, _) => _buildReactionsBarFor(cs, info),
|
||
);
|
||
}
|
||
return _buildReactionsBar(cs);
|
||
}
|
||
|
||
Widget _buildContent(_BubbleCtx ctx) {
|
||
switch (ctx.contentType) {
|
||
case MessageType.control:
|
||
return _buildControlContent(ctx.cs);
|
||
case MessageType.attachment:
|
||
return _buildAttachmentContent(ctx);
|
||
case MessageType.voice:
|
||
return _buildVoiceContent(ctx);
|
||
case MessageType.text:
|
||
return _buildTextContent(ctx);
|
||
}
|
||
}
|
||
|
||
Widget _buildReactionsBar(ColorScheme cs) {
|
||
final info = message.payload?['reactionInfo'];
|
||
return _buildReactionsBarFor(cs, info is Map ? info : null);
|
||
}
|
||
|
||
Widget _buildReactionsBarFor(ColorScheme cs, Map? info) {
|
||
final chips = _buildReactionChipsFor(cs, info);
|
||
if (chips.isEmpty) return const SizedBox.shrink();
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Wrap(spacing: 4, runSpacing: 4, children: chips),
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildReactionChipsFor(ColorScheme cs, Map? info) {
|
||
if (info == null) return const [];
|
||
final counters = info['counters'];
|
||
if (counters is! List || counters.isEmpty) return const [];
|
||
final yourReaction = info['yourReaction']?.toString();
|
||
|
||
final chips = <Widget>[];
|
||
for (final c in counters) {
|
||
if (c is! Map) continue;
|
||
final reaction = c['reaction']?.toString();
|
||
final count = c['count'];
|
||
if (reaction == null || reaction.isEmpty) continue;
|
||
final isYours = yourReaction == reaction;
|
||
chips.add(
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: isYours
|
||
? cs.primary.withValues(alpha: 0.22)
|
||
: _reactionChipBg,
|
||
borderRadius: _reactionChipRadius,
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(reaction, style: const TextStyle(fontSize: 13)),
|
||
if (count is int && count > 1) ...[
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
count.toString(),
|
||
style: TextStyle(
|
||
color: isYours ? cs.primary : cs.onSurfaceVariant,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return chips;
|
||
}
|
||
|
||
Widget _buildControlContent(ColorScheme cs) {
|
||
final attachments = message.attachments;
|
||
if (attachments == null || attachments.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
final control = attachments.first;
|
||
if (control is! ControlAttachment) return const SizedBox.shrink();
|
||
|
||
String? text;
|
||
switch (control.event) {
|
||
case 'system':
|
||
text = control.title;
|
||
break;
|
||
case 'new':
|
||
text =
|
||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат';
|
||
break;
|
||
case 'add':
|
||
final names = (control.userIds ?? [])
|
||
.map((id) => ContactCache.get(id) ?? 'Пользователь')
|
||
.join(', ');
|
||
text =
|
||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names';
|
||
break;
|
||
case 'leave':
|
||
text =
|
||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат';
|
||
break;
|
||
case 'joinByLink':
|
||
text =
|
||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату';
|
||
break;
|
||
default:
|
||
text = control.title;
|
||
}
|
||
|
||
if (text == null || text.isEmpty) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: cs.surfaceContainerHighest.withValues(alpha: 0.6),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
color: cs.onSurfaceVariant,
|
||
fontSize: 12,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTextContent(_BubbleCtx ctx) {
|
||
final attachments = message.attachments;
|
||
final isForwardedContact =
|
||
attachments != null &&
|
||
attachments.isNotEmpty &&
|
||
attachments.first is ForwardedMessageAttachment &&
|
||
(attachments.first as ForwardedMessageAttachment).originalContact !=
|
||
null;
|
||
|
||
final forwarded = _getForwardedAttachment();
|
||
final isForwarded = forwarded != null && !isForwardedContact;
|
||
|
||
final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo);
|
||
final hasReactions = reactionChips.isNotEmpty;
|
||
|
||
final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3);
|
||
final textWidget = isForwarded
|
||
? _buildForwardedInlineText(ctx, forwarded)
|
||
: (LinkText.hasLinks(message.text)
|
||
? LinkText(text: message.text!, style: textStyle)
|
||
: Text(message.text ?? '', style: textStyle));
|
||
|
||
final metaWidget = Text(
|
||
message.status == 'EDITED' ? '$_clockText ред.' : _clockText,
|
||
style: TextStyle(color: ctx.dim, fontSize: 10),
|
||
);
|
||
|
||
if (hasReactions) {
|
||
return IntrinsicWidth(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
textWidget,
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Expanded(
|
||
child: Wrap(
|
||
spacing: 4,
|
||
runSpacing: 4,
|
||
children: reactionChips,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 2),
|
||
child: metaWidget,
|
||
),
|
||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||
if (message.deleted) ...[
|
||
const SizedBox(width: 4),
|
||
_buildDeletedIcon(ctx),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Flexible(child: textWidget),
|
||
const SizedBox(width: 8),
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 2),
|
||
child: metaWidget,
|
||
),
|
||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||
if (message.deleted) ...[
|
||
const SizedBox(width: 4),
|
||
_buildDeletedIcon(ctx),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildReplyQuote(
|
||
BuildContext context,
|
||
ColorScheme cs,
|
||
Color textColor,
|
||
ReplyInfo reply,
|
||
) {
|
||
final accent = isMe ? cs.onPrimaryContainer : cs.primary;
|
||
final name = reply.senderId == myId
|
||
? 'Вы'
|
||
: (ContactCache.get(reply.senderId) ?? 'Сообщение');
|
||
final preview = reply.previewText();
|
||
|
||
final quote = Container(
|
||
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(6),
|
||
color: accent.withValues(alpha: 0.10),
|
||
border: Border(left: BorderSide(color: accent, width: 3)),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: accent,
|
||
fontSize: 12.5,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
if (preview.isNotEmpty)
|
||
Text(
|
||
preview,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: textColor.withValues(alpha: 0.85),
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
final mid = reply.messageId;
|
||
final cb = onReplyTap;
|
||
if (mid != null && mid != '0' && cb != null) {
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => cb(mid),
|
||
child: quote,
|
||
);
|
||
}
|
||
return quote;
|
||
}
|
||
|
||
Widget _buildForwardedInlineText(
|
||
_BubbleCtx ctx,
|
||
ForwardedMessageAttachment forwarded,
|
||
) {
|
||
final headerColor = ctx.dim;
|
||
final displaySender =
|
||
forwarded.originalSenderName ??
|
||
ContactCache.get(forwarded.originalSenderId) ??
|
||
forwarded.originalSenderId.toString();
|
||
final senderAvatar =
|
||
forwarded.originalSenderAvatar ??
|
||
ContactCache.getAvatar(forwarded.originalSenderId);
|
||
final origText = forwarded.originalText;
|
||
final hasOrigText = origText != null && origText.isNotEmpty;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Symbols.forward, size: 14, color: headerColor),
|
||
const SizedBox(width: 4),
|
||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||
CircleAvatar(
|
||
radius: 10,
|
||
backgroundImage: CachedNetworkImageProvider(
|
||
senderAvatar,
|
||
maxWidth: 96,
|
||
maxHeight: 96,
|
||
),
|
||
backgroundColor: ctx.cs.primaryContainer,
|
||
)
|
||
else
|
||
CircleAvatar(
|
||
radius: 10,
|
||
backgroundColor: ctx.cs.primaryContainer,
|
||
child: Text(
|
||
displaySender.isNotEmpty
|
||
? displaySender[0].toUpperCase()
|
||
: '?',
|
||
style: TextStyle(
|
||
fontSize: 9,
|
||
color: ctx.cs.onPrimaryContainer,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
displaySender,
|
||
style: TextStyle(
|
||
color: headerColor,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (hasOrigText) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
origText,
|
||
style: TextStyle(color: ctx.text, fontSize: 14),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
] else ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
message.text ?? '',
|
||
style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildForwardedHeader(
|
||
_BubbleCtx ctx,
|
||
ForwardedMessageAttachment forwarded,
|
||
) {
|
||
final headerColor = ctx.dim;
|
||
final displaySender =
|
||
forwarded.originalSenderName ??
|
||
ContactCache.get(forwarded.originalSenderId) ??
|
||
forwarded.originalSenderId.toString();
|
||
final senderAvatar =
|
||
forwarded.originalSenderAvatar ??
|
||
ContactCache.getAvatar(forwarded.originalSenderId);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(left: 8, top: 8, right: 8),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Symbols.forward, size: 14, color: headerColor),
|
||
const SizedBox(width: 4),
|
||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||
CircleAvatar(
|
||
radius: 10,
|
||
backgroundImage: CachedNetworkImageProvider(
|
||
senderAvatar,
|
||
maxWidth: 96,
|
||
maxHeight: 96,
|
||
),
|
||
backgroundColor: ctx.cs.primaryContainer,
|
||
)
|
||
else
|
||
CircleAvatar(
|
||
radius: 10,
|
||
backgroundColor: ctx.cs.primaryContainer,
|
||
child: Text(
|
||
displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?',
|
||
style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer),
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: Text(
|
||
displaySender,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: headerColor,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
ForwardedMessageAttachment? _getForwardedAttachment() {
|
||
final attachments = message.attachments;
|
||
if (attachments == null || attachments.isEmpty) return null;
|
||
for (final a in attachments) {
|
||
if (a is ForwardedMessageAttachment) return a;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Widget _buildAttachmentContent(_BubbleCtx ctx) {
|
||
final attachments = message.attachments;
|
||
if (attachments == null || attachments.isEmpty) {
|
||
return _buildTextContent(ctx);
|
||
}
|
||
|
||
final first = attachments.first;
|
||
if (first is ForwardedMessageAttachment) {
|
||
final fwd = first;
|
||
if (fwd.originalContact != null) {
|
||
return _buildForwardedContactContent(ctx, fwd);
|
||
}
|
||
final photos = fwd.originalAttachments
|
||
?.whereType<PhotoAttachment>()
|
||
.toList();
|
||
if (photos != null && photos.isNotEmpty) {
|
||
return _buildForwardedPhotoContent(ctx, fwd, photos);
|
||
}
|
||
final files = fwd.originalAttachments;
|
||
if (files != null && files.isNotEmpty) {
|
||
return _buildForwardedGenericContent(ctx, fwd, files);
|
||
}
|
||
return _buildTextContent(ctx);
|
||
}
|
||
|
||
final contacts = attachments.whereType<ContactAttachment>().toList();
|
||
if (contacts.isNotEmpty) {
|
||
return _buildContactAttachment(ctx, contacts.first);
|
||
}
|
||
|
||
final polls = attachments.whereType<PollAttachment>().toList();
|
||
if (polls.isNotEmpty) {
|
||
return _buildPollAttachment(ctx, polls.first);
|
||
}
|
||
|
||
final shares = attachments.whereType<ShareAttachment>().toList();
|
||
if (shares.isNotEmpty) {
|
||
return _buildShareContent(ctx, shares.first);
|
||
}
|
||
|
||
final photos = attachments.whereType<PhotoAttachment>().toList();
|
||
if (photos.isEmpty) {
|
||
return _buildGenericAttachment(ctx, attachments.first);
|
||
}
|
||
|
||
return _buildPhotoContent(ctx, photos);
|
||
}
|
||
|
||
Widget _buildPollAttachment(_BubbleCtx ctx, PollAttachment poll) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||
child: IntrinsicWidth(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
PollView(
|
||
chatId: message.chatId,
|
||
messageId: message.id,
|
||
pollId: poll.pollId,
|
||
myId: myId,
|
||
fallbackTitle: poll.title ?? message.text,
|
||
textColor: ctx.text,
|
||
dimColor: ctx.dim,
|
||
accentColor: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||
),
|
||
_buildMeta(ctx),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildShareContent(_BubbleCtx ctx, ShareAttachment share) {
|
||
final hasText = message.text != null && message.text!.isNotEmpty;
|
||
final image = share.image;
|
||
final imageUrl = image?.baseUrl ?? image?.previewData ?? '';
|
||
final cardColor = isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08)
|
||
: ctx.cs.surfaceContainerHigh;
|
||
final host =
|
||
share.host ??
|
||
(share.url != null ? Uri.tryParse(share.url!)?.host : null);
|
||
|
||
final card = GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: share.url == null
|
||
? null
|
||
: () {
|
||
Haptics.tap();
|
||
openExternalUrl(ctx.context, share.url!);
|
||
},
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: cardColor,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (imageUrl.isNotEmpty)
|
||
CachedNetworkImage(
|
||
imageUrl: imageUrl,
|
||
width: 280,
|
||
height: 140,
|
||
fit: BoxFit.cover,
|
||
memCacheWidth: 560,
|
||
fadeInDuration: const Duration(milliseconds: 120),
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (host != null && host.isNotEmpty) ...[
|
||
Text(
|
||
host,
|
||
style: TextStyle(
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer
|
||
: ctx.cs.primary,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
],
|
||
if (share.title != null && share.title!.isNotEmpty)
|
||
Text(
|
||
share.title!,
|
||
style: TextStyle(
|
||
color: ctx.text,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.25,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (share.description != null &&
|
||
share.description!.isNotEmpty) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
share.description!,
|
||
style: TextStyle(
|
||
color: ctx.dim,
|
||
fontSize: 13,
|
||
height: 1.25,
|
||
),
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 280),
|
||
child: IntrinsicWidth(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (hasText) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: LinkText(
|
||
text: message.text!,
|
||
style: TextStyle(
|
||
color: ctx.text,
|
||
fontSize: 16,
|
||
height: 1.3,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
],
|
||
card,
|
||
_buildMeta(ctx),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPhotoContent(_BubbleCtx ctx, List<PhotoAttachment> photos) {
|
||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||
final count = photos.length;
|
||
|
||
Widget photosWidget;
|
||
if (count == 1) {
|
||
photosWidget = _buildSinglePhoto(ctx, photos[0]);
|
||
} else if (count == 2) {
|
||
photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]);
|
||
} else {
|
||
photosWidget = _buildPhotoGrid(ctx, photos);
|
||
}
|
||
|
||
if (!hasCaption) {
|
||
return Stack(
|
||
children: [
|
||
photosWidget,
|
||
Positioned(
|
||
bottom: compactTimePadding,
|
||
right: compactTimePadding,
|
||
child: _buildCompactTime(),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
if (count == 1) {
|
||
final photo = photos[0];
|
||
final pw = photo.width?.toDouble() ?? 200;
|
||
final photoWidth = pw.clamp(photoMinSize, photoMaxSize);
|
||
|
||
return SizedBox(
|
||
width: photoWidth,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
photosWidget,
|
||
Padding(
|
||
padding: const EdgeInsets.only(
|
||
left: captionPaddingHorizontal,
|
||
right: captionPaddingRight,
|
||
bottom: 6,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Expanded(child: _buildCaption(ctx)),
|
||
_buildMeta(ctx),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
photosWidget,
|
||
Padding(
|
||
padding: const EdgeInsets.only(
|
||
left: captionPaddingHorizontal,
|
||
right: captionPaddingRight,
|
||
bottom: 6,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Expanded(child: _buildCaption(ctx)),
|
||
_buildMeta(ctx),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildForwardedPhotoContent(
|
||
_BubbleCtx ctx,
|
||
ForwardedMessageAttachment forwarded,
|
||
List<PhotoAttachment> photos,
|
||
) {
|
||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_buildForwardedHeader(ctx, forwarded),
|
||
const SizedBox(height: 4),
|
||
if (hasCaption) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 8),
|
||
child: Text(
|
||
message.text ?? '',
|
||
style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3),
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
],
|
||
_buildPhotoContent(ctx, photos),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildForwardedGenericContent(
|
||
_BubbleCtx ctx,
|
||
ForwardedMessageAttachment forwarded,
|
||
List<MessageAttachment> attachments,
|
||
) {
|
||
return IntrinsicWidth(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_buildForwardedHeader(ctx, forwarded),
|
||
const SizedBox(height: 4),
|
||
...attachments.map((a) {
|
||
if (a is FileAttachment) {
|
||
return _buildFileAttachment(ctx, a, fill: true);
|
||
}
|
||
return const SizedBox.shrink();
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSinglePhoto(_BubbleCtx ctx, PhotoAttachment photo) {
|
||
final width = photo.width?.toDouble() ?? 200;
|
||
final height = photo.height?.toDouble() ?? 200;
|
||
|
||
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
|
||
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
|
||
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
|
||
|
||
final matchTop = ctx.hasPhotoWithCaption;
|
||
final matchBottom = !ctx.hasPhotoWithCaption;
|
||
|
||
final topR = matchTop ? _bigRadius : _photoRadius;
|
||
final bottomL = matchBottom
|
||
? (isMe ? _bigRadius : _smallRadius)
|
||
: _smallRadius;
|
||
final bottomR = matchBottom
|
||
? (isMe ? _smallRadius : _bigRadius)
|
||
: _smallRadius;
|
||
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: topR,
|
||
topRight: topR,
|
||
bottomLeft: bottomL,
|
||
bottomRight: bottomR,
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
_buildPhotoImage(
|
||
ctx,
|
||
photo,
|
||
constrainedWidth,
|
||
constrainedHeight,
|
||
memWidth: (constrainedWidth * dpr).round(),
|
||
memHeight: (constrainedHeight * dpr).round(),
|
||
),
|
||
if (uploadProgress != null) _buildUploadOverlay(uploadProgress!, 0),
|
||
if (uploadProgress == null)
|
||
Positioned.fill(
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPhotoImage(
|
||
_BubbleCtx ctx,
|
||
PhotoAttachment photo,
|
||
double width,
|
||
double height, {
|
||
required int memWidth,
|
||
required int memHeight,
|
||
}) {
|
||
final localPath = photo.localPath;
|
||
if (localPath != null) {
|
||
return Image.file(
|
||
File(localPath),
|
||
width: width,
|
||
height: height,
|
||
fit: BoxFit.cover,
|
||
cacheWidth: memWidth,
|
||
gaplessPlayback: true,
|
||
errorBuilder: (_, _, _) =>
|
||
_buildPhotoPlaceholder(ctx.cs, width, height),
|
||
);
|
||
}
|
||
final imageUrl = photo.baseUrl ?? '';
|
||
if (imageUrl.isNotEmpty) {
|
||
return CachedNetworkImage(
|
||
imageUrl: imageUrl,
|
||
width: width,
|
||
height: height,
|
||
fit: BoxFit.cover,
|
||
memCacheWidth: memWidth,
|
||
memCacheHeight: memHeight,
|
||
fadeInDuration: Duration.zero,
|
||
placeholderFadeInDuration: Duration.zero,
|
||
errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height),
|
||
);
|
||
}
|
||
return _buildPhotoPlaceholder(ctx.cs, width, height);
|
||
}
|
||
|
||
Widget _buildUploadOverlay(
|
||
ValueListenable<List<double>> progress,
|
||
int index,
|
||
) {
|
||
return Positioned.fill(
|
||
child: ValueListenableBuilder<List<double>>(
|
||
valueListenable: progress,
|
||
builder: (context, values, _) {
|
||
final value = index < values.length ? values[index] : 1.0;
|
||
final indeterminate = value <= 0 || value >= 1.0;
|
||
return Container(
|
||
color: Colors.black.withValues(alpha: 0.4),
|
||
alignment: Alignment.center,
|
||
child: SizedBox(
|
||
width: 34,
|
||
height: 34,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2.5,
|
||
value: indeterminate ? null : value,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTwoPhotos(
|
||
_BubbleCtx ctx,
|
||
PhotoAttachment p1,
|
||
PhotoAttachment p2,
|
||
) {
|
||
final matchTop =
|
||
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
|
||
final matchBottom =
|
||
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom;
|
||
|
||
final topR = matchTop ? _bigRadius : _photoRadius;
|
||
final bottomL = matchBottom ? _smallRadius : _photoRadius;
|
||
final bottomR = matchBottom
|
||
? (isMe ? _smallRadius : _bigRadius)
|
||
: _photoRadius;
|
||
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: topR,
|
||
topRight: topR,
|
||
bottomLeft: bottomL,
|
||
bottomRight: bottomR,
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Expanded(child: _buildPhotoTile(ctx, p1, 0)),
|
||
const SizedBox(width: 2),
|
||
Expanded(child: _buildPhotoTile(ctx, p2, 1)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPhotoGrid(_BubbleCtx ctx, List<PhotoAttachment> photos) {
|
||
final displayCount = photos.length > 4 ? 4 : photos.length;
|
||
final remaining = photos.length - 4;
|
||
|
||
final matchTop =
|
||
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
|
||
final matchBottom =
|
||
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom;
|
||
|
||
final topR = matchTop ? _bigRadius : _photoRadius;
|
||
final bottomL = matchBottom ? _smallRadius : _photoRadius;
|
||
final bottomR = matchBottom
|
||
? (isMe ? _smallRadius : _bigRadius)
|
||
: _photoRadius;
|
||
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: topR,
|
||
topRight: topR,
|
||
bottomLeft: bottomL,
|
||
bottomRight: bottomR,
|
||
),
|
||
child: GridView.count(
|
||
crossAxisCount: 2,
|
||
mainAxisSpacing: 2,
|
||
crossAxisSpacing: 2,
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
children: List.generate(displayCount, (i) {
|
||
if (i == 3 && remaining > 0) {
|
||
return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining', i);
|
||
}
|
||
return _buildPhotoTile(ctx, photos[i], i);
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo, int index) {
|
||
final cachePx =
|
||
(photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio)
|
||
.round();
|
||
return AspectRatio(
|
||
aspectRatio: 1,
|
||
child: Stack(
|
||
children: [
|
||
_buildPhotoImage(
|
||
ctx,
|
||
photo,
|
||
double.infinity,
|
||
double.infinity,
|
||
memWidth: cachePx,
|
||
memHeight: cachePx,
|
||
),
|
||
if (uploadProgress != null)
|
||
_buildUploadOverlay(uploadProgress!, index),
|
||
if (uploadProgress == null)
|
||
Positioned.fill(
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPhotoTileWithOverlay(
|
||
_BubbleCtx ctx,
|
||
PhotoAttachment photo,
|
||
String overlay,
|
||
int index,
|
||
) {
|
||
final cachePx =
|
||
(photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio)
|
||
.round();
|
||
return AspectRatio(
|
||
aspectRatio: 1,
|
||
child: Stack(
|
||
children: [
|
||
_buildPhotoImage(
|
||
ctx,
|
||
photo,
|
||
double.infinity,
|
||
double.infinity,
|
||
memWidth: cachePx,
|
||
memHeight: cachePx,
|
||
),
|
||
Positioned.fill(
|
||
child: Container(
|
||
color: Colors.black45,
|
||
child: Center(
|
||
child: Text(
|
||
overlay,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (uploadProgress != null)
|
||
_buildUploadOverlay(uploadProgress!, index),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPhotoPlaceholder(
|
||
ColorScheme cs,
|
||
double w,
|
||
double h, {
|
||
VoidCallback? onRetry,
|
||
}) {
|
||
return Container(
|
||
width: w,
|
||
height: h,
|
||
color: cs.surfaceContainerHighest,
|
||
child: onRetry != null
|
||
? Center(
|
||
child: IconButton(
|
||
icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant),
|
||
onPressed: onRetry,
|
||
tooltip: 'Retry',
|
||
),
|
||
)
|
||
: Center(
|
||
child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCaption(_BubbleCtx ctx) {
|
||
final style = TextStyle(color: ctx.text, fontSize: 16, height: 1.3);
|
||
if (LinkText.hasLinks(message.text)) {
|
||
return LinkText(text: message.text!, style: style);
|
||
}
|
||
return Text(message.text ?? '', style: style);
|
||
}
|
||
|
||
Widget _buildGenericAttachment(_BubbleCtx ctx, MessageAttachment attachment) {
|
||
switch (attachment.type) {
|
||
case AttachmentType.video:
|
||
return _buildVideoAttachment(ctx, attachment);
|
||
case AttachmentType.file:
|
||
return _buildFileAttachment(ctx, attachment);
|
||
case AttachmentType.sticker:
|
||
return _buildStickerAttachment(ctx, attachment);
|
||
case AttachmentType.location:
|
||
return _buildLocationAttachment(ctx, attachment as LocationAttachment);
|
||
case AttachmentType.call:
|
||
return _buildCallAttachment(ctx, attachment as CallAttachment);
|
||
default:
|
||
return _buildTextContent(ctx);
|
||
}
|
||
}
|
||
|
||
Widget _buildCallAttachment(_BubbleCtx ctx, CallAttachment call) {
|
||
final missed = call.isMissedOrFailed;
|
||
final accent = isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary;
|
||
final iconColor = missed ? ctx.cs.error : accent;
|
||
|
||
final IconData icon;
|
||
final String label;
|
||
if (call.isGroup) {
|
||
icon = call.isVideo ? Symbols.videocam : Symbols.groups;
|
||
label = call.isVideo ? 'Групповой видеозвонок' : 'Групповой звонок';
|
||
} else if (call.isVideo) {
|
||
icon = Symbols.videocam;
|
||
label = missed
|
||
? (isMe ? 'Отменённый видеозвонок' : 'Пропущенный видеозвонок')
|
||
: (isMe ? 'Исходящий видеозвонок' : 'Входящий видеозвонок');
|
||
} else {
|
||
icon = Symbols.call;
|
||
label = missed
|
||
? (isMe ? 'Отменённый звонок' : 'Пропущенный звонок')
|
||
: (isMe ? 'Исходящий звонок' : 'Входящий звонок');
|
||
}
|
||
|
||
final directionIcon = isMe ? Symbols.call_made : Symbols.call_received;
|
||
|
||
final subtitle = missed
|
||
? _clockText
|
||
: '$_clockText · ${formatSecondsMmSs((call.durationMs / 1000).round())}';
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 10, 16, 10),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: missed
|
||
? ctx.cs.error.withValues(alpha: 0.12)
|
||
: (isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: ctx.cs.primaryContainer),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(icon, color: iconColor, size: 20),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Flexible(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
color: ctx.text,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.2,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
directionIcon,
|
||
size: 13,
|
||
color: missed ? ctx.cs.error : ctx.dim,
|
||
),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
subtitle,
|
||
style: TextStyle(
|
||
color: ctx.dim,
|
||
fontSize: 12,
|
||
height: 1.2,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLocationAttachment(_BubbleCtx ctx, LocationAttachment location) {
|
||
final lat = location.latitude;
|
||
final lon = location.longitude;
|
||
final coords = lat != null && lon != null
|
||
? '${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}'
|
||
: null;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||
child: IntrinsicWidth(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: lat == null || lon == null
|
||
? null
|
||
: () {
|
||
Haptics.tap();
|
||
openLocationOnMap(
|
||
ctx.context,
|
||
lat,
|
||
lon,
|
||
zoom: location.zoom,
|
||
);
|
||
},
|
||
child: Container(
|
||
width: 240,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08)
|
||
: ctx.cs.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: ctx.cs.primaryContainer,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
Symbols.location_on,
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer
|
||
: ctx.cs.primary,
|
||
size: 22,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
location.title ?? 'Геопозиция',
|
||
style: TextStyle(
|
||
color: ctx.text,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
location.address ?? coords ?? 'Открыть на карте',
|
||
style: TextStyle(color: ctx.dim, fontSize: 12),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_buildMeta(ctx),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) {
|
||
if (video is VideoAttachment && video.isNote) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
_VideoNoteBubble(
|
||
attachment: video,
|
||
messageId: message.id,
|
||
chatId: message.chatId,
|
||
cs: ctx.cs,
|
||
),
|
||
const SizedBox(height: 6),
|
||
_buildMeta(ctx),
|
||
],
|
||
);
|
||
}
|
||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||
final thumb = (video as dynamic).thumbnail as String?;
|
||
final durationMs = (video as dynamic).duration as int?;
|
||
final previewUrl = (thumb != null && thumb.isNotEmpty)
|
||
? thumb
|
||
: (video.baseUrl != null && video.baseUrl!.isNotEmpty)
|
||
? video.baseUrl!
|
||
: (video.previewData ?? '');
|
||
|
||
final w = (video as dynamic).width as int?;
|
||
final h = (video as dynamic).height as int?;
|
||
final width = (w?.toDouble() ?? 200.0).clamp(photoMinSize, photoMaxSize);
|
||
final height = (h?.toDouble() ?? 150.0).clamp(photoMinSize, photoMaxSize);
|
||
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
|
||
|
||
Widget placeholder() => Container(
|
||
width: width,
|
||
height: height,
|
||
color: ctx.cs.surfaceContainerHighest,
|
||
child: Icon(
|
||
Symbols.videocam,
|
||
size: 48,
|
||
color: ctx.cs.onSurfaceVariant,
|
||
),
|
||
);
|
||
|
||
final preview = ClipRRect(
|
||
borderRadius: BorderRadius.circular(photoBorderRadius),
|
||
child: Stack(
|
||
children: [
|
||
previewUrl.isEmpty
|
||
? placeholder()
|
||
: CachedNetworkImage(
|
||
imageUrl: previewUrl,
|
||
width: width,
|
||
height: height,
|
||
fit: BoxFit.cover,
|
||
memCacheWidth: (width * dpr).round(),
|
||
fadeInDuration: Duration.zero,
|
||
placeholderFadeInDuration: Duration.zero,
|
||
errorWidget: (_, _, _) => placeholder(),
|
||
),
|
||
Positioned.fill(
|
||
child: Center(
|
||
child: Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.black54,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
Symbols.play_arrow,
|
||
color: Colors.white,
|
||
size: 30,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (durationMs != null && durationMs > 0)
|
||
Positioned(
|
||
left: 6,
|
||
bottom: 6,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
formatSecondsMmSs((durationMs / 1000).round()),
|
||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||
),
|
||
),
|
||
),
|
||
Positioned.fill(
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => _playVideo(ctx.context, video),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (!hasCaption) {
|
||
return Stack(
|
||
children: [
|
||
preview,
|
||
Positioned(
|
||
bottom: compactTimePadding,
|
||
right: compactTimePadding,
|
||
child: _buildCompactTime(),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
return SizedBox(
|
||
width: width,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
preview,
|
||
Padding(
|
||
padding: const EdgeInsets.only(
|
||
left: captionPaddingHorizontal,
|
||
right: captionPaddingRight,
|
||
bottom: 6,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Expanded(child: _buildCaption(ctx)),
|
||
_buildMeta(ctx),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _playVideo(BuildContext context, MessageAttachment video) async {
|
||
final videoId = (video as dynamic).videoId as int?;
|
||
final token = (video as dynamic).videoToken as String?;
|
||
if (videoId == null || token == null) {
|
||
showCustomNotification(context, 'Не удалось открыть видео');
|
||
return;
|
||
}
|
||
Haptics.tap();
|
||
|
||
final sources = await messagesModule.getVideoSources(
|
||
messageId: message.id,
|
||
chatId: message.chatId,
|
||
token: token,
|
||
videoId: videoId,
|
||
);
|
||
if (!context.mounted) return;
|
||
if (sources.isEmpty) {
|
||
showCustomNotification(context, 'Не удалось получить видео');
|
||
return;
|
||
}
|
||
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
fullscreenDialog: true,
|
||
builder: (_) => VideoPlayerScreen(sources: sources),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFileAttachment(
|
||
_BubbleCtx ctx,
|
||
MessageAttachment file, {
|
||
bool fill = false,
|
||
}) {
|
||
final name = (file as dynamic).name as String? ?? 'File';
|
||
final size = (file as dynamic).size as int? ?? 0;
|
||
final sizeStr = formatBytes(size);
|
||
final fileId = (file as dynamic).fileId as int?;
|
||
final cacheName = '${fileId}_$name';
|
||
|
||
final preview = file is FileAttachment ? file.preview : null;
|
||
final previewUrl = preview?.baseUrl ?? preview?.previewData ?? '';
|
||
|
||
final inner = Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (previewUrl.isNotEmpty) ...[
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(10),
|
||
child: CachedNetworkImage(
|
||
imageUrl: previewUrl,
|
||
width: 240,
|
||
height: 160,
|
||
fit: BoxFit.cover,
|
||
memCacheWidth: 480,
|
||
fadeInDuration: const Duration(milliseconds: 120),
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: ctx.cs.primaryContainer,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(
|
||
Symbols.description,
|
||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||
size: 20,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Flexible(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
name,
|
||
style: TextStyle(
|
||
color: ctx.text,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.2,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
ValueListenableBuilder<double?>(
|
||
valueListenable: MediaDownloadProgress.notifier(
|
||
cacheName,
|
||
),
|
||
builder: (context, progress, _) => Text(
|
||
progress != null
|
||
? '${(progress * 100).round()}% · $sizeStr'
|
||
: sizeStr,
|
||
style: TextStyle(
|
||
color: ctx.dim,
|
||
fontSize: 12,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
ValueListenableBuilder<double?>(
|
||
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||
builder: (context, progress, _) {
|
||
final downloading = progress != null;
|
||
return GestureDetector(
|
||
onTap: downloading
|
||
? null
|
||
: () => _downloadFile(ctx.context, file, name),
|
||
child: Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: ctx.cs.surfaceContainerHighest,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: downloading
|
||
? Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
value: progress > 0 ? progress : null,
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer
|
||
: ctx.cs.primary,
|
||
),
|
||
)
|
||
: Icon(
|
||
Symbols.download,
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer
|
||
: ctx.cs.primary,
|
||
size: 18,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
_buildMeta(ctx),
|
||
],
|
||
),
|
||
);
|
||
return fill ? inner : IntrinsicWidth(child: inner);
|
||
}
|
||
|
||
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
|
||
final url = sticker.baseUrl ?? '';
|
||
final preview = sticker.previewData ?? '';
|
||
final imageUrl = url.isNotEmpty ? url : preview;
|
||
|
||
final Widget image = ClipRRect(
|
||
borderRadius: BorderRadius.circular(photoBorderRadius),
|
||
child: Stack(
|
||
children: [
|
||
if (imageUrl.isNotEmpty)
|
||
CachedNetworkImage(
|
||
imageUrl: imageUrl,
|
||
width: 150,
|
||
height: 150,
|
||
fit: BoxFit.contain,
|
||
memCacheWidth: 300,
|
||
memCacheHeight: 300,
|
||
fadeInDuration: const Duration(milliseconds: 120),
|
||
errorWidget: (_, _, _) =>
|
||
_buildPhotoPlaceholder(ctx.cs, 150, 150),
|
||
)
|
||
else
|
||
_buildPhotoPlaceholder(ctx.cs, 150, 150),
|
||
],
|
||
),
|
||
);
|
||
|
||
final onTap = onStickerTap;
|
||
if (onTap == null || sticker is! StickerAttachment) return image;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => onTap(sticker),
|
||
child: image,
|
||
);
|
||
}
|
||
|
||
Widget _buildContactAttachment(_BubbleCtx ctx, MessageAttachment contact) {
|
||
final contactData = contact as ContactAttachment;
|
||
|
||
final firstName = contactData.firstName ?? '';
|
||
final lastName = contactData.lastName ?? '';
|
||
final hasFirstName = firstName.isNotEmpty;
|
||
final hasLastName = lastName.isNotEmpty;
|
||
|
||
final name = (hasFirstName || hasLastName)
|
||
? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}'
|
||
.trim()
|
||
: (contactData.name ?? 'Contact');
|
||
final photoUrl = contactData.photoUrl ?? contactData.baseUrl;
|
||
|
||
final bgColor = isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: ctx.cs.surfaceContainerHighest;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(24),
|
||
),
|
||
child: photoUrl != null && photoUrl.isNotEmpty
|
||
? ClipRRect(
|
||
borderRadius: BorderRadius.circular(24),
|
||
child: CachedNetworkImage(
|
||
imageUrl: photoUrl,
|
||
fit: BoxFit.cover,
|
||
memCacheWidth: 144,
|
||
memCacheHeight: 144,
|
||
fadeInDuration: const Duration(milliseconds: 120),
|
||
errorWidget: (_, _, _) => Icon(
|
||
Symbols.person,
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer
|
||
: ctx.cs.primary,
|
||
size: 24,
|
||
),
|
||
),
|
||
)
|
||
: Icon(
|
||
Symbols.person,
|
||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||
size: 24,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Flexible(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
name.isNotEmpty ? name : 'Contact',
|
||
style: TextStyle(
|
||
color: ctx.text,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.2,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (contactData.phoneNumber != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
contactData.phoneNumber!,
|
||
style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildForwardedContactContent(
|
||
_BubbleCtx ctx,
|
||
ForwardedMessageAttachment forwarded,
|
||
) {
|
||
final contact = forwarded.originalContact!;
|
||
|
||
final firstName = contact.firstName ?? '';
|
||
final lastName = contact.lastName ?? '';
|
||
final hasFirstName = firstName.isNotEmpty;
|
||
final hasLastName = lastName.isNotEmpty;
|
||
|
||
final name = (hasFirstName || hasLastName)
|
||
? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}'
|
||
.trim()
|
||
: (contact.name ?? 'Contact');
|
||
final photoUrl = contact.photoUrl ?? contact.baseUrl;
|
||
|
||
final bgColor = isMe
|
||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: ctx.cs.surfaceContainerHighest;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_buildForwardedHeader(ctx, forwarded),
|
||
const SizedBox(height: 4),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(24),
|
||
),
|
||
child: photoUrl != null && photoUrl.isNotEmpty
|
||
? ClipRRect(
|
||
borderRadius: BorderRadius.circular(24),
|
||
child: CachedNetworkImage(
|
||
imageUrl: photoUrl,
|
||
fit: BoxFit.cover,
|
||
fadeInDuration: const Duration(milliseconds: 120),
|
||
errorWidget: (_, _, _) => Icon(
|
||
Symbols.person,
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer
|
||
: ctx.cs.primary,
|
||
size: 24,
|
||
),
|
||
),
|
||
)
|
||
: Icon(
|
||
Symbols.person,
|
||
color: isMe
|
||
? ctx.cs.onPrimaryContainer
|
||
: ctx.cs.primary,
|
||
size: 24,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Flexible(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
name.isNotEmpty ? name : 'Contact',
|
||
style: TextStyle(
|
||
color: ctx.text,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.2,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (contact.phoneNumber != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
contact.phoneNumber!,
|
||
style: TextStyle(
|
||
color: ctx.dim,
|
||
fontSize: 12,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
|
||
final url = photo.baseUrl ?? '';
|
||
if (url.isEmpty) return;
|
||
Navigator.of(ctx).push(
|
||
MaterialPageRoute(
|
||
fullscreenDialog: true,
|
||
builder: (_) => PhotoViewerScreen(baseUrl: url),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _downloadFile(
|
||
BuildContext context,
|
||
MessageAttachment file,
|
||
String name,
|
||
) async {
|
||
final fileId = (file as dynamic).fileId as int?;
|
||
if (fileId == null) {
|
||
showCustomNotification(context, 'Не удалось определить файл');
|
||
return;
|
||
}
|
||
Haptics.tap();
|
||
|
||
final cacheName = '${fileId}_$name';
|
||
|
||
MediaDownloadProgress.set(cacheName, 0);
|
||
final result = await openCachedFile(
|
||
cacheName,
|
||
() => messagesModule.getFileUrl(
|
||
messageId: message.id,
|
||
chatId: message.chatId,
|
||
fileId: fileId,
|
||
),
|
||
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
|
||
);
|
||
MediaDownloadProgress.set(cacheName, null);
|
||
if (!context.mounted) return;
|
||
if (!result.ok) {
|
||
showCustomNotification(
|
||
context,
|
||
'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}',
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildVoiceContent(_BubbleCtx ctx) {
|
||
int duration = 0;
|
||
String url = '';
|
||
String? waveData;
|
||
int? audioId;
|
||
|
||
final attaches = message.attachments;
|
||
if (attaches != null && attaches.isNotEmpty) {
|
||
for (final a in attaches) {
|
||
if (a is AudioAttachment) {
|
||
duration = ((a.duration ?? 0) / 1000).round();
|
||
url = a.fileUrl ?? a.baseUrl ?? '';
|
||
waveData = a.waveform;
|
||
audioId = a.audioId;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (duration == 0 && url.isEmpty) {
|
||
final payload = message.payload;
|
||
final voice = payload?['voice'] as Map<String, dynamic>?;
|
||
duration = ((voice?['duration'] as int? ?? 0) / 1000).round();
|
||
url = voice?['url']?.toString() ?? '';
|
||
}
|
||
|
||
final cachedTranscription = TranscriptionCache.get(message.id);
|
||
|
||
return _VoiceMessageBubble(
|
||
duration: duration,
|
||
url: url,
|
||
textColor: ctx.text,
|
||
isMe: isMe,
|
||
deleted: message.deleted,
|
||
status: overrideStatus ?? message.status,
|
||
time: message.time,
|
||
cs: ctx.cs,
|
||
waveData: waveData,
|
||
chatId: message.chatId,
|
||
messageId: message.id,
|
||
audioId: audioId,
|
||
preloadedText: cachedTranscription?.text,
|
||
);
|
||
}
|
||
|
||
Widget _buildMeta(_BubbleCtx ctx) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
Text(_clockText, style: TextStyle(color: ctx.dim, fontSize: 11)),
|
||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||
if (message.deleted) ...[
|
||
const SizedBox(width: 4),
|
||
_buildDeletedIcon(ctx),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCompactTime() {
|
||
final bgColor = isMe
|
||
? Colors.black.withValues(alpha: 0.4)
|
||
: Colors.black.withValues(alpha: 0.5);
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
_clockText,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
if (message.deleted) ...[
|
||
const SizedBox(width: 3),
|
||
const Icon(Symbols.delete, size: 11, color: Colors.white),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDeletedIcon(_BubbleCtx ctx) {
|
||
return Icon(Symbols.delete, size: 13, color: ctx.dim);
|
||
}
|
||
|
||
Widget _buildStatusIcon(_BubbleCtx ctx) {
|
||
final status = overrideStatus ?? message.status;
|
||
IconData icon;
|
||
Color color;
|
||
|
||
switch (status) {
|
||
case 'sending':
|
||
case 'pending':
|
||
icon = Symbols.schedule;
|
||
color = ctx.dim;
|
||
case null:
|
||
case 'sent':
|
||
icon = Symbols.check;
|
||
color = ctx.dim;
|
||
case 'delivered':
|
||
icon = Symbols.done_all;
|
||
color = ctx.dim;
|
||
case 'read':
|
||
icon = Symbols.done_all;
|
||
color = const Color(0xFF4FC3F7);
|
||
case 'error':
|
||
icon = Symbols.error;
|
||
color = Colors.redAccent;
|
||
default:
|
||
icon = Symbols.check;
|
||
color = ctx.dim;
|
||
}
|
||
|
||
return Icon(icon, size: 14, color: color);
|
||
}
|
||
}
|
||
|
||
class _VoiceMessageBubble extends StatefulWidget {
|
||
final int duration;
|
||
final String url;
|
||
final Color textColor;
|
||
final bool isMe;
|
||
final bool deleted;
|
||
final String? status;
|
||
final int time;
|
||
final ColorScheme cs;
|
||
final String? waveData;
|
||
final int chatId;
|
||
final String messageId;
|
||
final int? audioId;
|
||
final String? preloadedText;
|
||
|
||
const _VoiceMessageBubble({
|
||
required this.duration,
|
||
required this.url,
|
||
required this.textColor,
|
||
required this.isMe,
|
||
this.deleted = false,
|
||
this.status,
|
||
required this.time,
|
||
required this.cs,
|
||
this.waveData,
|
||
required this.chatId,
|
||
required this.messageId,
|
||
this.audioId,
|
||
this.preloadedText,
|
||
});
|
||
|
||
@override
|
||
State<_VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
|
||
}
|
||
|
||
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||
bool _isPlaying = false;
|
||
final ValueNotifier<double> _progress = ValueNotifier(0.0);
|
||
bool _transcriptionVisible = false;
|
||
String? _transcriptionText;
|
||
bool _transcriptionLoading = false;
|
||
|
||
OggOpusPlayer? _player;
|
||
bool _loadingAudio = false;
|
||
Timer? _ticker;
|
||
late final List<int> _amps = _parseWave(widget.waveData);
|
||
|
||
static List<int> _parseWave(String? data) {
|
||
if (data == null || data.isEmpty) return const [];
|
||
return data.codeUnits;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_transcriptionText = widget.preloadedText;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
_player?.state.removeListener(_onPlayerState);
|
||
_player?.dispose();
|
||
_progress.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _togglePlay() async {
|
||
if (_loadingAudio) return;
|
||
|
||
if (_player != null) {
|
||
if (_isPlaying) {
|
||
_player!.pause();
|
||
} else {
|
||
if (widget.duration > 0 &&
|
||
_player!.currentPosition >= widget.duration - 0.05) {
|
||
_progress.value = 0;
|
||
}
|
||
_player!.play();
|
||
}
|
||
return;
|
||
}
|
||
|
||
final url = widget.url;
|
||
if (url.isEmpty) return;
|
||
|
||
setState(() => _loadingAudio = true);
|
||
try {
|
||
final name = '${widget.audioId ?? widget.messageId}.ogg';
|
||
final file = await MediaCache.getOrDownload(name, url);
|
||
if (!mounted) return;
|
||
if (file == null) {
|
||
showCustomNotification(context, 'Не удалось загрузить аудио');
|
||
return;
|
||
}
|
||
final player = OggOpusPlayer(file.path);
|
||
_player = player;
|
||
player.state.addListener(_onPlayerState);
|
||
_ticker = Timer.periodic(
|
||
const Duration(milliseconds: 60),
|
||
(_) => _onTick(),
|
||
);
|
||
player.play();
|
||
} catch (e) {
|
||
if (mounted) showCustomNotification(context, 'Ошибка воспроизведения');
|
||
} finally {
|
||
if (mounted) setState(() => _loadingAudio = false);
|
||
}
|
||
}
|
||
|
||
void _onTick() {
|
||
final player = _player;
|
||
if (player == null || widget.duration <= 0) return;
|
||
final pos = player.currentPosition;
|
||
_progress.value = (pos / widget.duration).clamp(0.0, 1.0);
|
||
}
|
||
|
||
void _onPlayerState() {
|
||
final state = _player?.state.value;
|
||
if (!mounted) return;
|
||
final playing = state == PlayerState.playing;
|
||
if (playing != _isPlaying) setState(() => _isPlaying = playing);
|
||
if (state == PlayerState.ended) {
|
||
_progress.value = 1.0;
|
||
}
|
||
}
|
||
|
||
Widget _buildStatusIcon() {
|
||
final status = widget.status;
|
||
IconData icon;
|
||
Color color;
|
||
|
||
if (status == null || status == 'sent') {
|
||
icon = Symbols.check;
|
||
color = Colors.white54;
|
||
} else {
|
||
switch (status) {
|
||
case 'sending':
|
||
case 'pending':
|
||
icon = Symbols.schedule;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
case 'sent':
|
||
icon = Symbols.check;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
case 'delivered':
|
||
icon = Symbols.done_all;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
case 'read':
|
||
icon = Symbols.done_all;
|
||
color = const Color(0xFF4FC3F7);
|
||
case 'error':
|
||
icon = Symbols.error;
|
||
color = Colors.redAccent;
|
||
default:
|
||
icon = Symbols.check;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
}
|
||
}
|
||
|
||
return Icon(icon, size: 14, color: color);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final waveInactiveColor = widget.isMe
|
||
? widget.cs.onPrimaryContainer.withValues(alpha: 0.35)
|
||
: widget.cs.surfaceContainerHighest;
|
||
final waveActiveColor = widget.isMe
|
||
? widget.cs.onPrimaryContainer.withValues(alpha: 0.7)
|
||
: widget.cs.primary;
|
||
|
||
return SizedBox(
|
||
width: 240,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: _togglePlay,
|
||
child: Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: widget.isMe
|
||
? widget.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: widget.cs.primaryContainer,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: _loadingAudio
|
||
? Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: widget.isMe
|
||
? widget.cs.onPrimaryContainer
|
||
: widget.cs.primary,
|
||
),
|
||
)
|
||
: Icon(
|
||
_isPlaying ? Symbols.pause : Symbols.play_arrow,
|
||
color: widget.isMe
|
||
? widget.cs.onPrimaryContainer
|
||
: widget.cs.primary,
|
||
size: 18,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: SizedBox(
|
||
height: 26,
|
||
child: ValueListenableBuilder<double>(
|
||
valueListenable: _progress,
|
||
builder: (context, progress, _) => CustomPaint(
|
||
size: Size.infinite,
|
||
painter: _WaveformPainter(
|
||
amps: _amps,
|
||
progress: progress,
|
||
active: waveActiveColor,
|
||
inactive: waveInactiveColor,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
onTap: _requestTranscription,
|
||
child: SizedBox(
|
||
width: 20,
|
||
height: 32,
|
||
child: Center(
|
||
child: _transcriptionLoading
|
||
? SizedBox(
|
||
width: 12,
|
||
height: 12,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 1.5,
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
),
|
||
)
|
||
: Text(
|
||
'Т',
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 32,
|
||
child: Center(
|
||
child: Text(
|
||
formatSecondsMmSs(widget.duration),
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.7),
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: AnimatedSize(
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
alignment: Alignment.topLeft,
|
||
child: _transcriptionVisible
|
||
? Text(
|
||
_transcriptionText ?? '',
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.8),
|
||
fontSize: 12,
|
||
height: 1.3,
|
||
),
|
||
maxLines: 10,
|
||
overflow: TextOverflow.ellipsis,
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
),
|
||
if (!_transcriptionVisible) ...[
|
||
Text(
|
||
formatClock(
|
||
DateTime.fromMillisecondsSinceEpoch(widget.time),
|
||
withSeconds: KometSettings.fullTimestamp.value,
|
||
),
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
if (widget.isMe) ...[
|
||
const SizedBox(width: 2),
|
||
_buildStatusIcon(),
|
||
],
|
||
if (widget.deleted) ...[
|
||
const SizedBox(width: 2),
|
||
Icon(
|
||
Symbols.delete,
|
||
size: 13,
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
if (_transcriptionVisible) ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
formatClock(
|
||
DateTime.fromMillisecondsSinceEpoch(widget.time),
|
||
withSeconds: KometSettings.fullTimestamp.value,
|
||
),
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
if (widget.isMe) ...[
|
||
const SizedBox(width: 2),
|
||
_buildStatusIcon(),
|
||
],
|
||
if (widget.deleted) ...[
|
||
const SizedBox(width: 2),
|
||
Icon(
|
||
Symbols.delete,
|
||
size: 13,
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
|
||
Future<void> _requestTranscription() async {
|
||
if (widget.audioId == null) return;
|
||
|
||
if (_transcriptionVisible && _transcriptionText != null) {
|
||
setState(() {
|
||
_transcriptionVisible = false;
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (TranscriptionCache.has(widget.messageId)) {
|
||
final cached = TranscriptionCache.get(widget.messageId)!;
|
||
setState(() {
|
||
_transcriptionText = cached.text ?? 'не удалось распознать текст';
|
||
_transcriptionVisible = true;
|
||
});
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_transcriptionLoading = true;
|
||
});
|
||
|
||
try {
|
||
final result = await messagesModule.requestTranscription(
|
||
widget.chatId,
|
||
int.tryParse(widget.messageId) ?? 0,
|
||
widget.audioId!,
|
||
);
|
||
|
||
TranscriptionCache.put(widget.messageId, result);
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_transcriptionLoading = false;
|
||
if (result.status == 1) {
|
||
_transcriptionText = (result.text == null || result.text!.isEmpty)
|
||
? 'не удалось распознать текст'
|
||
: result.text;
|
||
_transcriptionVisible = true;
|
||
} else if (result.status == 0) {
|
||
_transcriptionText = 'транскрибация...';
|
||
_transcriptionVisible = true;
|
||
}
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_transcriptionLoading = false;
|
||
_transcriptionText = 'ошибка транскрибации';
|
||
_transcriptionVisible = true;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
class _WaveformPainter extends CustomPainter {
|
||
final List<int> amps;
|
||
final double progress;
|
||
final Color active;
|
||
final Color inactive;
|
||
|
||
const _WaveformPainter({
|
||
required this.amps,
|
||
required this.progress,
|
||
required this.active,
|
||
required this.inactive,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = size.height / 2;
|
||
|
||
if (amps.isEmpty) {
|
||
final track = Paint()
|
||
..strokeWidth = 3
|
||
..strokeCap = StrokeCap.round;
|
||
canvas.drawLine(
|
||
Offset(0, center),
|
||
Offset(size.width, center),
|
||
track..color = inactive,
|
||
);
|
||
if (progress > 0) {
|
||
canvas.drawLine(
|
||
Offset(0, center),
|
||
Offset(size.width * progress.clamp(0.0, 1.0), center),
|
||
track..color = active,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
final n = amps.length;
|
||
var maxAmp = 1;
|
||
for (final a in amps) {
|
||
if (a > maxAmp) maxAmp = a;
|
||
}
|
||
final slot = size.width / n;
|
||
final barW = (slot * 0.55).clamp(1.0, 3.0);
|
||
final paint = Paint();
|
||
|
||
for (var i = 0; i < n; i++) {
|
||
final h = ((amps[i] / maxAmp) * size.height).clamp(2.0, size.height);
|
||
final x = i * slot + (slot - barW) / 2;
|
||
paint.color = ((i + 0.5) / n) <= progress ? active : inactive;
|
||
canvas.drawRRect(
|
||
RRect.fromRectAndRadius(
|
||
Rect.fromLTWH(x, center - h / 2, barW, h),
|
||
Radius.circular(barW / 2),
|
||
),
|
||
paint,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_WaveformPainter old) =>
|
||
old.progress != progress ||
|
||
old.active != active ||
|
||
old.inactive != inactive ||
|
||
!identical(old.amps, amps);
|
||
}
|
||
|
||
class _VideoNoteBubble extends StatefulWidget {
|
||
final VideoAttachment attachment;
|
||
final String messageId;
|
||
final int chatId;
|
||
final ColorScheme cs;
|
||
|
||
const _VideoNoteBubble({
|
||
required this.attachment,
|
||
required this.messageId,
|
||
required this.chatId,
|
||
required this.cs,
|
||
});
|
||
|
||
@override
|
||
State<_VideoNoteBubble> createState() => _VideoNoteBubbleState();
|
||
}
|
||
|
||
class _VideoNoteBubbleState extends State<_VideoNoteBubble> {
|
||
static const double _size = 210;
|
||
VideoPlayerController? _controller;
|
||
bool _loading = false;
|
||
bool _error = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller?.removeListener(_onTick);
|
||
_controller?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onTick() {
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
static Uint8List? _previewBytes(String? data) {
|
||
if (data == null) return null;
|
||
const marker = 'base64,';
|
||
final idx = data.indexOf(marker);
|
||
if (idx < 0) return null;
|
||
try {
|
||
return base64Decode(data.substring(idx + marker.length));
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> _toggle() async {
|
||
final existing = _controller;
|
||
if (existing != null) {
|
||
setState(
|
||
() => existing.value.isPlaying ? existing.pause() : existing.play(),
|
||
);
|
||
return;
|
||
}
|
||
if (_loading) return;
|
||
|
||
final a = widget.attachment;
|
||
final videoId = a.videoId;
|
||
final token = a.videoToken;
|
||
if (videoId == null || token == null) {
|
||
setState(() => _error = true);
|
||
return;
|
||
}
|
||
|
||
setState(() => _loading = true);
|
||
Haptics.tap();
|
||
try {
|
||
final cacheName = 'videonote_$videoId.mp4';
|
||
var file = await MediaCache.existing(cacheName);
|
||
if (file == null) {
|
||
final url = await messagesModule.getVideoUrl(
|
||
messageId: widget.messageId,
|
||
chatId: widget.chatId,
|
||
token: token,
|
||
videoId: videoId,
|
||
);
|
||
if (url == null) throw Exception('no_url');
|
||
file = await MediaCache.getOrDownload(cacheName, url);
|
||
if (file == null) throw Exception('download');
|
||
}
|
||
if (!mounted) return;
|
||
final c = VideoPlayerController.file(file);
|
||
_controller = c;
|
||
await c.initialize();
|
||
if (!mounted) {
|
||
c.dispose();
|
||
return;
|
||
}
|
||
await c.setLooping(true);
|
||
c.addListener(_onTick);
|
||
c.play();
|
||
setState(() => _loading = false);
|
||
} catch (_) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_loading = false;
|
||
_error = true;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final a = widget.attachment;
|
||
final c = _controller;
|
||
final ready = c != null && c.value.isInitialized;
|
||
final playing = ready && c.value.isPlaying;
|
||
final preview = _previewBytes(a.previewData);
|
||
|
||
double progress = 0;
|
||
if (ready && c.value.duration.inMilliseconds > 0) {
|
||
progress =
|
||
c.value.position.inMilliseconds / c.value.duration.inMilliseconds;
|
||
}
|
||
|
||
return GestureDetector(
|
||
onTap: _toggle,
|
||
child: SizedBox(
|
||
width: _size,
|
||
height: _size,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
ClipOval(
|
||
child: SizedBox(
|
||
width: _size,
|
||
height: _size,
|
||
child: ready
|
||
? FittedBox(
|
||
fit: BoxFit.cover,
|
||
clipBehavior: Clip.hardEdge,
|
||
child: SizedBox(
|
||
width: c.value.size.width,
|
||
height: c.value.size.height,
|
||
child: VideoPlayer(c),
|
||
),
|
||
)
|
||
: preview != null
|
||
? Image.memory(
|
||
preview,
|
||
fit: BoxFit.cover,
|
||
gaplessPlayback: true,
|
||
)
|
||
: Container(color: widget.cs.surfaceContainerHighest),
|
||
),
|
||
),
|
||
if (ready)
|
||
SizedBox(
|
||
width: _size - 2,
|
||
height: _size - 2,
|
||
child: CircularProgressIndicator(
|
||
value: progress.clamp(0.0, 1.0),
|
||
strokeWidth: 3,
|
||
color: widget.cs.primary,
|
||
backgroundColor: Colors.white24,
|
||
),
|
||
),
|
||
if (!playing)
|
||
Container(
|
||
width: 52,
|
||
height: 52,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.black45,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: _loading
|
||
? const Padding(
|
||
padding: EdgeInsets.all(14),
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: Icon(
|
||
_error ? Symbols.error : Symbols.play_arrow,
|
||
color: Colors.white,
|
||
size: 30,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|