import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import '../theme.dart'; import '../utils/format.dart'; import 'avatar.dart'; /// 聊天页的消息气泡:文字 / 语音条 / 文件卡片 / 图片四种, /// 外加发送中(小菊花)与发送失败(红色感叹号)两种状态。 class MessageBubble extends StatelessWidget { final Message message; /// 是否己方发送 final bool isMine; /// 己方头像信息 final String selfName; final String? selfFaceURL; /// 对方头像信息(群聊里每条消息带各自发送者信息) final String peerName; final String? peerFaceURL; /// 群聊中是否显示发送者昵称(对方消息) final bool showSenderName; /// 点击语音气泡(播放) final void Function(Message message)? onTapVoice; /// 点击文件气泡(打开 / 下载) final void Function(Message message)? onTapFile; /// 点击红色感叹号重发 final void Function(Message message)? onResend; /// 正在播放的语音消息 ID(用于显示播放中状态,可为空) final String? playingVoiceId; const MessageBubble({ super.key, required this.message, required this.isMine, this.selfName = '', this.selfFaceURL, this.peerName = '', this.peerFaceURL, this.showSenderName = false, this.onTapVoice, this.onTapFile, this.onResend, this.playingVoiceId, }); bool get _failed => message.status == MessageStatus.failed; bool get _sending => message.status == MessageStatus.sending; @override Widget build(BuildContext context) { final avatar = NameAvatar( name: isMine ? selfName : (message.senderNickname?.isNotEmpty == true ? message.senderNickname! : peerName), faceURL: isMine ? selfFaceURL : (message.senderFaceUrl?.isNotEmpty == true ? message.senderFaceUrl : peerFaceURL), size: 40, ); final bubble = ConstrainedBox( constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.65), child: _buildContent(context), ); final statusIcon = _buildStatus(); final row = Row( mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: isMine ? [statusIcon, Flexible(child: bubble), const SizedBox(width: AppGap.x2), avatar] : [avatar, const SizedBox(width: AppGap.x2), Flexible(child: bubble), statusIcon], ); return Padding( padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), child: Column( crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (showSenderName && !isMine && (message.senderNickname?.isNotEmpty == true)) Padding( padding: const EdgeInsets.only(left: 48, bottom: AppGap.x1), child: Text(message.senderNickname!, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)), ), row, // 发送失败提示(效果图:气泡下方灰字) if (_failed) const Padding( padding: EdgeInsets.only(top: AppGap.x1), child: Text( '消息没发出去,点红色感叹号重发', style: TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary), ), ), ], ), ); } /// 发送中小菊花 / 失败红色感叹号 Widget _buildStatus() { if (_sending) { return const Padding( padding: EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x3), child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.textSecondary)), ); } if (_failed) { return Padding( padding: const EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x3), child: GestureDetector( onTap: () => onResend?.call(message), child: const Icon(Icons.error, size: 20, color: AppColors.danger), ), ); } return const SizedBox.shrink(); } Widget _buildContent(BuildContext context) { switch (message.contentType) { case MessageType.voice: return _voiceBubble(); case MessageType.file: return _fileCard(); case MessageType.picture: return _imageBubble(); case MessageType.custom: return _textBubble(FormatUtils.messagePreview(message, isGroup: false)); default: if ((message.contentType ?? 0) >= MessageType.notificationBegin) { // 通知类消息:居中灰字(如入群通知) return const SizedBox.shrink(); } return _textBubble(message.textElem?.content ?? ''); } } /// 文字气泡:己方浅蓝、对方白色 Widget _textBubble(String text) { return Container( padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), decoration: BoxDecoration( color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther, borderRadius: BorderRadius.circular(8), ), child: Text(text, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), ); } /// 语音气泡:喇叭图标 + 秒数;未读语音右侧小红点 Widget _voiceBubble() { final duration = message.soundElem?.duration ?? 0; final playing = playingVoiceId != null && playingVoiceId == message.clientMsgID; // 宽度随时长增加,封顶 final width = 72.0 + (duration > 30 ? 60 : duration * 2); final unread = !isMine && message.isRead == false; return Row( mainAxisSize: MainAxisSize.min, children: [ GestureDetector( onTap: () => onTapVoice?.call(message), child: Container( width: width, padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), decoration: BoxDecoration( color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start, children: isMine ? [ Text('$duration″', style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), const SizedBox(width: AppGap.x2), Icon(playing ? Icons.volume_up : Icons.volume_up_outlined, size: 20, color: AppColors.textPrimary), ] : [ Icon(playing ? Icons.volume_up : Icons.volume_up_outlined, size: 20, color: AppColors.textPrimary), const SizedBox(width: AppGap.x2), Text('$duration″', style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), ], ), ), ), if (unread) Container( width: 8, height: 8, margin: const EdgeInsets.only(left: AppGap.x1), decoration: const BoxDecoration(color: AppColors.danger, shape: BoxShape.circle), ), ], ); } /// 文件气泡:卡片样式(文件图标、文件名、大小) Widget _fileCard() { final name = message.fileElem?.fileName ?? '未知文件'; final size = FormatUtils.fileSize(message.fileElem?.fileSize); return GestureDetector( onTap: () => onTapFile?.call(message), child: Container( padding: const EdgeInsets.all(AppGap.x3), decoration: BoxDecoration( color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: AppColors.pageBg, borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.insert_drive_file_outlined, size: 28, color: AppColors.primary), ), const SizedBox(width: AppGap.x2), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary), ), const SizedBox(height: AppGap.x1), Text(size, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)), ], ), ), ], ), ), ); } /// 图片气泡:本地优先,其次网络图 Widget _imageBubble() { final localPath = message.pictureElem?.sourcePath; final url = message.pictureElem?.snapshotPicture?.url ?? message.pictureElem?.sourcePicture?.url; Widget child; if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) { child = Image.file(File(localPath), fit: BoxFit.cover); } else if (url != null && url.isNotEmpty) { child = Image.network( url, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _imagePlaceholder(), loadingBuilder: (_, c, p) => p == null ? c : _imagePlaceholder(), ); } else { child = _imagePlaceholder(); } return ClipRRect( borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 160, maxHeight: 200), child: child, ), ); } Widget _imagePlaceholder() { return Container( width: 120, height: 120, color: AppColors.searchBg, alignment: Alignment.center, child: const Icon(Icons.image_outlined, size: 40, color: AppColors.textSecondary), ); } }