From a9877b2a9b1ea1f566fc016944a9f7a7e5742ebd Mon Sep 17 00:00:00 2001 From: roxy Date: Sat, 9 May 2026 11:28:40 +0800 Subject: [PATCH] bug --- lib/app/constants/sc_room_msg_type.dart | 227 +++--- lib/modules/chat/message_chat_page.dart | 85 ++- lib/modules/room/seat/sc_seat_item.dart | 20 +- lib/modules/room/voice_room_page.dart | 1 + lib/services/audio/rtc_manager.dart | 124 +++- lib/services/audio/rtm_manager.dart | 224 +++++- lib/services/music/room_music_manager.dart | 70 +- .../res/sc_broad_cast_luck_gift_push.dart | 95 +++ ...oating_room_redenvelope_screen_widget.dart | 17 +- .../room_red_packet_open_dialog.dart | 22 +- .../room_red_packet_pending_cache.dart | 10 + .../room_red_packet_send_panel.dart | 646 ++++++++++++++---- lib/ui_kit/widgets/room/room_msg_item.dart | 90 ++- test/lucky_gift_reward_merge_test.dart | 42 ++ 需求进度.md | 3 + 15 files changed, 1301 insertions(+), 375 deletions(-) create mode 100644 test/lucky_gift_reward_merge_test.dart diff --git a/lib/app/constants/sc_room_msg_type.dart b/lib/app/constants/sc_room_msg_type.dart index d6cb82e..9b60dc4 100644 --- a/lib/app/constants/sc_room_msg_type.dart +++ b/lib/app/constants/sc_room_msg_type.dart @@ -1,113 +1,114 @@ -class SCRoomMsgType { - static const String text = "TEXT"; - static const String image = "IMAGE"; - static const String joinRoom = "JOIN_ROOM"; - static const String systemTips = "SYSTEM_TIPS"; - - ///表情包 - static const String emoticons = "EMOTICONS"; - - - ///新用户 - static const String newUser = "NEW_USER"; - - ///上麦 - static const String shangMai = "SHANG_MAI"; - - ///下麦 - static const String xiaMai = "XIA_MAI"; - - ///踢下麦 - static const String killXiaMai = "KILL_XIA_MAI"; - - ///请出房间 - static const String qcfj = "QCFJ"; - - ///封麦 - static const String fengMai = "FENG_MAI"; - - ///解封 - static const String jieFeng = "JIE_FENG"; - - ///禁麦 - static const String jinMai = "JIN_MAI"; - - ///解禁 - static const String jieJin = "JIE_JIN"; - - ///送礼 - static const String gift = "GIFT"; - - ///幸运礼物飘向麦位 - static const String luckGiftAnimOther = "LUCK_GIFT_ANIM_OTHER"; - - ///送礼 - static const String allGift = "ALL_GIFT"; - - ///收到礼物 - static const String reciverGift = "RECIVER_GIFT"; - - ///抱上麦 - static const String bsm = "BSM"; - - ///欢迎语 - static const String welcome = "WELCOME"; - - ///禁言变动 - static const String jinyanList = "JINYAN_LIST"; - - ///麦位变动 - static const String micChange = "MIC_CHANGE"; - - ///房间身份变动 - static const String roomRoleChange = "ROOM_ROLE_CHANGE"; - - ///火箭进度条更新 - static const String rocketEnergyUpdate = "ROCKET_ENERGY_UPDSCE"; - - ///房间红包 - static const String roomRedPacket = "ROOM_RED_PACKET"; - - ///邀请进入房间 - static const String inviteRoom = "INVITE_ROOM"; - - ///用户火箭中奖 - static const String rocketRewardUser = "ROCKET_REWARD_USER"; - - ///管理变动 - static const String managerList = "MANAGER_LIST"; - - ///房间设置更新 - static const String roomSettingUpdate = "ROOM_SETTING_UPDSCE"; - - ///掷骰子 - static const String roomDice = "ROOM_DICE"; - - ///石头剪刀布 - static const String roomRPS = "ROOM_RPS"; - - ///幸运数字 - static const String roomLuckNumber = "ROOM_LUCK_NUMBER"; - - ///房间背景更新 - static const String roomBGUpdate = "ROOM_BG_UPDSCE"; - - ///幸运礼物 - static const String gameLuckyGift = "GAME_LUCKY_GIFT"; - - ///幸运礼物中奖5倍以及以上 - static const String gameLuckyGift_5 = "GAME_LUCKY_GIFT_5"; - - ///房间多人游戏关闭 - static const String roomGameClose = "ROOM_GAME_CLOSE"; - - ///房间游戏创建 - static const String roomGameCreate = "ROOM_GAME_CRESCE"; - - ///暂时不用监听这个 - static const String sendGift = "SEND_GIFT"; - static const String gameBurstCrystalSprint = "GAME_BURST_CRYSTAL_SPRINT"; - static const String gameBurstCrystalBox = "GAME_BURST_CRYSTAL_BOX"; - static const String refreshOnlineUser = "REFRESH_ONLINE_USER"; - -} +class SCRoomMsgType { + static const String text = "TEXT"; + static const String image = "IMAGE"; + static const String joinRoom = "JOIN_ROOM"; + static const String systemTips = "SYSTEM_TIPS"; + + ///表情包 + static const String emoticons = "EMOTICONS"; + + ///新用户 + static const String newUser = "NEW_USER"; + + ///上麦 + static const String shangMai = "SHANG_MAI"; + + ///下麦 + static const String xiaMai = "XIA_MAI"; + + ///踢下麦 + static const String killXiaMai = "KILL_XIA_MAI"; + + ///请出房间 + static const String qcfj = "QCFJ"; + + ///封麦 + static const String fengMai = "FENG_MAI"; + + ///解封 + static const String jieFeng = "JIE_FENG"; + + ///禁麦 + static const String jinMai = "JIN_MAI"; + + ///解禁 + static const String jieJin = "JIE_JIN"; + + ///送礼 + static const String gift = "GIFT"; + + ///幸运礼物飘向麦位 + static const String luckGiftAnimOther = "LUCK_GIFT_ANIM_OTHER"; + + ///送礼 + static const String allGift = "ALL_GIFT"; + + ///收到礼物 + static const String reciverGift = "RECIVER_GIFT"; + + ///抱上麦 + static const String bsm = "BSM"; + + ///欢迎语 + static const String welcome = "WELCOME"; + + ///禁言变动 + static const String jinyanList = "JINYAN_LIST"; + + ///麦位变动 + static const String micChange = "MIC_CHANGE"; + + ///房间背景音乐播放状态 + static const String roomMusic = "ROOM_MUSIC"; + + ///房间身份变动 + static const String roomRoleChange = "ROOM_ROLE_CHANGE"; + + ///火箭进度条更新 + static const String rocketEnergyUpdate = "ROCKET_ENERGY_UPDSCE"; + + ///房间红包 + static const String roomRedPacket = "ROOM_RED_PACKET"; + + ///邀请进入房间 + static const String inviteRoom = "INVITE_ROOM"; + + ///用户火箭中奖 + static const String rocketRewardUser = "ROCKET_REWARD_USER"; + + ///管理变动 + static const String managerList = "MANAGER_LIST"; + + ///房间设置更新 + static const String roomSettingUpdate = "ROOM_SETTING_UPDSCE"; + + ///掷骰子 + static const String roomDice = "ROOM_DICE"; + + ///石头剪刀布 + static const String roomRPS = "ROOM_RPS"; + + ///幸运数字 + static const String roomLuckNumber = "ROOM_LUCK_NUMBER"; + + ///房间背景更新 + static const String roomBGUpdate = "ROOM_BG_UPDSCE"; + + ///幸运礼物 + static const String gameLuckyGift = "GAME_LUCKY_GIFT"; + + ///幸运礼物中奖5倍以及以上 + static const String gameLuckyGift_5 = "GAME_LUCKY_GIFT_5"; + + ///房间多人游戏关闭 + static const String roomGameClose = "ROOM_GAME_CLOSE"; + + ///房间游戏创建 + static const String roomGameCreate = "ROOM_GAME_CRESCE"; + + ///暂时不用监听这个 + static const String sendGift = "SEND_GIFT"; + static const String gameBurstCrystalSprint = "GAME_BURST_CRYSTAL_SPRINT"; + static const String gameBurstCrystalBox = "GAME_BURST_CRYSTAL_BOX"; + static const String refreshOnlineUser = "REFRESH_ONLINE_USER"; +} diff --git a/lib/modules/chat/message_chat_page.dart b/lib/modules/chat/message_chat_page.dart index 7df9e7a..b5ae82c 100644 --- a/lib/modules/chat/message_chat_page.dart +++ b/lib/modules/chat/message_chat_page.dart @@ -1556,8 +1556,8 @@ class _MessageItem extends StatelessWidget { 260.w, ); final multilineMaxTextWidth = math.max( - 90.w, - multilineMaxBubbleWidth - 58.w, + 82.w, + multilineMaxBubbleWidth - 76.w, ); final multilineTextPainter = TextPainter( text: TextSpan(text: content, style: textStyle), @@ -1587,11 +1587,36 @@ class _MessageItem extends StatelessWidget { required Size textSize, required double maxBubbleWidth, }) { + final leftPadding = 34.w; + final rightPadding = 42.w; + final topPadding = 16.w; + final bottomPadding = 18.w; final bubbleWidth = math - .max(126.w, textSize.width + 58.w) + .max(126.w, textSize.width + leftPadding + rightPadding) .clamp(126.w, maxBubbleWidth); - final textMaxWidth = math.max(90.w, bubbleWidth - 58.w); - final bubbleHeight = math.max(58.w, textSize.height + 26.w); + final textMaxWidth = math.max( + 64.w, + bubbleWidth - leftPadding - rightPadding, + ); + final textPainter = TextPainter( + text: TextSpan(text: content, style: textStyle), + textDirection: Directionality.of(context), + )..layout(maxWidth: textMaxWidth); + final lineMetrics = textPainter.computeLineMetrics(); + final lineCount = math.max(1, lineMetrics.length); + final lineHeight = + lineMetrics.isEmpty + ? textPainter.preferredLineHeight + : lineMetrics.fold( + 0, + (height, line) => math.max(height, line.height), + ); + final lineSafetyPadding = lineHeight * math.max(0, lineCount - 1) * 0.55; + final textBoxHeight = textPainter.size.height + lineSafetyPadding; + final bubbleHeight = math.max( + 76.w, + textBoxHeight + topPadding + bottomPadding, + ); return SizedBox( width: bubbleWidth, @@ -1606,11 +1631,17 @@ class _MessageItem extends StatelessWidget { fallback: SizedBox(width: bubbleWidth, height: bubbleHeight), ), Padding( - padding: EdgeInsetsDirectional.fromSTEB(29.w, 11.w, 29.w, 11.w), + padding: EdgeInsetsDirectional.fromSTEB( + leftPadding, + topPadding, + rightPadding, + bottomPadding, + ), child: Align( alignment: AlignmentDirectional.centerStart, child: SizedBox( width: textMaxWidth, + height: textBoxHeight, child: ExtendedText(content, style: textStyle), ), ), @@ -1625,12 +1656,15 @@ class _MessageItem extends StatelessWidget { required String imageUrl, required TextStyle textStyle, }) { - final horizontalPadding = 18.w; - final verticalPadding = 8.w; - final minSkinWidth = 132.w; - final minSkinHeight = 44.w; + final leftPadding = 22.w; + final rightPadding = 24.w; + final topPadding = 12.w; + final bottomPadding = 16.w; + final minSkinWidth = 50.w; + final minSkinHeight = 58.w; + final textWidthSafety = 10.w; final maxTextWidth = width(220); - final maxBubbleWidth = maxTextWidth + horizontalPadding * 2; + final maxBubbleWidth = maxTextWidth + leftPadding + rightPadding; final textSize = _measureTextSize( text: content, style: textStyle, @@ -1638,12 +1672,18 @@ class _MessageItem extends StatelessWidget { ); final bubbleWidth = math.min( maxBubbleWidth, - math.max(minSkinWidth, textSize.width + horizontalPadding * 2), + math.max( + minSkinWidth, + textSize.width + textWidthSafety + leftPadding + rightPadding, + ), + ); + final textMaxWidth = math.max( + 1.0, + bubbleWidth - leftPadding - rightPadding, ); - final textMaxWidth = math.max(1.0, bubbleWidth - horizontalPadding * 2); final bubbleHeight = math.max( minSkinHeight, - textSize.height + verticalPadding * 2, + textSize.height + topPadding + bottomPadding, ); final skinAlignment = message.isSelf! @@ -1666,15 +1706,22 @@ class _MessageItem extends StatelessWidget { ), ), Padding( - padding: EdgeInsetsDirectional.symmetric( - horizontal: horizontalPadding, - vertical: verticalPadding, + padding: EdgeInsetsDirectional.fromSTEB( + leftPadding, + topPadding, + rightPadding, + bottomPadding, ), child: Align( alignment: AlignmentDirectional.centerStart, child: SizedBox( width: textMaxWidth, - child: ExtendedText(content, style: textStyle), + child: ExtendedText( + content, + style: textStyle, + maxLines: 1, + softWrap: false, + ), ), ), ), @@ -1702,6 +1749,7 @@ class _MessageItem extends StatelessWidget { width: width, height: height, fit: BoxFit.fill, + loop: true, centerSliceRatio: _vipBubbleCenterSliceRatio, sourceEdgeInset: _vipBubbleSourceEdgeInset, fallback: SizedBox(width: width, height: height), @@ -1727,6 +1775,7 @@ class _MessageItem extends StatelessWidget { width: width, height: height, fit: BoxFit.fill, + loop: true, centerSliceRatio: _vipBubbleCenterSliceRatio, sourceEdgeInset: _vipBubbleSourceEdgeInset, fallback: fallback, diff --git a/lib/modules/room/seat/sc_seat_item.dart b/lib/modules/room/seat/sc_seat_item.dart index 7286cff..86cddbe 100644 --- a/lib/modules/room/seat/sc_seat_item.dart +++ b/lib/modules/room/seat/sc_seat_item.dart @@ -10,7 +10,6 @@ import 'package:provider/provider.dart'; import 'package:yumi/app/constants/sc_screen.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/services/music/room_music_manager.dart'; import 'package:yumi/shared/data_sources/models/enum/sc_room_special_mike_type.dart'; ///麦位 @@ -115,16 +114,8 @@ class _SCSeatItemState extends State with TickerProviderStateMixin { bottom: widget.isGameModel ? 2.w : 5.w, left: widget.isGameModel ? 2.w : 5.w, child: - seatSnapshot.isCurrentUser - ? Selector( - selector: - (_, manager) => manager.isPublishingToRoom, - builder: (context, isPlayingBgm, child) { - return isPlayingBgm - ? const _BgmPlayingIndicator() - : const SizedBox.shrink(); - }, - ) + seatSnapshot.isBgmPlaying + ? const _BgmPlayingIndicator() : const SizedBox.shrink(), ), Positioned( @@ -337,6 +328,7 @@ class _SeatRenderSnapshot { required this.micMute, required this.soundWaveEnabled, required this.isCurrentUser, + required this.isBgmPlaying, }); factory _SeatRenderSnapshot.fromProvider(RtcProvider provider, num index) { @@ -360,6 +352,7 @@ class _SeatRenderSnapshot { micMute: roomSeat?.micMute ?? false, soundWaveEnabled: provider.shouldShowSeatSoundWave(index), isCurrentUser: provider.isOnMaiInIndex(index), + isBgmPlaying: provider.isUserPublishingRoomMusic(user?.id), ); } @@ -377,6 +370,7 @@ class _SeatRenderSnapshot { final bool micMute; final bool soundWaveEnabled; final bool isCurrentUser; + final bool isBgmPlaying; bool get hasUser => userId.isNotEmpty; @@ -400,7 +394,8 @@ class _SeatRenderSnapshot { other.micLock == micLock && other.micMute == micMute && other.soundWaveEnabled == soundWaveEnabled && - other.isCurrentUser == isCurrentUser; + other.isCurrentUser == isCurrentUser && + other.isBgmPlaying == isBgmPlaying; } @override @@ -419,6 +414,7 @@ class _SeatRenderSnapshot { micMute, soundWaveEnabled, isCurrentUser, + isBgmPlaying, ); } diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 165f6f8..4293b05 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -108,6 +108,7 @@ class _VoiceRoomPageState extends State _rtcProvider?.removeListener(_handleRoomProviderChanged); _rtcProvider = rtcProvider; rtcProvider.addListener(_handleRoomProviderChanged); + context.read().attachRtcProvider(rtcProvider); } _ensureRoomVisualEffectsEnabled(); } diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 847b0ad..858396f 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -349,6 +349,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { ClientRoleType? _lastAppliedClientRole; bool? _lastAppliedLocalAudioMuted; bool? _lastAppliedRecordingSignalMuted; + int? _lastAppliedRecordingSignalVolume; String? _lastAppliedRtcToken; bool? _lastScheduledVoiceLiveOnMic; String? _lastScheduledVoiceLiveRoomId; @@ -364,6 +365,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { Map roomWheatMap = {}; final Map _seatEmojiEventVersions = {}; int _emojiPlaybackEventVersion = 0; + final Set _roomMusicPublishingUserIds = {}; RtcEngine? engine; RtcEngineEventHandler? _rtcEngineEventHandler; @@ -408,6 +410,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { ///音乐是否正在播放 bool isMusicPlaying = false; + bool isUserPublishingRoomMusic(String? userId) { + final normalizedUserId = (userId ?? "").trim(); + return normalizedUserId.isNotEmpty && + _roomMusicPublishingUserIds.contains(normalizedUserId); + } + ///房间红包列表 List redPacketList = []; @@ -1229,6 +1237,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _lastAppliedClientRole = null; _lastAppliedLocalAudioMuted = null; _lastAppliedRecordingSignalMuted = null; + _lastAppliedRecordingSignalVolume = null; _lastAppliedRtcToken = null; return; } @@ -1254,13 +1263,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { _lastAppliedLocalAudioMuted = localAudioMuted; } - final recordingSignalMuted = shouldPublishLocalAudio ? muted : true; + final recordingSignalMuted = !shouldPublishLocalAudio; if (_lastAppliedRecordingSignalMuted != recordingSignalMuted) { engine?.muteRecordingSignal(recordingSignalMuted); _lastAppliedRecordingSignalMuted = recordingSignalMuted; } - if (shouldPublishLocalAudio && !recordingSignalMuted) { - adjustRecordingSignalVolume(100); + + final recordingSignalVolume = + shouldPublishLocalAudio ? (muted ? 0 : 100) : 0; + if (_lastAppliedRecordingSignalVolume != recordingSignalVolume) { + adjustRecordingSignalVolume(recordingSignalVolume); + _lastAppliedRecordingSignalVolume = recordingSignalVolume; } } @@ -1274,6 +1287,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _lastAppliedClientRole = null; _lastAppliedLocalAudioMuted = null; _lastAppliedRecordingSignalMuted = null; + _lastAppliedRecordingSignalVolume = null; _lastAppliedRtcToken = null; } @@ -1284,11 +1298,28 @@ class RealTimeCommunicationManager extends ChangeNotifier { final changed = !_sameMicMaps(roomWheatMap, nextMap); roomWheatMap = nextMap; _syncSelfMicRuntimeState(); - if (changed || notifyIfUnchanged) { + final roomMusicChanged = _pruneRoomMusicPublishingUsersToMicSeats(); + if (changed || notifyIfUnchanged || roomMusicChanged) { notifyListeners(); } } + bool _pruneRoomMusicPublishingUsersToMicSeats() { + if (_roomMusicPublishingUserIds.isEmpty) { + return false; + } + final seatedUserIds = + roomWheatMap.values + .map((seat) => (seat.user?.id ?? "").trim()) + .where((userId) => userId.isNotEmpty) + .toSet(); + final beforeCount = _roomMusicPublishingUserIds.length; + _roomMusicPublishingUserIds.removeWhere( + (userId) => !seatedUserIds.contains(userId), + ); + return beforeCount != _roomMusicPublishingUserIds.length; + } + int? _tryParseAgoraUid(String? value) { final normalizedValue = (value ?? '').trim(); if (normalizedValue.isEmpty) { @@ -1597,13 +1628,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { } await engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster); await engine?.muteLocalAudioStream(false); - await engine?.muteRecordingSignal(muted); - if (!muted) { - await engine?.adjustRecordingSignalVolume(100); - } + await engine?.muteRecordingSignal(false); + await engine?.adjustRecordingSignalVolume(muted ? 0 : 100); _lastAppliedClientRole = ClientRoleType.clientRoleBroadcaster; _lastAppliedLocalAudioMuted = false; - _lastAppliedRecordingSignalMuted = muted; + _lastAppliedRecordingSignalMuted = false; + _lastAppliedRecordingSignalVolume = muted ? 0 : 100; _agoraRoleIsBroadcaster = true; _resyncHeartbeatFromAgoraState(); return true; @@ -1620,9 +1650,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { await engine?.setClientRole(role: ClientRoleType.clientRoleAudience); await engine?.muteLocalAudioStream(true); await engine?.muteRecordingSignal(true); + await engine?.adjustRecordingSignalVolume(0); _lastAppliedClientRole = ClientRoleType.clientRoleAudience; _lastAppliedLocalAudioMuted = true; _lastAppliedRecordingSignalMuted = true; + _lastAppliedRecordingSignalVolume = 0; _lastAppliedRtcToken = null; _agoraRoleIsBroadcaster = false; _resyncHeartbeatFromAgoraState(); @@ -2961,8 +2993,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { return result; } - void sendRoomTextMessage(String message) { + void sendRoomTextMessage(String message, {String? dedupKey}) { final normalizedMessage = message.trim(); + final normalizedDedupKey = dedupKey?.trim() ?? ""; final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? ""; if (normalizedMessage.isEmpty || groupId.isEmpty) { return; @@ -2974,6 +3007,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { msg: normalizedMessage, type: SCRoomMsgType.text, role: currenRoom?.entrants?.roles ?? "", + giftBatchId: + normalizedDedupKey.isEmpty ? null : normalizedDedupKey, user: AccountStorage().getCurrentUser()?.userProfile, ), ) ?? @@ -3329,6 +3364,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _pendingMicListRefreshNotifyIfUnchanged = false; _seatEmojiEventVersions.clear(); _emojiPlaybackEventVersion = 0; + _roomMusicPublishingUserIds.clear(); roomRocketStatus = null; rtmProvider ?.onNewMessageListenerGroupMap["${currenRoom?.roomProfile?.roomProfile?.roomAccount}"] = @@ -4078,6 +4114,74 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomMusicMicRouteChangedListener = listener; } + void updateRoomMusicPublishingState({ + required String? userId, + required bool publishing, + bool notifyIfChanged = true, + }) { + final normalizedUserId = (userId ?? "").trim(); + if (normalizedUserId.isEmpty) { + return; + } + final changed = + publishing + ? _roomMusicPublishingUserIds.add(normalizedUserId) + : _roomMusicPublishingUserIds.remove(normalizedUserId); + if (changed && notifyIfChanged) { + notifyListeners(); + } + } + + void handleRoomMusicPublishingMessage(Msg msg) { + final userId = + (msg.role ?? "").trim().isNotEmpty ? msg.role : msg.user?.id ?? ""; + final statusText = (msg.msg ?? "").trim().toLowerCase(); + final publishing = + statusText == "1" || statusText == "true" || (msg.number ?? 0) > 0; + updateRoomMusicPublishingState(userId: userId, publishing: publishing); + } + + void publishCurrentUserRoomMusicState(bool publishing) { + final currentUser = AccountStorage().getCurrentUser()?.userProfile; + final currentUserId = (currentUser?.id ?? "").trim(); + if (currentUserId.isEmpty) { + return; + } + + updateRoomMusicPublishingState( + userId: currentUserId, + publishing: publishing && isOnMai(), + ); + + final groupId = + currenRoom?.roomProfile?.roomProfile?.roomAccount?.trim() ?? ""; + if (groupId.isEmpty) { + return; + } + unawaited( + rtmProvider?.dispatchMessage( + Msg( + groupId: groupId, + msg: publishing && isOnMai() ? "1" : "0", + type: SCRoomMsgType.roomMusic, + role: currentUserId, + number: publishing && isOnMai() ? 1 : 0, + user: currentUser, + ), + addLocal: false, + ) ?? + Future.value(), + ); + } + + void republishCurrentUserRoomMusicStateIfNeeded() { + final currentUserId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + if (isUserPublishingRoomMusic(currentUserId)) { + publishCurrentUserRoomMusicState(true); + } + } + Future startRoomMusicAudioMixing({ required String filePath, required bool loopback, diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 69fee76..046faf7 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -75,6 +75,33 @@ typedef OnNewGroupMessageListener = typedef OnMessageRecvC2CReadListener = Function(List messageIDList); typedef RtmProvider = RealTimeMessagingManager; +class _LuckGiftPushQueueEntry { + _LuckGiftPushQueueEntry({ + required this.reward, + required this.eventKey, + required this.mergeKey, + required this.updatedAt, + }) { + if (eventKey.isNotEmpty) { + eventKeys.add(eventKey); + } + } + + SCBroadCastLuckGiftPush reward; + final String eventKey; + final String mergeKey; + int updatedAt; + final Set eventKeys = {}; + + void merge(SCBroadCastLuckGiftPush incoming, String incomingEventKey) { + reward = reward.mergeReward(incoming); + updatedAt = DateTime.now().millisecondsSinceEpoch; + if (incomingEventKey.isNotEmpty) { + eventKeys.add(incomingEventKey); + } + } +} + class RealTimeMessagingManager extends ChangeNotifier { static const int _giftComboMergeWindowMs = 3000; static const int _maxLuckGiftPushQueueLength = 12; @@ -82,6 +109,9 @@ class RealTimeMessagingManager extends ChangeNotifier { static const int _luckyGiftBurstMinMultiple = 10; static const int _luckyGiftBurstMinAwardAmount = 5000; static const int _luckyGiftBurstDisplayDurationMs = 2000; + static const int _luckyGiftBurstMergeDelayMs = 600; + static const int _luckyGiftBurstMergeWindowMs = 1800; + static const int _luckyGiftBurstRecentEventTtlMs = 5000; BuildContext? context; @@ -186,8 +216,12 @@ class RealTimeMessagingManager extends ChangeNotifier { int activityUnReadCount = 0; int notifcationUnReadCount = 0; SCBroadCastLuckGiftPush? currentPlayingLuckGift; - final Queue _luckGiftPushQueue = Queue(); + final Queue<_LuckGiftPushQueueEntry> _luckGiftPushQueue = Queue(); String? _currentLuckGiftPushKey; + Set _currentLuckGiftPushEventKeys = {}; + Timer? _luckGiftPushPlayTimer; + int _luckGiftPushPlaybackToken = 0; + final Map _recentLuckGiftPushEventTimes = {}; String? roomRedPacketBroadcastGroupId; String? roomRedPacketBroadcastRegionCode; Debouncer debouncer = Debouncer(); @@ -703,6 +737,7 @@ class RealTimeMessagingManager extends ChangeNotifier { case SCRoomMsgType.emoticons: case SCRoomMsgType.xiaMai: case SCRoomMsgType.killXiaMai: + case SCRoomMsgType.roomMusic: case SCRoomMsgType.roomRoleChange: case SCRoomMsgType.roomSettingUpdate: case SCRoomMsgType.roomBGUpdate: @@ -1653,6 +1688,25 @@ class RealTimeMessagingManager extends ChangeNotifier { return currentRoomId.isNotEmpty && currentRoomId == roomId.trim(); } + bool _isCurrentVisibleVoiceRoom(String roomId) { + final normalizedRoomId = roomId.trim(); + final currentContext = context; + if (normalizedRoomId.isEmpty || + currentContext == null || + !currentContext.mounted) { + return false; + } + final rtcProvider = Provider.of( + currentContext, + listen: false, + ); + final currentRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; + return rtcProvider.shouldShowRoomVisualEffects && + currentRoomId.isNotEmpty && + currentRoomId == normalizedRoomId; + } + String _currentVoiceRoomId() { final currentContext = context; if (currentContext == null || !currentContext.mounted) { @@ -1735,7 +1789,7 @@ class RealTimeMessagingManager extends ChangeNotifier { } final payloadMap = _broadcastPayloadMap(payload); final payloadRoomId = _payloadText(payloadMap['roomId']); - if (payloadRoomId.isNotEmpty && !_isCurrentVoiceRoom(payloadRoomId)) { + if (!_isCurrentVisibleVoiceRoom(payloadRoomId)) { return; } if (_isCurrentUserRoomRedPacketPayload(payload)) { @@ -1893,8 +1947,7 @@ class RealTimeMessagingManager extends ChangeNotifier { priority: 1000, ); final packetRoomId = _payloadText(fData["roomId"]); - if (packetRoomId.isNotEmpty && - packetRoomId == _currentVoiceRoomId()) { + if (_isCurrentVisibleVoiceRoom(packetRoomId)) { final rtcProvider = Provider.of( context!, listen: false, @@ -1978,13 +2031,16 @@ class RealTimeMessagingManager extends ChangeNotifier { packetRoomId != _currentVoiceRoomId()) { return; } + final isCurrentVisibleRoom = _isCurrentVisibleVoiceRoom(packetRoomId); Provider.of( context!, listen: false, ).loadRoomRedPacketList(1); addMsg(_buildRoomRedPacketMsg(groupID, fData)); - _showCurrentRoomRedPacketDialog(fData); - OverlayManager().addMessage(msg); + if (isCurrentVisibleRoom) { + _showCurrentRoomRedPacketDialog(fData); + OverlayManager().addMessage(msg); + } return; } Msg msg = Msg.fromJson(data); @@ -2086,6 +2142,11 @@ class RealTimeMessagingManager extends ChangeNotifier { context!, listen: false, ).micChange(BroadCastMicChangePush.fromJson(data).data?.mics); + } else if (msg.type == SCRoomMsgType.roomMusic) { + Provider.of( + context!, + listen: false, + ).handleRoomMusicPublishingMessage(msg); } else if (msg.type == SCRoomMsgType.shangMai || msg.type == SCRoomMsgType.xiaMai || msg.type == SCRoomMsgType.fengMai || @@ -2128,6 +2189,7 @@ class RealTimeMessagingManager extends ChangeNotifier { listen: false, ).addOnlineUser(msg.groupId ?? "", msg.user!); } + rtcProvider.republishCurrentUserRoomMusicStateIfNeeded(); if (msgUserJoinListener != null) { msgUserJoinListener!(msg); } @@ -2510,6 +2572,11 @@ class RealTimeMessagingManager extends ChangeNotifier { _luckGiftPushQueue.clear(); currentPlayingLuckGift = null; _currentLuckGiftPushKey = null; + _currentLuckGiftPushEventKeys.clear(); + _luckGiftPushPlayTimer?.cancel(); + _luckGiftPushPlayTimer = null; + _luckGiftPushPlaybackToken++; + _recentLuckGiftPushEventTimes.clear(); onNewMessageListenerGroupMap.forEach((k, v) { v = null; }); @@ -2521,22 +2588,42 @@ class RealTimeMessagingManager extends ChangeNotifier { if (!shouldPlayLuckyGiftBurst(broadCastRes.data)) { return; } + final now = DateTime.now().millisecondsSinceEpoch; + _pruneRecentLuckGiftPushEvents(now); final eventKey = _luckyGiftPushEventKey(broadCastRes); - if (eventKey.isNotEmpty) { - if (_currentLuckGiftPushKey == eventKey) { - return; - } - for (final queued in _luckGiftPushQueue) { - if (queued != null && _luckyGiftPushEventKey(queued) == eventKey) { - return; - } - } + if (_hasSeenLuckGiftPushEvent(eventKey, now)) { + return; } + final mergeKey = _luckyGiftPushMergeKey(broadCastRes); + final mergeTarget = _findMergeableLuckGiftPushEntry(mergeKey, now); + if (mergeTarget != null) { + mergeTarget.merge(broadCastRes, eventKey); + _rememberLuckGiftPushEvent(eventKey, now); + _giftFxLog( + 'merge lucky gift burst ' + 'mergeKey=$mergeKey ' + 'awardAmount=${mergeTarget.reward.data?.awardAmount} ' + 'giftQuantity=${mergeTarget.reward.data?.giftQuantity} ' + 'multiple=${mergeTarget.reward.data?.multiple} ' + 'eventCount=${mergeTarget.eventKeys.length}', + ); + _scheduleLuckGiftBackCoins(restart: true); + return; + } + while (_luckGiftPushQueue.length >= _maxLuckGiftPushQueueLength) { _luckGiftPushQueue.removeFirst(); } - _luckGiftPushQueue.add(broadCastRes); - playLuckGiftBackCoins(); + _luckGiftPushQueue.add( + _LuckGiftPushQueueEntry( + reward: broadCastRes, + eventKey: eventKey, + mergeKey: mergeKey, + updatedAt: now, + ), + ); + _rememberLuckGiftPushEvent(eventKey, now); + _scheduleLuckGiftBackCoins(); } } @@ -2553,29 +2640,106 @@ class RealTimeMessagingManager extends ChangeNotifier { void cleanLuckGiftBackCoins() { _luckGiftPushQueue.clear(); _currentLuckGiftPushKey = null; + _currentLuckGiftPushEventKeys.clear(); + _luckGiftPushPlayTimer?.cancel(); + _luckGiftPushPlayTimer = null; + _luckGiftPushPlaybackToken++; + } + + void _scheduleLuckGiftBackCoins({bool restart = false}) { + if (currentPlayingLuckGift != null || _luckGiftPushQueue.isEmpty) { + return; + } + if (restart) { + _luckGiftPushPlayTimer?.cancel(); + _luckGiftPushPlayTimer = null; + } + if (_luckGiftPushPlayTimer?.isActive ?? false) { + return; + } + _luckGiftPushPlayTimer = Timer( + Duration(milliseconds: _luckyGiftBurstMergeDelayMs), + () { + _luckGiftPushPlayTimer = null; + playLuckGiftBackCoins(); + }, + ); } void playLuckGiftBackCoins() { if (currentPlayingLuckGift != null || _luckGiftPushQueue.isEmpty) { return; } - currentPlayingLuckGift = _luckGiftPushQueue.removeFirst(); - _currentLuckGiftPushKey = - currentPlayingLuckGift == null - ? null - : _luckyGiftPushEventKey(currentPlayingLuckGift!); + _luckGiftPushPlayTimer?.cancel(); + _luckGiftPushPlayTimer = null; + final queueEntry = _luckGiftPushQueue.removeFirst(); + currentPlayingLuckGift = queueEntry.reward; + _currentLuckGiftPushKey = queueEntry.eventKey; + _currentLuckGiftPushEventKeys = Set.from(queueEntry.eventKeys); + final playbackToken = ++_luckGiftPushPlaybackToken; notifyListeners(); Future.delayed( Duration(milliseconds: _luckyGiftBurstDisplayDurationMs), () { + if (playbackToken != _luckGiftPushPlaybackToken) { + return; + } currentPlayingLuckGift = null; _currentLuckGiftPushKey = null; + _currentLuckGiftPushEventKeys.clear(); notifyListeners(); - playLuckGiftBackCoins(); + _scheduleLuckGiftBackCoins(); }, ); } + bool _hasSeenLuckGiftPushEvent(String eventKey, int now) { + if (eventKey.isEmpty) { + return false; + } + if (_currentLuckGiftPushKey == eventKey || + _currentLuckGiftPushEventKeys.contains(eventKey)) { + return true; + } + for (final queued in _luckGiftPushQueue) { + if (queued.eventKeys.contains(eventKey)) { + return true; + } + } + final seenAt = _recentLuckGiftPushEventTimes[eventKey]; + return seenAt != null && now - seenAt <= _luckyGiftBurstRecentEventTtlMs; + } + + void _rememberLuckGiftPushEvent(String eventKey, int now) { + if (eventKey.isEmpty) { + return; + } + _recentLuckGiftPushEventTimes[eventKey] = now; + } + + void _pruneRecentLuckGiftPushEvents(int now) { + _recentLuckGiftPushEventTimes.removeWhere( + (_, seenAt) => now - seenAt > _luckyGiftBurstRecentEventTtlMs, + ); + } + + _LuckGiftPushQueueEntry? _findMergeableLuckGiftPushEntry( + String mergeKey, + int now, + ) { + if (mergeKey.isEmpty) { + return null; + } + final queuedEntries = _luckGiftPushQueue.toList(growable: false); + for (final queued in queuedEntries.reversed) { + if (queued.mergeKey == mergeKey && + now - queued.updatedAt <= _luckyGiftBurstMergeWindowMs) { + return queued; + } + } + return null; + } + String _luckyGiftPushEventKey(SCBroadCastLuckGiftPush broadCastRes) { final rewardData = broadCastRes.data; if (rewardData == null) { @@ -2588,7 +2752,19 @@ class RealTimeMessagingManager extends ChangeNotifier { '${rewardData.giftQuantity ?? 0}|' '${rewardData.awardAmount ?? 0}|' '${rewardData.multiple ?? 0}|' - '${rewardData.normalizedMultipleType}'; + '${rewardData.normalizedMultipleType}|' + '${rewardData.balance ?? 0}'; + } + + String _luckyGiftPushMergeKey(SCBroadCastLuckGiftPush broadCastRes) { + final rewardData = broadCastRes.data; + if (rewardData == null) { + return ''; + } + return '${rewardData.roomId ?? ""}|' + '${rewardData.giftId ?? ""}|' + '${rewardData.sendUserId ?? ""}|' + '${rewardData.acceptUserId ?? ""}'; } void updateNotificationCount(int count) { diff --git a/lib/services/music/room_music_manager.dart b/lib/services/music/room_music_manager.dart index 75103f0..8243704 100644 --- a/lib/services/music/room_music_manager.dart +++ b/lib/services/music/room_music_manager.dart @@ -35,6 +35,8 @@ class RoomMusicManager extends ChangeNotifier { bool _isPaused = false; bool _isStarting = false; bool _isStoppingByUser = false; + bool _isRoutingSwitching = false; + bool _lastPublishedRoomState = false; bool _lastLoopback = true; bool _roomPlayerVisible = false; @@ -59,7 +61,11 @@ class RoomMusicManager extends ChangeNotifier { bool get roomPlayerVisible => _roomPlayerVisible; - bool get isPublishingToRoom => _isPlaying && !_lastLoopback; + bool get isPublishingToRoom => + _isPlaying && + !_isPaused && + !_lastLoopback && + (_rtcProvider?.isOnMai() ?? false); int get currentIndex { final currentId = _current?.id; @@ -78,6 +84,7 @@ class RoomMusicManager extends ChangeNotifier { _rtcProvider = provider; provider.setRoomMusicMixingStateListener(_handleMixingStateChanged); provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged); + _syncPublishingState(force: true); } Future reload() async { @@ -134,7 +141,11 @@ class RoomMusicManager extends ChangeNotifier { await _savePlaylist(next); } - Future play(SCMusicMode item, {int startPositionMs = 0}) async { + Future play( + SCMusicMode item, { + int startPositionMs = 0, + bool showRoomPlayer = true, + }) async { if (item.playPath.isEmpty) { SCTts.show("Music file not found"); return; @@ -149,7 +160,9 @@ class RoomMusicManager extends ChangeNotifier { _durationMs = item.durationMs; _positionMs = startPositionMs; _isPaused = false; - _roomPlayerVisible = true; + if (showRoomPlayer) { + _roomPlayerVisible = true; + } await _startCurrent(startPositionMs: startPositionMs); } @@ -161,6 +174,7 @@ class RoomMusicManager extends ChangeNotifier { _isPlaying = false; _isPaused = true; _stopPositionTimer(); + _syncPublishingState(); notifyListeners(); } @@ -169,10 +183,25 @@ class RoomMusicManager extends ChangeNotifier { return; } if (_isPaused) { + final nextLoopback = !_rtcProvider!.isOnMai(); + if (nextLoopback != _lastLoopback) { + final wasRoomPlayerVisible = _roomPlayerVisible; + _ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900; + _isRoutingSwitching = true; + try { + await _rtcProvider?.stopRoomMusicAudioMixing(); + await _startCurrent(startPositionMs: _positionMs); + _roomPlayerVisible = wasRoomPlayerVisible; + } finally { + _isRoutingSwitching = false; + } + return; + } await _rtcProvider?.resumeRoomMusicAudioMixing(); _isPlaying = true; _isPaused = false; _startPositionTimer(); + _syncPublishingState(); notifyListeners(); return; } @@ -196,6 +225,7 @@ class RoomMusicManager extends ChangeNotifier { _durationMs = 0; _roomPlayerVisible = false; } + _syncPublishingState(force: clearCurrent); notifyListeners(); } } @@ -219,7 +249,7 @@ class RoomMusicManager extends ChangeNotifier { return; } if (_playMode == RoomMusicPlayMode.single && fromAutoComplete) { - await play(_current ?? _playlist.first); + await play(_current ?? _playlist.first, showRoomPlayer: false); return; } int targetIndex = 0; @@ -235,7 +265,7 @@ class RoomMusicManager extends ChangeNotifier { } else { targetIndex = index < 0 ? 0 : (index + 1) % _playlist.length; } - await play(_playlist[targetIndex]); + await play(_playlist[targetIndex], showRoomPlayer: !fromAutoComplete); } Future seek(int positionMs) async { @@ -298,13 +328,19 @@ class RoomMusicManager extends ChangeNotifier { .getRoomMusicAudioMixingPosition() .catchError((_) => _positionMs); _ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900; + final wasRoomPlayerVisible = _roomPlayerVisible; + _isRoutingSwitching = true; try { await rtcProvider.stopRoomMusicAudioMixing(); await _startCurrent(startPositionMs: position); + _roomPlayerVisible = wasRoomPlayerVisible; } catch (_) { _isPlaying = false; _isPaused = false; + _syncPublishingState(); notifyListeners(); + } finally { + _isRoutingSwitching = false; } } @@ -341,11 +377,13 @@ class RoomMusicManager extends ChangeNotifier { _isPlaying = true; _isPaused = false; _startPositionTimer(); + _syncPublishingState(); notifyListeners(); } catch (_) { _isPlaying = false; _isPaused = false; _stopPositionTimer(); + _syncPublishingState(); SCTts.show("Music playback failed"); notifyListeners(); } finally { @@ -401,15 +439,20 @@ class RoomMusicManager extends ChangeNotifier { _isPlaying = true; _isPaused = false; _startPositionTimer(); + _syncPublishingState(); notifyListeners(); break; case AudioMixingStateType.audioMixingStatePaused: _isPlaying = false; _isPaused = true; _stopPositionTimer(); + _syncPublishingState(); notifyListeners(); break; case AudioMixingStateType.audioMixingStateStopped: + if (_isRoutingSwitching) { + return; + } if (DateTime.now().millisecondsSinceEpoch < _ignoreStoppedUntilMs) { return; } @@ -423,6 +466,7 @@ class RoomMusicManager extends ChangeNotifier { _isPlaying = false; _isPaused = false; _positionMs = 0; + _syncPublishingState(); notifyListeners(); } break; @@ -430,6 +474,7 @@ class RoomMusicManager extends ChangeNotifier { _stopPositionTimer(); _isPlaying = false; _isPaused = false; + _syncPublishingState(); SCTts.show("Music playback failed"); notifyListeners(); break; @@ -438,6 +483,20 @@ class RoomMusicManager extends ChangeNotifier { void _handleMicRouteChanged(bool isOnMic) { unawaited(syncPlaybackRouting()); + _syncPublishingState(); + } + + void _syncPublishingState({bool force = false}) { + final rtcProvider = _rtcProvider; + if (rtcProvider == null) { + return; + } + final publishing = isPublishingToRoom; + if (!force && publishing == _lastPublishedRoomState) { + return; + } + _lastPublishedRoomState = publishing; + rtcProvider.publishCurrentUserRoomMusicState(publishing); } void _restoreCurrentAfterPlaylistChange() { @@ -453,6 +512,7 @@ class RoomMusicManager extends ChangeNotifier { _current = null; _isPlaying = false; _isPaused = false; + _syncPublishingState(force: true); _roomPlayerVisible = false; _positionMs = 0; _durationMs = 0; diff --git a/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart b/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart index 3613ed8..b9a08cd 100644 --- a/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart +++ b/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart @@ -16,6 +16,21 @@ class SCBroadCastLuckGiftPush { Data? get data => _data; String? get type => _type; + SCBroadCastLuckGiftPush mergeReward(SCBroadCastLuckGiftPush incoming) { + final currentData = _data; + final incomingData = incoming.data; + if (currentData == null) { + return incoming; + } + if (incomingData == null) { + return this; + } + return SCBroadCastLuckGiftPush( + data: currentData.mergeReward(incomingData), + type: _type ?? incoming.type, + ); + } + Map toJson() { final map = {}; if (_data != null) { @@ -164,6 +179,35 @@ class Data { bool get isBigReward => normalizedMultipleType == 'BIG_WIN' || normalizedMultipleType == 'SUPER'; + Data mergeReward(Data incoming) { + return Data( + acceptNickname: _firstText(_acceptNickname, incoming.acceptNickname), + acceptUserId: _firstText(_acceptUserId, incoming.acceptUserId), + account: _firstText(_account, incoming.account), + avatarFrameCover: _firstText( + _avatarFrameCover, + incoming.avatarFrameCover, + ), + avatarFrameSvg: _firstText(_avatarFrameSvg, incoming.avatarFrameSvg), + balance: incoming.balance ?? _balance, + awardAmount: _sumNum(_awardAmount, incoming.awardAmount), + giftCandy: _giftCandy ?? incoming.giftCandy, + giftCover: _firstText(_giftCover, incoming.giftCover), + giftQuantity: _sumNum(_giftQuantity, incoming.giftQuantity), + globalNews: (_globalNews ?? false) || (incoming.globalNews ?? false), + multiple: _maxNum(_multiple, incoming.multiple), + multipleType: _strongerMultipleType(_multipleType, incoming.multipleType), + nickname: _firstText(_nickname, incoming.nickname), + regionCode: _firstText(_regionCode, incoming.regionCode), + roomAccount: _firstText(_roomAccount, incoming.roomAccount), + roomId: _firstText(_roomId, incoming.roomId), + giftId: _firstText(_giftId, incoming.giftId), + sendUserId: _firstText(_sendUserId, incoming.sendUserId), + sysOrigin: _firstText(_sysOrigin, incoming.sysOrigin), + userAvatar: _firstText(_userAvatar, incoming.userAvatar), + ); + } + static String? _asString(dynamic value) { if (value == null) { return null; @@ -198,6 +242,57 @@ class Data { return null; } + static String? _firstText(String? current, String? incoming) { + final currentText = current?.trim(); + if (currentText != null && currentText.isNotEmpty) { + return current; + } + final incomingText = incoming?.trim(); + if (incomingText != null && incomingText.isNotEmpty) { + return incoming; + } + return current ?? incoming; + } + + static num? _sumNum(num? current, num? incoming) { + if (current == null) { + return incoming; + } + if (incoming == null) { + return current; + } + return current + incoming; + } + + static num? _maxNum(num? current, num? incoming) { + if (current == null) { + return incoming; + } + if (incoming == null) { + return current; + } + return current >= incoming ? current : incoming; + } + + static String? _strongerMultipleType(String? current, String? incoming) { + return _multipleTypeRank(incoming) > _multipleTypeRank(current) + ? incoming + : current ?? incoming; + } + + static int _multipleTypeRank(String? value) { + switch ((value ?? '').trim().toUpperCase()) { + case 'SUPER': + return 3; + case 'BIG_WIN': + return 2; + case 'WIN': + return 1; + default: + return 0; + } + } + Map toJson() { final map = {}; map['acceptNickname'] = _acceptNickname; diff --git a/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart index cbcad56..72ce255 100644 --- a/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart +++ b/lib/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart @@ -286,8 +286,11 @@ class _FloatingRoomRedenvelopeScreenWidgetState listen: false, ); final currentRoomId = rtcProvider.currenRoom?.roomProfile?.roomProfile?.id; - if (currentRoomId == roomId) { - final fallbackData = RoomRedPacketUiData.fromJson({ + final isCurrentVisibleRoom = + currentRoomId == roomId && rtcProvider.shouldShowRoomVisualEffects; + if (isCurrentVisibleRoom) { + final packetId = widget.message.toUserId?.trim() ?? ''; + final baseFallbackData = RoomRedPacketUiData.fromJson({ 'packetId': widget.message.toUserId, 'userName': widget.message.userName, 'userAvatar': widget.message.userAvatarUrl, @@ -296,6 +299,14 @@ class _FloatingRoomRedenvelopeScreenWidgetState 'totalCount': widget.message.number, 'remainCount': widget.message.number, }); + final cachedData = RoomRedPacketPendingCache.instance.packetForRoom( + roomId, + packetId, + ); + final fallbackData = + cachedData == null + ? baseFallbackData + : baseFallbackData.withFallbackProfile(cachedData); var dialogData = fallbackData; try { final detail = await SCChatRoomRepository().roomRedPacketDetail( @@ -313,7 +324,7 @@ class _FloatingRoomRedenvelopeScreenWidgetState } return; } - if ((currentRoomId ?? '').trim().isEmpty) { + if ((currentRoomId ?? '').trim().isEmpty || currentRoomId == roomId) { rtcProvider.joinVoiceRoomSession(currentContext, roomId); return; } diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart index 360663d..942a5dc 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_open_dialog.dart @@ -183,18 +183,22 @@ class RoomRedPacketOpenDialog extends StatefulWidget { packetId.isEmpty ? -1 : packets.indexWhere((item) => item.packetId.trim() == packetId); + final mergedData = + existingIndex >= 0 + ? data.withFallbackProfile(packets[existingIndex]) + : data; if (existingIndex >= 0) { if (insertAtStart) { packets ..removeAt(existingIndex) - ..insert(0, data); + ..insert(0, mergedData); } return; } if (insertAtStart) { - packets.insert(0, data); + packets.insert(0, mergedData); } else { - packets.add(data); + packets.add(mergedData); } } @@ -784,10 +788,14 @@ class _RoomRedPacketOpenCardState extends State<_RoomRedPacketOpenCard> { _formatNumber(amount.round()), senderName, ); - Provider.of( - context, - listen: false, - ).sendRoomTextMessage(message); + final packetId = widget.data.packetId.trim(); + Provider.of(context, listen: false).sendRoomTextMessage( + message, + dedupKey: + packetId.isEmpty + ? null + : 'room-red-packet-claim-$packetId-${DateTime.now().microsecondsSinceEpoch}', + ); } bool get _isLocalPreviewPacket { diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart index b56b475..6663213 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_pending_cache.dart @@ -82,6 +82,16 @@ class RoomRedPacketPendingCache extends ChangeNotifier { .toList(growable: false); } + RoomRedPacketUiData? packetForRoom(String roomId, String packetId) { + final normalizedRoomId = roomId.trim(); + final normalizedPacketId = packetId.trim(); + if (normalizedRoomId.isEmpty || normalizedPacketId.isEmpty) { + return null; + } + _pruneExpired(notify: false); + return _packetsByRoom[normalizedRoomId]?[normalizedPacketId]?.packet; + } + void pruneExpired() { _pruneExpired(); } diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart index 2daea98..e3c7f0f 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart @@ -1,22 +1,28 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:provider/provider.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; -import 'package:yumi/app_localizations.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/app_localizations.dart'; import 'package:yumi/main.dart'; -import 'package:yumi/modules/wallet/wallet_route.dart'; +import 'package:yumi/modules/wallet/recharge/recharge_page.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/services/payment/apple_payment_manager.dart'; +import 'package:yumi/services/payment/google_payment_manager.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_red_packet_list_res.dart'; import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; +import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; @@ -694,7 +700,7 @@ class _PickupOption { final int minutes; } -class _RoomRedPacketRechargeSheet extends StatelessWidget { +class _RoomRedPacketRechargeSheet extends StatefulWidget { const _RoomRedPacketRechargeSheet(); static const String dialogTag = 'showRoomRedPacketRechargeSheet'; @@ -711,12 +717,53 @@ class _RoomRedPacketRechargeSheet extends StatelessWidget { ); } + @override + State<_RoomRedPacketRechargeSheet> createState() => + _RoomRedPacketRechargeSheetState(); +} + +class _RoomRedPacketRechargeSheetState + extends State<_RoomRedPacketRechargeSheet> { + static const Color _primaryTextColor = Color(0xFF8D2F2A); + static const Color _secondaryTextColor = Color(0xFF9B604C); + static const Color _accentGoldColor = Color(0xFFF0B84D); + static const Color _itemBorderColor = Color(0xFFF2D2A2); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initializeRecharge(); + } + }); + } + + void _initializeRecharge() { + final paymentContext = navigatorKey.currentState?.context ?? context; + Provider.of( + paymentContext, + listen: false, + ).balance(); + if (Platform.isAndroid) { + Provider.of( + paymentContext, + listen: false, + ).initializePaymentProcessor(paymentContext); + } else if (Platform.isIOS) { + Provider.of( + paymentContext, + listen: false, + ).initializePaymentProcessor(paymentContext); + } + } + @override Widget build(BuildContext context) { final localizations = SCAppLocalizations.of(context)!; final bottomPadding = MediaQuery.of(context).padding.bottom; - final minSheetHeight = 320.w + bottomPadding; - final preferredSheetHeight = MediaQuery.of(context).size.height * 0.46; + final minSheetHeight = 430.w + bottomPadding; + final preferredSheetHeight = MediaQuery.of(context).size.height * 0.62; return Container( width: double.infinity, height: @@ -742,162 +789,459 @@ class _RoomRedPacketRechargeSheet extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 42.w, - height: 4.w, - decoration: BoxDecoration( - color: const Color(0xFFD6B28A).withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(2.w), - ), - ), + _buildDragHandle(), + _buildTitleRow(localizations), SizedBox(height: 8.w), - Align( - alignment: AlignmentDirectional.centerEnd, - child: SCDebounceWidget( - onTap: () => SmartDialog.dismiss(tag: dialogTag), - child: Icon( - Icons.close_rounded, - color: const Color(0xFF8C4A33), - size: 24.w, - ), - ), - ), - Image.asset( - _RoomRedPacketAssets.amountIcon, - width: 52.w, - height: 52.w, - fit: BoxFit.contain, - ), + _buildBalanceCard(localizations), + SizedBox(height: 14.w), + _buildMethodRow(localizations), SizedBox(height: 12.w), - Text( - localizations.roomRedPacketInsufficientBalance, - textAlign: TextAlign.center, - style: TextStyle( - color: const Color(0xFF8D2F2A), - fontSize: 20.sp, - height: 1.18, - fontWeight: FontWeight.w800, - decoration: TextDecoration.none, + Expanded(child: _buildNativeProductList(localizations)), + if (Platform.isIOS) ...[ + SizedBox(height: 10.w), + _buildSecondaryButton( + localizations.restorePurchases, + () => + Provider.of( + navigatorKey.currentState?.context ?? context, + listen: false, + ).recoverTransactions(), ), - ), - SizedBox(height: 8.w), - Text( - localizations.insufhcientGoldsGoToRecharge, - textAlign: TextAlign.center, - style: TextStyle( - color: const Color(0xFF9B604C), - fontSize: 13.sp, - height: 1.35, - fontWeight: FontWeight.w500, - decoration: TextDecoration.none, - ), - ), - SizedBox(height: 18.w), - Consumer( - builder: (context, profileManager, child) { - return Container( - width: double.infinity, - height: 46.w, - padding: EdgeInsets.symmetric(horizontal: 14.w), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.82), - borderRadius: BorderRadius.circular(14.w), - border: Border.all( - color: const Color(0xFFF2D2A2), - width: 1.w, - ), - ), - child: Row( - children: [ - Image.asset( - _RoomRedPacketAssets.amountIcon, - width: 24.w, - height: 24.w, - fit: BoxFit.contain, - ), - SizedBox(width: 8.w), - Expanded( - child: Text( - localizations.wallet, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: const Color(0xFF7B4133), - fontSize: 14.sp, - fontWeight: FontWeight.w700, - decoration: TextDecoration.none, - ), - ), - ), - Text( - _formatSheetNumber(profileManager.myBalance.round()), - style: TextStyle( - color: const Color(0xFFE33C35), - fontSize: 15.sp, - fontWeight: FontWeight.w800, - decoration: TextDecoration.none, - ), - ), - ], - ), - ); - }, - ), - const Spacer(), - SCDebounceWidget( - onTap: () => _goRecharge(context), - child: Container( - width: double.infinity, - height: 50.w, - alignment: Alignment.center, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFFF4741), Color(0xFFE91E26)], - begin: AlignmentDirectional.centerStart, - end: AlignmentDirectional.centerEnd, - ), - borderRadius: BorderRadius.circular(25.w), - border: Border.all(color: const Color(0xFFFFD282), width: 1.w), - boxShadow: [ - BoxShadow( - color: const Color(0xFFE22D25).withValues(alpha: 0.28), - blurRadius: 12.w, - offset: Offset(0, 4.w), - ), - ], - ), - child: Text( - localizations.goToRecharge, - style: TextStyle( - color: Colors.white, - fontSize: 17.sp, - fontWeight: FontWeight.w800, - decoration: TextDecoration.none, - ), - ), - ), - ), + ], + SizedBox(height: 10.w), + _buildRechargeButton(localizations), ], ), ); } - void _goRecharge(BuildContext context) { - final routeContext = navigatorKey.currentState?.context ?? context; - SmartDialog.dismiss(tag: dialogTag); - SCNavigatorUtils.push( - routeContext, - WalletRoute.recharge, - replace: false, - ).then((_) { - if (!routeContext.mounted) { + Widget _buildDragHandle() { + return Container( + width: 42.w, + height: 4.w, + decoration: BoxDecoration( + color: const Color(0xFFD6B28A).withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(2.w), + ), + ); + } + + Widget _buildTitleRow(SCAppLocalizations localizations) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset( + _RoomRedPacketAssets.amountIcon, + width: 40.w, + height: 40.w, + fit: BoxFit.contain, + ), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.roomRedPacketInsufficientBalance, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _primaryTextColor, + fontSize: 18.sp, + height: 1.15, + fontWeight: FontWeight.w800, + decoration: TextDecoration.none, + ), + ), + SizedBox(height: 4.w), + Text( + localizations.insufhcientGoldsGoToRecharge, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _secondaryTextColor, + fontSize: 12.sp, + height: 1.25, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ], + ), + ), + SCDebounceWidget( + onTap: + () => SmartDialog.dismiss( + tag: _RoomRedPacketRechargeSheet.dialogTag, + ), + child: Icon( + Icons.close_rounded, + color: const Color(0xFF8C4A33), + size: 24.w, + ), + ), + ], + ); + } + + Widget _buildBalanceCard(SCAppLocalizations localizations) { + return Consumer( + builder: (context, profileManager, child) { + return Container( + width: double.infinity, + height: 44.w, + padding: EdgeInsets.symmetric(horizontal: 14.w), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(14.w), + border: Border.all(color: _itemBorderColor, width: 1.w), + ), + child: Row( + children: [ + Image.asset( + SCGlobalConfig.businessLogicStrategy.getRechargePageGoldIcon(), + width: 24.w, + height: 24.w, + fit: BoxFit.contain, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + localizations.wallet, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color(0xFF7B4133), + fontSize: 14.sp, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + ), + Text( + _formatSheetNumber(profileManager.myBalance.round()), + style: TextStyle( + color: const Color(0xFFE33C35), + fontSize: 15.sp, + fontWeight: FontWeight.w800, + decoration: TextDecoration.none, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildMethodRow(SCAppLocalizations localizations) { + return Container( + width: double.infinity, + height: 50.w, + padding: EdgeInsets.symmetric(horizontal: 14.w), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(14.w), + border: Border.all(color: _itemBorderColor, width: 1.w), + ), + child: Row( + children: [ + _buildPaymentIcon(), + SizedBox(width: 10.w), + Expanded( + child: Text( + Platform.isAndroid ? 'Google Pay' : 'Apple Pay', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _primaryTextColor, + fontSize: 14.sp, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + ), + Icon(Icons.radio_button_checked, size: 18.w, color: _accentGoldColor), + ], + ), + ); + } + + Widget _buildPaymentIcon() { + if (Platform.isAndroid) { + return Image.asset( + 'sc_images/login/sc_icon_google.png', + width: 28.w, + height: 28.w, + fit: BoxFit.contain, + ); + } + return Icon(Icons.apple, size: 25.w, color: _primaryTextColor); + } + + Widget _buildNativeProductList(SCAppLocalizations localizations) { + if (Platform.isAndroid) { + return Consumer( + builder: (context, processor, child) { + return _buildProductList( + localizations, + processor.products, + processor.chooseProductConfig, + (product) => + processor.productMap[product.produc.id]?.obtainCandy ?? 0, + ); + }, + ); + } + if (Platform.isIOS) { + return Consumer( + builder: (context, processor, child) { + return _buildProductList( + localizations, + processor.products, + processor.chooseProductConfig, + (product) => + processor.productMap[product.produc.id]?.obtainCandy ?? 0, + ); + }, + ); + } + return _buildRechargeEmptyState(localizations); + } + + Widget _buildProductList( + SCAppLocalizations localizations, + List products, + ValueChanged onChoose, + num Function(SelecteProductConfig product) obtainCandy, + ) { + if (products.isEmpty) { + return _buildRechargeEmptyState(localizations); + } + return ListView.separated( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemCount: products.length, + itemBuilder: (context, index) { + return _buildProductItem( + products[index], + index, + obtainCandy(products[index]), + () => onChoose(index), + ); + }, + separatorBuilder: (context, index) => SizedBox(height: 8.w), + ); + } + + Widget _buildProductItem( + SelecteProductConfig productConfig, + int index, + num obtainCandy, + VoidCallback onTap, + ) { + final selected = productConfig.isSelecte; + return SCDebounceWidget( + onTap: onTap, + child: Container( + height: 48.w, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: + selected + ? const Color(0xFFFFF6E8) + : Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(14.w), + border: Border.all( + color: selected ? const Color(0xFFFFB94F) : _itemBorderColor, + width: selected ? 1.4.w : 1.w, + ), + boxShadow: [ + if (selected) + BoxShadow( + color: const Color(0xFFFFA53B).withValues(alpha: 0.18), + blurRadius: 10.w, + offset: Offset(0, 3.w), + ), + ], + ), + child: Row( + children: [ + Image.asset( + SCGlobalConfig.businessLogicStrategy + .getRechargePageGoldIconByIndex(index), + width: 30.w, + height: 30.w, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + _formatSheetNumber(obtainCandy.round()), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _primaryTextColor, + fontSize: 14.sp, + fontWeight: FontWeight.w800, + decoration: TextDecoration.none, + ), + ), + ), + Text( + productConfig.produc.price, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _secondaryTextColor, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + SizedBox(width: 8.w), + Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 18.w, + color: selected ? const Color(0xFFFF3D34) : _secondaryTextColor, + ), + ], + ), + ), + ); + } + + Widget _buildRechargeEmptyState(SCAppLocalizations localizations) { + return Center( + child: Text( + localizations.noData, + style: TextStyle( + color: _secondaryTextColor, + fontSize: 13.sp, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ); + } + + Widget _buildSecondaryButton(String title, VoidCallback onTap) { + return SCDebounceWidget( + onTap: onTap, + child: SizedBox( + height: 32.w, + child: Center( + child: Text( + title, + style: TextStyle( + color: _secondaryTextColor, + fontSize: 13.sp, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ), + ); + } + + Widget _buildRechargeButton(SCAppLocalizations localizations) { + return SCDebounceWidget( + onTap: _processNativePurchase, + child: Container( + width: double.infinity, + height: 50.w, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFF4741), Color(0xFFE91E26)], + begin: AlignmentDirectional.centerStart, + end: AlignmentDirectional.centerEnd, + ), + borderRadius: BorderRadius.circular(25.w), + border: Border.all(color: const Color(0xFFFFD282), width: 1.w), + boxShadow: [ + BoxShadow( + color: const Color(0xFFE22D25).withValues(alpha: 0.28), + blurRadius: 12.w, + offset: Offset(0, 4.w), + ), + ], + ), + child: Text( + localizations.recharge, + style: TextStyle( + color: Colors.white, + fontSize: 17.sp, + fontWeight: FontWeight.w800, + decoration: TextDecoration.none, + ), + ), + ), + ); + } + + Future _processNativePurchase() async { + final paymentContext = navigatorKey.currentState?.context ?? context; + if (Platform.isAndroid) { + final processor = Provider.of( + paymentContext, + listen: false, + ); + final product = _selectedProduct(processor.products); + if (product == null) { + SCTts.show(SCAppLocalizations.of(context)!.pleaseSelectaItem); return; } - Provider.of( - routeContext, + try { + await processor.recoverTransactions(); + SCLoadingManager.show(); + final success = await processor.iap.buyConsumable( + purchaseParam: PurchaseParam(productDetails: product.produc), + autoConsume: kReleaseMode, + ); + if (!success) { + SCTts.show('Purchase failed, please check your network connection'); + } + } catch (error) { + SCTts.show('Purchase failed: $error'); + } finally { + SCLoadingManager.hide(); + } + } else if (Platform.isIOS) { + final processor = Provider.of( + paymentContext, listen: false, - ).balance(); - }); + ); + final product = _selectedProduct(processor.products); + if (product == null) { + SCTts.show(SCAppLocalizations.of(context)!.pleaseSelectaItem); + return; + } + try { + SCLoadingManager.show(context: paymentContext); + final success = await processor.iap.buyConsumable( + purchaseParam: PurchaseParam(productDetails: product.produc), + ); + if (!success) { + SCTts.show('Purchase failed, please check your network connection'); + } + } catch (error) { + SCTts.show('Purchase failed: $error'); + } finally { + SCLoadingManager.hide(); + } + } + } + + SelecteProductConfig? _selectedProduct(List products) { + for (final product in products) { + if (product.isSelecte) { + return product; + } + } + return null; } static String _formatSheetNumber(int value) { diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index ff74391..a2d4b1a 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -268,8 +268,8 @@ class _MsgItemState extends State { 300.w, ); final multilineMaxTextWidth = math.max( - 90.w, - multilineMaxBubbleWidth - 58.w, + 82.w, + multilineMaxBubbleWidth - 64.w, ); final multilineTextPainter = TextPainter( text: TextSpan(text: messageText, style: textStyle), @@ -299,11 +299,26 @@ class _MsgItemState extends State { required Size textSize, required double maxBubbleWidth, }) { + final leftPadding = 22.w; + final rightPadding = 42.w; + final topPadding = 16.w; + final bottomPadding = 24.w; final bubbleWidth = math - .max(126.w, textSize.width + 58.w) + .max(126.w, textSize.width + leftPadding + rightPadding) .clamp(126.w, maxBubbleWidth); - final textMaxWidth = math.max(90.w, bubbleWidth - 58.w); - final bubbleHeight = math.max(58.w, textSize.height + 26.w); + final textMaxWidth = math.max( + 64.w, + bubbleWidth - leftPadding - rightPadding, + ); + final textPainter = TextPainter( + text: TextSpan(text: messageText, style: textStyle), + textDirection: Directionality.of(context), + )..layout(maxWidth: textMaxWidth); + final textBoxHeight = textPainter.size.height + 2.w; + final bubbleHeight = math.max( + 76.w, + textBoxHeight + topPadding + bottomPadding, + ); return Container( width: bubbleWidth, @@ -318,21 +333,15 @@ class _MsgItemState extends State { height: bubbleHeight, fallback: SizedBox(width: bubbleWidth, height: bubbleHeight), ), - Padding( - padding: EdgeInsetsDirectional.fromSTEB(29.w, 11.w, 29.w, 11.w), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: SizedBox( - width: textMaxWidth, - child: ExtendedText( - messageText, - style: textStyle, - softWrap: true, - specialTextSpanBuilder: AtTextSpanBuilder( - onTapCall: (userId) {}, - ), - ), - ), + PositionedDirectional( + start: leftPadding, + top: topPadding, + width: textMaxWidth, + child: ExtendedText( + messageText, + style: textStyle, + softWrap: true, + specialTextSpanBuilder: AtTextSpanBuilder(onTapCall: (userId) {}), ), ), ], @@ -345,12 +354,18 @@ class _MsgItemState extends State { required TextStyle textStyle, required String imageUrl, }) { - final horizontalPadding = 18.w; - final verticalPadding = 4.w; - final minSkinWidth = 132.w; - final minSkinHeight = 42.w; + final leftPadding = 22.w; + final rightPadding = 24.w; + final topPadding = 12.w; + final bottomPadding = 16.w; + final minSkinWidth = 96.w; + final minSkinHeight = 58.w; + final textWidthSafety = 10.w; final maxBubbleWidth = ScreenUtil().screenWidth * 0.7; - final maxTextWidth = math.max(1.0, maxBubbleWidth - horizontalPadding * 2); + final maxTextWidth = math.max( + 1.0, + maxBubbleWidth - leftPadding - rightPadding, + ); final textSize = _measureMessageTextSize( text: messageText, style: textStyle, @@ -358,12 +373,18 @@ class _MsgItemState extends State { ); final bubbleWidth = math.min( maxBubbleWidth, - math.max(minSkinWidth, textSize.width + horizontalPadding * 2), + math.max( + minSkinWidth, + textSize.width + textWidthSafety + leftPadding + rightPadding, + ), + ); + final textMaxWidth = math.max( + 1.0, + bubbleWidth - leftPadding - rightPadding, ); - final textMaxWidth = math.max(1.0, bubbleWidth - horizontalPadding * 2); final bubbleHeight = math.max( minSkinHeight, - textSize.height + verticalPadding * 2, + textSize.height + topPadding + bottomPadding, ); return Container( @@ -383,9 +404,11 @@ class _MsgItemState extends State { ), ), Padding( - padding: EdgeInsetsDirectional.symmetric( - horizontal: horizontalPadding, - vertical: verticalPadding, + padding: EdgeInsetsDirectional.fromSTEB( + leftPadding, + topPadding, + rightPadding, + bottomPadding, ), child: Align( alignment: AlignmentDirectional.centerStart, @@ -394,7 +417,8 @@ class _MsgItemState extends State { child: ExtendedText( messageText, style: textStyle, - softWrap: true, + maxLines: 1, + softWrap: false, specialTextSpanBuilder: AtTextSpanBuilder( onTapCall: (userId) {}, ), @@ -426,6 +450,7 @@ class _MsgItemState extends State { width: width, height: height, fit: BoxFit.fill, + loop: true, centerSliceRatio: _vipBubbleCenterSliceRatio, sourceEdgeInset: _vipBubbleSourceEdgeInset, fallback: SizedBox(width: width, height: height), @@ -451,6 +476,7 @@ class _MsgItemState extends State { width: width, height: height, fit: BoxFit.fill, + loop: true, centerSliceRatio: _vipBubbleCenterSliceRatio, sourceEdgeInset: _vipBubbleSourceEdgeInset, fallback: fallback, diff --git a/test/lucky_gift_reward_merge_test.dart b/test/lucky_gift_reward_merge_test.dart new file mode 100644 index 0000000..4387829 --- /dev/null +++ b/test/lucky_gift_reward_merge_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart'; + +void main() { + group('lucky gift reward merge', () { + test('accumulates reward amount and keeps the strongest multiplier', () { + final first = SCBroadCastLuckGiftPush.fromJson({ + 'type': 'GAME_LUCKY_GIFT', + 'data': { + 'roomId': 'room-1', + 'giftId': 'gift-1', + 'sendUserId': 'sender-1', + 'acceptUserId': 'receiver-1', + 'awardAmount': 5000, + 'giftQuantity': 1, + 'multiple': 10, + 'multipleType': 'WIN', + }, + }); + final second = SCBroadCastLuckGiftPush.fromJson({ + 'type': 'GAME_LUCKY_GIFT', + 'data': { + 'roomId': 'room-1', + 'giftId': 'gift-1', + 'sendUserId': 'sender-1', + 'acceptUserId': 'receiver-1', + 'awardAmount': 12000, + 'giftQuantity': 2, + 'multiple': 30, + 'multipleType': 'BIG_WIN', + }, + }); + + final merged = first.mergeReward(second); + + expect(merged.data?.awardAmount, 17000); + expect(merged.data?.giftQuantity, 3); + expect(merged.data?.multiple, 30); + expect(merged.data?.multipleType, 'BIG_WIN'); + }); + }); +} diff --git a/需求进度.md b/需求进度.md index 1fcd0d0..a271fa2 100644 --- a/需求进度.md +++ b/需求进度.md @@ -552,7 +552,10 @@ - 已按反馈统一红包入口图标:房间右侧待领红包入口去掉黑底和金色外圈,直接展示放大的红包图标,尺寸与游戏入口接近;房间菜单内红包项也从白色线性图标改为同一张红包图标。 - 已继续修正红包跨房间与消息链路:同语区区域红包飘屏现在允许在其它语音房内展示;首页点击红包飘屏 `GO` 会直接进房,不再先弹确认或自动弹领取层;进房加入 IM 群后会拉取最近至少 10 条、当前按 20 条房间历史自定义消息并补入红包聊天记录;红包领取成功后,领取者会自动发送一条“从某用户 Lucky Pack 领取多少金币”的多语言房内 IM 文本消息。 - 已继续修正红包入口状态刷新:房间右侧红包入口现在每秒清理过期缓存、每 5 秒刷新当前房间红包列表,并以后端列表里“已领/过期/抢完”的 packetId 作为权威隐藏来源;连续两次成功刷新后,若某红包曾在后端列表出现、但已不在后端可领列表中,也会过滤旧聊天消息或区域飘屏缓存,领取时遇到“已过期/已抢完”也会立刻本地隐藏入口,避免已不可领红包重新显示;同语区红包飘屏仍可跨房间展示,但即时红包领取弹层只会在 payload `roomId` 等于当前语音房时自动打开。 +- 已调整红包余额不足充值链路:`voice_room_red_packet_insufficient_balance` 不再只是展示“去充值”二次入口,底部弹窗内直接复用钱包页的 Google/Apple Pay 初始化、商品档位选择、余额刷新和支付发起逻辑;钱包页原本的充值确认弹窗保留,红包弹窗内点击充值会直接调起系统支付。 +- 已修正两个红包联调问题:当前房间点击红包飘窗 `GO` 打开领取层时会合并房间缓存中的完整红包 payload,避免限时红包丢失 `claimStartTime/countdown`;领取成功后发送的房内 IM 文本会附带不展示的唯一业务 key,避免同一用户连续领取同一发包人的多个红包时公屏只保留一条领取记录。 - 已收口红包发送记录页状态展示:不再显示 `Pending/等待中`,后端 `ACTIVE/PENDING` 等中间态统一展示为已发出;只保留已退回、已领完、已发出三类状态,并补齐多语言的“已领完”文案。 +- 已收口房间外红包领取层触发:红包飘屏 `GO` 只有在目标房间页面当前可见时才直接打开领取层,否则先进房;同语区或房间 IM 收到即时红包时,也只有当前可见房间匹配目标 `roomId` 才自动弹领取层,避免首页、房间列表、我的页直接弹抢红包。 ## 已知问题 - 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji` 与 `monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。