Files
Qlyra/lib/frontend/widgets/message_bubble.dart
T
2026-05-15 18:58:58 +07:00

2114 lines
67 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/material.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/contacts.dart';
import 'package:komet/main.dart';
import 'package:flutter/foundation.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../core/utils/haptics.dart';
import '../../models/attachment.dart';
enum MessageType { text, attachment, voice, control }
enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle }
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;
bool get _hasPhotoWithCaption {
if (message.attachments == null || message.attachments!.isEmpty)
return false;
final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment);
final hasCaption = message.text != null && message.text!.isNotEmpty;
return hasPhoto && hasCaption;
}
bool get _hasMultiplePhotosNoCaption {
if (message.attachments == null || message.attachments!.isEmpty)
return false;
final photoCount = message.attachments!.whereType<PhotoAttachment>().length;
final hasCaption = message.text != null && message.text!.isNotEmpty;
return photoCount >= 2 && !hasCaption;
}
final CachedMessage message;
final bool isMe;
final int myId;
final CachedMessage? prevMessage;
final CachedMessage? nextMessage;
final String chatType;
final String? overrideStatus;
const MessageBubble({
super.key,
required this.message,
required this.isMe,
required this.myId,
this.prevMessage,
this.nextMessage,
required this.chatType,
this.overrideStatus,
});
bool get isGroupedWithNext {
if (nextMessage == null) return false;
if (message.isControl) return false;
if (nextMessage!.senderId != message.senderId) return false;
final timeDiff = nextMessage!.time - message.time;
return timeDiff < 300000;
}
BubbleShape get shape {
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 bool groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000;
final bool 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 get contentType {
if (message.isControl) return MessageType.control;
if (message.attachments != null && message.attachments!.isNotEmpty) {
final first = message.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;
final voice = payload['voice'];
if (voice != null) return MessageType.voice;
return MessageType.text;
}
BorderRadius get _borderRadius {
final topRadius = Radius.circular(bubbleBorderRadius);
final smallRadius = const Radius.circular(4);
final cornerTL = isMe ? smallRadius : topRadius;
final cornerTR = isMe ? topRadius : smallRadius;
final cornerBL = isMe ? smallRadius : topRadius;
final cornerBR = isMe ? topRadius : smallRadius;
if (_hasPhotoWithCaption &&
(shape == BubbleShape.singleTop ||
shape == BubbleShape.singleMiddle ||
shape == BubbleShape.singleBottom)) {
return BorderRadius.only(
topLeft: topRadius,
topRight: topRadius,
bottomLeft: smallRadius,
bottomRight: smallRadius,
);
}
if (_hasMultiplePhotosNoCaption &&
(shape == BubbleShape.singleBottom ||
shape == BubbleShape.singleMiddle)) {
return BorderRadius.only(
topLeft: smallRadius,
topRight: smallRadius,
bottomLeft: smallRadius,
bottomRight: isMe ? smallRadius : topRadius,
);
}
switch (shape) {
case BubbleShape.singleTop:
return BorderRadius.only(
topLeft: cornerTL,
topRight: cornerTR,
bottomLeft: smallRadius,
bottomRight: smallRadius,
);
case BubbleShape.singleBottom:
return BorderRadius.only(
topLeft: smallRadius,
topRight: smallRadius,
bottomLeft: cornerBL,
bottomRight: cornerBR,
);
case BubbleShape.singleMiddle:
return BorderRadius.only(
topLeft: cornerTL,
topRight: cornerTR,
bottomLeft: cornerBL,
bottomRight: cornerBR,
);
case BubbleShape.groupedMiddle:
return BorderRadius.only(
topLeft: isMe ? cornerTL : smallRadius,
topRight: smallRadius,
bottomLeft: isMe ? cornerBL : smallRadius,
bottomRight: smallRadius,
);
}
}
// ВРОДЕ отступы между сообщениями, если они выглядят адекватно не трогайте пж я ради них кишку на шею намотал
// ОТСТУПЫ МЕЖДУ СООБЩЕНИЯМИ (top/bottom margin)
// Зависит от shape (группировка) И contentType (тип контента)
double get topMargin {
switch (contentType) {
case MessageType.text:
switch (shape) {
case BubbleShape.singleTop:
return 6;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.attachment:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 6;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.voice:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.control:
return 4;
}
return 4;
}
double get bottomMargin {
switch (contentType) {
case MessageType.text:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.attachment:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.attachment:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.voice:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.control:
return 4;
}
return 4;
}
// Внутренний отступ, размеры типа я хз
EdgeInsets get padding {
switch (contentType) {
case MessageType.text:
switch (shape) {
case BubbleShape.groupedMiddle:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
case BubbleShape.singleTop:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
case BubbleShape.singleBottom:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
case BubbleShape.singleMiddle:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
}
case MessageType.attachment:
return const EdgeInsets.all(0);
case MessageType.voice:
switch (shape) {
case BubbleShape.groupedMiddle:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
case BubbleShape.singleTop:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
case BubbleShape.singleBottom:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
case BubbleShape.singleMiddle:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
}
case MessageType.control:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
}
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
}
@override
Widget build(BuildContext context) {
if (message.isControl) {
return Padding(
padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin),
child: Center(child: _buildControlContent(context)),
);
}
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
String? senderAvatar = ContactCache.getAvatar(message.senderId);
String? displaySender = ContactCache.get(message.senderId);
return GestureDetector(
// TODO: действия с сообщением
onTap: () => Haptics.tap(),
child: Padding(
padding: EdgeInsets.only(
left: isMe ? 12 : 12,
right: isMe ? 12 : 12,
top: topMargin,
bottom: bottomMargin,
),
child: Align(
child: Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId)
...(senderAvatar != null && senderAvatar.isNotEmpty)
? [
CircleAvatar(
radius: 15,
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
]
: [
CircleAvatar(
radius: 15,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender != null && displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
),
)
]
else if (!isMe && chatType != "CHAT")
SizedBox(width: 0)
else if (!isMe)
CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)),
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isMe
? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF))
: (isDark
? cs.surfaceContainerHighest
: const Color(0xFFE9E9EB)),
borderRadius: _borderRadius,
),
padding: padding,
child: _buildContent(context),
),
],
)
),
)
);
}
Widget _buildContent(BuildContext context) {
switch (contentType) {
case MessageType.control:
return _buildControlContent(context);
case MessageType.attachment:
return _buildAttachmentContent(context);
case MessageType.voice:
return _buildVoiceContent(context);
case MessageType.text:
return _buildTextContent(context);
}
}
Widget _buildControlContent(BuildContext context) {
final cs = Theme.of(context).colorScheme;
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(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
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;
String? displaySender = ContactCache.get(message.senderId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId && chatType == "CHAT")
Text(
displaySender ?? "",
textAlign: TextAlign.left,
style: TextStyle(color: cs.onPrimaryContainer)
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: isForwarded
? _buildForwardedInlineText(context, forwarded!, textColor)
: Text(
message.text ?? '',
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
message.status == 'EDITED'
? '${_formatTime(message.time)} ред.'
: _formatTime(message.time),
style: TextStyle(
color: textColor.withValues(alpha: 0.7),
fontSize: 10,
),
),
),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)],
],
),
],
);
}
Widget _buildForwardedInlineText(
BuildContext context,
ForwardedMessageAttachment forwarded,
Color textColor,
) {
final cs = Theme.of(context).colorScheme;
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: cs.onSurfaceVariant;
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),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: 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: textColor, fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
] else ...[
const SizedBox(height: 2),
Text(
message.text ?? '',
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
),
],
],
);
}
ForwardedMessageAttachment? _getForwardedAttachment() {
if (message.attachments == null || message.attachments!.isEmpty)
return null;
for (final a in message.attachments!) {
if (a is ForwardedMessageAttachment) return a;
}
return null;
}
Widget _buildForwardedHeader(
BuildContext context,
ForwardedMessageAttachment forwarded,
) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93));
final senderName = forwarded.originalSenderName;
final displaySender = senderName ?? forwarded.originalSenderId.toString();
final origText = forwarded.originalText;
final hasOrigText = origText != null && origText.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),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (hasOrigText) ...[
const SizedBox(height: 2),
Text(
origText,
style: TextStyle(color: textColor, fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
);
}
Widget _buildAttachmentContent(BuildContext context) {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) {
return _buildTextContent(context);
}
final first = attachments.first;
if (first is ForwardedMessageAttachment) {
final fwd = first;
if (fwd.originalContact != null) {
return _buildForwardedContactContent(context, fwd);
}
final photos = fwd.originalAttachments
?.whereType<PhotoAttachment>()
.toList();
if (photos != null && photos.isNotEmpty) {
return _buildForwardedPhotoContent(context, fwd, photos);
}
final files = fwd.originalAttachments;
if (files != null && files.isNotEmpty) {
return _buildForwardedGenericContent(context, fwd, files);
}
return _buildTextContent(context);
}
final contacts = attachments.whereType<ContactAttachment>().toList();
if (contacts.isNotEmpty) {
return _buildContactAttachment(context, contacts.first);
}
final photos = attachments.whereType<PhotoAttachment>().toList();
if (photos.isEmpty) {
return _buildGenericAttachment(context, attachments.first);
}
return _buildPhotoContent(context, photos);
}
Widget _buildPhotoContent(BuildContext 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(ctx),
),
],
);
}
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(
BuildContext context,
ForwardedMessageAttachment forwarded,
List<PhotoAttachment> photos,
) {
final cs = Theme.of(context).colorScheme;
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: cs.onSurfaceVariant;
final senderName = forwarded.originalSenderName;
final displaySender = senderName ?? 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),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: 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: isMe ? Colors.white : const Color(0xFF1C1C1E),
fontSize: 16,
height: 1.3,
),
),
),
const SizedBox(height: 6),
],
_buildPhotoContent(context, photos),
],
);
}
Widget _buildForwardedFileContent(
BuildContext context,
ForwardedMessageAttachment forwarded,
List<FileAttachment> files,
) {
final cs = Theme.of(context).colorScheme;
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: cs.onSurfaceVariant;
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),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
),
),
const SizedBox(width: 6),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 4),
...files.map((file) => _buildFileAttachment(context, file)),
],
);
}
Widget _buildForwardedGenericContent(
BuildContext context,
ForwardedMessageAttachment forwarded,
List<MessageAttachment> attachments,
) {
final cs = Theme.of(context).colorScheme;
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: cs.onSurfaceVariant;
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),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: 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(context, a);
}
return const SizedBox.shrink();
}),
],
);
}
Widget _buildSinglePhoto(BuildContext 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 bool matchTop = _hasPhotoWithCaption;
final bool matchBottom = !_hasPhotoWithCaption;
return ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(
matchTop ? bubbleBorderRadius : photoBorderRadius,
),
topRight: Radius.circular(
matchTop ? bubbleBorderRadius : photoBorderRadius,
),
bottomLeft: Radius.circular(
matchBottom ? (isMe ? bubbleBorderRadius : 4) : 4,
),
bottomRight: Radius.circular(
matchBottom ? (isMe ? 4 : bubbleBorderRadius) : 4,
),
),
child: Stack(
children: [
if (imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: imageUrl,
width: constrainedWidth,
height: constrainedHeight,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => _buildPhotoPlaceholder(
ctx,
constrainedWidth,
constrainedHeight,
),
)
else
_buildPhotoPlaceholder(ctx, constrainedWidth, constrainedHeight),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)),
),
),
],
),
);
}
Widget _buildTwoPhotos(
BuildContext ctx,
PhotoAttachment p1,
PhotoAttachment p2,
) {
final matchTop =
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop;
final matchBottom =
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom;
return ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(
matchTop ? bubbleBorderRadius : photoBorderRadius,
),
topRight: Radius.circular(
matchTop ? bubbleBorderRadius : photoBorderRadius,
),
bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius),
bottomRight: Radius.circular(
matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPhotoTile(ctx, p1),
const SizedBox(width: 2),
_buildPhotoTile(ctx, p2),
],
),
);
}
Widget _buildPhotoGrid(BuildContext ctx, List<PhotoAttachment> photos) {
final displayCount = photos.length > 4 ? 4 : photos.length;
final remaining = photos.length - 4;
final matchTop =
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop;
final matchBottom =
_hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom;
return ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(
matchTop ? bubbleBorderRadius : photoBorderRadius,
),
topRight: Radius.circular(
matchTop ? bubbleBorderRadius : photoBorderRadius,
),
bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius),
bottomRight: Radius.circular(
matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius,
),
),
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(BuildContext ctx, PhotoAttachment photo) {
final imageUrl = photo.baseUrl ?? '';
return Expanded(
child: AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
if (imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
_buildPhotoPlaceholder(ctx, 100, 100),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)),
),
),
],
),
),
);
}
Widget _buildPhotoTileWithOverlay(
BuildContext ctx,
PhotoAttachment photo,
String overlay,
) {
final imageUrl = photo.baseUrl ?? '';
return Expanded(
child: AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
if (imageUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
_buildPhotoPlaceholder(ctx, 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(
BuildContext ctx,
double w,
double h, {
VoidCallback? onRetry,
}) {
final cs = Theme.of(ctx).colorScheme;
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(BuildContext ctx) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
return Text(
message.text ?? '',
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
);
}
Widget _buildGenericAttachment(
BuildContext ctx,
MessageAttachment attachment,
) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
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(BuildContext ctx, MessageAttachment video) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(photoBorderRadius),
child: Stack(
children: [
Container(
width: 200,
height: 150,
color: Theme.of(ctx).colorScheme.surfaceContainerHighest,
child: Icon(
Symbols.videocam,
size: 48,
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
),
),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(onTap: () {}),
),
),
],
),
),
const SizedBox(height: 6),
_buildMeta(ctx),
],
);
}
Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final name = (file as dynamic).name as String? ?? 'File';
final size = (file as dynamic).size as int? ?? 0;
final sizeStr = _formatFileSize(size);
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
final subtitleColor = isMe
? Colors.white.withValues(alpha: 0.65)
: (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: isMe
? Colors.white.withValues(alpha: 0.2)
: cs.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Symbols.description,
color: isMe ? Colors.white : cs.primary,
size: 20,
),
),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
sizeStr,
style: TextStyle(
color: subtitleColor,
fontSize: 12,
height: 1.2,
),
),
],
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () {},
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: isMe
? Colors.white.withValues(alpha: 0.15)
: (isDark
? cs.surfaceContainerHighest
: const Color(0xFFE5E5EA)),
shape: BoxShape.circle,
),
child: Icon(
Symbols.download,
color: isMe ? Colors.white : cs.primary,
size: 18,
),
),
),
],
),
);
}
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(BuildContext 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,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
_buildPhotoPlaceholder(ctx, 150, 150),
)
else
_buildPhotoPlaceholder(ctx, 150, 150),
],
),
);
}
Widget _buildContactAttachment(BuildContext ctx, MessageAttachment contact) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
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 textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
final subtitleColor = isMe
? Colors.white.withValues(alpha: 0.65)
: (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93));
final bgColor = isMe
? Colors.white.withValues(alpha: 0.2)
: (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA));
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,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => Icon(
Symbols.person,
color: isMe ? Colors.white : cs.primary,
size: 24,
),
),
)
: Icon(
Symbols.person,
color: isMe ? Colors.white : 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: textColor,
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: subtitleColor,
fontSize: 12,
height: 1.2,
),
),
],
],
),
),
],
),
);
}
Widget _buildForwardedContactContent(
BuildContext context,
ForwardedMessageAttachment forwarded,
) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
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 textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
final subtitleColor = isMe
? Colors.white.withValues(alpha: 0.65)
: (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93));
final bgColor = isMe
? Colors.white.withValues(alpha: 0.2)
: (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA));
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: cs.onSurfaceVariant;
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),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: 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 ? Colors.white : cs.primary,
size: 24,
),
),
)
: Icon(
Symbols.person,
color: isMe ? Colors.white : 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: textColor,
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: subtitleColor,
fontSize: 12,
height: 1.2,
),
),
],
],
),
),
],
),
),
],
);
}
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
// TODO: Open photo viewer
}
Widget _buildVoiceContent(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
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: textColor,
isMe: isMe,
status: overrideStatus ?? message.status,
time: message.time,
cs: cs,
waveData: waveData,
chatId: message.chatId,
messageId: message.id,
audioId: audioId,
preloadedText: cachedTranscription?.text,
);
}
Widget _buildMeta(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant;
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: timeColor, fontSize: 11),
),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)],
],
),
);
}
Widget _buildCompactTime(BuildContext ctx) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final bgColor = isMe
? Colors.black.withValues(alpha: 0.4)
: Colors.black.withValues(alpha: 0.5);
final textColor = Colors.white;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatTime(message.time),
style: TextStyle(
color: textColor,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildStatusIcon(BuildContext context) {
final status = overrideStatus ?? message.status;
IconData icon;
Color color;
switch (status) {
case 'sending':
case 'pending':
icon = Symbols.schedule;
color = Colors.white70;
case null:
case 'sent':
icon = Symbols.check;
color = Colors.white70;
case 'delivered':
icon = Symbols.done_all;
color = Colors.white70;
case 'read':
icon = Symbols.done_all;
color = const Color(0xFF4FC3F7);
case 'error':
icon = Symbols.error;
color = Colors.redAccent;
default:
icon = Symbols.check;
color = Colors.white70;
}
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';
}
String _formatDuration(int seconds) {
final min = seconds ~/ 60;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}';
}
}
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;
double _progress = 0.0;
bool _transcriptionVisible = false;
String? _transcriptionText;
bool _transcriptionLoading = false;
@override
void initState() {
super.initState();
if (widget.preloadedText != null) {
_transcriptionText = widget.preloadedText;
_transcriptionVisible = true;
}
}
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 = Colors.white54;
case 'sent':
icon = Symbols.check;
color = Colors.white54;
case 'delivered':
icon = Symbols.done_all;
color = Colors.white54;
case 'read':
icon = Symbols.done_all;
color = const Color(0xFF4FC3F7);
case 'error':
icon = Symbols.error;
color = Colors.redAccent;
default:
icon = Symbols.check;
color = Colors.white54;
}
}
return Icon(icon, size: 14, color: color);
}
Widget build(BuildContext context) {
final isDark = widget.cs.brightness == Brightness.dark;
final waveInactiveColor = widget.isMe
? Colors.white.withValues(alpha: 0.35)
: (isDark
? widget.cs.surfaceContainerHighest
: const Color(0xFFD1D1D6));
final waveActiveColor = widget.isMe
? Colors.white.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
? Colors.white.withValues(alpha: 0.2)
: widget.cs.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
color: widget.isMe ? Colors.white : widget.cs.primary,
size: 18,
),
),
),
const SizedBox(width: 10),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapDown: (details) {
setState(() {
_progress = (details.localPosition.dx /
constraints.maxWidth)
.clamp(0.0, 1.0);
});
},
onHorizontalDragUpdate: (details) {
setState(() {
_progress = (details.localPosition.dx /
constraints.maxWidth)
.clamp(0.0, 1.0);
});
},
child: Container(
height: 4,
decoration: BoxDecoration(
color: waveInactiveColor,
borderRadius: BorderRadius.circular(2),
),
child: 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: [
Text(
_formatDuration(widget.duration),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.7),
fontSize: 11,
),
),
const SizedBox(width: 8),
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(),
],
],
),
],
],
),
);
}
Widget _buildProgressBar(Color inactive, Color active) {
return Container(
height: 4,
decoration: BoxDecoration(
color: inactive,
borderRadius: BorderRadius.circular(2),
),
);
}
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;
});
}
}
}