Files
Qlyra/lib/frontend/widgets/message_bubble.dart
T

2231 lines
71 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart';
import '../../core/utils/bubble_radius.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 'custom_notification.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);
}
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;
const MessageBubble({
super.key,
required this.message,
required this.isMe,
required this.myId,
this.prevMessage,
this.nextMessage,
required this.chatType,
this.overrideStatus,
this.reactionsListenable,
});
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;
}
MessageType _computeContentType() {
if (message.isControl) return MessageType.control;
final attachments = message.attachments;
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;
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,
);
}
Widget _buildLeadingAvatar(ColorScheme cs) {
final senderAvatar = ContactCache.getAvatar(message.senderId);
final displaySender = ContactCache.get(message.senderId);
if (senderAvatar != null && senderAvatar.isNotEmpty) {
return CircleAvatar(
radius: 15,
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96),
backgroundColor: cs.primaryContainer,
);
}
return CircleAvatar(
radius: 15,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender != null && displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final contentType = _computeContentType();
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 maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
final bubbleColor =
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;
return GestureDetector(
onTap: Haptics.tap,
child: 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: _borderRadiusFor(
AppBubbleShape.current.value,
AppBubbleBehavior.current.value,
shape,
hasPhotoCap,
hasMultiPhotos,
),
),
padding: padding,
child: child,
),
child: reactionsInside
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [bubbleContent, _reactionsBar(cs)],
)
: bubbleContent,
),
if (reactionsUnder) _reactionsBar(cs),
],
),
],
),
),
),
);
}
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 displaySender = ContactCache.get(message.senderId);
final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo);
final hasReactions = reactionChips.isNotEmpty;
final textWidget = isForwarded
? _buildForwardedInlineText(ctx, forwarded)
: Text(
message.text ?? '',
style: TextStyle(
color: ctx.text,
fontSize: 16,
height: 1.3,
),
);
final metaWidget = Text(
message.status == 'EDITED'
? '${_formatTime(message.time)} ред.'
: _formatTime(message.time),
style: TextStyle(color: ctx.dim, fontSize: 10),
);
final showSender = message.senderId != message.accountId &&
prevMessage?.senderId != message.senderId &&
chatType == "CHAT";
if (hasReactions) {
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSender)
Text(
displaySender ?? "",
textAlign: TextAlign.left,
style: TextStyle(color: ctx.text),
),
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),
],
],
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSender)
Text(
displaySender ?? "",
textAlign: TextAlign.left,
style: TextStyle(color: ctx.text),
),
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),
],
],
),
],
);
}
Widget _buildForwardedInlineText(
_BubbleCtx ctx,
ForwardedMessageAttachment forwarded,
) {
final headerColor = ctx.dim;
final senderName = forwarded.originalSenderName;
final displaySender = senderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
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),
),
],
],
);
}
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 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: PollView(
chatId: message.chatId,
messageId: message.id,
pollId: poll.pollId,
fallbackTitle: poll.title ?? message.text,
textColor: ctx.text,
dimColor: ctx.dim,
accentColor: ctx.text,
),
);
}
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: [
Flexible(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 headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
final hasCaption = message.text != null && message.text!.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 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),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
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,
) {
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 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),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 4),
...attachments.map((a) {
if (a is FileAttachment) {
return _buildFileAttachment(ctx, a);
}
return const SizedBox.shrink();
}),
],
);
}
Widget _buildSinglePhoto(_BubbleCtx ctx, PhotoAttachment photo) {
final imageUrl = photo.baseUrl ?? '';
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: [
if (imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: imageUrl,
width: constrainedWidth,
height: constrainedHeight,
fit: BoxFit.cover,
memCacheWidth: (constrainedWidth * dpr).round(),
memCacheHeight: (constrainedHeight * dpr).round(),
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
ctx.cs,
constrainedWidth,
constrainedHeight,
),
)
else
_buildPhotoPlaceholder(ctx.cs, constrainedWidth, constrainedHeight),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(ctx.context, photo),
),
),
],
),
);
}
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)),
const SizedBox(width: 2),
Expanded(child: _buildPhotoTile(ctx, p2)),
],
),
);
}
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');
}
return _buildPhotoTile(ctx, photos[i]);
}),
),
);
}
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
final imageUrl = photo.baseUrl ?? '';
final cachePx =
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
if (imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: cachePx,
memCacheHeight: cachePx,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, 100, 100),
)
else
_buildPhotoPlaceholder(ctx.cs, 100, 100),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(ctx.context, photo),
),
),
],
),
);
}
Widget _buildPhotoTileWithOverlay(
_BubbleCtx ctx,
PhotoAttachment photo,
String overlay,
) {
final imageUrl = photo.baseUrl ?? '';
final cachePx =
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
if (imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: cachePx,
memCacheHeight: cachePx,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, 100, 100),
)
else
_buildPhotoPlaceholder(ctx.cs, 100, 100),
Positioned.fill(
child: Container(
color: Colors.black45,
child: Center(
child: Text(
overlay,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
);
}
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) {
return Text(
message.text ?? '',
style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3),
);
}
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);
default:
return _buildTextContent(ctx);
}
}
Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(photoBorderRadius),
child: Stack(
children: [
Container(
width: 200,
height: 150,
color: ctx.cs.surfaceContainerHighest,
child: Icon(
Symbols.videocam,
size: 48,
color: ctx.cs.onSurfaceVariant,
),
),
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),
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _playVideo(ctx.context, video),
),
),
],
),
),
const SizedBox(height: 6),
_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) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
Haptics.tap();
final cacheName = 'video_$videoId.mp4';
final cached = await MediaCache.existing(cacheName) != null;
if (!context.mounted) return;
String? url;
if (!cached) {
if (token == null) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
url = await messagesModule.getVideoUrl(
messageId: message.id,
chatId: message.chatId,
token: token,
videoId: videoId,
);
if (!context.mounted) return;
if (url == null) {
showCustomNotification(context, 'Не удалось получить видео');
return;
}
}
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => VideoPlayerScreen(cacheName: cacheName, url: url),
),
);
}
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
final name = (file as dynamic).name as String? ?? 'File';
final size = (file as dynamic).size as int? ?? 0;
final sizeStr = _formatFileSize(size);
final fileId = (file as dynamic).fileId as int?;
final cacheName = '${fileId}_$name';
return IntrinsicWidth(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
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),
],
),
),
);
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ';
}
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
final url = sticker.baseUrl ?? '';
final preview = sticker.previewData ?? '';
final imageUrl = url.isNotEmpty ? url : preview;
return 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),
],
),
);
}
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;
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 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),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
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,
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(
_formatTime(message.time),
style: TextStyle(color: ctx.dim, fontSize: 11),
),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(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: Text(
_formatTime(message.time),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
);
}
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);
}
String _formatTime(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
}
class _VoiceMessageBubble extends StatefulWidget {
final int duration;
final String url;
final Color textColor;
final bool isMe;
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.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;
@override
void initState() {
super.initState();
_transcriptionText = widget.preloadedText;
}
@override
void dispose() {
_progress.dispose();
super.dispose();
}
String _formatDuration(int seconds) {
final min = seconds ~/ 60;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}';
}
String _formatTime(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
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: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
color: widget.isMe
? widget.cs.onPrimaryContainer
: widget.cs.primary,
size: 18,
),
),
),
const SizedBox(width: 10),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapDown: (details) {
_progress.value =
(details.localPosition.dx / constraints.maxWidth)
.clamp(0.0, 1.0);
},
onHorizontalDragUpdate: (details) {
_progress.value =
(details.localPosition.dx / constraints.maxWidth)
.clamp(0.0, 1.0);
},
child: Container(
height: 4,
decoration: BoxDecoration(
color: waveInactiveColor,
borderRadius: BorderRadius.circular(2),
),
child: ValueListenableBuilder<double>(
valueListenable: _progress,
builder: (context, progress, _) => FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: progress.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: waveActiveColor,
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
);
},
),
),
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(
_formatDuration(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(
_formatTime(widget.time),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
),
),
if (widget.isMe) ...[
const SizedBox(width: 2),
_buildStatusIcon(),
],
],
],
),
if (_transcriptionVisible) ...[
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
_formatTime(widget.time),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
),
),
if (widget.isMe) ...[
const SizedBox(width: 2),
_buildStatusIcon(),
],
],
),
],
],
),
);
}
void _togglePlay() {
setState(() {
_isPlaying = !_isPlaying;
});
}
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);
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) {
setState(() {
_transcriptionLoading = false;
_transcriptionText = 'ошибка транскрибации';
_transcriptionVisible = true;
});
}
}
}