From 24861e271806193601c4d41ccfba946f242fdab2 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Fri, 26 Dec 2025 21:41:16 +0800 Subject: [PATCH 01/21] =?UTF-8?q?feat(live):=20=E6=B7=BB=E5=8A=A0=E8=BF=9E?= =?UTF-8?q?=E9=BA=A6=E5=8D=A1=E7=89=87=E5=92=8C=E7=8E=AB=E7=91=B0=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 API URL 中添加获取用户连麦卡片的接口 - 实现房间控制器中的连麦卡片和玫瑰数量响应式变量 - 添加获取用户道具连麦卡片和虚拟账户信息的网络请求方法 - 在直播页面中加载连麦卡片信息和玫瑰数量 - 实现连麦卡片文本显示功能,仅对男性用户在非直播状态显示 - 更新礼物弹窗中玫瑰数量的动态显示 - 生成对应的网络 API 代码实现 --- lib/controller/discover/room_controller.dart | 43 +++++++++++++++++++ lib/network/api_urls.dart | 3 ++ lib/network/rtc_api.dart | 5 +++ lib/network/rtc_api.g.dart | 35 +++++++++++++++ lib/pages/discover/live_room_page.dart | 19 ++++++++ lib/widget/live/live_gift_popup.dart | 14 ++++-- .../live/live_room_notice_chat_panel.dart | 39 ++++++++++++++--- 7 files changed, 147 insertions(+), 11 deletions(-) diff --git a/lib/controller/discover/room_controller.dart b/lib/controller/discover/room_controller.dart index 5e31248..857427e 100644 --- a/lib/controller/discover/room_controller.dart +++ b/lib/controller/discover/room_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:dating_touchme_app/controller/global.dart'; import 'package:dating_touchme_app/model/live/gift_product_model.dart'; +import 'package:dating_touchme_app/model/rtc/link_mic_card_model.dart'; import 'package:dating_touchme_app/model/rtc/rtc_channel_data.dart'; import 'package:dating_touchme_app/model/rtc/rtc_channel_detail.dart'; import 'package:dating_touchme_app/network/network_service.dart'; @@ -49,6 +50,12 @@ class RoomController extends GetxController with WidgetsBindingObserver { /// 礼物产品列表 final RxList giftProducts = [].obs; + /// 连麦卡片信息 + final Rxn linkMicCard = Rxn(); + + /// 玫瑰数量 + final RxInt roseCount = 0.obs; + /// 消息服务实例 final LiveChatMessageService _messageService = LiveChatMessageService.instance; @@ -631,4 +638,40 @@ class RoomController extends GetxController with WidgetsBindingObserver { SmartDialog.showToast('踢出用户失败'); } } + + /// 获取用户道具连麦卡片 + Future getUserPropLinkMicCard() async { + try { + final response = await _networkService.rtcApi.getUserPropLinkMicCard(); + final base = response.data; + if (base.isSuccess && base.data != null) { + linkMicCard.value = base.data; + print('✅ 获取连麦卡片成功: type=${base.data!.type}, num=${base.data!.num}, unitSellingPrice=${base.data!.unitSellingPrice}'); + } else { + linkMicCard.value = null; + print('❌ 获取连麦卡片失败: ${base.message}'); + } + } catch (e) { + linkMicCard.value = null; + print('❌ 获取连麦卡片异常: $e'); + } + } + + /// 获取虚拟账户(玫瑰数量) + Future getVirtualAccount() async { + try { + final response = await _networkService.userApi.getVirtualAccount({}); + final base = response.data; + if (base.isSuccess && base.data != null) { + roseCount.value = base.data!.balance ?? 0; + print('✅ 获取玫瑰数量成功: ${roseCount.value}'); + } else { + roseCount.value = 0; + print('❌ 获取玫瑰数量失败: ${base.message}'); + } + } catch (e) { + roseCount.value = 0; + print('❌ 获取玫瑰数量异常: $e'); + } + } } diff --git a/lib/network/api_urls.dart b/lib/network/api_urls.dart index 4c7da72..603ce0c 100644 --- a/lib/network/api_urls.dart +++ b/lib/network/api_urls.dart @@ -125,4 +125,7 @@ class ApiUrls { static const String listVisitorInfo = 'dating-agency-service/user/page/dongwo/visitor-info'; + + static const String getUserPropLinkMicCard = + 'dating-agency-chat-audio/user/get/user-prop/link-mic-card'; } diff --git a/lib/network/rtc_api.dart b/lib/network/rtc_api.dart index 7abf0d5..e3dc9a6 100644 --- a/lib/network/rtc_api.dart +++ b/lib/network/rtc_api.dart @@ -1,5 +1,6 @@ import 'package:dating_touchme_app/model/discover/rtc_channel_model.dart'; import 'package:dating_touchme_app/model/live/gift_product_model.dart'; +import 'package:dating_touchme_app/model/rtc/link_mic_card_model.dart'; import 'package:dating_touchme_app/model/rtc/rtc_channel_data.dart'; import 'package:dating_touchme_app/model/rtc/rtc_channel_detail.dart'; import 'package:dating_touchme_app/network/api_urls.dart'; @@ -88,4 +89,8 @@ abstract class RtcApi { Future>> kickingRtcChannelUser( @Body() Map data, ); + + /// 获取用户道具连麦卡片 + @GET(ApiUrls.getUserPropLinkMicCard) + Future>> getUserPropLinkMicCard(); } diff --git a/lib/network/rtc_api.g.dart b/lib/network/rtc_api.g.dart index 8f10ee6..fa855f3 100644 --- a/lib/network/rtc_api.g.dart +++ b/lib/network/rtc_api.g.dart @@ -501,6 +501,41 @@ class _RtcApi implements RtcApi { return httpResponse; } + @override + Future>> + getUserPropLinkMicCard() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'dating-agency-chat-audio/user/get/user-prop/link-mic-card', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl), + ), + ); + final _result = await _dio.fetch>(_options); + late BaseResponse _value; + try { + _value = BaseResponse.fromJson( + _result.data!, + (json) => LinkMicCardModel.fromJson(json as Map), + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index 301e7de..f8db0eb 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -1,4 +1,5 @@ import 'package:dating_touchme_app/controller/discover/room_controller.dart'; +import 'package:dating_touchme_app/controller/global.dart'; import 'package:dating_touchme_app/controller/overlay_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -47,6 +48,24 @@ class _LiveRoomPageState extends State { _overlayController = Get.find(); // 启用屏幕常亮 WakelockPlus.enable(); + // 如果当前用户是男性,请求连麦卡片信息 + _loadLinkMicCard(); + // 请求玫瑰数量 + _loadRoseCount(); + } + + /// 加载连麦卡片信息(仅男性用户) + Future _loadLinkMicCard() async { + final userData = GlobalData().userData; + if (userData?.genderCode == 0) { + // 男性用户才请求 + await _roomController.getUserPropLinkMicCard(); + } + } + + /// 加载玫瑰数量 + Future _loadRoseCount() async { + await _roomController.getVirtualAccount(); } @override diff --git a/lib/widget/live/live_gift_popup.dart b/lib/widget/live/live_gift_popup.dart index 1e6c37b..23bf623 100644 --- a/lib/widget/live/live_gift_popup.dart +++ b/lib/widget/live/live_gift_popup.dart @@ -366,10 +366,16 @@ class _LiveGiftPopupState extends State { children: [ Image.asset(Assets.imagesRoseGift, width: 21.w, height: 21.w), SizedBox(width: 8.w), - Text( - "9", - style: TextStyle(fontSize: 13.w, color: Colors.white), - ), + Obx(() { + final roomController = Get.isRegistered() + ? Get.find() + : null; + final roseCount = roomController?.roseCount.value ?? 0; + return Text( + roseCount.toString(), + style: TextStyle(fontSize: 13.w, color: Colors.white), + ); + }), SizedBox(width: 12.w), ], ), diff --git a/lib/widget/live/live_room_notice_chat_panel.dart b/lib/widget/live/live_room_notice_chat_panel.dart index 30aa6ef..970ddbe 100644 --- a/lib/widget/live/live_room_notice_chat_panel.dart +++ b/lib/widget/live/live_room_notice_chat_panel.dart @@ -31,6 +31,36 @@ class _LiveRoomNoticeChatPanelState extends State { super.dispose(); } + /// 构建连麦卡片文本(仅男性用户显示) + Widget _buildLinkMicCardText() { + final userData = GlobalData().userData; + final isMale = userData?.genderCode == 0; + + // 女性用户不显示 + if (!isMale) { + return const SizedBox(); + } + + // 使用 Obx 监听连麦卡片数据和直播状态变化 + return Obx(() { + // 直播状态下不显示 + if (controller.isLive.value) { + return const SizedBox(); + } + + final linkMicCard = controller.linkMicCard.value; + final cardNum = linkMicCard?.num ?? 0; + + return Text( + '剩余$cardNum张相亲卡', + style: TextStyle( + fontSize: 9.w, + color: Colors.white.withOpacity(0.8), + ), + ); + }); + } + @override Widget build(BuildContext context) { return Container( @@ -111,13 +141,8 @@ class _LiveRoomNoticeChatPanelState extends State { ), ), SizedBox(height: 2.w), - controller.isLive.value ? const SizedBox() :Text( - '剩余2张相亲卡', - style: TextStyle( - fontSize: 9.w, - color: Colors.white.withOpacity(0.8), - ), - ), + // 只有男性用户且在非直播状态下才显示剩余相亲卡 + _buildLinkMicCardText(), ], ), ], From adf838e7731f3e223eda4a2adf92a102bdbfffab Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Fri, 26 Dec 2025 22:50:02 +0800 Subject: [PATCH 02/21] =?UTF-8?q?feat(video-call):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=80=9A=E8=AF=9D=E5=B0=8F=E7=AA=97=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在OverlayController中添加视频通话overlay显示控制 - 实现VideoCallOverlayWidget组件用于视频通话小窗显示 - 在视频通话页面添加最小化到小窗功能 - 添加PopScope防止手势返回中断通话 - 优化IMManager中的空值检查和日志输出 - 改进直播房间邀请消息的头像URL处理 - 更新房间项目的封面图片获取逻辑和占位符样式 --- lib/controller/overlay_controller.dart | 37 ++++++++++-- lib/im/im_manager.dart | 14 +---- lib/main.dart | 31 +++++++--- lib/pages/message/video_call_page.dart | 58 ++++++++++++------- .../live/live_room_guest_list_dialog.dart | 10 +++- lib/widget/message/room_item.dart | 51 +++++++++------- pubspec.yaml | 3 +- 7 files changed, 131 insertions(+), 73 deletions(-) diff --git a/lib/controller/overlay_controller.dart b/lib/controller/overlay_controller.dart index fe7f851..f6e8d28 100644 --- a/lib/controller/overlay_controller.dart +++ b/lib/controller/overlay_controller.dart @@ -2,22 +2,49 @@ import 'package:get/get.dart'; /// 全局 Overlay 控制器 class OverlayController extends GetxController { - /// overlay 是否显示 + /// overlay 是否显示(直播房间) final showOverlay = false.obs; - /// 显示 overlay + /// 视频通话 overlay 是否显示 + final showVideoCallOverlay = false.obs; + + /// 视频通话信息 + String? videoCallTargetUserId; + String? videoCallTargetUserName; + String? videoCallTargetAvatarUrl; + + /// 显示 overlay(直播房间) void show() { showOverlay.value = true; } - /// 隐藏 overlay + /// 隐藏 overlay(直播房间) void hide() { showOverlay.value = false; } - /// 切换 overlay 显示状态 + /// 切换 overlay 显示状态(直播房间) void toggle() { showOverlay.value = !showOverlay.value; } -} + /// 显示视频通话 overlay + void showVideoCall({ + required String targetUserId, + String? targetUserName, + String? targetAvatarUrl, + }) { + videoCallTargetUserId = targetUserId; + videoCallTargetUserName = targetUserName; + videoCallTargetAvatarUrl = targetAvatarUrl; + showVideoCallOverlay.value = true; + } + + /// 隐藏视频通话 overlay + void hideVideoCall() { + showVideoCallOverlay.value = false; + videoCallTargetUserId = null; + videoCallTargetUserName = null; + videoCallTargetAvatarUrl = null; + } +} diff --git a/lib/im/im_manager.dart b/lib/im/im_manager.dart index 1940974..224c538 100644 --- a/lib/im/im_manager.dart +++ b/lib/im/im_manager.dart @@ -243,9 +243,7 @@ class IMManager { // 如果获取到 revenueInfo,确保存储到消息的 attributes 中(用于UI显示) if (revenueInfo != null && revenueInfo.isNotEmpty) { - if (message.attributes == null) { - message.attributes = {}; - } + message.attributes ??= {}; // 将 revenueInfo 存储到 coin_value 中,以便UI组件可以直接使用 message.attributes!['coin_value'] = revenueInfo; @@ -1478,10 +1476,6 @@ class IMManager { final notification = _notificationQueue.removeAt(0); _isShowingNotification = true; - if (Get.isLogEnable) { - Get.log('✅ [IMManager] 显示消息通知弹框: fromId=${notification.fromId}, nickName=${notification.nickName}, 剩余队列长度=${_notificationQueue.length}'); - } - // 显示弹框(从上方弹出) SmartDialog.show( builder: (context) { @@ -1531,10 +1525,6 @@ class IMManager { _isShowingNotification = false; - if (Get.isLogEnable) { - Get.log('✅ [IMManager] 消息通知弹框已关闭,剩余队列长度=${_notificationQueue.length}'); - } - // 延迟一小段时间后显示下一条消息(确保动画完成) Future.delayed(Duration(milliseconds: 300), () { if (!_isShowingNotification) { @@ -1570,7 +1560,7 @@ class IMManager { } // 检查是否是GIFT消息 - if (content != null && content.startsWith('[GIFT:]')) { + if (content.startsWith('[GIFT:]')) { return '[礼物]'; } diff --git a/lib/main.dart b/lib/main.dart index 316c7a9..a3e2bc0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:dating_touchme_app/pages/mine/login_page.dart'; import 'package:dating_touchme_app/pages/setting/teenager_mode_page.dart'; import 'package:dating_touchme_app/rtc/rtc_manager.dart'; import 'package:dating_touchme_app/widget/live/draggable_overlay_widget.dart'; +import 'package:dating_touchme_app/widget/message/video_call_overlay_widget.dart'; import 'package:dating_touchme_app/widget/user_agreement_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -125,15 +126,27 @@ void main() async { try { if (Get.isRegistered()) { final overlayController = Get.find(); - return overlayController.showOverlay.value - ? DraggableOverlayWidget( - size: 60, - backgroundColor: const Color.fromRGBO(0, 0, 0, 0.6), - onClose: () { - overlayController.hide(); - }, - ) - : const SizedBox.shrink(); + // 视频通话小窗 + if (overlayController.showVideoCallOverlay.value) { + return VideoCallOverlayWidget( + targetUserId: overlayController.videoCallTargetUserId ?? '', + targetUserName: overlayController.videoCallTargetUserName, + targetAvatarUrl: overlayController.videoCallTargetAvatarUrl, + onClose: () { + overlayController.hideVideoCall(); + }, + ); + } + // 直播房间小窗 + if (overlayController.showOverlay.value) { + return DraggableOverlayWidget( + size: 60, + backgroundColor: const Color.fromRGBO(0, 0, 0, 0.6), + onClose: () { + overlayController.hide(); + }, + ); + } } } catch (e) { print('获取OverlayController失败: $e'); diff --git a/lib/pages/message/video_call_page.dart b/lib/pages/message/video_call_page.dart index 3fbf119..6489454 100644 --- a/lib/pages/message/video_call_page.dart +++ b/lib/pages/message/video_call_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../../controller/message/call_manager.dart'; import '../../controller/message/conversation_controller.dart'; +import '../../controller/overlay_controller.dart'; import '../../model/home/marriage_data.dart'; /// 视频通话页面 @@ -237,25 +238,32 @@ class _VideoCallPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - // 背景视频/头像(模糊) - _buildBackground(), - - // 最小化按钮(左上角) - _buildMinimizeButton(), - - // 用户信息 - _buildUserInfo(), - - // 通话时长 - _buildCallDuration(), - - // 底部控制按钮 - _buildControlButtons(), - ], + return PopScope( + canPop: false, // 禁止手势返回 + onPopInvoked: (didPop) { + // 已经禁止返回,所以这里不会被调用 + // 如果需要返回,应该通过挂断按钮或其他明确的操作 + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // 背景视频/头像(模糊) + _buildBackground(), + + // 最小化按钮(左上角) + _buildMinimizeButton(), + + // 用户信息 + _buildUserInfo(), + + // 通话时长 + _buildCallDuration(), + + // 底部控制按钮 + _buildControlButtons(), + ], + ), ), ); } @@ -274,8 +282,16 @@ class _VideoCallPageState extends State { /// 最小化通话 void _minimizeCall() { - // TODO: 实现最小化逻辑 - // 可以返回上一页,或者显示一个小窗口 + // 显示视频通话小窗 + if (Get.isRegistered()) { + final overlayController = Get.find(); + overlayController.showVideoCall( + targetUserId: widget.targetUserId, + targetUserName: _targetUserName, + targetAvatarUrl: _targetAvatarUrl, + ); + } + // 返回上一页 Get.back(); } diff --git a/lib/widget/live/live_room_guest_list_dialog.dart b/lib/widget/live/live_room_guest_list_dialog.dart index 116644c..4bd220f 100644 --- a/lib/widget/live/live_room_guest_list_dialog.dart +++ b/lib/widget/live/live_room_guest_list_dialog.dart @@ -627,15 +627,21 @@ class _LiveRoomGuestListDialogState extends State { final channelDetail = roomController.rtcChannelDetail.value; final anchorName = channelDetail?.anchorInfo?.nickName ?? '主持人'; final anchorAvatar = channelDetail?.anchorInfo?.profilePhoto ?? ''; + + // 确保头像和封面URL不为空且格式正确 + final cleanedAvatar = anchorAvatar.trim().replaceAll('`', ''); + final cleanedCover = cleanedAvatar; // 封面使用主持人头像 // 构建消息体,包含房间信息 final messageData = { 'type': 'live_room_invite', 'channelId': channelId, - 'anchorAvatar': anchorAvatar, + 'anchorAvatar': cleanedAvatar, 'anchorName': anchorName, - 'coverImage': anchorAvatar, // 封面使用主持人头像 + 'coverImage': cleanedCover, // 封面使用主持人头像 }; + + print('📤 [LiveRoomGuestListDialog] 发送房间邀请消息: anchorAvatar=$cleanedAvatar, coverImage=$cleanedCover'); // 发送自定义消息 final result = await IMManager.instance.sendCustomMessage( diff --git a/lib/widget/message/room_item.dart b/lib/widget/message/room_item.dart index 4c63705..7678e3d 100644 --- a/lib/widget/message/room_item.dart +++ b/lib/widget/message/room_item.dart @@ -70,8 +70,10 @@ class RoomItem extends StatelessWidget { String _getCoverImage() { final roomInfo = _parseRoomInfo(); // 优先使用封面图片,如果没有则使用头像 - final coverImage = roomInfo?['anchorAvatar'] ?? ''; - return coverImage.trim().replaceAll('`', ''); + final coverImage = roomInfo?['coverImage'] ?? roomInfo?['anchorAvatar'] ?? ''; + final cleanedUrl = coverImage.trim().replaceAll('`', ''); + print('📸 [RoomItem] 封面图片URL: $cleanedUrl'); + return cleanedUrl; } /// 处理点击事件 @@ -124,7 +126,7 @@ class RoomItem extends StatelessWidget { final anchorName = _getAnchorName(); final anchorAvatar = _getAnchorAvatar(); - final coverImage = _getAnchorAvatar(); + final coverImage = _getCoverImage(); return Column( children: [ @@ -209,6 +211,8 @@ class RoomItem extends StatelessWidget { }, ) : Container( + width: 150.w, + height: 150.w, color: Colors.grey[200], child: Icon( Icons.live_tv, @@ -269,27 +273,30 @@ class RoomItem extends StatelessWidget { width: 24.w, height: 24.w, fit: BoxFit.cover, - placeholder: (context, url) => - Container( - color: Colors.grey[300], - child: Center( - child: SizedBox( - width: 12.w, - height: 12.w, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.grey[600], - ), - ), + placeholder: (context, url) => Container( + width: 24.w, + height: 24.w, + color: Colors.grey[300], + child: Center( + child: SizedBox( + width: 12.w, + height: 12.w, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.grey[600], ), ), - errorWidget: (context, url, error) => - Image.asset( - Assets.imagesUserAvatar, - width: 24.w, - height: 24.w, - fit: BoxFit.cover, - ), + ), + ), + errorWidget: (context, url, error) { + print('❌ [RoomItem] 头像加载失败: $url, error: $error'); + return Image.asset( + Assets.imagesUserAvatar, + width: 24.w, + height: 24.w, + fit: BoxFit.cover, + ); + }, ) : Image.asset( Assets.imagesUserAvatar, diff --git a/pubspec.yaml b/pubspec.yaml index aea3178..7be9d96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -108,8 +108,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - assets/images/ - - assets/images/emoji/ + - build/app/outputs/flutter-apk/ # - images/a_dot_ham.jpeg # An images asset can refer to one or more resolution-specific "variants", see From e175fe55dee4416c0e699063e3f92a7407446399 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Fri, 26 Dec 2025 22:53:39 +0800 Subject: [PATCH 03/21] =?UTF-8?q?feat(widget):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=BF=9E=E9=BA=A6=E5=8D=A1=E7=89=87=E6=A8=A1=E5=9E=8B=E5=92=8C?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=80=9A=E8=AF=9D=E5=B0=8F=E7=AA=97=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 LinkMicCardModel 类用于连麦卡片数据结构 - 实现连麦卡片模型的 JSON 序列化和反序列化 - 添加 VideoCallOverlayWidget 组件用于视频通话小窗显示 - 实现小窗拖拽和边缘吸附功能 - 集成通话时长显示和用户信息展示 - 添加点击小窗返回视频通话页面的功能 --- lib/model/rtc/link_mic_card_model.dart | 35 ++++ .../message/video_call_overlay_widget.dart | 195 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 lib/model/rtc/link_mic_card_model.dart create mode 100644 lib/widget/message/video_call_overlay_widget.dart diff --git a/lib/model/rtc/link_mic_card_model.dart b/lib/model/rtc/link_mic_card_model.dart new file mode 100644 index 0000000..83b55b8 --- /dev/null +++ b/lib/model/rtc/link_mic_card_model.dart @@ -0,0 +1,35 @@ +/// 连麦卡片模型 +class LinkMicCardModel { + final int type; + final int num; + final int? unitSellingPrice; + + LinkMicCardModel({ + required this.type, + required this.num, + this.unitSellingPrice, + }); + + factory LinkMicCardModel.fromJson(Map json) { + + return LinkMicCardModel( + type: json['type'] as int? ?? 0, + num: json['num'] as int? ?? 0, + unitSellingPrice: json['unitSellingPrice'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'type': type, + 'num': num, + 'unitSellingPrice': unitSellingPrice, + }; + } + + @override + String toString() { + return 'LinkMicCardModel(type: $type, num: $num, unitSellingPrice: $unitSellingPrice)'; + } +} + diff --git a/lib/widget/message/video_call_overlay_widget.dart b/lib/widget/message/video_call_overlay_widget.dart new file mode 100644 index 0000000..db82ade --- /dev/null +++ b/lib/widget/message/video_call_overlay_widget.dart @@ -0,0 +1,195 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dating_touchme_app/controller/message/call_manager.dart'; +import 'package:dating_touchme_app/extension/ex_widget.dart'; +import 'package:dating_touchme_app/generated/assets.dart'; +import 'package:dating_touchme_app/pages/message/video_call_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +/// 视频通话小窗组件 +class VideoCallOverlayWidget extends StatefulWidget { + final VoidCallback? onClose; + final String targetUserId; + final String? targetUserName; + final String? targetAvatarUrl; + + const VideoCallOverlayWidget({ + super.key, + this.onClose, + required this.targetUserId, + this.targetUserName, + this.targetAvatarUrl, + }); + + @override + State createState() => _VideoCallOverlayWidgetState(); +} + +class _VideoCallOverlayWidgetState extends State { + Offset _position = Offset.zero; + bool _isDragging = false; + final CallManager _callManager = CallManager.instance; + + @override + void initState() { + super.initState(); + // 初始位置设置为右上角 + WidgetsBinding.instance.addPostFrameCallback((_) { + final size = MediaQuery.of(context).size; + setState(() { + _position = Offset( + size.width - 100.w, + 100, + ); + }); + }); + } + + /// 吸附到边缘 + void _snapToEdge(double screenWidth) { + final centerX = screenWidth / 2; + final targetX = _position.dx < centerX + ? 0.0 + : screenWidth - 100.w; + + setState(() { + _position = Offset(targetX, _position.dy); + _isDragging = false; + }); + } + + /// 格式化通话时长 + String _formatDuration(int seconds) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final minutes = twoDigits(seconds ~/ 60); + final secs = twoDigits(seconds % 60); + return '$minutes:$secs'; + } + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + + return AnimatedPositioned( + duration: _isDragging + ? const Duration(milliseconds: 50) + : const Duration(milliseconds: 300), + curve: _isDragging ? Curves.linear : Curves.easeOut, + left: _position.dx, + top: _position.dy, + child: _buildContent(screenSize), + ); + } + + Widget _buildContent(Size screenSize) { + return GestureDetector( + onPanStart: (details) { + setState(() { + _isDragging = true; + }); + }, + onPanUpdate: (details) { + setState(() { + _position += details.delta; + _position = Offset( + _position.dx.clamp(0.0, screenSize.width - 100.w), + _position.dy.clamp(0.0, screenSize.height - 100.w), + ); + }); + }, + onPanEnd: (details) { + _snapToEdge(screenSize.width); + }, + child: Obx(() { + final callSession = _callManager.currentCall.value; + final isConnected = callSession != null && _callManager.callDurationSeconds.value > 0; + final callDuration = _callManager.callDurationSeconds.value; + + return Container( + width: 100.w, + height: 100.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.w), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.w), + child: Stack( + children: [ + // 背景:头像 + Container( + width: 100.w, + height: 100.w, + color: Colors.black, + child: widget.targetAvatarUrl != null && widget.targetAvatarUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: widget.targetAvatarUrl!, + fit: BoxFit.cover, + errorWidget: (context, url, error) => Image.asset( + Assets.imagesUserAvatar, + fit: BoxFit.cover, + ), + ) + : Image.asset( + Assets.imagesUserAvatar, + fit: BoxFit.cover, + ), + ), + // 半透明遮罩 + Container( + color: Colors.black.withOpacity(0.4), + ), + // 内容:昵称和状态 + Positioned( + bottom: 8.w, + left: 4.w, + right: 4.w, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 昵称 + Text( + widget.targetUserName ?? widget.targetUserId, + style: TextStyle( + color: Colors.white, + fontSize: 10.sp, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2.h), + // 通话状态 + Text( + isConnected ? _formatDuration(callDuration) : '通话中', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 8.sp, + ), + ), + ], + ), + ), + ], + ), + ), + ).onTap(() { + // 点击小窗,返回视频通话页面 + Get.to(() => VideoCallPage( + targetUserId: widget.targetUserId, + isInitiator: callSession?.isInitiator ?? true, + )); + widget.onClose?.call(); + }); + }), + ); + } +} + From 03545aeb1ca84e5f18e4c1f95c1b7c2ede0a1edd Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Fri, 26 Dec 2025 23:56:24 +0800 Subject: [PATCH 04/21] =?UTF-8?q?feat(im):=20=E6=B7=BB=E5=8A=A0=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=80=9A=E8=AF=9D=E9=82=80=E8=AF=B7=E5=BC=B9=E6=A1=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在IMManager中添加视频通话消息处理逻辑 - 解析CALL类型消息并显示视频通话邀请弹框 - 实现通话邀请的接听、拒绝和跳转功能 - 添加VideoCallInviteDialog组件用于显示通话邀请 - 优化消息通知弹框的边距样式 - 在pubspec.yaml中添加必要的资源文件路径配置 --- lib/im/im_manager.dart | 159 ++++++++++++++++++ .../message/message_notification_dialog.dart | 2 +- .../message/video_call_invite_dialog.dart | 157 +++++++++++++++++ pubspec.yaml | 8 +- 4 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 lib/widget/message/video_call_invite_dialog.dart diff --git a/lib/im/im_manager.dart b/lib/im/im_manager.dart index 224c538..e15b80a 100644 --- a/lib/im/im_manager.dart +++ b/lib/im/im_manager.dart @@ -17,6 +17,9 @@ import '../pages/mine/login_page.dart'; import '../pages/message/chat_page.dart'; import '../network/user_api.dart'; import '../widget/message/message_notification_dialog.dart'; +import '../widget/message/video_call_invite_dialog.dart'; +import '../pages/message/video_call_page.dart'; +import '../controller/message/call_manager.dart'; // 消息通知数据结构 class _NotificationMessage { @@ -1387,6 +1390,162 @@ class IMManager { return; } + // 处理视频通话消息(CALL消息)- 显示特殊的视频通话邀请弹框 + if (message.body.type == MessageType.TXT) { + try { + final textBody = message.body as EMTextMessageBody; + final content = textBody.content; + if (content != null && content.startsWith('[CALL:]')) { + // 解析通话信息 + try { + final jsonStr = content.substring(7); // 移除 '[CALL:]' 前缀 + final callInfo = jsonDecode(jsonStr) as Map; + final callType = callInfo['callType'] as String?; + final callStatus = callInfo['callStatus'] as String?; + + // 只处理视频通话且状态为 missed 或 calling 的消息(新邀请) + if (callType == 'video' && (callStatus == 'missed' || callStatus == 'calling')) { + // 获取用户信息 + Map? attributes; + try { + attributes = message.attributes; + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 无法访问消息扩展字段: $e'); + } + } + + String? nickName; + String? avatarUrl; + + if (attributes != null) { + nickName = attributes['sender_nickName'] as String?; + avatarUrl = attributes['sender_avatarUrl'] as String?; + } + + // 如果从消息扩展字段中获取不到,尝试从 ConversationController 的缓存中获取 + if ((nickName == null || nickName.isEmpty) || (avatarUrl == null || avatarUrl.isEmpty)) { + try { + if (Get.isRegistered()) { + final conversationController = Get.find(); + final cachedUserInfo = conversationController.getCachedUserInfo(fromId); + if (cachedUserInfo != null) { + nickName = nickName ?? cachedUserInfo.nickName; + avatarUrl = avatarUrl ?? cachedUserInfo.avatarUrl; + } + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 从ConversationController获取用户信息失败: $e'); + } + } + } + + final finalNickName = nickName ?? fromId; + final finalAvatarUrl = avatarUrl ?? ''; + + // 显示视频通话邀请弹框 + SmartDialog.show( + builder: (context) { + return VideoCallInviteDialog( + avatarUrl: finalAvatarUrl, + nickName: finalNickName, + onTap: () async { + // 关闭弹框 + SmartDialog.dismiss(); + + // 只跳转到视频通话页面,不自动接通 + Get.to(() => VideoCallPage( + targetUserId: fromId, + isInitiator: false, + )); + }, + onAccept: () async { + // 关闭弹框 + SmartDialog.dismiss(); + + // 接听通话 + final callManager = CallManager.instance; + ChatController? chatController; + try { + final tag = 'chat_$fromId'; + if (Get.isRegistered(tag: tag)) { + chatController = Get.find(tag: tag); + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 获取ChatController失败: $e'); + } + } + + final accepted = await callManager.acceptCall( + message: message, + chatController: chatController, + ); + + if (accepted) { + // 跳转到视频通话页面 + Get.to(() => VideoCallPage( + targetUserId: fromId, + isInitiator: false, + )); + } + }, + onReject: () async { + // 关闭弹框 + SmartDialog.dismiss(); + + // 拒绝通话 + final callManager = CallManager.instance; + ChatController? chatController; + try { + final tag = 'chat_$fromId'; + if (Get.isRegistered(tag: tag)) { + chatController = Get.find(tag: tag); + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 获取ChatController失败: $e'); + } + } + + await callManager.rejectCall( + message: message, + chatController: chatController, + ); + }, + ); + }, + alignment: Alignment.topCenter, + animationType: SmartAnimationType.centerFade_otherSlide, + animationTime: Duration(milliseconds: 300), + maskColor: Colors.transparent, + maskWidget: null, + clickMaskDismiss: false, + ); + + if (Get.isLogEnable) { + Get.log('📞 [IMManager] 显示视频通话邀请弹框: $fromId'); + } + } + + // 对于所有 CALL 消息(包括视频和语音),都不显示普通消息通知弹框 + return; + } catch (e) { + // 解析失败,继续处理普通消息 + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 解析CALL消息失败: $e'); + } + } + } + } catch (e) { + // 解析失败,继续处理 + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 解析消息内容失败: $e'); + } + } + } + // 检查发送者是否是当前正在聊天的用户 // 如果 _activeChatControllers 中包含该用户ID,说明当前正在和该用户聊天 if (_activeChatControllers.containsKey(fromId)) { diff --git a/lib/widget/message/message_notification_dialog.dart b/lib/widget/message/message_notification_dialog.dart index cd60e31..e3fd386 100644 --- a/lib/widget/message/message_notification_dialog.dart +++ b/lib/widget/message/message_notification_dialog.dart @@ -23,7 +23,7 @@ class MessageNotificationDialog extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - margin: EdgeInsets.symmetric(horizontal: 16.w), + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 30.h), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), decoration: BoxDecoration( color: Colors.white, diff --git a/lib/widget/message/video_call_invite_dialog.dart b/lib/widget/message/video_call_invite_dialog.dart new file mode 100644 index 0000000..a83ecf1 --- /dev/null +++ b/lib/widget/message/video_call_invite_dialog.dart @@ -0,0 +1,157 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dating_touchme_app/generated/assets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// 视频通话邀请弹框 +class VideoCallInviteDialog extends StatelessWidget { + final String avatarUrl; + final String nickName; + final VoidCallback? onTap; // 点击弹框主体区域(只跳转,不接通) + final VoidCallback? onAccept; // 点击接通按钮(接通并跳转) + final VoidCallback? onReject; // 点击挂断按钮(拒绝) + + const VideoCallInviteDialog({ + super.key, + required this.avatarUrl, + required this.nickName, + this.onTap, + this.onAccept, + this.onReject, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 30.h), + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.9), + borderRadius: BorderRadius.circular(16.w), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 12, + offset: Offset(0, 4.h), + ), + ], + ), + child: Row( + children: [ + // 左侧:头像和昵称、文案 + Expanded( + child: Row( + children: [ + // 头像 + ClipOval( + child: avatarUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: avatarUrl, + width: 56.w, + height: 56.w, + fit: BoxFit.cover, + placeholder: (context, url) => Image.asset( + Assets.imagesUserAvatar, + width: 56.w, + height: 56.w, + fit: BoxFit.cover, + ), + errorWidget: (context, url, error) => Image.asset( + Assets.imagesUserAvatar, + width: 56.w, + height: 56.w, + fit: BoxFit.cover, + ), + ) + : Image.asset( + Assets.imagesUserAvatar, + width: 56.w, + height: 56.w, + fit: BoxFit.cover, + ), + ), + SizedBox(width: 12.w), + // 昵称和文案 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 昵称 + Text( + nickName, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 4.h), + // 邀请文案 + Text( + '邀请你视频通话', + style: TextStyle( + fontSize: 13.sp, + color: Colors.white.withOpacity(0.8), + ), + ), + ], + ), + ), + ], + ), + ), + SizedBox(width: 12.w), + // 右侧:接通和挂断按钮 + Row( + children: [ + // 挂断按钮 + GestureDetector( + onTap: onReject, + behavior: HitTestBehavior.opaque, + child: Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: Icon( + Icons.call_end, + color: Colors.white, + size: 24.w, + ), + ), + ), + SizedBox(width: 12.w), + // 接通按钮 + GestureDetector( + onTap: onAccept, + behavior: HitTestBehavior.opaque, + child: Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: Icon( + Icons.call, + color: Colors.white, + size: 24.w, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + diff --git a/pubspec.yaml b/pubspec.yaml index 7be9d96..0d3fddd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,7 @@ dependencies: audioplayers: ^6.5.1 video_thumbnail: ^0.5.3 # 视频缩略图生成 fluwx: ^5.7.5 -# # tobias: ^5.3.1 + # # tobias: ^5.3.1 agora_rtc_engine: ^6.5.3 agora_rtm: ^2.2.5 agora_token_generator: ^1.0.0 @@ -79,7 +79,7 @@ dependencies: im_flutter_sdk: 4.15.2 webview_flutter: ^4.13.0 ota_update: ^7.1.0 - + dev_dependencies: flutter_test: sdk: flutter @@ -108,7 +108,8 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - build/app/outputs/flutter-apk/ + - assets/images/ + - assets/images/emoji/ # - images/a_dot_ham.jpeg # An images asset can refer to one or more resolution-specific "variants", see @@ -144,3 +145,4 @@ flutter_launcher_icons: image_path: "assets/images/app_logo.jpg" min_sdk_android: 21 remove_alpha_ios: true + From 26eb4a39a3a04e53ac92799ce25164aa68ae1120 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 09:06:16 +0800 Subject: [PATCH 05/21] =?UTF-8?q?feat(live):=20=E5=AE=8C=E5=96=84=E7=9B=B4?= =?UTF-8?q?=E6=92=AD=E6=88=BF=E9=97=B4=E9=80=80=E5=87=BA=E5=92=8C=E7=A4=BC?= =?UTF-8?q?=E7=89=A9=E5=BC=B9=E7=AA=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在登出时自动退出直播间并清理相关资源 - 添加小窗口隐藏功能确保登出时完全清理 - 实现礼物弹窗默认选中主持人逻辑 - 优化直播房间界面显示,根据好友关系控制加好友按钮 - 统一在多个控制器中添加登出时的资源清理逻辑 --- .../mine/deactivate_controller.dart | 23 ++++++++ .../setting/setting_controller.dart | 23 ++++++++ lib/network/network_config.dart | 26 +++++++-- lib/widget/live/live_gift_popup.dart | 50 +++++++++++++++++ .../live/live_room_anchor_showcase.dart | 54 ++++++++++--------- 5 files changed, 149 insertions(+), 27 deletions(-) diff --git a/lib/controller/mine/deactivate_controller.dart b/lib/controller/mine/deactivate_controller.dart index e5b3142..9c0a7de 100644 --- a/lib/controller/mine/deactivate_controller.dart +++ b/lib/controller/mine/deactivate_controller.dart @@ -1,5 +1,7 @@ import 'package:dating_touchme_app/controller/global.dart'; import 'package:dating_touchme_app/controller/message/conversation_controller.dart'; +import 'package:dating_touchme_app/controller/discover/room_controller.dart'; +import 'package:dating_touchme_app/controller/overlay_controller.dart'; import 'package:dating_touchme_app/im/im_manager.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; @@ -12,6 +14,27 @@ class DeactivateController extends GetxController { final s = "".obs; void logout() async { + // 退出直播间 + if (Get.isRegistered()) { + try { + final roomController = Get.find(); + await roomController.leaveChannel(); + } catch (e) { + print('退出直播间失败: $e'); + } + } + + // 取消小窗口 + if (Get.isRegistered()) { + try { + final overlayController = Get.find(); + overlayController.hide(); // 隐藏直播房间小窗 + overlayController.hideVideoCall(); // 隐藏视频通话小窗 + } catch (e) { + print('取消小窗口失败: $e'); + } + } + // 先退出 IM 登录 await IMManager.instance.logout(); // 清除会话列表和用户信息缓存 diff --git a/lib/controller/setting/setting_controller.dart b/lib/controller/setting/setting_controller.dart index aedd024..4f60ada 100644 --- a/lib/controller/setting/setting_controller.dart +++ b/lib/controller/setting/setting_controller.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:dating_touchme_app/controller/global.dart'; import 'package:dating_touchme_app/controller/message/conversation_controller.dart'; +import 'package:dating_touchme_app/controller/discover/room_controller.dart'; +import 'package:dating_touchme_app/controller/overlay_controller.dart'; import 'package:dating_touchme_app/im/im_manager.dart'; import 'package:dating_touchme_app/extension/ex_widget.dart'; import 'package:flutter/material.dart'; @@ -47,6 +49,27 @@ class SettingController extends GetxController { } void logout() async { + // 退出直播间 + if (Get.isRegistered()) { + try { + final roomController = Get.find(); + await roomController.leaveChannel(); + } catch (e) { + print('退出直播间失败: $e'); + } + } + + // 取消小窗口 + if (Get.isRegistered()) { + try { + final overlayController = Get.find(); + overlayController.hide(); // 隐藏直播房间小窗 + overlayController.hideVideoCall(); // 隐藏视频通话小窗 + } catch (e) { + print('取消小窗口失败: $e'); + } + } + // 先退出 IM 登录 await IMManager.instance.logout(); // 清除会话列表和用户信息缓存 diff --git a/lib/network/network_config.dart b/lib/network/network_config.dart index f88cfa3..19be50f 100644 --- a/lib/network/network_config.dart +++ b/lib/network/network_config.dart @@ -1,8 +1,7 @@ -import 'package:dating_touchme_app/controller/discover/discover_controller.dart'; import 'package:dating_touchme_app/controller/global.dart'; -import 'package:dating_touchme_app/controller/home/home_controller.dart'; import 'package:dating_touchme_app/controller/message/conversation_controller.dart'; -import 'package:dating_touchme_app/controller/mine/mine_controller.dart'; +import 'package:dating_touchme_app/controller/discover/room_controller.dart'; +import 'package:dating_touchme_app/controller/overlay_controller.dart'; import 'package:dating_touchme_app/im/im_manager.dart'; import 'package:dio/dio.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -163,6 +162,27 @@ class ResponseInterceptor extends Interceptor { // 处理token过期逻辑,如清除本地数据、跳转登录页等 // 这里可以使用Get.offAllNamed('/login')等方式跳转 // 先退出 IM 登录 + // 退出直播间 + if (Get.isRegistered()) { + try { + final roomController = Get.find(); + await roomController.leaveChannel(); + } catch (e) { + print('退出直播间失败: $e'); + } + } + + // 取消小窗口 + if (Get.isRegistered()) { + try { + final overlayController = Get.find(); + overlayController.hide(); // 隐藏直播房间小窗 + overlayController.hideVideoCall(); // 隐藏视频通话小窗 + } catch (e) { + print('取消小窗口失败: $e'); + } + } + await IMManager.instance.logout(); // 清除会话列表和用户信息缓存 if (Get.isRegistered()) { diff --git a/lib/widget/live/live_gift_popup.dart b/lib/widget/live/live_gift_popup.dart index 23bf623..8c33c09 100644 --- a/lib/widget/live/live_gift_popup.dart +++ b/lib/widget/live/live_gift_popup.dart @@ -37,6 +37,8 @@ class LiveGiftPopup extends StatefulWidget { class _LiveGiftPopupState extends State { // 选中的用户ID(单选) int? _selectedUserId; + // 标记是否已经尝试过设置默认选中 + bool _hasTriedSetDefault = false; @override void initState() { @@ -44,6 +46,9 @@ class _LiveGiftPopupState extends State { // 如果传入了目标用户ID,直接设置 if (widget.targetUserId != null) { _selectedUserId = widget.targetUserId; + } else if (widget.showHeader) { + // 如果没有传入目标用户ID且显示头部,默认选中主持人 + _initDefaultSelectedUser(); } // 默认选择第一个礼物 if (widget.giftList.isNotEmpty && widget.activeGift.value == null) { @@ -51,6 +56,36 @@ class _LiveGiftPopupState extends State { } } + /// 初始化默认选中的用户(主持人) + void _initDefaultSelectedUser() { + try { + final roomController = Get.isRegistered() + ? Get.find() + : null; + + if (roomController != null) { + final rtcChannelDetail = roomController.rtcChannelDetail.value; + // 默认选中主持人(anchorInfo) + if (rtcChannelDetail?.anchorInfo != null) { + final anchorInfo = rtcChannelDetail!.anchorInfo!; + // 获取当前用户ID,排除本人 + final currentUserId = GlobalData().userId ?? GlobalData().userData?.id; + // 如果主持人不是本人,则默认选中 + if (anchorInfo.userId != currentUserId && anchorInfo.miId != currentUserId) { + // 使用 uid 作为选中标识 + if (anchorInfo.uid != null) { + setState(() { + _selectedUserId = anchorInfo.uid; + }); + } + } + } + } + } catch (e) { + print('初始化默认选中用户失败: $e'); + } + } + // 切换用户选中状态(单选模式) void _toggleUserSelection(int userId) { setState(() { @@ -201,6 +236,21 @@ class _LiveGiftPopupState extends State { // 最多显示3个用户 final displayUsers = filteredUserList.take(3).toList(); + // 如果还没有选中用户,且主持人存在且不是本人,默认选中主持人 + if (!_hasTriedSetDefault && _selectedUserId == null && rtcChannelDetail?.anchorInfo != null) { + final anchorInfo = rtcChannelDetail!.anchorInfo!; + if (anchorInfo.userId != currentUserId && anchorInfo.miId != currentUserId && anchorInfo.uid != null) { + _hasTriedSetDefault = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _selectedUserId == null) { + setState(() { + _selectedUserId = anchorInfo.uid; + }); + } + }); + } + } + return Container( height: 53.w, padding: EdgeInsets.symmetric(horizontal: 10.w), diff --git a/lib/widget/live/live_room_anchor_showcase.dart b/lib/widget/live/live_room_anchor_showcase.dart index 4972769..0d9d6ad 100644 --- a/lib/widget/live/live_room_anchor_showcase.dart +++ b/lib/widget/live/live_room_anchor_showcase.dart @@ -89,35 +89,41 @@ class _LiveRoomAnchorShowcaseState extends State { ), ), if(_roomController.currentRole != CurrentRole.broadcaster) - Positioned( - bottom: 5.w, - right: 5.w, - child: Container( - width: 47.w, - height: 20.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(20.w)), - color: Colors.white, - ), - child: Center( - child: Text( - "加好友", - style: TextStyle( - fontSize: 11.w, - color: const Color.fromRGBO(117, 98, 249, 1), + Obx(() { + final anchorInfo = _roomController + .rtcChannelDetail + .value + ?.anchorInfo; + // 如果已经是好友,不显示加好友按钮 + if (anchorInfo?.isFriend == true) { + return const SizedBox.shrink(); + } + return Positioned( + bottom: 5.w, + right: 5.w, + child: Container( + width: 47.w, + height: 20.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(20.w)), + color: Colors.white, + ), + child: Center( + child: Text( + "加好友", + style: TextStyle( + fontSize: 11.w, + color: const Color.fromRGBO(117, 98, 249, 1), + ), ), ), ), ).onTap((){ - final anchorInfo = _roomController - .rtcChannelDetail - .value - ?.anchorInfo; if (anchorInfo != null) { _showGiftPopupForUser(anchorInfo, 2); } - }), - ), + }); + }), ], ), SizedBox(height: 5.w), @@ -243,7 +249,7 @@ class _LiveRoomAnchorShowcaseState extends State { ), ), ), - if(!isCurrentUser) + if(!isCurrentUser && userInfo.isFriend != true) Positioned( top: 5.w, right: 5.w, @@ -267,7 +273,7 @@ class _LiveRoomAnchorShowcaseState extends State { ), ), ), - if(!isCurrentUser) + if(!isCurrentUser && userInfo.isFriend != true) Positioned( bottom: 5.w, right: 5.w, From 0dbc7e9f17bb11838b7d72da43f5fd51d72165e9 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 09:17:48 +0800 Subject: [PATCH 06/21] =?UTF-8?q?feat(live):=20=E6=B7=BB=E5=8A=A0=E7=9B=B4?= =?UTF-8?q?=E6=92=AD=E9=97=B4=E8=A2=AB=E8=B8=A2=E5=87=BA=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E4=BC=98=E5=8C=96RTC=E8=BF=9E=E6=8E=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在LiveEndPage中添加isKickedOut和operatorName参数用于显示被踢出信息 - 优化live_room_anchor_showcase中RTC连接逻辑,添加channelId验证 - 添加被踢出用户的小窗口关闭功能,使用OverlayController管理 - 修改踢人逻辑,被踢出用户跳转到结束直播页面并显示相应提示 - 优化踢人API调用和消息发送流程,修复参数传递问题 - 添加连麦卡片获取的日志输出优化 --- lib/controller/discover/room_controller.dart | 42 ++++++++++++------- lib/pages/discover/live_end_page.dart | 13 +++++- .../live/live_room_anchor_showcase.dart | 12 ++++-- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/lib/controller/discover/room_controller.dart b/lib/controller/discover/room_controller.dart index 857427e..12c90f1 100644 --- a/lib/controller/discover/room_controller.dart +++ b/lib/controller/discover/room_controller.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:dating_touchme_app/controller/global.dart'; +import 'package:dating_touchme_app/controller/overlay_controller.dart'; import 'package:dating_touchme_app/model/live/gift_product_model.dart'; import 'package:dating_touchme_app/model/rtc/link_mic_card_model.dart'; import 'package:dating_touchme_app/model/rtc/rtc_channel_data.dart'; @@ -16,8 +17,8 @@ import 'package:get/get.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../model/live/live_chat_message.dart'; +import '../../pages/discover/live_end_page.dart'; import '../../pages/mine/real_name_page.dart'; -import '../../pages/setting/match_league_page.dart'; import '../../pages/setting/match_spread_page.dart'; import 'svga_player_manager.dart'; @@ -556,14 +557,26 @@ class RoomController extends GetxController with WidgetsBindingObserver { final operatorName = message['operatorName']?.toString() ?? '主持人'; print('✅ 收到踢人消息: 被踢用户ID $kickingUId'); - + // 判断当前用户是否是被踢的用户 if (rtcChannel.value?.uid == kickingUId) { + // 被踢用户:关闭小窗口 + if (Get.isRegistered()) { + try { + final overlayController = Get.find(); + overlayController.hide(); // 隐藏直播房间小窗 + } catch (e) { + print('关闭小窗口失败: $e'); + } + } + // 被踢用户:离开房间 - SmartDialog.showToast('您已被$operatorName踢出房间'); await leaveChannel(); - // 可以选择自动返回上一页或跳转到其他页面 - Get.back(); + + // 跳转到结束直播页面,并传入被踢出标识 + Get.off( + () => LiveEndPage(isKickedOut: true, operatorName: operatorName), + ); } else { // 其他用户:刷新房间详情 final channelName = RTCManager.instance.currentChannelId; @@ -596,10 +609,7 @@ class RoomController extends GetxController with WidgetsBindingObserver { required int kickingUId, }) async { try { - final requestData = { - 'channelId': channelId, - 'kickingUId': kickingUId, - }; + final requestData = {'channelId': channelId, 'kickingUId': kickingUId}; final response = await _networkService.rtcApi.kickingRtcChannelUser( requestData, @@ -607,7 +617,7 @@ class RoomController extends GetxController with WidgetsBindingObserver { if (response.data.isSuccess) { SmartDialog.showToast('已踢出用户'); - + // 发送 RTM 消息通知所有用户 final channelName = RTCManager.instance.currentChannelId; if (channelName != null && channelName.isNotEmpty) { @@ -617,19 +627,19 @@ class RoomController extends GetxController with WidgetsBindingObserver { 'operatorId': GlobalData().userData?.id ?? '', 'operatorName': GlobalData().userData?.nickName ?? '', }; - + await RTMManager.instance.publishChannelMessage( channelName: channelName, message: json.encode(messageData), ); print('✅ 踢人消息已发送: 踢出用户ID $kickingUId'); - + // 刷新频道详情 await fetchRtcChannelDetail(channelName); } } else { - final message = response.data.message.isNotEmpty - ? response.data.message + final message = response.data.message.isNotEmpty + ? response.data.message : '踢出用户失败'; SmartDialog.showToast(message); } @@ -646,7 +656,9 @@ class RoomController extends GetxController with WidgetsBindingObserver { final base = response.data; if (base.isSuccess && base.data != null) { linkMicCard.value = base.data; - print('✅ 获取连麦卡片成功: type=${base.data!.type}, num=${base.data!.num}, unitSellingPrice=${base.data!.unitSellingPrice}'); + print( + '✅ 获取连麦卡片成功: type=${base.data!.type}, num=${base.data!.num}, unitSellingPrice=${base.data!.unitSellingPrice}', + ); } else { linkMicCard.value = null; print('❌ 获取连麦卡片失败: ${base.message}'); diff --git a/lib/pages/discover/live_end_page.dart b/lib/pages/discover/live_end_page.dart index 8b4bb67..2c39f57 100644 --- a/lib/pages/discover/live_end_page.dart +++ b/lib/pages/discover/live_end_page.dart @@ -8,7 +8,14 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; class LiveEndPage extends StatelessWidget { - const LiveEndPage({super.key}); + final bool isKickedOut; // 是否是被踢出的 + final String? operatorName; // 操作者名称(踢人者) + + const LiveEndPage({ + super.key, + this.isKickedOut = false, + this.operatorName, + }); @override Widget build(BuildContext context) { @@ -127,7 +134,9 @@ class LiveEndPage extends StatelessWidget { Padding( padding: EdgeInsets.symmetric(horizontal: 16.w), child: Text( - '当前相亲已结束', + isKickedOut + ? '您已被${operatorName ?? '主持人'}踢出直播间' + : '当前相亲已结束', style: TextStyle( fontSize: 14.w, color: Colors.white, diff --git a/lib/widget/live/live_room_anchor_showcase.dart b/lib/widget/live/live_room_anchor_showcase.dart index 0d9d6ad..4ccf29d 100644 --- a/lib/widget/live/live_room_anchor_showcase.dart +++ b/lib/widget/live/live_room_anchor_showcase.dart @@ -156,8 +156,11 @@ class _LiveRoomAnchorShowcaseState extends State { Widget _buildAnchorVideo(bool joined, int? remoteUid) { final engine = _rtcManager.engine; + final currentChannelId = _rtcManager.currentChannelId; if (_roomController.rtcChannelDetail.value?.anchorInfo == null || - engine == null) { + engine == null || + currentChannelId == null || + currentChannelId.isEmpty) { return _buildWaitingPlaceholder(); } return ClipRRect( @@ -180,7 +183,7 @@ class _LiveRoomAnchorShowcaseState extends State { _roomController.rtcChannelDetail.value?.anchorInfo?.uid, ), connection: RtcConnection( - channelId: _rtcManager.currentChannelId!, + channelId: currentChannelId, ), ), ), @@ -232,7 +235,8 @@ class _LiveRoomAnchorShowcaseState extends State { userInfo != null && userInfo.uid != null && joined && - engine != null + engine != null && + (_rtcManager.currentChannelId != null && _rtcManager.currentChannelId!.isNotEmpty) ? Stack( children: [ AgoraVideoView( @@ -245,7 +249,7 @@ class _LiveRoomAnchorShowcaseState extends State { rtcEngine: engine, canvas: VideoCanvas(uid: userInfo.uid!), connection: RtcConnection( - channelId: _rtcManager.currentChannelId ?? '', + channelId: _rtcManager.currentChannelId!, ), ), ), From 51c3ffc0bda0740d69361cbebf290248ff3e0c85 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 09:20:38 +0800 Subject: [PATCH 07/21] =?UTF-8?q?fix(live):=20=E8=A7=A3=E5=86=B3=E5=B0=8F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E9=9A=90=E8=97=8F=E4=B8=8E=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E6=97=B6=E5=BA=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在跳转到直播间前先隐藏小窗口,避免界面显示异常 - 使用 Future.microtask 确保小窗口隐藏后再执行页面导航 - 在直播间页面初始化时主动隐藏小窗口,防止重叠显示 --- lib/pages/discover/live_room_page.dart | 2 ++ lib/widget/live/draggable_overlay_widget.dart | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index f8db0eb..bfaa61b 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -46,6 +46,8 @@ class _LiveRoomPageState extends State { ? Get.find() : Get.put(RoomController()); _overlayController = Get.find(); + // 进入直播间时,确保隐藏小窗口 + _overlayController.hide(); // 启用屏幕常亮 WakelockPlus.enable(); // 如果当前用户是男性,请求连麦卡片信息 diff --git a/lib/widget/live/draggable_overlay_widget.dart b/lib/widget/live/draggable_overlay_widget.dart index e581fda..dc9e728 100644 --- a/lib/widget/live/draggable_overlay_widget.dart +++ b/lib/widget/live/draggable_overlay_widget.dart @@ -188,8 +188,12 @@ class _DraggableOverlayWidgetState extends State { ), ), ).onTap(() { - Get.to(() => const LiveRoomPage(id: 0)); + // 先隐藏小窗口,再跳转到直播间 widget.onClose?.call(); + // 使用 Future.microtask 确保小窗口先隐藏,然后再导航 + Future.microtask(() { + Get.to(() => const LiveRoomPage(id: 0)); + }); }); }), ); From 268e32bf025bd51471a5c999b65b657fe5aea70c Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 10:15:07 +0800 Subject: [PATCH 08/21] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E6=95=8F?= =?UTF-8?q?=E6=84=9F=E8=AF=8D=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加敏感词错误提示状态管理集合 - 实现敏感词错误消息ID的添加和检查方法 - 在消息加载时从attributes恢复敏感词错误状态 - 在聊天页面显示敏感词错误提示信息 - 在IM管理器中处理敏感词错误码E0001 - 防止敏感词错误消息显示重发按钮 - 在直播房间中处理玫瑰不足时的充值提示 --- lib/controller/message/chat_controller.dart | 49 ++++++++++++++++++- lib/im/im_manager.dart | 36 +++++++++++--- lib/pages/message/chat_page.dart | 26 ++++++++++ .../live/live_room_notice_chat_panel.dart | 33 +++++++++++++ lib/widget/message/text_item.dart | 14 ++++++ 5 files changed, 151 insertions(+), 7 deletions(-) diff --git a/lib/controller/message/chat_controller.dart b/lib/controller/message/chat_controller.dart index 3dc20e2..d9a20ed 100644 --- a/lib/controller/message/chat_controller.dart +++ b/lib/controller/message/chat_controller.dart @@ -45,6 +45,9 @@ class ChatController extends GetxController { // 需要显示508错误提示的消息ID集合(临时状态,不持久化) final Set _roseErrorMessageIds = {}; + // 需要显示敏感词错误提示的消息ID集合(临时状态,不持久化) + final Set _sensitiveWordMessageIds = {}; + // 网络服务 final NetworkService _networkService = NetworkService(); @@ -58,6 +61,17 @@ class ChatController extends GetxController { _roseErrorMessageIds.add(messageId); update(); } + + /// 检查消息是否需要显示敏感词错误提示 + bool shouldShowSensitiveWordError(String messageId) { + return _sensitiveWordMessageIds.contains(messageId); + } + + /// 添加需要显示敏感词错误提示的消息ID + void addSensitiveWordMessageId(String messageId) { + _sensitiveWordMessageIds.add(messageId); + update(); + } ChatController({ required this.userId, @@ -753,6 +767,22 @@ class ChatController extends GetxController { .toList(); if (newMessages.isNotEmpty) { + // 从新消息的 attributes 中恢复错误码状态 + for (var msg in newMessages) { + if (msg.status == MessageStatus.FAIL && msg.direction == MessageDirection.SEND) { + try { + final errorCode = msg.attributes?['errorCode'] as String?; + if (errorCode == 'E0002') { + _roseErrorMessageIds.add(msg.msgId); + } else if (errorCode == 'E0001') { + _sensitiveWordMessageIds.add(msg.msgId); + } + } catch (e) { + // 忽略错误 + } + } + } + messages.insertAll(0, newMessages); // 更新游标为最旧的消息ID(列表开头) _cursor = newMessages.first.msgId; @@ -769,8 +799,9 @@ class ChatController extends GetxController { } else { // 刷新时替换整个列表,但需要去重(处理重发消息的情况) // 对于相同内容的消息,只保留最新的(msgId更大的) - // 重新进入页面时,清空临时错误提示状态(不持久化) + // 重新进入页面时,清空临时错误提示状态,然后从消息 attributes 中恢复 _roseErrorMessageIds.clear(); + _sensitiveWordMessageIds.clear(); final Map contentToMessage = {}; @@ -802,6 +833,22 @@ class ChatController extends GetxController { // 按时间戳排序(从旧到新) deduplicatedMessages.sort((a, b) => a.serverTime.compareTo(b.serverTime)); + // 从消息 attributes 中恢复错误码状态 + for (var msg in deduplicatedMessages) { + if (msg.status == MessageStatus.FAIL && msg.direction == MessageDirection.SEND) { + try { + final errorCode = msg.attributes?['errorCode'] as String?; + if (errorCode == 'E0002') { + _roseErrorMessageIds.add(msg.msgId); + } else if (errorCode == 'E0001') { + _sensitiveWordMessageIds.add(msg.msgId); + } + } catch (e) { + // 忽略错误 + } + } + } + messages.assignAll(deduplicatedMessages); // 更新游标为最旧的消息ID(列表开头) if (deduplicatedMessages.isNotEmpty) { diff --git a/lib/im/im_manager.dart b/lib/im/im_manager.dart index e15b80a..59a6050 100644 --- a/lib/im/im_manager.dart +++ b/lib/im/im_manager.dart @@ -286,7 +286,7 @@ class IMManager { // 检查错误码是否为508(玫瑰不足) try { - final errorCode = err.code; + final errorCode = err.description; // 通知 ChatController 更新消息状态 final targetId = message.to; @@ -299,11 +299,23 @@ class IMManager { // 更新消息对象 controller.messages[index] = message; - // 如果是508错误,添加到临时错误提示集合中(不存储到消息attributes) - if (errorCode == 508) { + // 将错误码保存到消息 attributes 中(用于页面重新加载时恢复状态) + message.attributes ??= {}; + message.attributes!['errorCode'] = errorCode; + + // 如果是508错误,添加到临时错误提示集合中 + if (errorCode == 'E0002') { controller.addRoseErrorMessageId(message.msgId); if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 检测到错误码508(玫瑰不足),已添加到临时错误提示集合: msgId=${message.msgId}'); + Get.log('⚠️ [IMManager] 检测到错误码E0002(玫瑰不足),已添加到临时错误提示集合: msgId=${message.msgId}'); + } + } + + // 如果是E0001错误(敏感词拦截),添加到临时错误提示集合中 + if (errorCode == 'E0001') { + controller.addSensitiveWordMessageId(message.msgId); + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 检测到错误码E0001(敏感词拦截),已添加到临时错误提示集合: msgId=${message.msgId}'); } } @@ -338,11 +350,23 @@ class IMManager { final matchedMessage = controller.messages[contentIndex]; controller.messages[contentIndex] = message; + // 将错误码保存到消息 attributes 中(用于页面重新加载时恢复状态) + message.attributes ??= {}; + message.attributes!['errorCode'] = errorCode.toString(); + // 如果是508错误,添加到临时错误提示集合中(使用匹配到的消息ID) - if (errorCode == 508) { + if (errorCode == 508 || errorCode == 'E0002') { controller.addRoseErrorMessageId(matchedMessage.msgId); if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 检测到错误码508(玫瑰不足),已通过内容匹配添加到临时错误提示集合: msgId=${matchedMessage.msgId}'); + Get.log('⚠️ [IMManager] 检测到错误码508/E0002(玫瑰不足),已通过内容匹配添加到临时错误提示集合: msgId=${matchedMessage.msgId}'); + } + } + + // 如果是E0001错误(敏感词拦截),添加到临时错误提示集合中 + if (errorCode == 'E0001') { + controller.addSensitiveWordMessageId(matchedMessage.msgId); + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 检测到错误码E0001(敏感词拦截),已通过内容匹配添加到临时错误提示集合: msgId=${matchedMessage.msgId}'); } } diff --git a/lib/pages/message/chat_page.dart b/lib/pages/message/chat_page.dart index b2e6bf9..06f0d64 100644 --- a/lib/pages/message/chat_page.dart +++ b/lib/pages/message/chat_page.dart @@ -252,6 +252,11 @@ class _ChatPageState extends State { message.status == MessageStatus.FAIL && controller.shouldShowRoseError(message.msgId); + // 检查是否需要显示敏感词错误提示(使用临时状态,不持久化) + final showSensitiveWordError = isSentByMe && + message.status == MessageStatus.FAIL && + controller.shouldShowSensitiveWordError(message.msgId); + // 🚀 性能优化:为每个消息项设置唯一的 key return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -265,6 +270,8 @@ class _ChatPageState extends State { ), // 显示错误提示(错误码508) if (showRoseError) _buildRoseErrorHint(context), + // 显示敏感词错误提示(错误码E0001) + if (showSensitiveWordError) _buildSensitiveWordErrorHint(context), ], ); }, @@ -647,4 +654,23 @@ class _ChatPageState extends State { ), ); } + + // 构建敏感词错误提示 + Widget _buildSensitiveWordErrorHint(BuildContext context) { + return Container( + margin: EdgeInsets.only(top: 8.h, bottom: 8.h), + padding: EdgeInsets.symmetric(horizontal: 16.w), + width: double.infinity, + child: Center( + child: Text( + '聊天内容包含敏感词', + style: TextStyle( + fontSize: 11.sp, + color: Color.fromRGBO(199, 199, 199, 1), + ), + textAlign: TextAlign.center, + ), + ), + ); + } } diff --git a/lib/widget/live/live_room_notice_chat_panel.dart b/lib/widget/live/live_room_notice_chat_panel.dart index 970ddbe..9a278bd 100644 --- a/lib/widget/live/live_room_notice_chat_panel.dart +++ b/lib/widget/live/live_room_notice_chat_panel.dart @@ -3,8 +3,10 @@ import 'package:dating_touchme_app/controller/global.dart'; import 'package:dating_touchme_app/extension/ex_widget.dart'; import 'package:dating_touchme_app/generated/assets.dart'; import 'package:dating_touchme_app/widget/live/live_room_chat_item.dart'; +import 'package:dating_touchme_app/widget/live/live_recharge_popup.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class LiveRoomNoticeChatPanel extends StatefulWidget { @@ -51,6 +53,18 @@ class _LiveRoomNoticeChatPanelState extends State { final linkMicCard = controller.linkMicCard.value; final cardNum = linkMicCard?.num ?? 0; + // 如果 cardNum 为 0,显示"上麦20玫瑰" + if (cardNum == 0) { + return Text( + '上麦20玫瑰', + style: TextStyle( + fontSize: 9.w, + color: Colors.white.withOpacity(0.8), + ), + ); + } + + // 否则显示剩余相亲卡数量 return Text( '剩余$cardNum张相亲卡', style: TextStyle( @@ -148,6 +162,25 @@ class _LiveRoomNoticeChatPanelState extends State { ], ), ).onTap(() async{ + // 检查是否需要弹出充值弹框 + if (!controller.isLive.value) { + final userData = GlobalData().userData; + final isMale = userData?.genderCode == 0; + if (isMale) { + final linkMicCard = controller.linkMicCard.value; + final cardNum = linkMicCard?.num ?? 0; + // 如果显示"上麦20玫瑰"且玫瑰数量小于20,弹出充值弹框 + if (cardNum == 0 && controller.roseCount.value < 20) { + SmartDialog.show( + alignment: Alignment.bottomCenter, + maskColor: Colors.black.withOpacity(0.5), + builder: (_) => const LiveRechargePopup(), + ); + return; + } + } + } + if(controller.isLive.value){ await controller.leaveChat(); }else{ diff --git a/lib/widget/message/text_item.dart b/lib/widget/message/text_item.dart index 7574b51..fe303b6 100644 --- a/lib/widget/message/text_item.dart +++ b/lib/widget/message/text_item.dart @@ -261,6 +261,20 @@ class TextItem extends StatelessWidget { return SizedBox.shrink(); } + // 检查是否是敏感词消息 + bool isSensitiveWordMessage = false; + try { + final chatController = Get.find(); + isSensitiveWordMessage = chatController.shouldShowSensitiveWordError(message.msgId); + } catch (e) { + // ChatController 可能不存在,忽略错误 + } + + // 如果是敏感词消息,不显示重发按钮 + if (isSensitiveWordMessage) { + return SizedBox.shrink(); + } + // 检查消息状态 final status = message.status; From 531a1dcc663055b030b17ac10264ef6e7a815004 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 11:40:14 +0800 Subject: [PATCH 09/21] =?UTF-8?q?style(message):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8F=91=E9=80=81=E4=B8=AD=E7=9A=84=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 call_item.dart 中消息发送中的圆形加载动画 - 移除 gift_item.dart 中消息发送中的圆形加载动画 - 移除 image_item.dart 中消息发送中的圆形加载动画 - 移除 room_item.dart 中消息发送中的圆形加载动画 - 移除 text_item.dart 中消息发送中的圆形加载动画 - 移除 video_item.dart 中消息发送中的圆形加载动画 - 调整礼物发送失败时的错误提示信息 - 保持聊天礼物弹窗组件的布局结构不变 --- lib/controller/message/chat_controller.dart | 4 +- lib/widget/message/call_item.dart | 11 +--- lib/widget/message/chat_gift_popup.dart | 70 ++++++++++----------- lib/widget/message/gift_item.dart | 11 +--- lib/widget/message/image_item.dart | 14 +---- lib/widget/message/room_item.dart | 11 +--- lib/widget/message/text_item.dart | 13 +--- lib/widget/message/video_item.dart | 13 +--- 8 files changed, 49 insertions(+), 98 deletions(-) diff --git a/lib/controller/message/chat_controller.dart b/lib/controller/message/chat_controller.dart index d9a20ed..0373cbc 100644 --- a/lib/controller/message/chat_controller.dart +++ b/lib/controller/message/chat_controller.dart @@ -1742,9 +1742,9 @@ class ChatController extends GetxController { requestData, ); - if (!response.data.isSuccess) { + if (response.data.isSuccess && !response.data.data['success']) { SmartDialog.showToast( - response.data.message.isNotEmpty ? response.data.message : '发送礼物失败', + response.data.data['code'] == 'E0002' ? '玫瑰不足请充值' : '发送礼物失败', ); return false; } diff --git a/lib/widget/message/call_item.dart b/lib/widget/message/call_item.dart index 5f93448..9aa5169 100644 --- a/lib/widget/message/call_item.dart +++ b/lib/widget/message/call_item.dart @@ -368,15 +368,8 @@ class CallItem extends StatelessWidget { ), ); } else if (status == MessageStatus.PROGRESS) { - // 发送中,显示加载动画 - return Container( - width: 16.w, - height: 16.w, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.grey), - ), - ); + // 发送中,不显示loading + return SizedBox.shrink(); } else { // 发送成功,不显示任何状态 return SizedBox.shrink(); diff --git a/lib/widget/message/chat_gift_popup.dart b/lib/widget/message/chat_gift_popup.dart index f000fea..a99bec8 100644 --- a/lib/widget/message/chat_gift_popup.dart +++ b/lib/widget/message/chat_gift_popup.dart @@ -146,45 +146,45 @@ class _ChatGiftPopupState extends State { ? MediaQuery.of(context).padding.bottom : 10.h, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // 数量选择(暂时不实现,固定为1) - SizedBox(width: 1.w), - ValueListenableBuilder( - valueListenable: widget.giftNum, - builder: (context, num, _) { - return Row( - children: [ - GestureDetector( - onTap: () => _handleSendGift(), - child: Container( - width: 63.w, - height: 30.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(30.w)), - gradient: const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Color.fromRGBO(61, 138, 224, 1), - Color.fromRGBO(131, 89, 255, 1), - ], - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 数量选择(暂时不实现,固定为1) + SizedBox(width: 1.w), + ValueListenableBuilder( + valueListenable: widget.giftNum, + builder: (context, num, _) { + return Row( + children: [ + GestureDetector( + onTap: () => _handleSendGift(), + child: Container( + width: 63.w, + height: 30.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(30.w)), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color.fromRGBO(61, 138, 224, 1), + Color.fromRGBO(131, 89, 255, 1), + ], ), - child: Center( - child: Text( - "赠送", - style: TextStyle(fontSize: 13.w, color: Colors.white), - ), + ), + child: Center( + child: Text( + "赠送", + style: TextStyle(fontSize: 13.w, color: Colors.white), ), ), ), - ], - ); - }, - ), - ], + ), + ], + ); + }, + ), + ], ), ), ); diff --git a/lib/widget/message/gift_item.dart b/lib/widget/message/gift_item.dart index 8c2590e..9a43b50 100644 --- a/lib/widget/message/gift_item.dart +++ b/lib/widget/message/gift_item.dart @@ -346,15 +346,8 @@ class GiftItem extends StatelessWidget { ), ); } else if (status == MessageStatus.PROGRESS) { - // 发送中,显示加载动画 - return Container( - width: 16.w, - height: 16.w, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.grey), - ), - ); + // 发送中,不显示loading + return SizedBox.shrink(); } else { // 发送成功,不显示任何状态 return SizedBox.shrink(); diff --git a/lib/widget/message/image_item.dart b/lib/widget/message/image_item.dart index f29ffd2..146882a 100644 --- a/lib/widget/message/image_item.dart +++ b/lib/widget/message/image_item.dart @@ -701,18 +701,8 @@ class _ImageItemState extends State { ), ); } else if (status == MessageStatus.PROGRESS) { - // 如果应该隐藏PROGRESS状态(已超时且有远程路径),不显示loading - if (_shouldHideProgressStatus) { - return SizedBox.shrink(); - } - return Container( - width: 16.w, - height: 16.w, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.grey), - ), - ); + // 发送中,不显示loading + return SizedBox.shrink(); } else { return SizedBox.shrink(); } diff --git a/lib/widget/message/room_item.dart b/lib/widget/message/room_item.dart index 7678e3d..cea6bbf 100644 --- a/lib/widget/message/room_item.dart +++ b/lib/widget/message/room_item.dart @@ -467,15 +467,8 @@ class RoomItem extends StatelessWidget { ), ); } else if (status == MessageStatus.PROGRESS) { - // 发送中,显示加载动画 - return Container( - width: 16.w, - height: 16.w, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.grey), - ), - ); + // 发送中,不显示loading + return SizedBox.shrink(); } else { // 发送成功,不显示任何状态 return SizedBox.shrink(); diff --git a/lib/widget/message/text_item.dart b/lib/widget/message/text_item.dart index fe303b6..78f0ad7 100644 --- a/lib/widget/message/text_item.dart +++ b/lib/widget/message/text_item.dart @@ -301,17 +301,8 @@ class TextItem extends StatelessWidget { ), ); } else if (status == MessageStatus.PROGRESS) { - // 发送中,显示加载动画 - return Container( - width: 16.w, - height: 16.w, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.grey, - ), - ), - ); + // 发送中,不显示loading + return SizedBox.shrink(); } else { // 发送成功,不显示任何状态 return SizedBox.shrink(); diff --git a/lib/widget/message/video_item.dart b/lib/widget/message/video_item.dart index ea3599e..62e365c 100644 --- a/lib/widget/message/video_item.dart +++ b/lib/widget/message/video_item.dart @@ -508,17 +508,8 @@ class _VideoItemState extends State { ), ); } else if (status == MessageStatus.PROGRESS) { - // 发送中,显示加载动画 - return Container( - width: 16.w, - height: 16.w, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.grey, - ), - ), - ); + // 发送中,不显示loading + return SizedBox.shrink(); } else { // 发送成功,不显示任何状态 return SizedBox.shrink(); From 51077a5c7f81a019150e2fa8e48f555972d38e07 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 11:52:06 +0800 Subject: [PATCH 10/21] =?UTF-8?q?refactor(message):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E7=B3=BB=E7=BB=9F=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=B6=88=E6=81=AF=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将礼物消息从文本消息改为自定义消息格式,使用 event 和 params 参数 - 更新聊天控制器中的消息发送逻辑,使用 sendCustomMessage 替代 sendTextMessage - 修改礼物消息解析逻辑,同时支持新格式自定义消息和旧格式文本消息 - 更新直播间邀请消息为自定义消息格式,移除不必要的 type 字段 - 添加对旧格式特殊消息的过滤,避免显示 JSON 内容 - 优化消息类型判断逻辑,统一处理新旧格式的消息识别 --- lib/controller/message/chat_controller.dart | 25 ++++++++--------- .../live/live_room_guest_list_dialog.dart | 7 +++-- lib/widget/message/gift_item.dart | 27 ++++++++++++++++--- lib/widget/message/message_item.dart | 16 ++++++----- lib/widget/message/text_item.dart | 15 +++++++++++ 5 files changed, 62 insertions(+), 28 deletions(-) diff --git a/lib/controller/message/chat_controller.dart b/lib/controller/message/chat_controller.dart index 0373cbc..2a882f2 100644 --- a/lib/controller/message/chat_controller.dart +++ b/lib/controller/message/chat_controller.dart @@ -1749,31 +1749,28 @@ class ChatController extends GetxController { return false; } - // 发送成功,创建礼物消息 - final giftMessageContent = jsonEncode({ - 'type': 'gift', + // 发送成功,创建礼物消息参数(需要转换为 Map) + final giftParams = { 'giftProductId': gift.productId, 'giftProductTitle': gift.productTitle, 'giftMainPic': gift.mainPic, - 'giftPrice': gift.unitSellingPrice, - 'quantity': quantity, - }); - - // 创建文本消息,内容为礼物信息JSON - final content = '[GIFT:]$giftMessageContent'; + 'giftPrice': gift.unitSellingPrice.toString(), + 'quantity': quantity.toString(), + }; - // 先创建消息对象(即使发送失败也要显示在列表中) - final tempMessage = EMMessage.createTxtSendMessage( + // 先创建自定义消息对象(即使发送失败也要显示在列表中) + final tempMessage = EMMessage.createCustomSendMessage( targetId: userId, - content: content, + event: 'gift', + params: giftParams, ); // 将消息添加到列表末尾(显示发送中状态) messages.add(tempMessage); update(); - // 发送消息 - final message = await IMManager.instance.sendTextMessage(content, userId); + // 发送自定义消息 + final message = await IMManager.instance.sendCustomMessage(userId, 'gift', giftParams); if (message != null) { // 发送成功,替换临时消息 final index = messages.indexWhere((msg) => msg.msgId == tempMessage.msgId); diff --git a/lib/widget/live/live_room_guest_list_dialog.dart b/lib/widget/live/live_room_guest_list_dialog.dart index 4bd220f..8682d31 100644 --- a/lib/widget/live/live_room_guest_list_dialog.dart +++ b/lib/widget/live/live_room_guest_list_dialog.dart @@ -632,9 +632,8 @@ class _LiveRoomGuestListDialogState extends State { final cleanedAvatar = anchorAvatar.trim().replaceAll('`', ''); final cleanedCover = cleanedAvatar; // 封面使用主持人头像 - // 构建消息体,包含房间信息 - final messageData = { - 'type': 'live_room_invite', + // 构建消息参数,包含房间信息(不需要 type 字段,因为 event 已经是 'live_room_invite') + final messageParams = { 'channelId': channelId, 'anchorAvatar': cleanedAvatar, 'anchorName': anchorName, @@ -647,7 +646,7 @@ class _LiveRoomGuestListDialogState extends State { final result = await IMManager.instance.sendCustomMessage( targetUserId, 'live_room_invite', - messageData, + messageParams, ); if (result != null) { diff --git a/lib/widget/message/gift_item.dart b/lib/widget/message/gift_item.dart index 9a43b50..7a17382 100644 --- a/lib/widget/message/gift_item.dart +++ b/lib/widget/message/gift_item.dart @@ -25,14 +25,28 @@ class GiftItem extends StatelessWidget { super.key, }); - /// 从消息内容中解析礼物信息(使用特殊的JSON格式) + /// 从自定义消息的 params 或旧格式的文本消息中解析礼物信息 Map? _parseGiftInfo() { try { + // 新格式:自定义消息 + if (message.body.type == MessageType.CUSTOM) { + final customBody = message.body as EMCustomMessageBody; + if (customBody.event == 'gift' && customBody.params != null) { + // 将 Map 转换为 Map + final params = customBody.params!; + return { + 'giftProductId': params['giftProductId'] ?? '', + 'giftProductTitle': params['giftProductTitle'] ?? '', + 'giftMainPic': params['giftMainPic'] ?? '', + 'giftPrice': params['giftPrice'] ?? '0', + 'quantity': int.tryParse(params['quantity'] ?? '1') ?? 1, + }; + } + } + // 旧格式:文本消息,内容以 [GIFT:] 开头 if (message.body.type == MessageType.TXT) { final textBody = message.body as EMTextMessageBody; final content = textBody.content; - - // 检查是否是礼物消息(以 [GIFT:] 开头) if (content.startsWith('[GIFT:]')) { final jsonStr = content.substring(7); // 移除 '[GIFT:]' 前缀 return jsonDecode(jsonStr) as Map; @@ -66,7 +80,12 @@ class GiftItem extends StatelessWidget { int _getGiftQuantity() { final giftInfo = _parseGiftInfo(); if (giftInfo != null) { - return giftInfo['quantity'] as int? ?? 1; + final quantity = giftInfo['quantity']; + if (quantity is int) { + return quantity; + } else if (quantity is String) { + return int.tryParse(quantity) ?? 1; + } } return 1; } diff --git a/lib/widget/message/message_item.dart b/lib/widget/message/message_item.dart index add9483..340d4fc 100644 --- a/lib/widget/message/message_item.dart +++ b/lib/widget/message/message_item.dart @@ -41,14 +41,18 @@ class MessageItem extends StatelessWidget { return false; } - // 检查是否是礼物消息(通过消息内容识别) + // 检查是否是礼物消息(支持新格式的自定义消息和旧格式的文本消息) bool _isGiftMessage() { try { + // 新格式:自定义消息 + if (message.body.type == MessageType.CUSTOM) { + final customBody = message.body as EMCustomMessageBody; + return customBody.event == 'gift'; + } + // 旧格式:文本消息,内容以 [GIFT:] 开头 if (message.body.type == MessageType.TXT) { final textBody = message.body as EMTextMessageBody; - final content = textBody.content; - // 检查是否是礼物消息(以 [GIFT:] 开头) - return content.startsWith('[GIFT:]'); + return textBody.content.startsWith('[GIFT:]'); } } catch (e) { // 解析失败,不是礼物消息 @@ -92,8 +96,8 @@ class MessageItem extends StatelessWidget { ); } - // 处理礼物消息(通过文本消息的扩展属性识别) - if (message.body.type == MessageType.TXT && _isGiftMessage()) { + // 处理礼物消息(支持新格式的自定义消息和旧格式的文本消息) + if (_isGiftMessage()) { return GiftItem( message: message, isSentByMe: isSentByMe, diff --git a/lib/widget/message/text_item.dart b/lib/widget/message/text_item.dart index 78f0ad7..de1b402 100644 --- a/lib/widget/message/text_item.dart +++ b/lib/widget/message/text_item.dart @@ -27,8 +27,23 @@ class TextItem extends StatelessWidget { super.key, }); + /// 检查是否是旧格式的特殊消息(礼物、直播间邀请等) + bool _isLegacySpecialMessage() { + final content = textBody.content; + // 检查是否是旧格式的礼物消息或直播间邀请消息 + return content.startsWith('[GIFT:]') || + content.startsWith('[ROOM:]') || + content.startsWith('[CALL:]'); + } + @override Widget build(BuildContext context) { + // 如果是旧格式的特殊消息,不显示 JSON 内容 + if (_isLegacySpecialMessage()) { + // 返回空组件,不显示这些旧格式的消息 + return SizedBox.shrink(); + } + // 检查是否有金币信息(只对接收的消息显示) final revenueInfo = _getRevenueInfo(); From 17c482b50754c46d0f3b206c4a1de3270bc10931 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 12:55:42 +0800 Subject: [PATCH 11/21] =?UTF-8?q?feat(im):=20=E6=B7=BB=E5=8A=A0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=BF=AE=E6=94=B9=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DGIFT=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加modifyMessage方法支持同时修改消息体和扩展属性 - 实现_notifyMessageModified方法通知ChatController更新消息 - 修复GIFT消息检查时content可能为空的潜在问题 - 在直播间页面中延迟执行overlay隐藏避免build过程中触发setState - 移除ota_update依赖包 - 实现用户协议只在第一次安装时显示的逻辑 - 添加mounted检查确保组件存在时才更新状态 --- lib/im/im_manager.dart | 94 +++++++++++++++++++++++++- lib/main.dart | 28 +++++--- lib/pages/discover/live_room_page.dart | 6 +- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/lib/im/im_manager.dart b/lib/im/im_manager.dart index 59a6050..18a7587 100644 --- a/lib/im/im_manager.dart +++ b/lib/im/im_manager.dart @@ -1743,7 +1743,7 @@ class IMManager { } // 检查是否是GIFT消息 - if (content.startsWith('[GIFT:]')) { + if (content != null && content.startsWith('[GIFT:]')) { return '[礼物]'; } @@ -1823,6 +1823,98 @@ class IMManager { } } + /// 修改消息(可同时修改消息体和消息扩展属性) + /// [messageId] 要修改的消息ID + /// [msgBody] 新的消息体(可选,如果为null则不修改消息体) + /// [attributes] 新的消息扩展属性(可选,如果为null则不修改扩展属性) + Future modifyMessage({ + required String messageId, + EMMessageBody? msgBody, + Map? attributes, + }) async { + try { + if (messageId.isEmpty) { + if (Get.isLogEnable) { + Get.log('❌ [IMManager] 消息ID为空,无法修改'); + } + return false; + } + + // 如果既没有提供消息体也没有提供扩展属性,则无法修改 + if (msgBody == null && attributes == null) { + if (Get.isLogEnable) { + Get.log('❌ [IMManager] 消息体和扩展属性都为空,无法修改'); + } + return false; + } + + // 调用SDK的修改消息方法 + await EMClient.getInstance.chatManager.modifyMessage( + messageId: messageId, + msgBody: msgBody, + attributes: attributes, + ); + + // 刷新会话列表 + _refreshConversationList(); + + // 通知对应的 ChatController 更新消息 + _notifyMessageModified(messageId, msgBody, attributes); + + return true; + } catch (e) { + if (Get.isLogEnable) { + Get.log('❌ [IMManager] 消息修改失败: messageId=$messageId, 错误: $e'); + } + return false; + } + } + + /// 通知 ChatController 消息已被修改 + void _notifyMessageModified( + String messageId, + EMMessageBody? msgBody, + Map? attributes, + ) { + try { + // 遍历所有活跃的 ChatController,查找包含该消息的控制器 + for (var entry in _activeChatControllers.entries) { + final controller = entry.value; + final index = controller.messages.indexWhere((msg) => msg.msgId == messageId); + if (index != -1) { + // 找到消息,更新它 + final message = controller.messages[index]; + + // 如果提供了新的消息体,更新消息体 + if (msgBody != null) { + // 注意:EMMessage 的 body 是只读的,需要通过重新获取消息来更新 + // 这里我们更新消息的扩展属性,消息体需要通过重新获取消息来更新 + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 消息体修改需要重新获取消息: messageId=$messageId'); + } + } + + // 如果提供了新的扩展属性,更新扩展属性 + if (attributes != null) { + message.attributes ??= {}; + message.attributes!.addAll(attributes); + } + + // 通知UI更新 + controller.update(); + + if (Get.isLogEnable) { + Get.log('✅ [IMManager] 已通知ChatController更新消息: userId=${entry.key}, messageId=$messageId'); + } + } + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 通知ChatController消息修改失败: $e'); + } + } + } + /// 撤回消息 Future recallMessage(EMMessage message) async { try { diff --git a/lib/main.dart b/lib/main.dart index a3e2bc0..15a5e4b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,7 +21,6 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:fluwx/fluwx.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; -import 'package:ota_update/ota_update.dart'; import 'extension/my_cupertino_localizations.dart'; @@ -206,27 +205,36 @@ class _MyAppState extends State { } - /// 检查是否已同意用户协议 + /// 检查是否已同意用户协议(只在第一次安装时显示) void _checkAgreement() { final storage = GetStorage(); + // 检查是否已经同意过协议,如果已同意则不显示弹框 final hasAgreed = storage.read('hasAgreedUserAgreement') ?? false; if (!hasAgreed) { - // 延迟显示弹框,确保UI已初始化 + // 第一次安装,延迟显示弹框,确保UI已初始化 WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - _showAgreementDialog = true; - }); + if (mounted) { + setState(() { + _showAgreementDialog = true; + }); + } }); + } else { + // 已经同意过协议,不显示弹框 + _showAgreementDialog = false; } } - /// 处理同意协议 + /// 处理同意协议(保存同意状态,确保下次不再显示) void _onAgreeAgreement() { final storage = GetStorage(); + // 保存用户已同意协议的状态,确保只在第一次安装时显示 storage.write('hasAgreedUserAgreement', true); - setState(() { - _showAgreementDialog = false; - }); + if (mounted) { + setState(() { + _showAgreementDialog = false; + }); + } } /// 处理不同意协议 - 退出应用 diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index bfaa61b..edb665a 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -46,8 +46,10 @@ class _LiveRoomPageState extends State { ? Get.find() : Get.put(RoomController()); _overlayController = Get.find(); - // 进入直播间时,确保隐藏小窗口 - _overlayController.hide(); + // 进入直播间时,确保隐藏小窗口(延迟到 build 完成后执行,避免在 build 过程中触发 setState) + WidgetsBinding.instance.addPostFrameCallback((_) { + _overlayController.hide(); + }); // 启用屏幕常亮 WakelockPlus.enable(); // 如果当前用户是男性,请求连麦卡片信息 From 1e1b67aa5720fe115f6c24235dd8693222101687 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 13:00:37 +0800 Subject: [PATCH 12/21] =?UTF-8?q?feat(message):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=9A=E8=AF=9D=E6=B6=88=E6=81=AF=E7=9A=84=E6=96=B0=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E8=87=AA=E5=AE=9A=E4=B9=89=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现自定义消息格式的通话消息解析和发送 - 在CallItem组件中支持新旧两种格式的通话消息识别 - 修改CallManager使用IMManager发送自定义通话消息 - 更新ChatController中通话消息的创建和发送逻辑 - 添加对Map参数格式的支持 - 移除过时的JSON字符串解析方式 - 保持向后兼容旧格式文本消息的处理 --- lib/controller/message/call_manager.dart | 38 ++++++++++----- lib/controller/message/chat_controller.dart | 54 ++++++++++----------- lib/widget/message/call_item.dart | 18 ++++++- lib/widget/message/message_item.dart | 12 +++-- 4 files changed, 79 insertions(+), 43 deletions(-) diff --git a/lib/controller/message/call_manager.dart b/lib/controller/message/call_manager.dart index 8c0d59b..8eedc7e 100644 --- a/lib/controller/message/call_manager.dart +++ b/lib/controller/message/call_manager.dart @@ -4,6 +4,7 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:im_flutter_sdk/im_flutter_sdk.dart'; +import '../../im/im_manager.dart'; import 'chat_controller.dart'; /// 通话类型 @@ -282,24 +283,22 @@ class CallManager extends GetxController { ); } - // 否则直接通过 IMManager 发送 - final callInfoMap = { + // 否则直接通过 IMManager 发送自定义消息 + final callParams = { 'callType': callType, 'callStatus': callStatus, }; if (callDuration != null) { - callInfoMap['callDuration'] = callDuration; + callParams['callDuration'] = callDuration.toString(); } - final callInfoJson = jsonEncode(callInfoMap); - final content = '[CALL:]$callInfoJson'; - final message = EMMessage.createTxtSendMessage( - targetId: targetUserId, - content: content, + final message = await IMManager.instance.sendCustomMessage( + targetUserId, + 'call', + callParams, ); - - await EMClient.getInstance.chatManager.sendMessage(message); - return true; + + return message != null; } catch (e) { print('❌ [CallManager] 发送通话消息失败: $e'); return false; @@ -337,9 +336,24 @@ class CallManager extends GetxController { } } - /// 从消息中解析通话信息 + /// 从消息中解析通话信息(支持新格式的自定义消息和旧格式的文本消息) Map? _parseCallInfo(EMMessage message) { try { + // 新格式:自定义消息 + if (message.body.type == MessageType.CUSTOM) { + final customBody = message.body as EMCustomMessageBody; + if (customBody.event == 'call' && customBody.params != null) { + final params = customBody.params!; + return { + 'callType': params['callType'] ?? 'voice', + 'callStatus': params['callStatus'] ?? 'missed', + 'callDuration': params['callDuration'] != null + ? int.tryParse(params['callDuration']!) + : null, + }; + } + } + // 旧格式:文本消息 if (message.body.type == MessageType.TXT) { final textBody = message.body as EMTextMessageBody; final content = textBody.content; diff --git a/lib/controller/message/chat_controller.dart b/lib/controller/message/chat_controller.dart index 2a882f2..eed4816 100644 --- a/lib/controller/message/chat_controller.dart +++ b/lib/controller/message/chat_controller.dart @@ -1,6 +1,5 @@ import 'package:get/get.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'dart:convert'; import '../../im/im_manager.dart'; import '../../im/chat_presence_manager.dart'; @@ -1057,47 +1056,48 @@ class ChatController extends GetxController { int? callDuration, // 通话时长(秒) }) async { try { - // 构建通话信息 - final callInfoMap = { + // 构建通话消息参数(需要转换为 Map) + final callParams = { 'callType': callType, 'callStatus': callStatus, }; if (callDuration != null) { - callInfoMap['callDuration'] = callDuration; + callParams['callDuration'] = callDuration.toString(); } - final callInfoJson = jsonEncode(callInfoMap); - - // 创建文本消息,内容格式为 [CALL:] + JSON - final content = '[CALL:]$callInfoJson'; - // 先创建消息对象(即使发送失败也要显示在列表中) - final tempMessage = EMMessage.createTxtSendMessage( + // 先创建自定义消息对象(即使发送失败也要显示在列表中) + final tempMessage = EMMessage.createCustomSendMessage( targetId: userId, - content: content, + event: 'call', + params: callParams, ); // 将消息添加到列表末尾(显示发送中状态) 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; + final message = await IMManager.instance.sendCustomMessage(userId, 'call', callParams); + if (message != null) { + // 发送成功,替换临时消息 + final index = messages.indexWhere((msg) => msg.msgId == tempMessage.msgId); + if (index != -1) { + messages[index] = message; + } + update(); + // 更新会话列表 + _refreshConversationList(); + return true; + } else { + // 发送失败,消息状态会自动变为FAIL + update(); + if (Get.isLogEnable) { + Get.log('发送通话消息失败: message为null'); + } + SmartDialog.showToast('通话消息发送失败'); + return false; } - update(); - // 更新会话列表 - _refreshConversationList(); - return true; } catch (e) { // 发送失败,消息状态会自动变为FAIL update(); diff --git a/lib/widget/message/call_item.dart b/lib/widget/message/call_item.dart index 9aa5169..5fa3b12 100644 --- a/lib/widget/message/call_item.dart +++ b/lib/widget/message/call_item.dart @@ -39,9 +39,25 @@ class CallItem extends StatelessWidget { super.key, }); - /// 从消息内容中解析通话信息(使用特殊的JSON格式) + /// 从消息内容中解析通话信息(支持新格式的自定义消息和旧格式的文本消息) Map? _parseCallInfo() { try { + // 新格式:自定义消息 + if (message.body.type == MessageType.CUSTOM) { + final customBody = message.body as EMCustomMessageBody; + if (customBody.event == 'call' && customBody.params != null) { + // 将 Map 转换为 Map + final params = customBody.params!; + return { + 'callType': params['callType'] ?? 'voice', + 'callStatus': params['callStatus'] ?? 'missed', + 'callDuration': params['callDuration'] != null + ? int.tryParse(params['callDuration']!) + : null, + }; + } + } + // 旧格式:文本消息,内容以 [CALL:] 开头 if (message.body.type == MessageType.TXT) { final textBody = message.body as EMTextMessageBody; final content = textBody.content; diff --git a/lib/widget/message/message_item.dart b/lib/widget/message/message_item.dart index 340d4fc..96406ae 100644 --- a/lib/widget/message/message_item.dart +++ b/lib/widget/message/message_item.dart @@ -26,9 +26,15 @@ class MessageItem extends StatelessWidget { super.key, }); - // 检查是否是通话消息(通过消息内容识别) + // 检查是否是通话消息(支持新格式的自定义消息和旧格式的文本消息) bool _isCallMessage() { try { + // 新格式:自定义消息 + if (message.body.type == MessageType.CUSTOM) { + final customBody = message.body as EMCustomMessageBody; + return customBody.event == 'call'; + } + // 旧格式:文本消息,内容以 [CALL:] 开头 if (message.body.type == MessageType.TXT) { final textBody = message.body as EMTextMessageBody; final content = textBody.content; @@ -77,8 +83,8 @@ class MessageItem extends StatelessWidget { Widget build(BuildContext context) { print('📨 [MessageItem] 渲染消息,类型: ${message.body.type}'); - // 处理通话消息(通过文本消息的扩展属性识别) - if (message.body.type == MessageType.TXT && _isCallMessage()) { + // 处理通话消息(支持新格式的自定义消息和旧格式的文本消息) + if (_isCallMessage()) { return CallItem( message: message, isSentByMe: isSentByMe, From 814a40a3582a96fde501d96a948621aaeb32dd7f Mon Sep 17 00:00:00 2001 From: ZHR007 Date: Sat, 27 Dec 2025 15:13:59 +0800 Subject: [PATCH 13/21] =?UTF-8?q?=E9=80=80=E5=87=BA=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/controller/discover/room_controller.dart | 4 ++++ lib/controller/setting/setting_controller.dart | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/controller/discover/room_controller.dart b/lib/controller/discover/room_controller.dart index 12c90f1..a6663c2 100644 --- a/lib/controller/discover/room_controller.dart +++ b/lib/controller/discover/room_controller.dart @@ -330,6 +330,10 @@ class RoomController extends GetxController with WidgetsBindingObserver { } Future leaveChannel() async { + if(!isLive.value){ + // 如果是非直播中,不需要处理 + return; + } // 如果是主播,先销毁 RTC 频道,然后发送结束直播消息 if (currentRole == CurrentRole.broadcaster) { try { diff --git a/lib/controller/setting/setting_controller.dart b/lib/controller/setting/setting_controller.dart index 4f60ada..82bbbb3 100644 --- a/lib/controller/setting/setting_controller.dart +++ b/lib/controller/setting/setting_controller.dart @@ -77,8 +77,11 @@ class SettingController extends GetxController { final conversationController = Get.find(); conversationController.clearConversations(); } - // 清除本地存储 - storage.erase(); + // App清除本地存储,有待处理, + // storage.erase(); + storage.remove('userId'); + storage.remove('token'); + // storage.write('hasAgreedUserAgreement', true); // 清除全局数据 GlobalData().logout(); } From b6915f264d54553e74f34e7c94840bf2c64ac3fe Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 16:05:34 +0800 Subject: [PATCH 14/21] =?UTF-8?q?feat(call):=20=E5=AE=9E=E7=8E=B0=E9=80=9A?= =?UTF-8?q?=E8=AF=9D=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加音频播放器支持来电铃声循环播放 - 实现通话消息状态更新和消息修改功能 - 优化会话列表加载和刷新防抖机制 - 支持自定义通话消息格式替换旧文本格式 - 添加通话音频播放和停止控制 - 优化直播间小窗口显示和隐藏逻辑 - 实现通话邀请弹框和消息解析功能 - 添加资产文件支持音频资源 - 优化消息通知对话框支持emoji显示 - 移除旧格式消息处理逻辑简化代码结构 --- assets/audio/call.mp3 | Bin 0 -> 33437 bytes lib/controller/message/call_manager.dart | 158 ++++++-- .../message/conversation_controller.dart | 93 +++-- lib/generated/assets.dart | 1 + lib/im/im_manager.dart | 342 +++++++++--------- lib/pages/discover/live_room_page.dart | 21 +- lib/pages/message/conversation_tab.dart | 52 ++- lib/widget/live/draggable_overlay_widget.dart | 27 +- lib/widget/message/call_item.dart | 15 +- lib/widget/message/gift_item.dart | 13 +- lib/widget/message/message_item.dart | 18 +- .../message/message_notification_dialog.dart | 12 +- lib/widget/message/text_item.dart | 15 - pubspec.yaml | 1 + 14 files changed, 439 insertions(+), 329 deletions(-) create mode 100644 assets/audio/call.mp3 diff --git a/assets/audio/call.mp3 b/assets/audio/call.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ebc4b7e1534a070ea670e3f540f8507e06080ea8 GIT binary patch literal 33437 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 literal 0 HcmV?d00001 diff --git a/lib/controller/message/call_manager.dart b/lib/controller/message/call_manager.dart index 8eedc7e..270fdd0 100644 --- a/lib/controller/message/call_manager.dart +++ b/lib/controller/message/call_manager.dart @@ -1,9 +1,10 @@ import 'dart:async'; -import 'dart:convert'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:im_flutter_sdk/im_flutter_sdk.dart'; +import '../../generated/assets.dart'; import '../../im/im_manager.dart'; import 'chat_controller.dart'; @@ -37,9 +38,25 @@ class CallManager extends GetxController { Timer? _callTimer; int _callDurationSeconds = 0; final RxInt callDurationSeconds = RxInt(0); + + // 音频播放器(用于播放来电铃声) + final AudioPlayer _callAudioPlayer = AudioPlayer(); + bool _isPlayingCallAudio = false; CallManager() { print('📞 [CallManager] 通话管理器已初始化'); + // 监听音频播放完成事件,实现循环播放 + _callAudioPlayer.onPlayerComplete.listen((_) async { + if (_isPlayingCallAudio) { + // 如果还在播放状态,重新播放(循环播放) + try { + await _callAudioPlayer.play(AssetSource(Assets.audioCall.replaceFirst('assets/', ''))); + } catch (e) { + print('❌ [CallManager] 循环播放来电铃声失败: $e'); + _isPlayingCallAudio = false; + } + } + }); } /// 发起通话 @@ -78,6 +95,11 @@ class CallManager extends GetxController { chatController: chatController, ); + // 如果是视频通话,开始循环播放来电铃声 + if (callType == CallType.video) { + startCallAudio(); + } + // TODO: 这里可以集成实际的通话SDK,发起真正的通话 // 例如:await RTCManager.instance.startCall(targetUserId, callType); @@ -119,6 +141,9 @@ class CallManager extends GetxController { ); currentCall.value = session; + // 停止播放来电铃声(已接通) + stopCallAudio(); + // 开始计时 _startCallTimer(); @@ -151,6 +176,9 @@ class CallManager extends GetxController { try { print('📞 [CallManager] 拒绝通话'); + // 停止播放来电铃声(已拒绝) + stopCallAudio(); + // 更新通话消息状态为已拒绝 await _updateCallMessageStatus( message: message, @@ -191,6 +219,9 @@ class CallManager extends GetxController { ); } + // 停止播放来电铃声(已取消) + stopCallAudio(); + // 停止计时 _stopCallTimer(); @@ -219,6 +250,9 @@ class CallManager extends GetxController { try { print('📞 [CallManager] 结束通话,时长: ${callDuration}秒'); + // 停止播放来电铃声(通话结束) + stopCallAudio(); + // 停止计时 _stopCallTimer(); @@ -305,7 +339,7 @@ class CallManager extends GetxController { } } - /// 更新通话消息状态 + /// 更新通话消息状态(使用modifyMessage修改现有消息) Future _updateCallMessageStatus({ required EMMessage message, required String callStatus, @@ -320,26 +354,80 @@ class CallManager extends GetxController { } final callType = callInfo['callType'] as String? ?? 'voice'; - final targetUserId = message.from ?? message.to ?? ''; + final messageId = message.msgId; - // 发送更新的通话消息 - return await _sendCallMessage( - targetUserId: targetUserId, - callType: callType, - callStatus: callStatus, - callDuration: callDuration, - chatController: chatController, - ); + if (messageId.isEmpty) { + print('❌ [CallManager] 消息ID为空,无法修改消息'); + return false; + } + + // 如果是自定义消息,使用modifyMessage修改 + if (message.body.type == MessageType.CUSTOM) { + // 构建新的参数 + final callParams = { + 'callType': callType, + 'callStatus': callStatus, + }; + if (callDuration != null) { + callParams['callDuration'] = callDuration.toString(); + } + + // 创建新的消息体 + final customBody = EMCustomMessageBody( + event: 'call', + params: callParams, + ); + + // 使用modifyMessage修改消息 + final success = await IMManager.instance.modifyMessage( + messageId: messageId, + msgBody: customBody, + attributes: null, // 不修改扩展属性 + ); + + if (success) { + print('✅ [CallManager] 消息修改成功: messageId=$messageId, callStatus=$callStatus'); + + // 如果提供了chatController,更新本地消息列表 + if (chatController != null) { + // 更新消息体中的参数 + try { + final index = chatController.messages.indexWhere((msg) => msg.msgId == messageId); + if (index != -1) { + final updatedMessage = chatController.messages[index]; + if (updatedMessage.body.type == MessageType.CUSTOM) { + final customBody = updatedMessage.body as EMCustomMessageBody; + // 创建新的参数Map并更新 + final updatedParams = Map.from(customBody.params ?? {}); + updatedParams['callType'] = callType; + updatedParams['callStatus'] = callStatus; + if (callDuration != null) { + updatedParams['callDuration'] = callDuration.toString(); + } + // 注意:EMCustomMessageBody的params可能是只读的,这里可能需要重新创建消息 + // 暂时先通知UI更新,实际的消息体更新会在收到onMessageContentChanged回调时处理 + chatController.update(); + } + } + } catch (e) { + print('⚠️ [CallManager] 更新本地消息列表失败: $e'); + } + } + } + + return success; + } + // 如果不是自定义消息,返回失败 + return false; } catch (e) { print('❌ [CallManager] 更新通话消息状态失败: $e'); return false; } } - /// 从消息中解析通话信息(支持新格式的自定义消息和旧格式的文本消息) + /// 从自定义消息中解析通话信息 Map? _parseCallInfo(EMMessage message) { try { - // 新格式:自定义消息 if (message.body.type == MessageType.CUSTOM) { final customBody = message.body as EMCustomMessageBody; if (customBody.event == 'call' && customBody.params != null) { @@ -353,15 +441,6 @@ class CallManager extends GetxController { }; } } - // 旧格式:文本消息 - 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'); } @@ -374,10 +453,45 @@ class CallManager extends GetxController { /// 获取当前通话时长(秒) int get currentCallDuration => callDurationSeconds.value; + /// 开始播放来电铃声(循环播放) + /// 可以是发起方或接收方调用 + Future startCallAudio() async { + if (_isPlayingCallAudio) { + return; // 已经在播放中 + } + + try { + _isPlayingCallAudio = true; + print('🔊 [CallManager] 开始播放来电铃声'); + await _callAudioPlayer.play(AssetSource(Assets.audioCall.replaceFirst('assets/', ''))); + } catch (e) { + print('❌ [CallManager] 播放来电铃声失败: $e'); + _isPlayingCallAudio = false; + } + } + + /// 停止播放来电铃声 + /// 可以是发起方或接收方调用 + Future stopCallAudio() async { + if (!_isPlayingCallAudio) { + return; // 没有在播放 + } + + try { + _isPlayingCallAudio = false; + print('🔇 [CallManager] 停止播放来电铃声'); + await _callAudioPlayer.stop(); + } catch (e) { + print('❌ [CallManager] 停止播放来电铃声失败: $e'); + } + } + @override void onClose() { + stopCallAudio(); _stopCallTimer(); currentCall.value = null; + _callAudioPlayer.dispose(); super.onClose(); } } diff --git a/lib/controller/message/conversation_controller.dart b/lib/controller/message/conversation_controller.dart index 73b414c..f76b192 100644 --- a/lib/controller/message/conversation_controller.dart +++ b/lib/controller/message/conversation_controller.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import 'dart:async'; import 'package:get/get.dart'; import 'package:im_flutter_sdk/im_flutter_sdk.dart'; import '../../im/im_manager.dart'; @@ -62,6 +62,9 @@ class ConversationController extends GetxController { // 总未读数 final totalUnreadCount = 0.obs; + // 防抖定时器,用于避免频繁刷新会话列表 + Timer? _refreshDebounceTimer; + /// 缓存用户信息(公开方法,供 ChatController 调用) void cacheUserInfo(String userId, ExtendedUserInfo userInfo) { _userInfoCache[userId] = userInfo; @@ -118,8 +121,8 @@ class ConversationController extends GetxController { } /// 加载会话列表 - Future loadConversations() async { - if (isLoading.value) return; + Future loadConversations({bool showLoading = true}) async { + if (isLoading.value && showLoading) return; // 检查 IM 登录状态 if (!IMManager.instance.isLoggedIn) { @@ -132,7 +135,10 @@ class ConversationController extends GetxController { } try { - isLoading.value = true; + // 只有在需要显示加载状态时才设置 + if (showLoading) { + isLoading.value = true; + } errorMessage.value = ''; // 从IMManager获取会话列表 @@ -160,7 +166,9 @@ class ConversationController extends GetxController { } errorMessage.value = '加载会话列表失败,请稍后重试'; } finally { - isLoading.value = false; + if (showLoading) { + isLoading.value = false; + } } } @@ -297,8 +305,28 @@ class ConversationController extends GetxController { } } - /// 刷新会话列表 - Future refreshConversations() async { + @override + void onClose() { + _refreshDebounceTimer?.cancel(); + super.onClose(); + } + + /// 刷新会话列表(带防抖,避免频繁刷新导致闪烁) + Future refreshConversations({bool force = false}) async { + // 如果正在加载且不是强制刷新,使用防抖机制 + if (!force && isLoading.value) { + // 取消之前的定时器 + _refreshDebounceTimer?.cancel(); + // 设置新的定时器,延迟300ms后刷新 + _refreshDebounceTimer = Timer(const Duration(milliseconds: 300), () { + refreshConversations(force: true); + }); + return; + } + + // 取消之前的定时器 + _refreshDebounceTimer?.cancel(); + // 如果IM未登录,先尝试登录 if (!IMManager.instance.isLoggedIn) { if (Get.isLogEnable) { @@ -361,8 +389,8 @@ class ConversationController extends GetxController { return; } } else { - // 如果已登录,直接加载会话列表 - await loadConversations(); + // 如果已登录,直接加载会话列表(不显示加载状态,避免闪烁) + await loadConversations(showLoading: false); } } @@ -390,33 +418,7 @@ class ConversationController extends GetxController { if(message.body.type == MessageType.TXT){ final body = message.body as EMTextMessageBody; - final content = body.content; - - // 检查是否是CALL消息 - if (content != null && content.startsWith('[CALL:]')) { - try { - final jsonStr = content.substring(7); // 移除 '[CALL:]' 前缀 - final callInfo = jsonDecode(jsonStr) as Map; - final callType = callInfo['callType'] as String?; - if (callType == 'video') { - return '[视频通话]'; - } else if (callType == 'voice') { - return '[语音通话]'; - } - } catch (e) { - // 解析失败,返回原始内容 - if (Get.isLogEnable) { - Get.log('⚠️ [ConversationController] 解析CALL消息失败: $e'); - } - } - } - - // 检查是否是GIFT消息 - if (content != null && content.startsWith('[GIFT:]')) { - return '[礼物]'; - } - - return content ?? ''; + return body.content; }else if(message.body.type == MessageType.IMAGE){ return '[图片]'; }else if(message.body.type == MessageType.VOICE){ @@ -432,6 +434,25 @@ class ConversationController extends GetxController { // 检查是否是分享房间类型 if(body.event == 'live_room_invite'){ return '[分享房间]'; + } else if (body.event == 'gift') { + return '[礼物]'; + } else if (body.event == 'call') { + // 解析通话类型 + try { + if (body.params != null) { + final callType = body.params!['callType'] ?? 'voice'; + if (callType == 'video') { + return '[视频通话]'; + } else if (callType == 'voice') { + return '[语音通话]'; + } + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [ConversationController] 解析通话消息类型失败: $e'); + } + } + return '[通话消息]'; } return '[自定义消息]'; } diff --git a/lib/generated/assets.dart b/lib/generated/assets.dart index 50c6531..5fe7891 100644 --- a/lib/generated/assets.dart +++ b/lib/generated/assets.dart @@ -2,6 +2,7 @@ class Assets { Assets._(); + static const String audioCall = 'assets/audio/call.mp3'; static const String emojiEmoji01 = 'assets/images/emoji/emoji_01.png'; static const String emojiEmoji02 = 'assets/images/emoji/emoji_02.png'; static const String emojiEmoji03 = 'assets/images/emoji/emoji_03.png'; diff --git a/lib/im/im_manager.dart b/lib/im/im_manager.dart index 18a7587..581f43b 100644 --- a/lib/im/im_manager.dart +++ b/lib/im/im_manager.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'dart:async'; -import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -1415,158 +1414,174 @@ class IMManager { } // 处理视频通话消息(CALL消息)- 显示特殊的视频通话邀请弹框 - if (message.body.type == MessageType.TXT) { - try { - final textBody = message.body as EMTextMessageBody; - final content = textBody.content; - if (content != null && content.startsWith('[CALL:]')) { - // 解析通话信息 + // 支持新格式的自定义消息和旧格式的文本消息 + Map? callInfo; + String? callType; + String? callStatus; + + try { + // 自定义消息 + if (message.body.type == MessageType.CUSTOM) { + final customBody = message.body as EMCustomMessageBody; + if (customBody.event == 'call' && customBody.params != null) { + final params = customBody.params!; + callType = params['callType'] ?? 'voice'; + callStatus = params['callStatus'] ?? 'missed'; + callInfo = { + 'callType': callType, + 'callStatus': callStatus, + }; + } + } + + // 如果解析到通话信息,检查是否需要显示视频通话邀请弹框 + if (callInfo != null && callType != null && callStatus != null) { + // 只处理视频通话且状态为 missed 或 calling 的消息(新邀请) + if (callType == 'video' && (callStatus == 'missed' || callStatus == 'calling')) { + // 获取用户信息 + Map? attributes; try { - final jsonStr = content.substring(7); // 移除 '[CALL:]' 前缀 - final callInfo = jsonDecode(jsonStr) as Map; - final callType = callInfo['callType'] as String?; - final callStatus = callInfo['callStatus'] as String?; - - // 只处理视频通话且状态为 missed 或 calling 的消息(新邀请) - if (callType == 'video' && (callStatus == 'missed' || callStatus == 'calling')) { - // 获取用户信息 - Map? attributes; - try { - attributes = message.attributes; - } catch (e) { - if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 无法访问消息扩展字段: $e'); - } - } + attributes = message.attributes; + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 无法访问消息扩展字段: $e'); + } + } + + String? nickName; + String? avatarUrl; - String? nickName; - String? avatarUrl; + if (attributes != null) { + nickName = attributes['sender_nickName'] as String?; + avatarUrl = attributes['sender_avatarUrl'] as String?; + } - if (attributes != null) { - nickName = attributes['sender_nickName'] as String?; - avatarUrl = attributes['sender_avatarUrl'] as String?; + // 如果从消息扩展字段中获取不到,尝试从 ConversationController 的缓存中获取 + if ((nickName == null || nickName.isEmpty) || (avatarUrl == null || avatarUrl.isEmpty)) { + try { + if (Get.isRegistered()) { + final conversationController = Get.find(); + final cachedUserInfo = conversationController.getCachedUserInfo(fromId); + if (cachedUserInfo != null) { + nickName = nickName ?? cachedUserInfo.nickName; + avatarUrl = avatarUrl ?? cachedUserInfo.avatarUrl; + } + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 从ConversationController获取用户信息失败: $e'); } + } + } - // 如果从消息扩展字段中获取不到,尝试从 ConversationController 的缓存中获取 - if ((nickName == null || nickName.isEmpty) || (avatarUrl == null || avatarUrl.isEmpty)) { - try { - if (Get.isRegistered()) { - final conversationController = Get.find(); - final cachedUserInfo = conversationController.getCachedUserInfo(fromId); - if (cachedUserInfo != null) { - nickName = nickName ?? cachedUserInfo.nickName; - avatarUrl = avatarUrl ?? cachedUserInfo.avatarUrl; + final finalNickName = nickName ?? fromId; + final finalAvatarUrl = avatarUrl ?? ''; + + // 接收方收到视频通话时,开始播放来电铃声 + final callManager = CallManager.instance; + callManager.startCallAudio(); + + // 显示视频通话邀请弹框 + SmartDialog.show( + builder: (context) { + return VideoCallInviteDialog( + avatarUrl: finalAvatarUrl, + nickName: finalNickName, + onTap: () async { + // 关闭弹框 + SmartDialog.dismiss(); + + // 停止播放来电铃声 + callManager.stopCallAudio(); + + // 只跳转到视频通话页面,不自动接通 + Get.to(() => VideoCallPage( + targetUserId: fromId, + isInitiator: false, + )); + }, + onAccept: () async { + // 关闭弹框 + SmartDialog.dismiss(); + + // 停止播放来电铃声(acceptCall 中也会停止,但这里提前停止以更快响应) + callManager.stopCallAudio(); + + // 接听通话 + ChatController? chatController; + try { + final tag = 'chat_$fromId'; + if (Get.isRegistered(tag: tag)) { + chatController = Get.find(tag: tag); + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 获取ChatController失败: $e'); } } - } catch (e) { - if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 从ConversationController获取用户信息失败: $e'); + + final accepted = await callManager.acceptCall( + message: message, + chatController: chatController, + ); + + if (accepted) { + // 跳转到视频通话页面 + Get.to(() => VideoCallPage( + targetUserId: fromId, + isInitiator: false, + )); } - } - } - - final finalNickName = nickName ?? fromId; - final finalAvatarUrl = avatarUrl ?? ''; - - // 显示视频通话邀请弹框 - SmartDialog.show( - builder: (context) { - return VideoCallInviteDialog( - avatarUrl: finalAvatarUrl, - nickName: finalNickName, - onTap: () async { - // 关闭弹框 - SmartDialog.dismiss(); - - // 只跳转到视频通话页面,不自动接通 - Get.to(() => VideoCallPage( - targetUserId: fromId, - isInitiator: false, - )); - }, - onAccept: () async { - // 关闭弹框 - SmartDialog.dismiss(); - - // 接听通话 - final callManager = CallManager.instance; - ChatController? chatController; - try { - final tag = 'chat_$fromId'; - if (Get.isRegistered(tag: tag)) { - chatController = Get.find(tag: tag); - } - } catch (e) { - if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 获取ChatController失败: $e'); - } - } - - final accepted = await callManager.acceptCall( - message: message, - chatController: chatController, - ); - - if (accepted) { - // 跳转到视频通话页面 - Get.to(() => VideoCallPage( - targetUserId: fromId, - isInitiator: false, - )); - } - }, - onReject: () async { - // 关闭弹框 - SmartDialog.dismiss(); - - // 拒绝通话 - final callManager = CallManager.instance; - ChatController? chatController; - try { - final tag = 'chat_$fromId'; - if (Get.isRegistered(tag: tag)) { - chatController = Get.find(tag: tag); - } - } catch (e) { - if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 获取ChatController失败: $e'); - } - } - - await callManager.rejectCall( - message: message, - chatController: chatController, - ); - }, + }, + onReject: () async { + // 先关闭弹框 + SmartDialog.dismiss(); + + // 停止播放来电铃声(rejectCall 中也会停止,但这里提前停止以更快响应) + callManager.stopCallAudio(); + + // 拒绝通话(会修改消息状态为 rejected) + ChatController? chatController; + try { + final tag = 'chat_$fromId'; + if (Get.isRegistered(tag: tag)) { + chatController = Get.find(tag: tag); + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 获取ChatController失败: $e'); + } + } + + // 调用拒绝通话,会使用 modifyMessage 修改消息状态 + await callManager.rejectCall( + message: message, + chatController: chatController, ); }, - alignment: Alignment.topCenter, - animationType: SmartAnimationType.centerFade_otherSlide, - animationTime: Duration(milliseconds: 300), - maskColor: Colors.transparent, - maskWidget: null, - clickMaskDismiss: false, ); - - if (Get.isLogEnable) { - Get.log('📞 [IMManager] 显示视频通话邀请弹框: $fromId'); - } - } - - // 对于所有 CALL 消息(包括视频和语音),都不显示普通消息通知弹框 - return; - } catch (e) { - // 解析失败,继续处理普通消息 - if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 解析CALL消息失败: $e'); - } + }, + alignment: Alignment.topCenter, + animationType: SmartAnimationType.centerFade_otherSlide, + animationTime: Duration(milliseconds: 300), + maskColor: Colors.transparent, + maskWidget: null, + clickMaskDismiss: false, + keepSingle: true, // 确保只有一个弹框显示 + ); + + if (Get.isLogEnable) { + Get.log('📞 [IMManager] 显示视频通话邀请弹框: $fromId'); } } - } catch (e) { - // 解析失败,继续处理 - if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 解析消息内容失败: $e'); - } + + // 对于所有 CALL 消息(包括视频和语音),都不显示普通消息通知弹框 + return; + } + } catch (e) { + // 解析失败,继续处理普通消息 + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 解析CALL消息失败: $e'); } } @@ -1721,33 +1736,7 @@ class IMManager { try { if (message.body.type == MessageType.TXT) { final body = message.body as EMTextMessageBody; - final content = body.content; - - // 检查是否是CALL消息 - if (content != null && content.startsWith('[CALL:]')) { - try { - final jsonStr = content.substring(7); // 移除 '[CALL:]' 前缀 - final callInfo = jsonDecode(jsonStr) as Map; - final callType = callInfo['callType'] as String?; - if (callType == 'video') { - return '[视频通话]'; - } else if (callType == 'voice') { - return '[语音通话]'; - } - } catch (e) { - // 解析失败,返回原始内容 - if (Get.isLogEnable) { - Get.log('⚠️ [IMManager] 解析CALL消息失败: $e'); - } - } - } - - // 检查是否是GIFT消息 - if (content != null && content.startsWith('[GIFT:]')) { - return '[礼物]'; - } - - return content ?? ''; + return body.content; } else if (message.body.type == MessageType.IMAGE) { return '[图片]'; } else if (message.body.type == MessageType.VOICE) { @@ -1762,6 +1751,25 @@ class IMManager { final body = message.body as EMCustomMessageBody; if (body.event == 'live_room_invite') { return '[分享房间]'; + } else if (body.event == 'gift') { + return '[礼物]'; + } else if (body.event == 'call') { + // 解析通话类型 + try { + if (body.params != null) { + final callType = body.params!['callType'] ?? 'voice'; + if (callType == 'video') { + return '[视频通话]'; + } else if (callType == 'voice') { + return '[语音通话]'; + } + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [IMManager] 解析通话消息类型失败: $e'); + } + } + return '[通话消息]'; } return '[自定义消息]'; } diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index edb665a..162d275 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -48,7 +48,9 @@ class _LiveRoomPageState extends State { _overlayController = Get.find(); // 进入直播间时,确保隐藏小窗口(延迟到 build 完成后执行,避免在 build 过程中触发 setState) WidgetsBinding.instance.addPostFrameCallback((_) { - _overlayController.hide(); + if (_overlayController.showOverlay.value) { + _overlayController.hide(); + } }); // 启用屏幕常亮 WakelockPlus.enable(); @@ -133,6 +135,13 @@ class _LiveRoomPageState extends State { @override Widget build(BuildContext context) { + // 在 build 方法开始时立即隐藏小窗口(使用 addPostFrameCallback 避免在 build 过程中触发 setState) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_overlayController.showOverlay.value) { + _overlayController.hide(); + } + }); + return PopScope( onPopInvokedWithResult: (bool didPop, Object? result) async { SmartDialog.dismiss(); @@ -194,10 +203,14 @@ class _LiveRoomPageState extends State { popularityText: popularityText, avatarAsset: avatarAsset, onCloseTap: () { + SmartDialog.dismiss(); + // 退出房间时清空RTM消息 + if (Get.isRegistered()) { + final roomController = Get.find(); + roomController.chatMessages.clear(); + } + _overlayController.show(); Get.back(); - Future.delayed(Duration(seconds: 1), (){ - _overlayController.show(); - }); }, ); }), diff --git a/lib/pages/message/conversation_tab.dart b/lib/pages/message/conversation_tab.dart index 21d0657..743b8d0 100644 --- a/lib/pages/message/conversation_tab.dart +++ b/lib/pages/message/conversation_tab.dart @@ -48,40 +48,30 @@ class _ConversationTabState extends State ); } - // 监听筛选类型变化,获取筛选后的会话列表 - // 使用 Obx 监听筛选类型变化,触发 FutureBuilder 重建 + // 直接使用 Obx 监听 conversations 和 filterType,避免 FutureBuilder 重建导致的闪烁 return Obx(() { - return FutureBuilder>( - future: controller.getFilteredConversations(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } + final filteredConversations = controller.conversations; - final filteredConversations = snapshot.data ?? []; - - if (filteredConversations.isEmpty) { - return Center( - child: Text( - controller.filterType.value == FilterType.none - ? '暂无会话' - : '暂无符合条件的会话', - style: const TextStyle( - fontSize: 14, - color: Colors.grey, - ), - ), - ); - } + if (filteredConversations.isEmpty) { + return Center( + child: Text( + controller.filterType.value == FilterType.none + ? '暂无会话' + : '暂无符合条件的会话', + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ); + } - return ListView.builder( - padding: const EdgeInsets.only(top: 8), - itemCount: filteredConversations.length, - itemBuilder: (context, index) { - final conversation = filteredConversations[index]; - return _buildConversationItem(conversation); - }, - ); + return ListView.builder( + padding: const EdgeInsets.only(top: 8), + itemCount: filteredConversations.length, + itemBuilder: (context, index) { + final conversation = filteredConversations[index]; + return _buildConversationItem(conversation); }, ); }); diff --git a/lib/widget/live/draggable_overlay_widget.dart b/lib/widget/live/draggable_overlay_widget.dart index dc9e728..062a41f 100644 --- a/lib/widget/live/draggable_overlay_widget.dart +++ b/lib/widget/live/draggable_overlay_widget.dart @@ -1,5 +1,6 @@ import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:dating_touchme_app/controller/discover/room_controller.dart'; +import 'package:dating_touchme_app/controller/overlay_controller.dart'; import 'package:dating_touchme_app/extension/ex_widget.dart'; import 'package:dating_touchme_app/pages/discover/live_room_page.dart'; import 'package:flutter/material.dart'; @@ -31,6 +32,7 @@ class _DraggableOverlayWidgetState extends State { bool _isDragging = false; final RTCManager _rtcManager = RTCManager.instance; final RoomController _roomController = Get.find(); + final OverlayController _overlayController = Get.find(); @override void initState() { @@ -187,13 +189,26 @@ class _DraggableOverlayWidgetState extends State { ], ), ), - ).onTap(() { - // 先隐藏小窗口,再跳转到直播间 - widget.onClose?.call(); - // 使用 Future.microtask 确保小窗口先隐藏,然后再导航 - Future.microtask(() { + ).onTap(() async { + // 先隐藏小窗口(直接调用 OverlayController) + _overlayController.hide(); + // 等待响应式更新完成,确保小窗口完全隐藏后再跳转 + // 使用循环检查,最多等待300ms,确保小窗口已隐藏 + int waitCount = 0; + const maxWait = 6; // 最多等待6次,每次50ms,总共300ms + while (waitCount < maxWait && _overlayController.showOverlay.value) { + await Future.delayed(const Duration(milliseconds: 50)); + waitCount++; + } + // 确保小窗口已隐藏后再跳转到直播间 + if (!_overlayController.showOverlay.value) { Get.to(() => const LiveRoomPage(id: 0)); - }); + } else { + // 如果还是显示状态,强制隐藏后再跳转 + _overlayController.hide(); + await Future.delayed(const Duration(milliseconds: 100)); + Get.to(() => const LiveRoomPage(id: 0)); + } }); }), ); diff --git a/lib/widget/message/call_item.dart b/lib/widget/message/call_item.dart index 5fa3b12..ca55b31 100644 --- a/lib/widget/message/call_item.dart +++ b/lib/widget/message/call_item.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:im_flutter_sdk/im_flutter_sdk.dart'; @@ -39,10 +38,9 @@ class CallItem extends StatelessWidget { super.key, }); - /// 从消息内容中解析通话信息(支持新格式的自定义消息和旧格式的文本消息) + /// 从自定义消息中解析通话信息 Map? _parseCallInfo() { try { - // 新格式:自定义消息 if (message.body.type == MessageType.CUSTOM) { final customBody = message.body as EMCustomMessageBody; if (customBody.event == 'call' && customBody.params != null) { @@ -57,17 +55,6 @@ class CallItem extends StatelessWidget { }; } } - // 旧格式:文本消息,内容以 [CALL:] 开头 - if (message.body.type == MessageType.TXT) { - final textBody = message.body as EMTextMessageBody; - final content = textBody.content; - - // 检查是否是通话消息(以 [CALL:] 开头) - if (content.startsWith('[CALL:]')) { - final jsonStr = content.substring(7); // 移除 '[CALL:]' 前缀 - return jsonDecode(jsonStr) as Map; - } - } } catch (e) { print('解析通话信息失败: $e'); } diff --git a/lib/widget/message/gift_item.dart b/lib/widget/message/gift_item.dart index 7a17382..663cdc2 100644 --- a/lib/widget/message/gift_item.dart +++ b/lib/widget/message/gift_item.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -25,10 +24,9 @@ class GiftItem extends StatelessWidget { super.key, }); - /// 从自定义消息的 params 或旧格式的文本消息中解析礼物信息 + /// 从自定义消息的 params 中解析礼物信息 Map? _parseGiftInfo() { try { - // 新格式:自定义消息 if (message.body.type == MessageType.CUSTOM) { final customBody = message.body as EMCustomMessageBody; if (customBody.event == 'gift' && customBody.params != null) { @@ -43,15 +41,6 @@ class GiftItem extends StatelessWidget { }; } } - // 旧格式:文本消息,内容以 [GIFT:] 开头 - if (message.body.type == MessageType.TXT) { - final textBody = message.body as EMTextMessageBody; - final content = textBody.content; - if (content.startsWith('[GIFT:]')) { - final jsonStr = content.substring(7); // 移除 '[GIFT:]' 前缀 - return jsonDecode(jsonStr) as Map; - } - } } catch (e) { print('解析礼物信息失败: $e'); } diff --git a/lib/widget/message/message_item.dart b/lib/widget/message/message_item.dart index 96406ae..31e86a5 100644 --- a/lib/widget/message/message_item.dart +++ b/lib/widget/message/message_item.dart @@ -26,40 +26,26 @@ class MessageItem extends StatelessWidget { super.key, }); - // 检查是否是通话消息(支持新格式的自定义消息和旧格式的文本消息) + // 检查是否是通话消息(自定义消息) bool _isCallMessage() { try { - // 新格式:自定义消息 if (message.body.type == MessageType.CUSTOM) { final customBody = message.body as EMCustomMessageBody; return customBody.event == 'call'; } - // 旧格式:文本消息,内容以 [CALL:] 开头 - if (message.body.type == MessageType.TXT) { - final textBody = message.body as EMTextMessageBody; - final content = textBody.content; - // 检查是否是通话消息(以 [CALL:] 开头) - return content.startsWith('[CALL:]'); - } } catch (e) { // 解析失败,不是通话消息 } return false; } - // 检查是否是礼物消息(支持新格式的自定义消息和旧格式的文本消息) + // 检查是否是礼物消息(自定义消息) bool _isGiftMessage() { try { - // 新格式:自定义消息 if (message.body.type == MessageType.CUSTOM) { final customBody = message.body as EMCustomMessageBody; return customBody.event == 'gift'; } - // 旧格式:文本消息,内容以 [GIFT:] 开头 - if (message.body.type == MessageType.TXT) { - final textBody = message.body as EMTextMessageBody; - return textBody.content.startsWith('[GIFT:]'); - } } catch (e) { // 解析失败,不是礼物消息 } diff --git a/lib/widget/message/message_notification_dialog.dart b/lib/widget/message/message_notification_dialog.dart index e3fd386..c5a5cbd 100644 --- a/lib/widget/message/message_notification_dialog.dart +++ b/lib/widget/message/message_notification_dialog.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:dating_touchme_app/generated/assets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'emoji_text_widget.dart'; /// 消息通知弹框 class MessageNotificationDialog extends StatelessWidget { @@ -85,15 +86,14 @@ class MessageNotificationDialog extends StatelessWidget { overflow: TextOverflow.ellipsis, ), SizedBox(height: 4.h), - // 消息内容 - Text( - messageContent, - style: TextStyle( + // 消息内容(使用EmojiTextWidget支持emoji显示) + EmojiTextWidget( + text: messageContent, + textStyle: TextStyle( fontSize: 13.sp, color: Color(0xFF666666), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + emojiSize: 20.w, ), ], ), diff --git a/lib/widget/message/text_item.dart b/lib/widget/message/text_item.dart index de1b402..78f0ad7 100644 --- a/lib/widget/message/text_item.dart +++ b/lib/widget/message/text_item.dart @@ -27,23 +27,8 @@ class TextItem extends StatelessWidget { super.key, }); - /// 检查是否是旧格式的特殊消息(礼物、直播间邀请等) - bool _isLegacySpecialMessage() { - final content = textBody.content; - // 检查是否是旧格式的礼物消息或直播间邀请消息 - return content.startsWith('[GIFT:]') || - content.startsWith('[ROOM:]') || - content.startsWith('[CALL:]'); - } - @override Widget build(BuildContext context) { - // 如果是旧格式的特殊消息,不显示 JSON 内容 - if (_isLegacySpecialMessage()) { - // 返回空组件,不显示这些旧格式的消息 - return SizedBox.shrink(); - } - // 检查是否有金币信息(只对接收的消息显示) final revenueInfo = _getRevenueInfo(); diff --git a/pubspec.yaml b/pubspec.yaml index 0d3fddd..4e65d18 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,6 +109,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/images/ + - assets/audio/ - assets/images/emoji/ # - images/a_dot_ham.jpeg From 414ec255aacf1f6001ba70e903f592a0e38591dc Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 16:41:04 +0800 Subject: [PATCH 15/21] =?UTF-8?q?feat(live):=20=E5=AE=9E=E7=8E=B0=E7=9B=B4?= =?UTF-8?q?=E6=92=AD=E6=88=BF=E9=97=B4=E8=BE=93=E5=85=A5=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加输入对话框组件,支持消息输入和发送 - 在LiveRoomActionBar中添加输入点击事件回调 - 实现输入对话框的显示/隐藏逻辑和焦点管理 - 添加键盘状态监听,自动调整UI布局 - 在IMManager中添加在线状态检查过滤消息 - 集成flutter_local_notifications和app_badge_plus依赖 - 优化消息发送流程和界面交互体验 --- lib/im/im_manager.dart | 2 +- lib/pages/discover/live_room_page.dart | 244 ++++++++++++++++++++-- lib/widget/live/live_room_action_bar.dart | 49 ++--- pubspec.yaml | 2 + 4 files changed, 247 insertions(+), 50 deletions(-) diff --git a/lib/im/im_manager.dart b/lib/im/im_manager.dart index 581f43b..a8ff812 100644 --- a/lib/im/im_manager.dart +++ b/lib/im/im_manager.dart @@ -211,7 +211,7 @@ class IMManager { debugPrint('📩 收到消息数: ${messages.length}'); // 从消息扩展字段中解析用户信息并缓存 for (var message in messages) { - if (message.direction == MessageDirection.RECEIVE) { + if (message.direction == MessageDirection.RECEIVE && message.onlineState) { _parseUserInfoFromMessageExt(message); // 检查发送者是否是当前正在聊天的用户,如果不是则显示弹框 _checkAndShowNotificationDialog(message); diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index 162d275..ff7bc85 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -29,6 +29,10 @@ class _LiveRoomPageState extends State { late final OverlayController _overlayController; String message = ''; final TextEditingController _messageController = TextEditingController(); + final TextEditingController _inputDialogController = TextEditingController(); + final FocusNode _inputDialogFocusNode = FocusNode(); + bool _showInputDialog = false; + bool _isOpeningDialog = false; // 标记是否正在打开对话框 final activeGift = ValueNotifier(null); @@ -52,6 +56,8 @@ class _LiveRoomPageState extends State { _overlayController.hide(); } }); + // 监听输入对话框的焦点变化,当键盘收起时隐藏对话框 + _inputDialogFocusNode.addListener(_onInputDialogFocusChanged); // 启用屏幕常亮 WakelockPlus.enable(); // 如果当前用户是男性,请求连麦卡片信息 @@ -74,11 +80,32 @@ class _LiveRoomPageState extends State { await _roomController.getVirtualAccount(); } + /// 输入对话框焦点变化回调 + void _onInputDialogFocusChanged() { + if (!mounted) return; + // 如果焦点丢失且对话框仍显示,检查键盘状态 + if (!_inputDialogFocusNode.hasFocus && _showInputDialog) { + // 使用 addPostFrameCallback 确保在下一帧检查,此时键盘状态已更新 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 如果键盘已收起,隐藏对话框 + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + if (keyboardHeight == 0) { + _hideInputDialog(); + } + }); + } + } + @override void dispose() { + // 移除焦点监听器 + _inputDialogFocusNode.removeListener(_onInputDialogFocusChanged); // 禁用屏幕常亮 WakelockPlus.disable(); _messageController.dispose(); + _inputDialogController.dispose(); + _inputDialogFocusNode.dispose(); // 退出房间时清空RTM消息 if (Get.isRegistered()) { final roomController = Get.find(); @@ -87,6 +114,54 @@ class _LiveRoomPageState extends State { super.dispose(); } + /// 显示输入对话框 + void _openInputDialog() { + _isOpeningDialog = true; + setState(() { + _showInputDialog = true; + }); + // 延迟获取焦点,确保对话框已显示 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _showInputDialog) { + _inputDialogFocusNode.requestFocus(); + // 键盘弹起后,重置标志 + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + _isOpeningDialog = false; + } + }); + } + }); + } + + /// 隐藏输入对话框 + void _hideInputDialog() { + _isOpeningDialog = false; + setState(() { + _showInputDialog = false; + }); + _inputDialogFocusNode.unfocus(); + _inputDialogController.clear(); + } + + /// 发送输入对话框中的消息 + void _sendInputDialogMessage() { + final content = _inputDialogController.text.trim(); + if (content.isEmpty) { + return; + } + + // 更新主 controller + _messageController.text = content; + message = content; + + // 发送消息 + _sendMessage(); + + // 关闭对话框 + _hideInputDialog(); + } + /// 发送消息 Future _sendMessage() async { final content = _messageController.text.trim(); @@ -140,8 +215,22 @@ class _LiveRoomPageState extends State { if (_overlayController.showOverlay.value) { _overlayController.hide(); } + // 检查键盘状态,如果键盘收起但对话框仍显示,且不是正在打开对话框,则隐藏对话框 + if (MediaQuery.of(context).viewInsets.bottom == 0 && + _showInputDialog && + !_isOpeningDialog) { + // 延迟一点时间再检查,避免在键盘弹起过程中误判 + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted && + MediaQuery.of(context).viewInsets.bottom == 0 && + _showInputDialog && + !_isOpeningDialog) { + _hideInputDialog(); + } + }); + } }); - + return PopScope( onPopInvokedWithResult: (bool didPop, Object? result) async { SmartDialog.dismiss(); @@ -154,6 +243,7 @@ class _LiveRoomPageState extends State { Get.back(); }, child: Scaffold( + resizeToAvoidBottomInset: false, body: Stack( children: [ Container( @@ -206,7 +296,8 @@ class _LiveRoomPageState extends State { SmartDialog.dismiss(); // 退出房间时清空RTM消息 if (Get.isRegistered()) { - final roomController = Get.find(); + final roomController = + Get.find(); roomController.chatMessages.clear(); } _overlayController.show(); @@ -225,33 +316,148 @@ class _LiveRoomPageState extends State { ), ), ), - SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - bottom: 10.w, - left: 0, - right: 0, - ), - child: LiveRoomActionBar( - messageController: _messageController, - onMessageChanged: (value) { - message = value; - }, - onSendTap: _sendMessage, - onGiftTap: _showGiftPopup, - onChargeTap: _showRechargePopup, + // 根据键盘状态显示/隐藏 LiveRoomActionBar + if (MediaQuery.of(context).viewInsets.bottom == 0) + SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + bottom: 10.w, + left: 0, + right: 0, + ), + child: LiveRoomActionBar( + messageController: _messageController, + onMessageChanged: (value) { + message = value; + }, + onSendTap: _sendMessage, + onGiftTap: _showGiftPopup, + onChargeTap: _showRechargePopup, + onInputTap: _openInputDialog, + ), ), ), - ), ], ), ), // SVGA 动画播放组件 const SvgaPlayerWidget(), + // 输入对话框 + if (_showInputDialog) + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + left: 0, + right: 0, + bottom: MediaQuery.of(context).viewInsets.bottom, + child: _InputDialogWidget( + controller: _inputDialogController, + focusNode: _inputDialogFocusNode, + onSend: _sendInputDialogMessage, + onClose: _hideInputDialog, + ), + ), ], ), ), ); } } + +class _InputDialogWidget extends StatelessWidget { + const _InputDialogWidget({ + required this.controller, + required this.focusNode, + required this.onSend, + required this.onClose, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final VoidCallback onSend; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20.w), + topRight: Radius.circular(20.w), + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.all(16.w), + child: Row( + children: [ + Expanded( + child: Container( + height: 38.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(38.w)), + color: const Color.fromRGBO(245, 245, 245, 1), + ), + alignment: Alignment.centerLeft, + child: TextField( + controller: controller, + focusNode: focusNode, + keyboardType: TextInputType.text, + textAlignVertical: TextAlignVertical.center, + style: TextStyle( + fontSize: ScreenUtil().setWidth(14), + height: 1.2, + color: Colors.black, + ), + decoration: InputDecoration( + contentPadding: EdgeInsets.symmetric( + vertical: 0, + horizontal: 15.w, + ), + hintText: "说点什么", + hintStyle: TextStyle( + color: const Color.fromRGBO(144, 144, 144, 1), + height: 1.2, + fontSize: ScreenUtil().setWidth(14), + ), + border: InputBorder.none, + isDense: true, + ), + onSubmitted: (_) { + onSend(); + }, + ), + ), + ), + SizedBox(width: 12.w), + GestureDetector( + onTap: onSend, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 24.w, + vertical: 10.h, + ), + decoration: BoxDecoration( + color: const Color.fromRGBO(117, 98, 249, 1), + borderRadius: BorderRadius.circular(19.w), + ), + child: Text( + "发送", + style: TextStyle( + fontSize: ScreenUtil().setWidth(14), + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widget/live/live_room_action_bar.dart b/lib/widget/live/live_room_action_bar.dart index 05d01c9..867d375 100644 --- a/lib/widget/live/live_room_action_bar.dart +++ b/lib/widget/live/live_room_action_bar.dart @@ -10,6 +10,7 @@ class LiveRoomActionBar extends StatelessWidget { required this.onSendTap, required this.onGiftTap, required this.onChargeTap, + required this.onInputTap, }); final TextEditingController messageController; @@ -17,6 +18,7 @@ class LiveRoomActionBar extends StatelessWidget { final VoidCallback onSendTap; final VoidCallback onGiftTap; final VoidCallback onChargeTap; + final VoidCallback onInputTap; @override Widget build(BuildContext context) { @@ -43,37 +45,25 @@ class LiveRoomActionBar extends StatelessWidget { ), SizedBox(width: 9.w), Expanded( - child: Container( - height: 38.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(38.w)), - color: const Color.fromRGBO(0, 0, 0, .3), - ), - child: Center( - child: TextField( - controller: messageController, - keyboardType: TextInputType.text, - textAlignVertical: TextAlignVertical.center, - style: TextStyle( - fontSize: ScreenUtil().setWidth(14), - height: 1.2, - color: Colors.white, - ), - decoration: InputDecoration( - contentPadding: EdgeInsets.symmetric( - vertical: 0, - horizontal: 15.w, - ), - hintText: "聊点什么吧~", - hintStyle: TextStyle( - color: const Color.fromRGBO(144, 144, 144, 1), + child: InkWell( + onTap: onInputTap, + child: Container( + height: 38.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(38.w)), + color: const Color.fromRGBO(0, 0, 0, .3), + ), + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 15.w), + child: Text( + "聊点什么吧~", + style: TextStyle( + fontSize: ScreenUtil().setWidth(14), height: 1.2, + color: const Color.fromRGBO(144, 144, 144, 1), ), - border: InputBorder.none, - isDense: true, ), - onChanged: onMessageChanged, - onSubmitted: (_) => onSendTap(), ), ), ), @@ -127,5 +117,4 @@ class LiveRoomActionBar extends StatelessWidget { ], ); } -} - +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 4e65d18..f7b5ece 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,8 @@ dependencies: im_flutter_sdk: 4.15.2 webview_flutter: ^4.13.0 ota_update: ^7.1.0 + flutter_local_notifications: ^19.5.0 + app_badge_plus: ^1.2.6 dev_dependencies: flutter_test: From 2411199174f0e4b698984f0af6b8699214fc51da Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 16:44:12 +0800 Subject: [PATCH 16/21] =?UTF-8?q?feat(live=5Froom):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=9B=B4=E6=92=AD=E9=97=B4=E8=BE=93=E5=85=A5=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E9=81=AE=E7=BD=A9=E5=B1=82=E7=82=B9=E5=87=BB=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Positioned.fill 遮罩层组件 - 实现点击遮罩层隐藏对话框和键盘的功能 - 使用 GestureDetector 拦截点击事件 - 调用 FocusScope.of(context).unfocus() 隐藏键盘 - 调用 _hideInputDialog() 方法隐藏对话框 - 设置遮罩层颜色为透明 - 调整组件层级结构确保对话框在遮罩层上方 --- lib/pages/discover/live_room_page.dart | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index ff7bc85..1611e89 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -344,7 +344,21 @@ class _LiveRoomPageState extends State { // SVGA 动画播放组件 const SvgaPlayerWidget(), // 输入对话框 - if (_showInputDialog) + if (_showInputDialog) ...[ + // 遮罩层,点击时隐藏对话框和键盘(放在对话框下方,对话框会在上面拦截点击) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + // 隐藏键盘 + FocusScope.of(context).unfocus(); + // 隐藏对话框 + _hideInputDialog(); + }, + child: Container(color: Colors.transparent), + ), + ), + // 输入对话框(放在遮罩层上面,会自动拦截点击事件) AnimatedPositioned( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, @@ -358,6 +372,7 @@ class _LiveRoomPageState extends State { onClose: _hideInputDialog, ), ), + ], ], ), ), From 67a4628d916222706ea99e0362667f133ae3c876 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 16:47:45 +0800 Subject: [PATCH 17/21] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=E7=A4=BC?= =?UTF-8?q?=E7=89=A9=E5=BC=B9=E7=AA=97=E6=98=BE=E7=A4=BA=E6=97=B6=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E6=9C=AA=E9=9A=90=E8=97=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 FocusScope.of(context).unfocus() 隐藏键盘 - 确保礼物弹窗显示前键盘已收起 --- lib/pages/message/chat_page.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pages/message/chat_page.dart b/lib/pages/message/chat_page.dart index 06f0d64..5603578 100644 --- a/lib/pages/message/chat_page.dart +++ b/lib/pages/message/chat_page.dart @@ -103,6 +103,8 @@ class _ChatPageState extends State { // 显示礼物弹窗 void _showGiftPopup() { + // 隐藏键盘 + FocusScope.of(context).unfocus(); final giftProducts = _controller.giftProducts.toList(); if (giftProducts.isEmpty) { SmartDialog.showToast('礼物列表加载中,请稍候...'); From c66b7bcd21963946b2a455cb01061aeda144fe2c Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 17:02:07 +0800 Subject: [PATCH 18/21] =?UTF-8?q?feat(notification):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E8=A7=92=E6=A0=87=E6=9C=AA=E8=AF=BB=E6=95=B0?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AndroidManifest.xml 中添加各厂商桌面角标权限配置 - 集成 app_badge_plus 插件实现角标更新功能 - 在 ConversationController 中监听未读数变化并同步更新应用角标 - 优化消息通知逻辑,仅在 APP 前台时显示通知弹框 - 添加角标更新的错误处理和日志记录 --- android/app/src/main/AndroidManifest.xml | 22 ++++++++++++++++++ .../message/conversation_controller.dart | 23 +++++++++++++++++++ lib/im/im_manager.dart | 6 ++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b1c9205..209992c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,28 @@ + + + + + + + + + + + + + + + + + + + + + + _updateAppBadge(int count) async { + try { + await AppBadgePlus.updateBadge(count); + if (Get.isLogEnable) { + Get.log('✅ [ConversationController] 应用角标已更新: $count'); + } + } catch (e) { + if (Get.isLogEnable) { + Get.log('⚠️ [ConversationController] 更新应用角标失败: $e'); + } + } + } + /// 检查 IM 登录状态并加载会话列表 Future _checkAndLoadConversations() async { // 如果已登录,直接加载 @@ -481,6 +500,8 @@ class ConversationController extends GetxController { total += unreadCount; } totalUnreadCount.value = total; + // 更新应用角标 + await _updateAppBadge(total); if (Get.isLogEnable) { Get.log('✅ [ConversationController] 总未读数已更新: $total'); } @@ -489,6 +510,8 @@ class ConversationController extends GetxController { Get.log('⚠️ [ConversationController] 更新总未读数失败: $e'); } totalUnreadCount.value = 0; + // 更新应用角标为 0 + await _updateAppBadge(0); } } diff --git a/lib/im/im_manager.dart b/lib/im/im_manager.dart index a8ff812..2f409fc 100644 --- a/lib/im/im_manager.dart +++ b/lib/im/im_manager.dart @@ -214,7 +214,11 @@ class IMManager { if (message.direction == MessageDirection.RECEIVE && message.onlineState) { _parseUserInfoFromMessageExt(message); // 检查发送者是否是当前正在聊天的用户,如果不是则显示弹框 - _checkAndShowNotificationDialog(message); + // 只有在 APP 处于前台时才显示弹框 + final lifecycleState = WidgetsBinding.instance.lifecycleState; + if (lifecycleState == AppLifecycleState.resumed) { + _checkAndShowNotificationDialog(message); + } } } // 收到新消息时,更新会话列表 From 917316d76a1df766c73f5eeab6d23769cc616e29 Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Sat, 27 Dec 2025 17:23:04 +0800 Subject: [PATCH 19/21] =?UTF-8?q?feat(notification):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AndroidManifest.xml 中添加 RECEIVE_BOOT_COMPLETED 权限和启动器徽章权限 - 为应用启动器 Activity 添加 showWhenLocked 和 turnScreenOn 属性 - 集成 flutter_local_notifications 插件并配置 Android 和 iOS 平台设置 - 创建 LocalNotificationService 服务处理本地通知的初始化和显示 - 实现消息类型判断和内容解析功能 - 添加视频通话通知的特殊处理逻辑 - 支持通知点击跳转到对应聊天页面 - 在 IMManager 中集成本地通知服务 - 优化 iOS 平台通知权限申请 - 配置 Podfile 依赖并更新原生项目设置 --- android/app/src/main/AndroidManifest.xml | 12 + ios/Podfile.lock | 18 +- ios/Runner.xcodeproj/project.pbxproj | 8 - ios/Runner/AppDelegate.swift | 3 + lib/im/im_manager.dart | 14 +- lib/service/local_notification_service.dart | 315 ++++++++++++++++++++ 6 files changed, 358 insertions(+), 12 deletions(-) create mode 100644 lib/service/local_notification_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 209992c..bc038ab 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,7 @@ + + + + + + + + + +