Merge branch 'feature' of gitea.haiyihy.com:hy/chatapp3-flutter into feature

This commit is contained in:
zhx 2026-05-09 11:29:34 +08:00
commit 5f79a8b523
15 changed files with 1301 additions and 375 deletions

View File

@ -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";
}

View File

@ -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<double>(
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,

View File

@ -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<SCSeatItem> with TickerProviderStateMixin {
bottom: widget.isGameModel ? 2.w : 5.w,
left: widget.isGameModel ? 2.w : 5.w,
child:
seatSnapshot.isCurrentUser
? Selector<RoomMusicManager, bool>(
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,
);
}

View File

@ -108,6 +108,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = rtcProvider;
rtcProvider.addListener(_handleRoomProviderChanged);
context.read<RoomMusicManager>().attachRtcProvider(rtcProvider);
}
_ensureRoomVisualEffectsEnabled();
}

View File

@ -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<num, MicRes> roomWheatMap = {};
final Map<num, int> _seatEmojiEventVersions = {};
int _emojiPlaybackEventVersion = 0;
final Set<String> _roomMusicPublishingUserIds = <String>{};
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<SCRoomRedPacketListRes> 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<void>.value(),
);
}
void republishCurrentUserRoomMusicStateIfNeeded() {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (isUserPublishingRoomMusic(currentUserId)) {
publishCurrentUserRoomMusicState(true);
}
}
Future<void> startRoomMusicAudioMixing({
required String filePath,
required bool loopback,

View File

@ -75,6 +75,33 @@ typedef OnNewGroupMessageListener =
typedef OnMessageRecvC2CReadListener = Function(List<String> 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<String> eventKeys = <String>{};
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<SCBroadCastLuckGiftPush?> _luckGiftPushQueue = Queue();
final Queue<_LuckGiftPushQueueEntry> _luckGiftPushQueue = Queue();
String? _currentLuckGiftPushKey;
Set<String> _currentLuckGiftPushEventKeys = <String>{};
Timer? _luckGiftPushPlayTimer;
int _luckGiftPushPlaybackToken = 0;
final Map<String, int> _recentLuckGiftPushEventTimes = <String, int>{};
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<RealTimeCommunicationManager>(
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<RealTimeCommunicationManager>(
context!,
listen: false,
@ -1978,13 +2031,16 @@ class RealTimeMessagingManager extends ChangeNotifier {
packetRoomId != _currentVoiceRoomId()) {
return;
}
final isCurrentVisibleRoom = _isCurrentVisibleVoiceRoom(packetRoomId);
Provider.of<RealTimeCommunicationManager>(
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<RealTimeCommunicationManager>(
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<String>.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) {

View File

@ -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<void> reload() async {
@ -134,7 +141,11 @@ class RoomMusicManager extends ChangeNotifier {
await _savePlaylist(next);
}
Future<void> play(SCMusicMode item, {int startPositionMs = 0}) async {
Future<void> 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<void> 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;

View File

@ -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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['acceptNickname'] = _acceptNickname;

View File

@ -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;
}

View File

@ -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<RtcProvider>(
context,
listen: false,
).sendRoomTextMessage(message);
final packetId = widget.data.packetId.trim();
Provider.of<RtcProvider>(context, listen: false).sendRoomTextMessage(
message,
dedupKey:
packetId.isEmpty
? null
: 'room-red-packet-claim-$packetId-${DateTime.now().microsecondsSinceEpoch}',
);
}
bool get _isLocalPreviewPacket {

View File

@ -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();
}

View File

@ -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<SocialChatUserProfileManager>(
paymentContext,
listen: false,
).balance();
if (Platform.isAndroid) {
Provider.of<AndroidPaymentProcessor>(
paymentContext,
listen: false,
).initializePaymentProcessor(paymentContext);
} else if (Platform.isIOS) {
Provider.of<IOSPaymentProcessor>(
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<IOSPaymentProcessor>(
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<SocialChatUserProfileManager>(
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<SocialChatUserProfileManager>(
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<AndroidPaymentProcessor>(
builder: (context, processor, child) {
return _buildProductList(
localizations,
processor.products,
processor.chooseProductConfig,
(product) =>
processor.productMap[product.produc.id]?.obtainCandy ?? 0,
);
},
);
}
if (Platform.isIOS) {
return Consumer<IOSPaymentProcessor>(
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<SelecteProductConfig> products,
ValueChanged<int> 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<void> _processNativePurchase() async {
final paymentContext = navigatorKey.currentState?.context ?? context;
if (Platform.isAndroid) {
final processor = Provider.of<AndroidPaymentProcessor>(
paymentContext,
listen: false,
);
final product = _selectedProduct(processor.products);
if (product == null) {
SCTts.show(SCAppLocalizations.of(context)!.pleaseSelectaItem);
return;
}
Provider.of<SocialChatUserProfileManager>(
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<IOSPaymentProcessor>(
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<SelecteProductConfig> products) {
for (final product in products) {
if (product.isSelecte) {
return product;
}
}
return null;
}
static String _formatSheetNumber(int value) {

View File

@ -268,8 +268,8 @@ class _MsgItemState extends State<MsgItem> {
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<MsgItem> {
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<MsgItem> {
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<MsgItem> {
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<MsgItem> {
);
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<MsgItem> {
),
),
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<MsgItem> {
child: ExtendedText(
messageText,
style: textStyle,
softWrap: true,
maxLines: 1,
softWrap: false,
specialTextSpanBuilder: AtTextSpanBuilder(
onTapCall: (userId) {},
),
@ -426,6 +450,7 @@ class _MsgItemState extends State<MsgItem> {
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<MsgItem> {
width: width,
height: height,
fit: BoxFit.fill,
loop: true,
centerSliceRatio: _vipBubbleCenterSliceRatio,
sourceEdgeInset: _vipBubbleSourceEdgeInset,
fallback: fallback,

View File

@ -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');
});
});
}

View File

@ -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 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。