- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
289 lines
10 KiB
Dart
289 lines
10 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|