diff --git a/assets/images/accept_call.png b/assets/images/accept_call.png new file mode 100644 index 0000000..dfed16e Binary files /dev/null and b/assets/images/accept_call.png differ diff --git a/assets/images/plat_voice_message.png b/assets/images/plat_voice_message.png new file mode 100644 index 0000000..6721f54 Binary files /dev/null and b/assets/images/plat_voice_message.png differ diff --git a/assets/images/plat_voice_message_self.png b/assets/images/plat_voice_message_self.png new file mode 100644 index 0000000..19fb251 Binary files /dev/null and b/assets/images/plat_voice_message_self.png differ diff --git a/assets/images/reject_call.png b/assets/images/reject_call.png new file mode 100644 index 0000000..bc3d5d5 Binary files /dev/null and b/assets/images/reject_call.png differ diff --git a/assets/images/send_call.png b/assets/images/send_call.png new file mode 100644 index 0000000..86b46bc Binary files /dev/null and b/assets/images/send_call.png differ diff --git a/assets/images/send_video_call.png b/assets/images/send_video_call.png new file mode 100644 index 0000000..6ce4932 Binary files /dev/null and b/assets/images/send_video_call.png differ diff --git a/assets/images/voice_bg_message.png b/assets/images/voice_bg_message.png new file mode 100644 index 0000000..dfa677a Binary files /dev/null and b/assets/images/voice_bg_message.png differ diff --git a/assets/images/voice_bg_message_self.png b/assets/images/voice_bg_message_self.png new file mode 100644 index 0000000..19a98fe Binary files /dev/null and b/assets/images/voice_bg_message_self.png differ diff --git a/lib/controller/message/call_manager.dart b/lib/controller/message/call_manager.dart new file mode 100644 index 0000000..8c0d59b --- /dev/null +++ b/lib/controller/message/call_manager.dart @@ -0,0 +1,395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:im_flutter_sdk/im_flutter_sdk.dart'; + +import 'chat_controller.dart'; + +/// 通话类型 +enum CallType { + voice, // 语音通话 + video, // 视频通话 +} + +/// 通话状态 +enum CallStatus { + calling, // 通话中 + missed, // 未接听 + cancelled, // 已取消 + rejected, // 已拒绝 + connected, // 已接通(暂时用不到,但保留用于未来扩展) +} + +/// 通话管理器,单例模式,统一管理通话逻辑 +class CallManager extends GetxController { + static CallManager? _instance; + static CallManager get instance { + _instance ??= Get.put(CallManager()); + return _instance!; + } + + // 当前正在进行的通话 + final Rx currentCall = Rx(null); + + // 通话计时器(用于记录通话时长) + Timer? _callTimer; + int _callDurationSeconds = 0; + final RxInt callDurationSeconds = RxInt(0); + + CallManager() { + print('📞 [CallManager] 通话管理器已初始化'); + } + + /// 发起通话 + /// [targetUserId] 目标用户ID + /// [callType] 通话类型:语音或视频 + /// [chatController] 聊天控制器,用于发送通话消息 + Future initiateCall({ + required String targetUserId, + required CallType callType, + ChatController? chatController, + }) async { + try { + if (currentCall.value != null) { + SmartDialog.showToast('已有通话正在进行中'); + return false; + } + + print('📞 [CallManager] 发起${callType == CallType.video ? "视频" : "语音"}通话,目标用户: $targetUserId'); + + // 创建通话会话 + final session = CallSession( + targetUserId: targetUserId, + callType: callType, + status: CallStatus.calling, + isInitiator: true, + startTime: DateTime.now(), + ); + currentCall.value = session; + + // 发送通话消息(发起) + final callTypeStr = callType == CallType.video ? 'video' : 'voice'; + await _sendCallMessage( + targetUserId: targetUserId, + callType: callTypeStr, + callStatus: 'missed', // 初始状态为未接听,等待对方响应 + chatController: chatController, + ); + + // TODO: 这里可以集成实际的通话SDK,发起真正的通话 + // 例如:await RTCManager.instance.startCall(targetUserId, callType); + + return true; + } catch (e) { + print('❌ [CallManager] 发起通话失败: $e'); + SmartDialog.showToast('发起通话失败: $e'); + currentCall.value = null; + return false; + } + } + + /// 接听通话 + /// [message] 通话消息 + /// [chatController] 聊天控制器,用于更新通话消息 + Future acceptCall({ + required EMMessage message, + ChatController? chatController, + }) async { + try { + final callInfo = _parseCallInfo(message); + if (callInfo == null) { + return false; + } + + final targetUserId = message.from ?? ''; + final callTypeStr = callInfo['callType'] as String?; + final callType = callTypeStr == 'video' ? CallType.video : CallType.voice; + + print('📞 [CallManager] 接听${callType == CallType.video ? "视频" : "语音"}通话'); + + // 创建通话会话 + final session = CallSession( + targetUserId: targetUserId, + callType: callType, + status: CallStatus.calling, + isInitiator: false, + startTime: DateTime.now(), + ); + currentCall.value = session; + + // 开始计时 + _startCallTimer(); + + // 更新通话消息状态为通话中 + await _updateCallMessageStatus( + message: message, + callStatus: 'calling', + callDuration: 0, + chatController: chatController, + ); + + // TODO: 这里可以集成实际的通话SDK,接听通话 + // 例如:await RTCManager.instance.acceptCall(targetUserId, callType); + + return true; + } catch (e) { + print('❌ [CallManager] 接听通话失败: $e'); + SmartDialog.showToast('接听通话失败: $e'); + return false; + } + } + + /// 拒绝通话 + /// [message] 通话消息 + /// [chatController] 聊天控制器,用于更新通话消息 + Future rejectCall({ + required EMMessage message, + ChatController? chatController, + }) async { + try { + print('📞 [CallManager] 拒绝通话'); + + // 更新通话消息状态为已拒绝 + await _updateCallMessageStatus( + message: message, + callStatus: 'rejected', + chatController: chatController, + ); + + // 清理通话会话 + currentCall.value = null; + + // TODO: 这里可以集成实际的通话SDK,拒绝通话 + // 例如:await RTCManager.instance.rejectCall(message.from ?? ''); + + return true; + } catch (e) { + print('❌ [CallManager] 拒绝通话失败: $e'); + SmartDialog.showToast('拒绝通话失败: $e'); + return false; + } + } + + /// 取消通话 + /// [message] 通话消息(可选,如果是发起方取消) + /// [chatController] 聊天控制器,用于更新通话消息 + Future cancelCall({ + EMMessage? message, + ChatController? chatController, + }) async { + try { + print('📞 [CallManager] 取消通话'); + + // 如果有消息,更新通话消息状态为已取消 + if (message != null) { + await _updateCallMessageStatus( + message: message, + callStatus: 'cancelled', + chatController: chatController, + ); + } + + // 停止计时 + _stopCallTimer(); + + // 清理通话会话 + currentCall.value = null; + + // TODO: 这里可以集成实际的通话SDK,取消通话 + // 例如:await RTCManager.instance.cancelCall(); + + return true; + } catch (e) { + print('❌ [CallManager] 取消通话失败: $e'); + SmartDialog.showToast('取消通话失败: $e'); + return false; + } + } + + /// 结束通话(通话完成) + /// [callDuration] 通话时长(秒) + /// [chatController] 聊天控制器,用于更新通话消息 + Future endCall({ + required int callDuration, + EMMessage? message, + ChatController? chatController, + }) async { + try { + print('📞 [CallManager] 结束通话,时长: ${callDuration}秒'); + + // 停止计时 + _stopCallTimer(); + + // 如果有消息,更新通话消息状态为通话中(显示时长) + if (message != null) { + await _updateCallMessageStatus( + message: message, + callStatus: 'calling', + callDuration: callDuration, + chatController: chatController, + ); + } + + // 清理通话会话 + currentCall.value = null; + + // TODO: 这里可以集成实际的通话SDK,结束通话 + // 例如:await RTCManager.instance.endCall(); + + return true; + } catch (e) { + print('❌ [CallManager] 结束通话失败: $e'); + SmartDialog.showToast('结束通话失败: $e'); + return false; + } + } + + /// 开始通话计时 + void _startCallTimer() { + _callDurationSeconds = 0; + callDurationSeconds.value = 0; + _callTimer?.cancel(); + _callTimer = Timer.periodic(Duration(seconds: 1), (timer) { + _callDurationSeconds++; + callDurationSeconds.value = _callDurationSeconds; + }); + } + + /// 停止通话计时 + void _stopCallTimer() { + _callTimer?.cancel(); + _callTimer = null; + _callDurationSeconds = 0; + callDurationSeconds.value = 0; + } + + /// 发送通话消息 + Future _sendCallMessage({ + required String targetUserId, + required String callType, + required String callStatus, + int? callDuration, + ChatController? chatController, + }) async { + try { + // 如果提供了 chatController,使用它发送消息 + if (chatController != null) { + return await chatController.sendCallMessage( + callType: callType, + callStatus: callStatus, + callDuration: callDuration, + ); + } + + // 否则直接通过 IMManager 发送 + final callInfoMap = { + 'callType': callType, + 'callStatus': callStatus, + }; + if (callDuration != null) { + callInfoMap['callDuration'] = callDuration; + } + final callInfoJson = jsonEncode(callInfoMap); + final content = '[CALL:]$callInfoJson'; + + final message = EMMessage.createTxtSendMessage( + targetId: targetUserId, + content: content, + ); + + await EMClient.getInstance.chatManager.sendMessage(message); + return true; + } catch (e) { + print('❌ [CallManager] 发送通话消息失败: $e'); + return false; + } + } + + /// 更新通话消息状态 + Future _updateCallMessageStatus({ + required EMMessage message, + required String callStatus, + int? callDuration, + ChatController? chatController, + }) async { + try { + // 解析现有通话信息 + final callInfo = _parseCallInfo(message); + if (callInfo == null) { + return false; + } + + final callType = callInfo['callType'] as String? ?? 'voice'; + final targetUserId = message.from ?? message.to ?? ''; + + // 发送更新的通话消息 + return await _sendCallMessage( + targetUserId: targetUserId, + callType: callType, + callStatus: callStatus, + callDuration: callDuration, + chatController: chatController, + ); + } catch (e) { + print('❌ [CallManager] 更新通话消息状态失败: $e'); + return false; + } + } + + /// 从消息中解析通话信息 + Map? _parseCallInfo(EMMessage message) { + try { + if (message.body.type == MessageType.TXT) { + final textBody = message.body as EMTextMessageBody; + final content = textBody.content; + if (content != null && content.startsWith('[CALL:]')) { + final jsonStr = content.substring(7); // 移除 '[CALL:]' 前缀 + return jsonDecode(jsonStr) as Map; + } + } + } catch (e) { + print('解析通话信息失败: $e'); + } + return null; + } + + /// 检查是否有正在进行的通话 + bool get isInCall => currentCall.value != null; + + /// 获取当前通话时长(秒) + int get currentCallDuration => callDurationSeconds.value; + + @override + void onClose() { + _stopCallTimer(); + currentCall.value = null; + super.onClose(); + } +} + +/// 通话会话信息 +class CallSession { + final String targetUserId; + final CallType callType; + final CallStatus status; + final bool isInitiator; // 是否是发起方 + final DateTime startTime; + DateTime? endTime; + + CallSession({ + required this.targetUserId, + required this.callType, + required this.status, + required this.isInitiator, + required this.startTime, + this.endTime, + }); + + /// 获取通话时长(秒) + int get duration { + final end = endTime ?? DateTime.now(); + return end.difference(startTime).inSeconds; + } +} + diff --git a/lib/controller/message/chat_controller.dart b/lib/controller/message/chat_controller.dart index d653a37..dc40ac2 100644 --- a/lib/controller/message/chat_controller.dart +++ b/lib/controller/message/chat_controller.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'dart:convert'; import '../../im/im_manager.dart'; import 'package:im_flutter_sdk/im_flutter_sdk.dart'; @@ -64,9 +65,10 @@ class ChatController extends GetxController { /// 发送消息 Future sendMessage(String content) async { + EMMessage? tempMessage; try { // 先创建消息对象(即使发送失败也要显示在列表中) - final tempMessage = EMMessage.createTxtSendMessage( + tempMessage = EMMessage.createTxtSendMessage( targetId: userId, content: content, ); @@ -79,16 +81,31 @@ class ChatController extends GetxController { final message = await IMManager.instance.sendTextMessage(content, userId); if (message != null) { // 发送成功,替换临时消息 - final index = messages.indexWhere((msg) => msg.msgId == tempMessage.msgId); + final index = messages.indexWhere((msg) => msg.msgId == tempMessage!.msgId); if (index != -1) { messages[index] = message; } update(); // 更新会话列表 _refreshConversationList(); + + // 如果消息状态仍然是 PROGRESS,说明 SDK 的状态更新是异步的 + // 等待一段时间后再次检查并更新状态 + if (message.status == MessageStatus.PROGRESS) { + // 轮询检查消息状态,直到状态不再是 PROGRESS 或超过3秒 + _checkMessageStatusUntilComplete(message.msgId, maxAttempts: 6); + } + return true; } else { - // 发送失败,消息状态会自动变为FAIL + // 发送失败,更新消息状态 + final index = messages.indexWhere((msg) => msg.msgId == tempMessage!.msgId); + if (index != -1) { + // 等待一下让 SDK 更新状态为 FAIL + Future.delayed(Duration(milliseconds: 300), () { + update(); + }); + } update(); SmartDialog.showToast('消息发送失败,请点击重发'); return false; @@ -97,6 +114,13 @@ class ChatController extends GetxController { if (Get.isLogEnable) { Get.log('发送消息失败: $e'); } + // 发送异常,更新消息状态 + if (tempMessage != null) { + final index = messages.indexWhere((msg) => msg.msgId == tempMessage!.msgId); + if (index != -1) { + update(); + } + } SmartDialog.showToast('消息发送失败: $e'); return false; } @@ -486,6 +510,75 @@ class ChatController extends GetxController { return message.status == MessageStatus.FAIL; } + /// 发送通话消息 + /// [callType] 通话类型:'voice' 语音通话,'video' 视频通话 + /// [callStatus] 通话状态:'calling' 通话中,'missed' 未接听,'cancelled' 已取消,'rejected' 已拒绝 + /// [callDuration] 通话时长(秒),仅在 callStatus 为 'calling' 时有效 + Future sendCallMessage({ + required String callType, // 'voice' 或 'video' + required String callStatus, // 'calling', 'missed', 'cancelled', 'rejected' + int? callDuration, // 通话时长(秒) + }) async { + try { + // 构建通话信息 + final callInfoMap = { + 'callType': callType, + 'callStatus': callStatus, + }; + if (callDuration != null) { + callInfoMap['callDuration'] = callDuration; + } + final callInfoJson = jsonEncode(callInfoMap); + + // 创建文本消息,内容格式为 [CALL:] + JSON + final content = '[CALL:]$callInfoJson'; + + // 先创建消息对象(即使发送失败也要显示在列表中) + final tempMessage = EMMessage.createTxtSendMessage( + targetId: userId, + content: content, + ); + + // 将消息添加到列表末尾(显示发送中状态) + messages.add(tempMessage); + update(); + + // 创建消息并发送 + final messageToSend = EMMessage.createTxtSendMessage( + targetId: userId, + content: content, + ); + + // 直接发送消息 + try { + final sentMessage = await EMClient.getInstance.chatManager.sendMessage(messageToSend); + // 发送成功,替换临时消息 + final index = messages.indexWhere((msg) => msg.msgId == tempMessage.msgId); + if (index != -1) { + messages[index] = sentMessage; + } + update(); + // 更新会话列表 + _refreshConversationList(); + return true; + } catch (e) { + // 发送失败,消息状态会自动变为FAIL + update(); + if (Get.isLogEnable) { + Get.log('发送通话消息失败: $e'); + } + SmartDialog.showToast('通话消息发送失败: $e'); + return false; + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('发送通话消息失败: $e'); + } + SmartDialog.showToast('通话消息发送失败: $e'); + return false; + } + } + /// 重发消息 Future resendMessage(EMMessage failedMessage) async { try { @@ -541,4 +634,27 @@ class ChatController extends GetxController { } } } + + /// 轮询检查消息状态,直到状态不再是 PROGRESS + void _checkMessageStatusUntilComplete(String messageId, {int maxAttempts = 6, int attempt = 0}) { + if (attempt >= maxAttempts) { + // 超过最大尝试次数,停止检查 + return; + } + + Future.delayed(Duration(milliseconds: 500), () { + final index = messages.indexWhere((msg) => msg.msgId == messageId); + if (index != -1) { + final message = messages[index]; + if (message.status != MessageStatus.PROGRESS) { + // 状态已更新,刷新UI + update(); + return; + } else { + // 状态仍然是 PROGRESS,继续检查 + _checkMessageStatusUntilComplete(messageId, maxAttempts: maxAttempts, attempt: attempt + 1); + } + } + }); + } } diff --git a/lib/generated/assets.dart b/lib/generated/assets.dart index b2a99dc..29125fb 100644 --- a/lib/generated/assets.dart +++ b/lib/generated/assets.dart @@ -67,6 +67,7 @@ class Assets { static const String emojiEmoji63 = 'assets/images/emoji/emoji_63.png'; static const String emojiEmoji64 = 'assets/images/emoji/emoji_64.png'; static const String imagesAd = 'assets/images/ad.png'; + static const String imagesAcceptCall = 'assets/images/accept_call.png'; static const String imagesAdd = 'assets/images/add.png'; static const String imagesAliPay = 'assets/images/ali_pay.png'; static const String imagesArrow = 'assets/images/arrow.png'; @@ -138,17 +139,22 @@ class Assets { static const String imagesPhotoUncheck = 'assets/images/photo_uncheck.png'; static const String imagesPlayIcon = 'assets/images/play_icon.png'; static const String imagesPlayer = 'assets/images/player.png'; + static const String imagesPlatVoiceMessage = 'assets/images/plat_voice_message.png'; + static const String imagesPlatVoiceMessageSelf = 'assets/images/plat_voice_message_self.png'; static const String imagesRealChecked = 'assets/images/real_checked.png'; static const String imagesRealName = 'assets/images/real_name.png'; static const String imagesRealUncheck = 'assets/images/real_uncheck.png'; static const String imagesRealnameHelp = 'assets/images/realname_help.png'; + static const String imagesRejectCall = 'assets/images/reject_call.png'; static const String imagesRose = 'assets/images/rose.png'; static const String imagesRoseBanner = 'assets/images/rose_banner.png'; static const String imagesRoseGift = 'assets/images/rose_gift.png'; static const String imagesRoseWhite = 'assets/images/rose_white.png'; static const String imagesSearch = 'assets/images/search.png'; static const String imagesSeat = 'assets/images/seat.png'; + static const String imagesSendCall = 'assets/images/send_call.png'; static const String imagesSendMessageIcon = 'assets/images/send_message_icon.png'; + static const String imagesSendVideoCall = 'assets/images/send_video_call.png'; static const String imagesSetting = 'assets/images/setting.png'; static const String imagesSettingIcon = 'assets/images/setting_icon.png'; static const String imagesShop = 'assets/images/shop.png'; @@ -174,6 +180,8 @@ class Assets { static const String imagesVipVisitor = 'assets/images/vip_visitor.png'; static const String imagesVipVoice = 'assets/images/vip_voice.png'; static const String imagesVoice = 'assets/images/voice.png'; + static const String imagesVoiceBgMessage = 'assets/images/voice_bg_message.png'; + static const String imagesVoiceBgMessageSelf = 'assets/images/voice_bg_message_self.png'; static const String imagesVoiceIcon = 'assets/images/voice_icon.png'; static const String imagesWallet = 'assets/images/wallet.png'; static const String imagesWechatPay = 'assets/images/wechat_pay.png'; diff --git a/lib/pages/message/chat_page.dart b/lib/pages/message/chat_page.dart index da0839e..6ac3441 100644 --- a/lib/pages/message/chat_page.dart +++ b/lib/pages/message/chat_page.dart @@ -6,6 +6,7 @@ import 'package:im_flutter_sdk/im_flutter_sdk.dart'; import '../../controller/message/chat_controller.dart'; import '../../controller/message/voice_player_manager.dart'; +// import '../../controller/message/call_manager.dart'; // 暂时注释 import '../../generated/assets.dart'; import '../../../widget/message/chat_input_bar.dart'; import '../../../widget/message/message_item.dart'; @@ -24,6 +25,8 @@ class _ChatPageState extends State { final ScrollController _scrollController = ScrollController(); bool _isLoadingMore = false; late ChatController _controller; + bool _isInitialLoad = true; // 标记是否是首次加载 + int _previousMessageCount = 0; // 记录之前的消息数量 @override void initState() { @@ -43,18 +46,49 @@ class _ChatPageState extends State { } }); - // 监听消息列表变化,自动滚动到底部(跳过用户信息卡片) + // 监听消息列表变化 _controller.messages.listen((_) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients && _controller.messages.isNotEmpty) { - // 滚动到消息列表的底部(跳过顶部的用户信息卡片) - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - }); + final currentCount = _controller.messages.length; + // 只有在首次加载完成时,或者收到新消息时(消息数量增加),才滚动到底部 + // 但不包括发送消息的情况(因为发送消息时消息数量也会增加) + final shouldScroll = _isInitialLoad || (currentCount > _previousMessageCount && !_controller.messages.last.direction.isSend); + + if (shouldScroll) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients && _controller.messages.isNotEmpty) { + // 滚动到消息列表的底部(跳过顶部的用户信息卡片) + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + // 首次加载完成后,标记为已完成 + if (_isInitialLoad) { + _isInitialLoad = false; + } + _previousMessageCount = currentCount; + }); + } else { + _previousMessageCount = currentCount; + } + }); + + // 首次加载完成后,滚动到底部 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_controller.messages.isNotEmpty) { + Future.delayed(Duration(milliseconds: 100), () { + if (_scrollController.hasClients && mounted) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + _isInitialLoad = false; + _previousMessageCount = _controller.messages.length; + } + }); + } }); } @@ -175,6 +209,24 @@ class _ChatPageState extends State { // 处理视频录制/选择完成,回传文件路径和时长 await controller.sendVideoMessage(filePath, duration); }, + // 语音通话回调(暂时注释) + // onVoiceCall: () async { + // // 发起语音通话 + // await CallManager.instance.initiateCall( + // targetUserId: widget.userId, + // callType: CallType.voice, + // chatController: controller, + // ); + // }, + // 视频通话回调(暂时注释) + // onVideoCall: () async { + // // 发起视频通话 + // await CallManager.instance.initiateCall( + // targetUserId: widget.userId, + // callType: CallType.video, + // chatController: controller, + // ); + // }, ), ], ), diff --git a/lib/widget/message/call_item.dart b/lib/widget/message/call_item.dart new file mode 100644 index 0000000..e4fb1ee --- /dev/null +++ b/lib/widget/message/call_item.dart @@ -0,0 +1,314 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:im_flutter_sdk/im_flutter_sdk.dart'; + +import '../../generated/assets.dart'; + +/// 通话类型 +enum CallType { + voice, // 语音通话 + video, // 视频通话 +} + +/// 通话状态 +enum CallStatus { + calling, // 通话中(显示时长) + missed, // 未接听 + cancelled, // 已取消 + rejected, // 已拒绝 +} + +class CallItem extends StatelessWidget { + final EMMessage message; + final bool isSentByMe; + final bool showTime; + final String formattedTime; + final VoidCallback? onResend; + + const CallItem({ + required this.message, + required this.isSentByMe, + required this.showTime, + required this.formattedTime, + this.onResend, + super.key, + }); + + /// 从消息内容中解析通话信息(使用特殊的JSON格式) + Map? _parseCallInfo() { + try { + if (message.body.type == MessageType.TXT) { + final textBody = message.body as EMTextMessageBody; + final content = textBody.content; + + // 检查是否是通话消息(以 [CALL:] 开头) + if (content != null && content.startsWith('[CALL:]')) { + final jsonStr = content.substring(7); // 移除 '[CALL:]' 前缀 + return jsonDecode(jsonStr) as Map; + } + } + } catch (e) { + print('解析通话信息失败: $e'); + } + return null; + } + + /// 从消息中解析通话类型 + CallType? _getCallType() { + final callInfo = _parseCallInfo(); + if (callInfo != null) { + final callTypeStr = callInfo['callType'] as String?; + if (callTypeStr == 'voice') { + return CallType.voice; + } else if (callTypeStr == 'video') { + return CallType.video; + } + } + return null; + } + + /// 从消息中解析通话状态 + CallStatus? _getCallStatus() { + final callInfo = _parseCallInfo(); + if (callInfo != null) { + final statusStr = callInfo['callStatus'] as String?; + if (statusStr == 'calling') { + return CallStatus.calling; + } else if (statusStr == 'missed') { + return CallStatus.missed; + } else if (statusStr == 'cancelled') { + return CallStatus.cancelled; + } else if (statusStr == 'rejected') { + return CallStatus.rejected; + } + } + return CallStatus.missed; // 默认未接听 + } + + /// 获取通话时长(秒) + int? _getCallDuration() { + final callInfo = _parseCallInfo(); + if (callInfo != null) { + return callInfo['callDuration'] as int?; + } + return null; + } + + /// 格式化通话时长 + String _formatDuration(int seconds) { + final minutes = seconds ~/ 60; + final secs = seconds % 60; + if (minutes > 0) { + return '${minutes}:${secs.toString().padLeft(2, '0')}'; + } else { + return '${secs}秒'; + } + } + + /// 获取图标资源 + String _getIconAsset() { + final callType = _getCallType(); + final callStatus = _getCallStatus(); + + if (isSentByMe) { + // 自己发起的通话 + if (callType == CallType.video) { + return Assets.imagesSendVideoCall; + } else { + return Assets.imagesSendCall; + } + } else { + // 别人发起的通话 + if (callStatus == CallStatus.rejected) { + return Assets.imagesRejectCall; + } else { + return Assets.imagesAcceptCall; + } + } + } + + /// 获取状态文本 + String _getStatusText() { + final callType = _getCallType(); + final callStatus = _getCallStatus(); + final duration = _getCallDuration(); + + if (callStatus == CallStatus.calling && duration != null) { + // 通话中,显示时长 + return _formatDuration(duration); + } else if (callStatus == CallStatus.missed) { + return callType == CallType.video ? '未接听视频通话' : '未接听语音通话'; + } else if (callStatus == CallStatus.cancelled) { + return callType == CallType.video ? '已取消视频通话' : '已取消语音通话'; + } else if (callStatus == CallStatus.rejected) { + return callType == CallType.video ? '已拒绝视频通话' : '已拒绝语音通话'; + } else { + return callType == CallType.video ? '视频通话' : '语音通话'; + } + } + + @override + Widget build(BuildContext context) { + final callType = _getCallType(); + if (callType == null) { + // 如果解析失败,显示默认文本消息 + return SizedBox.shrink(); + } + + final iconAsset = _getIconAsset(); + final statusText = _getStatusText(); + final callStatus = _getCallStatus(); + final isCalling = callStatus == CallStatus.calling; + + return Column( + children: [ + // 显示时间 + if (showTime) _buildTimeLabel(), + Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + child: Row( + mainAxisAlignment: + isSentByMe ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!isSentByMe) _buildAvatar(), + if (!isSentByMe) SizedBox(width: 8.w), + // 发送消息时,状态在左侧 + if (isSentByMe) + Align( + alignment: Alignment.center, + child: Container( + margin: EdgeInsets.only(top: 10.h), + child: _buildMessageStatus(), + ), + ), + if (isSentByMe) SizedBox(width: 10.w), + // 通话消息容器 + Container( + constraints: BoxConstraints(maxWidth: 200.w), + margin: EdgeInsets.only(top: 10.h), + padding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 12.h, + ), + decoration: BoxDecoration( + color: isSentByMe ? Color(0xff8E7BF6) : Colors.white, + borderRadius: BorderRadius.only( + topLeft: + isSentByMe ? Radius.circular(12.w) : Radius.circular(0), + topRight: + isSentByMe ? Radius.circular(0) : Radius.circular(12.w), + bottomLeft: Radius.circular(12.w), + bottomRight: Radius.circular(12.w), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 图标 + Image.asset( + iconAsset, + width: 24.w, + height: 24.w, + fit: BoxFit.contain, + ), + SizedBox(width: 8.w), + // 状态文本 + Flexible( + child: Text( + statusText, + style: TextStyle( + fontSize: 14.sp, + color: isSentByMe ? Colors.white : Colors.black87, + fontWeight: isCalling ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (isSentByMe) SizedBox(width: 8.w), + if (isSentByMe) _buildAvatar(), + ], + ), + ), + ], + ); + } + + // 构建时间标签 + Widget _buildTimeLabel() { + return Container( + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Text( + formattedTime, + style: TextStyle(fontSize: 12.sp, color: Colors.grey), + ), + ), + ); + } + + // 构建头像 + Widget _buildAvatar() { + return Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.w), + image: DecorationImage( + image: AssetImage(Assets.imagesAvatarsExample), + fit: BoxFit.cover, + ), + ), + ); + } + + // 构建消息状态(发送中、已发送、失败重发) + Widget _buildMessageStatus() { + if (!isSentByMe) { + return SizedBox.shrink(); + } + + final status = message.status; + + if (status == MessageStatus.FAIL) { + // 发送失败,显示重发按钮 + return GestureDetector( + onTap: onResend, + child: Container( + width: 20.w, + height: 20.w, + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.refresh, + size: 14.w, + color: Colors.red, + ), + ), + ); + } else if (status == MessageStatus.PROGRESS) { + // 发送中,显示加载动画 + return Container( + width: 16.w, + height: 16.w, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.grey), + ), + ); + } else { + // 发送成功,不显示任何状态 + return SizedBox.shrink(); + } + } +} + diff --git a/lib/widget/message/chat_input_bar.dart b/lib/widget/message/chat_input_bar.dart index 094ab26..dffb0c3 100644 --- a/lib/widget/message/chat_input_bar.dart +++ b/lib/widget/message/chat_input_bar.dart @@ -14,12 +14,16 @@ class ChatInputBar extends StatefulWidget { final ValueChanged>? onImageSelected; final Function(String filePath, int seconds)? onVoiceRecorded; final Function(String filePath, int duration)? onVideoRecorded; + final VoidCallback? onVoiceCall; // 语音通话回调 + final VoidCallback? onVideoCall; // 视频通话回调 const ChatInputBar({ required this.onSendMessage, this.onImageSelected, this.onVoiceRecorded, this.onVideoRecorded, + this.onVoiceCall, + this.onVideoCall, super.key, }); @@ -29,6 +33,7 @@ class ChatInputBar extends StatefulWidget { class _ChatInputBarState extends State { final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); bool _isMoreOptionsVisible = false; bool _isVoiceVisible = false; bool _isVideoVisible = false; @@ -108,6 +113,38 @@ class _ChatInputBarState extends State { }); } + // 关闭所有控制面板 + void _closeAllPanels() { + if (!mounted) return; + if (_isMoreOptionsVisible || _isVoiceVisible || _isVideoVisible || _isEmojiVisible) { + setState(() { + _isMoreOptionsVisible = false; + _isVoiceVisible = false; + _isVideoVisible = false; + _isEmojiVisible = false; + }); + } + } + + @override + void initState() { + super.initState(); + // 监听输入框焦点变化 + _focusNode.addListener(() { + if (_focusNode.hasFocus && mounted) { + // 输入框获得焦点(键盘弹起),关闭所有控制面板 + _closeAllPanels(); + } + }); + } + + @override + void dispose() { + _focusNode.dispose(); + _textController.dispose(); + super.dispose(); + } + void _handleEmojiSelected(EmojiItem emoji) { // 将表情添加到输入框 final currentText = _textController.text; @@ -203,6 +240,7 @@ class _ChatInputBarState extends State { // 真实的输入框 TextField( controller: _textController, + focusNode: _focusNode, decoration: InputDecoration( border: InputBorder.none, hintText: "请输入聊天内容~", @@ -267,18 +305,34 @@ class _ChatInputBarState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - // 语音按钮 + // 语音消息按钮 Image.asset( Assets.imagesAudio, width: 24.w, height: 24.w, ).onTap(_toggleVoiceOptions), - // 视频按钮 + // 视频消息按钮 Image.asset( Assets.imagesVideo, width: 24.w, height: 24.w, ).onTap(_toggleVideoOptions), + // 语音通话按钮(暂时注释) + // Image.asset( + // Assets.imagesSendCall, + // width: 24.w, + // height: 24.w, + // ).onTap(() { + // widget.onVoiceCall?.call(); + // }), + // 视频通话按钮(暂时注释) + // Image.asset( + // Assets.imagesSendVideoCall, + // width: 24.w, + // height: 24.w, + // ).onTap(() { + // widget.onVideoCall?.call(); + // }), // 礼物按钮 Image.asset(Assets.imagesGift, width: 24.w, height: 24.w), // 表情按钮 diff --git a/lib/widget/message/message_item.dart b/lib/widget/message/message_item.dart index b29f011..82d835d 100644 --- a/lib/widget/message/message_item.dart +++ b/lib/widget/message/message_item.dart @@ -7,6 +7,7 @@ import 'text_item.dart'; import 'image_item.dart'; import 'voice_item.dart'; import 'video_item.dart'; +import 'call_item.dart'; import '../../controller/message/chat_controller.dart'; class MessageItem extends StatelessWidget { @@ -21,10 +22,46 @@ class MessageItem extends StatelessWidget { super.key, }); + // 检查是否是通话消息(通过消息内容识别) + bool _isCallMessage() { + try { + if (message.body.type == MessageType.TXT) { + final textBody = message.body as EMTextMessageBody; + final content = textBody.content; + // 检查是否是通话消息(以 [CALL:] 开头) + if (content != null && content.startsWith('[CALL:]')) { + return true; + } + } + } catch (e) { + // 解析失败,不是通话消息 + } + return false; + } + @override Widget build(BuildContext context) { print('📨 [MessageItem] 渲染消息,类型: ${message.body.type}'); + // 处理通话消息(通过文本消息的扩展属性识别) + if (message.body.type == MessageType.TXT && _isCallMessage()) { + return CallItem( + message: message, + isSentByMe: isSentByMe, + showTime: shouldShowTime(), + formattedTime: formatMessageTime(message.serverTime), + onResend: () { + // 通过Get找到ChatController并调用重发方法 + try { + final controller = Get.find(); + controller.resendMessage(message); + } catch (e) { + print('重发消息失败: $e'); + } + }, + ); + } + // 处理文本消息 if (message.body.type == MessageType.TXT) { final textBody = message.body as EMTextMessageBody;