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

1161 lines
35 KiB
Dart

import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../models/attachment.dart';
enum MessageType { text, attachment, voice }
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;
const MessageBubble({
super.key,
required this.message,
required this.isMe,
required this.myId,
this.prevMessage,
this.nextMessage,
});
bool get isGroupedWithNext {
if (nextMessage == null) return false;
if (nextMessage!.senderId != message.senderId) return false;
final timeDiff = nextMessage!.time - message.time;
return timeDiff < 300000;
}
BubbleShape get shape {
final hasPrevFromMe = prevMessage?.senderId == message.senderId;
final prevTimeDiff = hasPrevFromMe
? message.time - prevMessage!.time
: 999999999;
final hasNextFromMe = nextMessage?.senderId == message.senderId;
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.attachments != null && message.attachments!.isNotEmpty) {
final first = message.attachments!.first;
if (first is ForwardedMessageAttachment) return MessageType.text;
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;
}
// скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие
BorderRadius get _borderRadius {
final topRadius = Radius.circular(bubbleBorderRadius);
final bottomRadius = Radius.circular(bubbleBorderRadius);
final smallRadius = const Radius.circular(4);
if (_hasPhotoWithCaption &&
(shape == BubbleShape.singleTop ||
shape == BubbleShape.singleMiddle ||
shape == BubbleShape.singleBottom)) {
return BorderRadius.only(
topLeft: topRadius,
topRight: isMe ? topRadius : topRadius,
bottomLeft: smallRadius,
bottomRight: smallRadius,
);
}
if (_hasMultiplePhotosNoCaption &&
(shape == BubbleShape.singleBottom ||
shape == BubbleShape.singleMiddle)) {
return BorderRadius.only(
topLeft: smallRadius,
topRight: smallRadius,
bottomLeft: isMe ? smallRadius : smallRadius,
bottomRight: isMe ? smallRadius : bottomRadius,
);
}
switch (shape) {
case BubbleShape.singleTop:
return BorderRadius.only(
topLeft: isMe ? topRadius : smallRadius,
topRight: isMe ? smallRadius : topRadius,
bottomLeft: smallRadius,
bottomRight: smallRadius,
);
case BubbleShape.singleBottom:
return BorderRadius.only(
topLeft: smallRadius,
topRight: smallRadius,
bottomLeft: isMe ? topRadius : smallRadius,
bottomRight: isMe ? smallRadius : topRadius,
);
case BubbleShape.singleMiddle:
return BorderRadius.only(
topLeft: topRadius,
topRight: topRadius,
bottomLeft: isMe ? topRadius : smallRadius,
bottomRight: isMe ? smallRadius : topRadius,
);
case BubbleShape.groupedMiddle:
return BorderRadius.only(
topLeft: isMe ? topRadius : smallRadius,
topRight: isMe ? smallRadius : smallRadius,
bottomLeft: isMe ? topRadius : smallRadius,
bottomRight: isMe ? smallRadius : 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;
}
}
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.voice:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
}
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: 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);
}
}
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
return Padding(
padding: EdgeInsets.only(
left: isMe ? 60 : 12,
right: isMe ? 12 : 60,
top: topMargin,
bottom: bottomMargin,
),
child: Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: 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.attachment:
return _buildAttachmentContent(context);
case MessageType.voice:
return _buildVoiceContent(context);
case MessageType.text:
default:
return _buildTextContent(context);
}
}
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 forwarded = _getForwardedAttachment();
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (forwarded != null) ...[
_buildForwardedHeader(context, forwarded),
const SizedBox(height: 4),
],
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(
_formatTime(message.time),
style: TextStyle(
color: textColor.withValues(alpha: 0.7),
fontSize: 10,
),
),
),
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)],
],
);
}
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: [
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) {
return _buildTextContent(context);
}
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 _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)
Image.network(
imageUrl,
width: constrainedWidth,
height: constrainedHeight,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _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)
Image.network(
imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) =>
_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)
Image.network(
imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) =>
_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 preview = (sticker as dynamic).previewData as String? ?? '';
return ClipRRect(
borderRadius: BorderRadius.circular(photoBorderRadius),
child: Stack(
children: [
if (preview.isNotEmpty)
Image.network(
preview,
width: 150,
height: 150,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
_buildPhotoPlaceholder(ctx, 150, 150),
)
else
_buildPhotoPlaceholder(ctx, 150, 150),
],
),
);
}
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));
final payload = message.payload;
final voice = payload?['voice'] as Map<String, dynamic>?;
final duration = voice?['duration'] as int? ?? 0;
final url = voice?['url']?.toString() ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_VoiceMessageBubble(
duration: duration,
url: url,
textColor: textColor,
isMe: isMe,
),
const SizedBox(height: 6),
_buildMeta(context),
],
);
}
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 = message.status;
IconData icon;
Color color;
if (status == null || status == 'sending' || status == 'pending') {
icon = Symbols.check;
color = Colors.white70;
} else {
switch (status) {
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(0xFF34C759);
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;
const _VoiceMessageBubble({
required this.duration,
required this.url,
required this.textColor,
required this.isMe,
});
@override
State<_VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
}
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
bool _isPlaying = false;
double _progress = 0.0;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
return Container(
width: 220,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
GestureDetector(
onTap: _togglePlay,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: widget.isMe
? Colors.white.withValues(alpha: 0.2)
: cs.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
color: widget.isMe ? Colors.white : cs.primary,
size: 20,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Container(
height: 24,
decoration: BoxDecoration(
color: widget.isMe
? Colors.white.withValues(alpha: 0.2)
: (isDark
? cs.surfaceContainerHighest
: const Color(0xFFD1D1D6)),
borderRadius: BorderRadius.circular(2),
),
),
FractionallySizedBox(
widthFactor: _progress.clamp(0.0, 1.0),
child: Container(
height: 24,
decoration: BoxDecoration(
color: widget.isMe
? Colors.white.withValues(alpha: 0.5)
: cs.primary,
borderRadius: BorderRadius.circular(2),
),
),
),
SizedBox(
height: 24,
child: Center(
child: Text(
_formatDuration(widget.duration),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.8),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
),
),
],
),
);
}
void _togglePlay() {
setState(() {
_isPlaying = !_isPlaying;
});
}
String _formatDuration(int seconds) {
final min = seconds ~/ 60;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}';
}
}