diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index d61df79..5645722 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -280,8 +280,8 @@ "bDLeaderInviteYouToBecomeBDLeader": "ندعوك لتصبح قائد تطوير الأعمال", "win2": "فوز {1}", "level": "مستوى", - "wealthLevel": "مستوى الثروة", - "userLevel": "مستوى المستخدم", + "wealthLevel": "مستوى الثراء", + "userLevel": "مستوى الجاذبية", "themeGoToUploadTips": "1. ستتم مراجعة التحميل خلال 24 ساعة بعد نجاحه.\n2. ستُعاد جميع العملات إذا فشلت المراجعة.", "pleaseUploadAccordingToExample": "يرجى الرفع وفقًا للمثال المعروض.", "goToUpload": "الانتقال للرفع", @@ -665,8 +665,10 @@ "mountains": "مركبات", "buy": "شراء", "visitorList": "قائمة الزوار", - "spendCoinsToGainExperiencePoints": "اصرف العملات لكسب نقاط الخبرة", - "howToUpgrade": "كيف يمكن الترقية؟", + "spendCoinsToGainExperiencePoints": "اصرف العملات لكسب نقاط الخبرة", + "richLevelUpgradeAnswer": "دع ثروتك تغزو كل شيء", + "charmLevelUpgradeAnswer": "اجعل الآخرين يخضعون لسحرك", + "howToUpgrade": "كيف يمكن الترقية؟", "higherLevelFancierAvatarFrame": "مستوى أعلى، شارات/إطار صور أفخم.", "use": "استخدم", "expired": "منتهي", diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index e162d18..cb84480 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -267,7 +267,7 @@ "aboutUs": "আমাদের সম্পর্কে", "theme": "থিম", "wealthLevel": "সম্পদ স্তর", - "userLevel": "ব্যবহারকারী স্তর", + "userLevel": "আকর্ষণ স্তর", "goToUpload": "আপলোড করতে যান", "logout": "লগআউট", "luck": "ভাগ্য", @@ -600,6 +600,8 @@ "toConsume": "খরচ করার জন্য", "howToUpgrade": "কীভাবে উন্নত করব?", "spendCoinsToGainExperiencePoints": "অভিজ্ঞতা পয়েন্ট পাওয়ার জন্য কয়েন খরচ করুন", + "richLevelUpgradeAnswer": "তোমার সম্পদ সবকিছু জয় করুক", + "charmLevelUpgradeAnswer": "অন্যদের তোমার আকর্ষণের কাছে নত করো", "higherLevelFancierAvatarFrame": "উচ্চতর স্তর, আরও সুন্দর ব্যাজ/প্রোফাইল ফ্রেম", "all": "সব", "gift": "গিফট", diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index 43bde15..47fbd80 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -236,8 +236,8 @@ "about": "About", "aboutUs": "About Us", "theme": "Theme", - "wealthLevel": "Wealth Level", - "userLevel": "User Level", + "wealthLevel": "Rich Level", + "userLevel": "Charm Level", "goToUpload": "Go to upload", "logout": "Logout", "luck": "Luck", @@ -569,6 +569,8 @@ "toConsume": "To consume", "howToUpgrade": "How to upgrade?", "spendCoinsToGainExperiencePoints": "Spend Coins to gain experience points", + "richLevelUpgradeAnswer": "Let your wealth conquer everything", + "charmLevelUpgradeAnswer": "Make others submit to your charm", "higherLevelFancierAvatarFrame": "Higher level, fancier badges/avatar frame", "all": "All", "gift": "Gift", diff --git a/assets/l10n/intl_fr.json b/assets/l10n/intl_fr.json index c2ff866..64b4b84 100644 --- a/assets/l10n/intl_fr.json +++ b/assets/l10n/intl_fr.json @@ -237,7 +237,7 @@ "aboutUs": "À propos de nous", "theme": "Thème", "wealthLevel": "Niveau de richesse", - "userLevel": "Niveau utilisateur", + "userLevel": "Niveau de charme", "goToUpload": "Aller au téléchargement", "logout": "Déconnexion", "luck": "Chance", @@ -569,6 +569,8 @@ "toConsume": "Consommer", "howToUpgrade": "Comment mettre à niveau ?", "spendCoinsToGainExperiencePoints": "Dépensez des pièces pour gagner des points d'expérience", + "richLevelUpgradeAnswer": "Que ta richesse conquière tout", + "charmLevelUpgradeAnswer": "Fais s'incliner les autres devant ton charme", "higherLevelFancierAvatarFrame": "Cadre de badges/avatar de niveau supérieur et plus sophistiqué", "all": "Tout", "gift": "Cadeau", diff --git a/assets/l10n/intl_it.json b/assets/l10n/intl_it.json index 7188dbb..a819d3c 100644 --- a/assets/l10n/intl_it.json +++ b/assets/l10n/intl_it.json @@ -237,7 +237,7 @@ "aboutUs": "Chi siamo", "theme": "Tema", "wealthLevel": "Livello di ricchezza", - "userLevel": "Livello utente", + "userLevel": "Livello di fascino", "goToUpload": "Vai al caricamento", "logout": "Esci", "luck": "Fortuna", @@ -569,6 +569,8 @@ "toConsume": "Consumare", "howToUpgrade": "Come aggiornare?", "spendCoinsToGainExperiencePoints": "Spendi monete per guadagnare punti esperienza", + "richLevelUpgradeAnswer": "Lascia che la tua ricchezza conquisti tutto", + "charmLevelUpgradeAnswer": "Fa' che gli altri si arrendano al tuo fascino", "higherLevelFancierAvatarFrame": "Distintivi/cornice avatar di livello superiore e più elaborati", "all": "Tutto", "gift": "Regalo", diff --git a/assets/l10n/intl_pt.json b/assets/l10n/intl_pt.json index 4c8759f..877cc4a 100644 --- a/assets/l10n/intl_pt.json +++ b/assets/l10n/intl_pt.json @@ -237,7 +237,7 @@ "aboutUs": "Sobre nós", "theme": "Tema", "wealthLevel": "Nível de riqueza", - "userLevel": "Nível de usuário", + "userLevel": "Nível de charme", "goToUpload": "Ir para carregar", "logout": "Sair", "luck": "Sorte", @@ -569,6 +569,8 @@ "toConsume": "Para consumir", "howToUpgrade": "Como atualizar?", "spendCoinsToGainExperiencePoints": "Gaste moedas para ganhar pontos de experiência", + "richLevelUpgradeAnswer": "Deixe sua riqueza conquistar tudo", + "charmLevelUpgradeAnswer": "Faça os outros se renderem ao seu charme", "higherLevelFancierAvatarFrame": "Emblemas/moldura de avatar de nível superior e mais sofisticados", "all": "Todos", "gift": "Presente", diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index bffb8a7..d539abd 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -225,8 +225,8 @@ "about": "Hakkında", "aboutUs": "Hakkımızda", "theme": "Tema", - "wealthLevel": "Servet Seviyesi", - "userLevel": "Kullanıcı Seviyesi", + "wealthLevel": "Zenginlik Seviyesi", + "userLevel": "Çekicilik Seviyesi", "goToUpload": "Yüklemeye Git", "logout": "Çıkış Yap", "luck": "Şans", @@ -566,10 +566,12 @@ "obtain": "Al", "win2": "{1} Kazan", "backTheRoom": "Odaya Dön", - "toConsume": "Tüketmek İçin", - "howToUpgrade": "Nasıl yükseltirim?", - "spendCoinsToGainExperiencePoints": "Deneyim puanı kazanmak için jetton harcayın", - "higherLevelFancierAvatarFrame": "Daha yüksek seviye, daha şık rozetler/profil çerçevesi", + "toConsume": "Tüketmek İçin", + "howToUpgrade": "Nasıl yükseltirim?", + "spendCoinsToGainExperiencePoints": "Deneyim puanı kazanmak için jetton harcayın", + "richLevelUpgradeAnswer": "Servetin her şeyi fethetsin", + "charmLevelUpgradeAnswer": "Başkalarını cazibene boyun eğdir", + "higherLevelFancierAvatarFrame": "Daha yüksek seviye, daha şık rozetler/profil çerçevesi", "all": "Tümü", "gift": "Hediye", "chat": "Sohbet", diff --git a/lib/app/constants/sc_room_msg_type.dart b/lib/app/constants/sc_room_msg_type.dart index a38aae4..d98291b 100644 --- a/lib/app/constants/sc_room_msg_type.dart +++ b/lib/app/constants/sc_room_msg_type.dart @@ -79,6 +79,10 @@ class SCRoomMsgType { ///用户火箭中奖 static const String rocketRewardUser = "ROCKET_REWARD_USER"; + ///语音房火箭用户中奖 + static const String voiceRoomRocketRewardUser = + "VOICE_ROOM_ROCKET_REWARD_USER"; + ///火箭燃料满倒计时发射提示 static const String rocketLaunchNotice = "ROCKET_LAUNCH_NOTICE"; @@ -129,4 +133,8 @@ class SCRoomMsgType { static const String gameBurstCrystalSprint = "GAME_BURST_CRYSTAL_SPRINT"; static const String gameBurstCrystalBox = "GAME_BURST_CRYSTAL_BOX"; static const String refreshOnlineUser = "REFRESH_ONLINE_USER"; + + static bool isRocketRewardUserType(String? type) { + return type == rocketRewardUser || type == voiceRoomRocketRewardUser; + } } diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart index d6ffbb0..06192a1 100644 --- a/lib/app_localizations.dart +++ b/lib/app_localizations.dart @@ -1042,10 +1042,14 @@ class SCAppLocalizations { String get backTheRoom => translate('backTheRoom'); - String get spendCoinsToGainExperiencePoints => - translate('spendCoinsToGainExperiencePoints'); - - String get enterRoomName => translate('enterRoomName'); + String get spendCoinsToGainExperiencePoints => + translate('spendCoinsToGainExperiencePoints'); + + String get richLevelUpgradeAnswer => translate('richLevelUpgradeAnswer'); + + String get charmLevelUpgradeAnswer => translate('charmLevelUpgradeAnswer'); + + String get enterRoomName => translate('enterRoomName'); String get selectYourCountry => translate('selectYourCountry'); diff --git a/lib/modules/user/level/user/user_level_page.dart b/lib/modules/user/level/user/user_level_page.dart index 4b7e800..8f52131 100644 --- a/lib/modules/user/level/user/user_level_page.dart +++ b/lib/modules/user/level/user/user_level_page.dart @@ -110,18 +110,26 @@ class _UserLevelPageState extends State { ], ), SizedBox(height: 3.w), - Stack( - children: [ - Container( - width: 185.w, - height: 6.w, + Stack( + children: [ + Container( + width: 185.w, + height: 6.w, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - color: Colors.white24, - ), - ), - ], - ), + color: Colors.white24, + ), + ), + Container( + width: getLevelExperience(185.w), + height: 6.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: SocialChatTheme.primaryLight, + ), + ), + ], + ), SizedBox(height: 5.w), Row( children: [ @@ -150,19 +158,7 @@ class _UserLevelPageState extends State { ], ), ), - text( - SCAppLocalizations.of(context)!.medalAndAvatarFrameRewards, - fontSize: 20.sp, - fontWeight: FontWeight.bold, - textColor: SocialChatTheme.primaryLight, - ), - text( - SCAppLocalizations.of(context)!.higherLevelFancierAvatarFrame, - fontSize: 12.sp, - fontWeight: FontWeight.w600, - textColor: Colors.white, - ), - Image.asset("sc_images/level/sc_icon_user_level_center_bg_1.png"), + Image.asset("sc_images/level/sc_icon_user_level_center_bg_1.png"), text( SCAppLocalizations.of(context)!.howToUpgrade, fontSize: 20.sp, @@ -170,14 +166,15 @@ class _UserLevelPageState extends State { textColor: SocialChatTheme.primaryLight, ), SizedBox(height: 3), - text( - SCAppLocalizations.of( - context, - )!.spendCoinsToGainExperiencePoints, - fontSize: 12.sp, - fontWeight: FontWeight.w600, - textColor: Colors.white, - ), + text( + SCAppLocalizations.of(context)!.charmLevelUpgradeAnswer, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + textColor: Colors.white, + maxLines: 2, + maxWidth: 320.w, + textAlign: TextAlign.center, + ), SizedBox(height: 25.w), GestureDetector( child: Container( @@ -205,12 +202,15 @@ class _UserLevelPageState extends State { : Container(); } - double getLevelExperience(double width) { - int thatExp = SCStringUtils.convertToInteger(res?.thatExperience ?? "0"); - int nexExp = SCStringUtils.convertToInteger(res?.nextExperience ?? "0"); - int levelExp = thatExp + nexExp; - return (thatExp / levelExp) * width; - } + double getLevelExperience(double width) { + int thatExp = SCStringUtils.convertToInteger(res?.thatExperience ?? "0"); + int nexExp = SCStringUtils.convertToInteger(res?.nextExperience ?? "0"); + int levelExp = thatExp + nexExp; + if (levelExp <= 0) { + return 0; + } + return (thatExp / levelExp) * width; + } num getLevelTotalExperience() { int thatExp = SCStringUtils.convertToInteger(res?.thatExperience ?? "0"); diff --git a/lib/modules/user/level/wealth/wealth_level_page.dart b/lib/modules/user/level/wealth/wealth_level_page.dart index 8488206..fbda466 100644 --- a/lib/modules/user/level/wealth/wealth_level_page.dart +++ b/lib/modules/user/level/wealth/wealth_level_page.dart @@ -110,18 +110,26 @@ class _WealthLevelPageState extends State { ], ), SizedBox(height: 3.w), - Stack( - children: [ - Container( - width: 185.w, - height: 6.w, + Stack( + children: [ + Container( + width: 185.w, + height: 6.w, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - color: Colors.white24, - ), - ), - ], - ), + color: Colors.white24, + ), + ), + Container( + width: getLevelExperience(185.w), + height: 6.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: SocialChatTheme.primaryLight, + ), + ), + ], + ), SizedBox(height: 5.w), Row( children: [ @@ -150,19 +158,7 @@ class _WealthLevelPageState extends State { ], ), ), - text( - SCAppLocalizations.of(context)!.medalAndAvatarFrameRewards, - fontSize: 20.sp, - fontWeight: FontWeight.bold, - textColor: SocialChatTheme.primaryLight, - ), - text( - SCAppLocalizations.of(context)!.higherLevelFancierAvatarFrame, - fontSize: 12.sp, - fontWeight: FontWeight.w600, - textColor: Colors.white, - ), - Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_1.png"), + Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_1.png"), text( SCAppLocalizations.of(context)!.howToUpgrade, fontSize: 20.sp, @@ -170,14 +166,15 @@ class _WealthLevelPageState extends State { textColor: SocialChatTheme.primaryLight, ), SizedBox(height: 3), - text( - SCAppLocalizations.of( - context, - )!.spendCoinsToGainExperiencePoints, - fontSize: 12.sp, - fontWeight: FontWeight.w600, - textColor: Colors.white, - ), + text( + SCAppLocalizations.of(context)!.richLevelUpgradeAnswer, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + textColor: Colors.white, + maxLines: 2, + maxWidth: 320.w, + textAlign: TextAlign.center, + ), SizedBox(height: 25.w), GestureDetector( child: Container( @@ -205,12 +202,15 @@ class _WealthLevelPageState extends State { : Container(); } - double getLevelExperience(double width) { - int thatExp = SCStringUtils.convertToInteger(res?.thatExperience ?? "0"); - int nexExp = SCStringUtils.convertToInteger(res?.nextExperience ?? "0"); - int levelExp = thatExp + nexExp; - return (thatExp / levelExp) * width; - } + double getLevelExperience(double width) { + int thatExp = SCStringUtils.convertToInteger(res?.thatExperience ?? "0"); + int nexExp = SCStringUtils.convertToInteger(res?.nextExperience ?? "0"); + int levelExp = thatExp + nexExp; + if (levelExp <= 0) { + return 0; + } + return (thatExp / levelExp) * width; + } num getLevelTotalExperience() { int thatExp = SCStringUtils.convertToInteger(res?.thatExperience ?? "0"); diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 7914825..2e0c421 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -2826,19 +2826,19 @@ class RealTimeCommunicationManager extends ChangeNotifier { void _loadRoomSecondaryData() { final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; - SCChatRoomRepository().rocketClaim(roomId).catchError((e) { - return true; // 错误已处理 - }); SCChatRoomRepository() .rocketStatus(roomId) .then((res) { roomRocketStatus = res; notifyListeners(); + if (!res.isEnabled) { + return; + } _scheduleRoomRocketAssetPreload(roomId, res, afterRoomEntry: true); _scheduleRoomRocketEntryReadyFallback(roomId); + _loadRoomRocketRewardPopups(roomId); }) .catchError((e) {}); - _loadRoomRocketRewardPopups(roomId); _refreshRoomRedPacketListAfterEntry(); fetchRoomTaskClaimableCount(); @@ -2894,6 +2894,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { void updateRoomRocketConfigurationStatus(SCRoomRocketStatusRes res) { roomRocketStatus = res; notifyListeners(); + if (!res.isEnabled) { + return; + } _scheduleRoomRocketAssetPreload( (res.roomId ?? currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(), res, @@ -2914,6 +2917,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { } roomRocketStatus = res; notifyListeners(); + if (!res.isEnabled) { + return; + } _scheduleRoomRocketAssetPreload(resolvedRoomId, res); } @@ -2926,6 +2932,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (resolvedRoomId.isEmpty) { return; } + if (!status.isEnabled) { + return; + } if (afterRoomEntry) { RoomRocketAssetPreloader.preloadAfterRoomEntry( roomId: resolvedRoomId, diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 922fd8f..72b488c 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:extended_image/extended_image.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_debouncer/flutter_debouncer.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -143,6 +144,9 @@ class RealTimeMessagingManager extends ChangeNotifier { Duration(seconds: 2), Duration(seconds: 5), ]; + static const Duration _roomRocketRewardAfterLaunchDelay = Duration( + milliseconds: 350, + ); static const List _roomRocketRoomReadyRewardRetryDelays = [ Duration(milliseconds: 300), Duration(milliseconds: 900), @@ -272,6 +276,8 @@ class RealTimeMessagingManager extends ChangeNotifier { final Map _roomRocketLaunchEffectTimers = {}; final Map _consumedRoomRocketLaunchEffectTimes = {}; final Set _hiddenCurrentRoomRocketLaunchKeys = {}; + final Map _roomRocketRewardHoldUntilTimes = {}; + final Map _roomRocketRewardHoldTimers = {}; Timer? _roomRocketRewardPopupRetryTimer; Timer? _roomRocketRoomReadyRewardRetryTimer; int get currentLuckGiftBurstPlaybackToken => _luckGiftPushPlaybackToken; @@ -1162,6 +1168,7 @@ class RealTimeMessagingManager extends ChangeNotifier { case SCRoomMsgType.roomRedPacketClaim: return true; case SCRoomMsgType.rocketRewardUser: + case SCRoomMsgType.voiceRoomRocketRewardUser: return true; case SCRoomMsgType.rocketLaunchNotice: return true; @@ -1195,7 +1202,7 @@ class RealTimeMessagingManager extends ChangeNotifier { _hasRoomRedPacketClaimMessage(msg)) { return; } - if (msg.type == SCRoomMsgType.rocketRewardUser && + if (SCRoomMsgType.isRocketRewardUserType(msg.type) && _hasRoomRocketRewardMessage(msg)) { return; } @@ -1235,7 +1242,7 @@ class RealTimeMessagingManager extends ChangeNotifier { roomChatMsgList.removeAt(roomChatMsgList.length - 1); } msgChatListener?.call(msg); - } else if (msg.type == SCRoomMsgType.rocketRewardUser) { + } else if (SCRoomMsgType.isRocketRewardUserType(msg.type)) { roomChatMsgList.insert(0, msg); if (roomChatMsgList.length > 250) { roomChatMsgList.removeAt(roomChatMsgList.length - 1); @@ -1388,7 +1395,7 @@ class RealTimeMessagingManager extends ChangeNotifier { } return roomAllMsgList.any( (item) => - item.type == SCRoomMsgType.rocketRewardUser && + SCRoomMsgType.isRocketRewardUserType(item.type) && _roomRocketRewardMessageKey(item) == key, ); } @@ -2091,6 +2098,9 @@ class RealTimeMessagingManager extends ChangeNotifier { void _handleVoiceRoomRocketRewardPopup(dynamic payload) { final data = _broadcastPayloadMap(payload); + if (!_isCurrentUserInRoomRocketRewardPopup(data)) { + return; + } final roomId = _payloadRoomId(data); final currentRoomId = _currentVoiceRoomId(); final resolvedRoomId = roomId.isEmpty ? currentRoomId : roomId; @@ -2119,6 +2129,13 @@ class RealTimeMessagingManager extends ChangeNotifier { ); return; } + if (_isRoomRocketRewardPopupHeld(resolvedRoomId)) { + _scheduleRoomRocketRewardPopupAfterHold( + resolvedRoomId, + initialRoomRecords: initialRoomRecords, + ); + return; + } _pendingRoomRocketRewardPopups.remove(resolvedRoomId); _recentRoomRocketRewardProbeTimes.remove(resolvedRoomId); @@ -2130,6 +2147,87 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } + bool _isCurrentUserInRoomRocketRewardPopup(Map payload) { + final userIds = _roomRocketRewardPopupUserIds(payload); + if (userIds == null) { + return true; + } + if (userIds.isEmpty) { + return false; + } + final profile = AccountStorage().getCurrentUser()?.userProfile; + final currentKeys = { + profile?.id?.trim() ?? '', + profile?.account?.trim() ?? '', + }..removeWhere((item) => item.isEmpty); + if (currentKeys.isEmpty) { + return false; + } + return currentKeys.any(userIds.contains); + } + + Set? _roomRocketRewardPopupUserIds(Map payload) { + for (final key in const [ + 'userIds', + 'user_ids', + 'targetUserIds', + 'target_user_ids', + 'winnerUserIds', + 'winner_user_ids', + 'userId', + 'user_id', + 'winnerUserId', + 'winner_user_id', + 'rewardUserId', + 'reward_user_id', + 'receiverUserId', + 'receiver_user_id', + 'targetUserId', + 'target_user_id', + 'toUserId', + 'to_user_id', + ]) { + if (payload.containsKey(key)) { + return _payloadStringSet(payload[key]); + } + } + return null; + } + + Set _payloadStringSet(dynamic value) { + if (value == null) { + return {}; + } + if (value is Iterable && value is! String) { + return value + .map((item) => item?.toString().trim() ?? '') + .where((item) => item.isNotEmpty) + .toSet(); + } + if (value is Map) { + return value.values + .map((item) => item?.toString().trim() ?? '') + .where((item) => item.isNotEmpty) + .toSet(); + } + final raw = value.toString().trim(); + if (raw.isEmpty) { + return {}; + } + if (raw.startsWith('[') || raw.startsWith('{')) { + try { + return _payloadStringSet(jsonDecode(raw)); + } catch (_) { + return {}; + } + } + return raw + .split(',') + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toSet(); + } + void handleVoiceRoomReadyForRocketRewards(String roomId, {int attempt = 0}) { final normalizedRoomId = roomId.trim(); if (normalizedRoomId.isEmpty) { @@ -2166,6 +2264,15 @@ class RealTimeMessagingManager extends ChangeNotifier { _recentRoomRocketRewardProbeTimes.remove(normalizedRoomId); _roomRocketRoomReadyRewardRetryTimer?.cancel(); _roomRocketRoomReadyRewardRetryTimer = null; + if (_isRoomRocketRewardPopupHeld(normalizedRoomId)) { + _scheduleRoomRocketRewardPopupAfterHold( + normalizedRoomId, + initialRoomRecords: + pending?.initialRoomRecords ?? + const [], + ); + return; + } unawaited( _showRoomRocketRewardPopupDialog( normalizedRoomId, @@ -2225,6 +2332,113 @@ class RealTimeMessagingManager extends ChangeNotifier { _recentRoomRocketRewardProbeTimes[normalizedRoomId] = now; } + void _holdRoomRocketRewardPopupUntilLaunchEnds( + RoomRocketLaunchBroadcastMessage launch, + ) { + if (!launch.isValid) { + return; + } + final normalizedRoomId = launch.roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + final holdUntil = + now + + launch.displayDuration.inMilliseconds + + _roomRocketRewardAfterLaunchDelay.inMilliseconds; + final existing = _roomRocketRewardHoldUntilTimes[normalizedRoomId] ?? 0; + if (holdUntil > existing) { + _roomRocketRewardHoldUntilTimes[normalizedRoomId] = holdUntil; + } + } + + bool _isRoomRocketRewardPopupHeld(String roomId) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return false; + } + final now = DateTime.now().millisecondsSinceEpoch; + final holdUntil = _roomRocketRewardHoldUntilTimes[normalizedRoomId] ?? 0; + if (holdUntil > now) { + return true; + } + if (holdUntil > 0) { + _roomRocketRewardHoldUntilTimes.remove(normalizedRoomId); + } + return _pendingRoomRocketCountdownLaunches.containsKey(normalizedRoomId); + } + + int _roomRocketRewardHoldUntil(String roomId) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return 0; + } + final now = DateTime.now().millisecondsSinceEpoch; + var holdUntil = _roomRocketRewardHoldUntilTimes[normalizedRoomId] ?? 0; + final launch = _pendingRoomRocketCountdownLaunches[normalizedRoomId]; + if (launch != null) { + final key = _roomRocketLaunchCountdownKeysByRoom[normalizedRoomId]; + final launchDueAt = + key == null ? now : _roomRocketLaunchCountdownDueTimes[key] ?? now; + final countdownHoldUntil = + launchDueAt + + launch.displayDuration.inMilliseconds + + _roomRocketRewardAfterLaunchDelay.inMilliseconds; + if (countdownHoldUntil > holdUntil) { + holdUntil = countdownHoldUntil; + } + } + return holdUntil; + } + + void _scheduleRoomRocketRewardPopupAfterHold( + String roomId, { + List initialRoomRecords = + const [], + }) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + _cachePendingRoomRocketRewardPopup( + normalizedRoomId, + initialRoomRecords: initialRoomRecords, + ); + final now = DateTime.now().millisecondsSinceEpoch; + final holdUntil = _roomRocketRewardHoldUntil(normalizedRoomId); + final delayMs = holdUntil <= now ? 120 : holdUntil - now; + _roomRocketRewardHoldTimers[normalizedRoomId]?.cancel(); + _roomRocketRewardHoldTimers[normalizedRoomId] = Timer( + Duration(milliseconds: delayMs), + () { + _roomRocketRewardHoldTimers.remove(normalizedRoomId); + if (_isRoomRocketRewardPopupHeld(normalizedRoomId)) { + _scheduleRoomRocketRewardPopupAfterHold(normalizedRoomId); + return; + } + if (!_isCurrentVisibleVoiceRoom(normalizedRoomId)) { + _scheduleRoomRocketReadyRewardRetry(normalizedRoomId, 0); + return; + } + final pending = _pendingRoomRocketRewardPopups.remove(normalizedRoomId); + _recentRoomRocketRewardProbeTimes.remove(normalizedRoomId); + unawaited( + _showRoomRocketRewardPopupDialog( + normalizedRoomId, + initialRoomRecords: + pending?.initialRoomRecords ?? + const [], + ), + ); + }, + ); + _roomRocketRewardDebug( + 'defer popup roomId=$normalizedRoomId delayMs=$delayMs ' + 'initialRoomRecords=${initialRoomRecords.length}', + ); + } + void _pruneRoomRocketRewardFallbacks(int now) { _pendingRoomRocketRewardPopups.removeWhere( (_, pending) => now - pending.receivedAt > _roomRocketRewardPendingTtlMs, @@ -2241,6 +2455,11 @@ class RealTimeMessagingManager extends ChangeNotifier { _hiddenCurrentRoomRocketLaunchKeys.clear(); _roomRocketRoomReadyRewardRetryTimer?.cancel(); _roomRocketRoomReadyRewardRetryTimer = null; + for (final timer in _roomRocketRewardHoldTimers.values) { + timer.cancel(); + } + _roomRocketRewardHoldTimers.clear(); + _roomRocketRewardHoldUntilTimes.clear(); _clearRoomRocketLaunchCountdownState(); } @@ -2322,15 +2541,14 @@ class RealTimeMessagingManager extends ChangeNotifier { final pending = _pendingRoomRocketRewardPopups.remove(launch.roomId); _recentRoomRocketRewardProbeTimes.remove(launch.roomId); _markRoomRocketLaunchEffectConsumed(launch); + _holdRoomRocketRewardPopupUntilLaunchEnds(launch); rtcProvider.handleRoomRocketLaunchBroadcast(launch); if (shouldFetchReward) { - unawaited( - _showRoomRocketRewardPopupDialog( - launch.roomId, - initialRoomRecords: - pending?.initialRoomRecords ?? - const [], - ), + _scheduleRoomRocketRewardPopupAfterHold( + launch.roomId, + initialRoomRecords: + pending?.initialRoomRecords ?? + const [], ); } } @@ -2616,6 +2834,13 @@ class RealTimeMessagingManager extends ChangeNotifier { if (!_isCurrentVisibleVoiceRoom(roomId)) { return; } + if (_isRoomRocketRewardPopupHeld(roomId)) { + _scheduleRoomRocketRewardPopupAfterHold( + roomId, + initialRoomRecords: initialRoomRecords, + ); + return; + } try { final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId); if (!_isCurrentVisibleVoiceRoom(roomId)) { @@ -2698,6 +2923,13 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } + void _roomRocketRewardDebug(String message) { + if (!kDebugMode) { + return; + } + debugPrint('[RoomRocketRewardPopup] $message'); + } + RoomRocketRewardUserProfileResolver? _roomRocketRewardUserProfileResolver() { final currentContext = context; if (currentContext == null || !currentContext.mounted) { @@ -3278,7 +3510,9 @@ class RealTimeMessagingManager extends ChangeNotifier { } else if (type == SCRoomMsgType.voiceRoomRocketStatusUpdate) { _handleVoiceRoomRocketStatusUpdate(data["data"]); } else if (type == SCRoomMsgType.voiceRoomRocketRewardPopup) { - _handleVoiceRoomRocketRewardPopup(data["data"]); + _handleVoiceRoomRocketRewardPopup(data["data"] ?? data); + } else if (SCRoomMsgType.isRocketRewardUserType(type?.toString())) { + _handleVoiceRoomRocketRewardPopup(data["data"] ?? data); } else if (type == SCRoomMsgType.roomRedPacket) { ///红包触发飘屏 var fData = data["data"]; @@ -3376,9 +3610,12 @@ class RealTimeMessagingManager extends ChangeNotifier { return; } if (data["type"] == SCRoomMsgType.voiceRoomRocketRewardPopup) { - _handleVoiceRoomRocketRewardPopup(data["data"]); + _handleVoiceRoomRocketRewardPopup(data["data"] ?? data); return; } + if (SCRoomMsgType.isRocketRewardUserType(data["type"]?.toString())) { + _handleVoiceRoomRocketRewardPopup(data["data"] ?? data); + } if (data["type"] == SCRoomMsgType.roomRedPacket) { ///房间红包 var fData = data["data"]; @@ -4088,6 +4325,11 @@ class RealTimeMessagingManager extends ChangeNotifier { _hiddenCurrentRoomRocketLaunchKeys.clear(); _roomRocketRewardPopupRetryTimer?.cancel(); _roomRocketRewardPopupRetryTimer = null; + for (final timer in _roomRocketRewardHoldTimers.values) { + timer.cancel(); + } + _roomRocketRewardHoldTimers.clear(); + _roomRocketRewardHoldUntilTimes.clear(); onNewMessageListenerGroupMap.forEach((k, v) { v = null; }); diff --git a/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart b/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart index 7584e17..1416745 100644 --- a/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart +++ b/lib/shared/business_logic/models/res/sc_room_rocket_status_res.dart @@ -37,6 +37,8 @@ class SCRoomRocketStatusRes { bool? hasClaimed, num? myContribution, num? myContributionRate, + bool? configured, + bool? enabled, int? roundNo, bool? displayRound, int? currentLevel, @@ -69,6 +71,8 @@ class SCRoomRocketStatusRes { _hasClaimed = hasClaimed; _myContribution = myContribution; _myContributionRate = myContributionRate; + _configured = configured; + _enabled = enabled; _roundNo = roundNo; _displayRound = displayRound; _currentLevel = currentLevel; @@ -87,6 +91,15 @@ class SCRoomRocketStatusRes { } SCRoomRocketStatusRes.fromJson(dynamic json) { + _configured = _boolValue(json['configured']); + _enabled = + _boolValue( + json['enabled'] ?? + json['enable'] ?? + json['rocketEnabled'] ?? + json['rocket_enabled'], + ) ?? + true; _roomId = json['roomId'] ?? json['room_id']; _date = json['date'] ?? json['dayKey'] ?? json['day_key']; _roundNo = _intValue(json['roundNo'] ?? json['round_no']); @@ -219,6 +232,8 @@ class SCRoomRocketStatusRes { bool? _hasClaimed; num? _myContribution; num? _myContributionRate; + bool? _configured; + bool? _enabled; int? _roundNo; bool? _displayRound; int? _currentLevel; @@ -267,6 +282,12 @@ class SCRoomRocketStatusRes { num? get myContributionRate => _myContributionRate; + bool? get configured => _configured; + + bool? get enabled => _enabled; + + bool get isEnabled => _enabled != false; + int? get roundNo => _roundNo; bool? get displayRound => _displayRound; @@ -371,6 +392,8 @@ class SCRoomRocketStatusRes { map['hasClaimed'] = _hasClaimed; map['myContribution'] = _myContribution; map['myContributionRate'] = _myContributionRate; + map['configured'] = _configured; + map['enabled'] = _enabled; map['roundNo'] = _roundNo; map['displayRound'] = _displayRound; map['currentLevel'] = _currentLevel; diff --git a/lib/ui_kit/components/sc_compontent.dart b/lib/ui_kit/components/sc_compontent.dart index e97585c..4ca3fa0 100644 --- a/lib/ui_kit/components/sc_compontent.dart +++ b/lib/ui_kit/components/sc_compontent.dart @@ -57,15 +57,18 @@ Widget head({ final String headdressCoverUrl = headdressCover?.trim() ?? ""; final String headdressExtension = SCPathUtils.getFileExtension(headdressUrl).toLowerCase(); + final bool hasLocalVirtualHeaddress = _isLocalAssetsVirtualUrl(headdressUrl); final bool hasPictureHeaddress = headdressUrl.isNotEmpty && SCPathUtils.fileTypeIsPic2(headdressUrl); final bool hasAnimatedHeaddress = headdressExtension == ".svga" || headdressExtension == ".mp4"; final bool shouldShowAnimatedHeaddress = - hasAnimatedHeaddress && SCGlobalConfig.allowsHighCostAnimations; + hasAnimatedHeaddress && + SCGlobalConfig.allowsHighCostAnimations && + !hasLocalVirtualHeaddress; final bool shouldShowStaticHeaddressFallback = hasAnimatedHeaddress && - !SCGlobalConfig.allowsHighCostAnimations && + (!SCGlobalConfig.allowsHighCostAnimations || hasLocalVirtualHeaddress) && headdressCoverUrl.isNotEmpty; final bool hasHeaddress = headdressUrl.isNotEmpty || shouldShowStaticHeaddressFallback; @@ -205,6 +208,11 @@ Widget head({ ); } +bool _isLocalAssetsVirtualUrl(String value) { + final uri = Uri.tryParse(value.trim()); + return uri?.host.toLowerCase() == "local.assets"; +} + Widget netImage({ required String url, String? defaultImg, diff --git a/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart b/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart index f7d8a1a..481b439 100644 --- a/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart +++ b/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart @@ -514,10 +514,7 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { child: Stack( fit: StackFit.expand, children: [ - if (_failed || !_started) - widget.fallback - else - const ColoredBox(color: Colors.black), + widget.fallback, if (!_failed) VapView( scaleType: _scaleTypeForFit(widget.fit), diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart index d42c660..429961c 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart @@ -209,6 +209,20 @@ class RoomRocketDialog extends StatelessWidget { } } +@visibleForTesting +String debugRoomRocketTitleText({required int level, required int round}) { + return _roomRocketTitleText(level: level, round: round); +} + +String _roomRocketTitleText({required int level, required int round}) { + final normalizedLevel = level.clamp(1, 99).toInt(); + final normalizedRound = round.clamp(1, 99).toInt(); + if (normalizedRound <= 1) { + return 'Lv.$normalizedLevel Today'; + } + return 'Lv.$normalizedLevel · Round $normalizedRound Today'; +} + class RoomRocketRewardItem { const RoomRocketRewardItem({ this.asset, @@ -449,7 +463,7 @@ class _RocketCanvas extends StatelessWidget { top: s(120), width: s(_designWidth), child: _GradientText( - 'Round $round Today', + _roomRocketTitleText(level: selectedLevelItem.level, round: round), fontSize: s(16), fontWeight: FontWeight.w600, textAlign: TextAlign.center, @@ -463,31 +477,11 @@ class _RocketCanvas extends StatelessWidget { top: s(212), width: s(40), height: s(190), - child: ScrollConfiguration( - behavior: const _NoGlowScrollBehavior(), - child: ListView.separated( - padding: EdgeInsets.zero, - primary: false, - physics: const BouncingScrollPhysics(), - itemCount: levels.length, - separatorBuilder: (_, __) => SizedBox(height: s(4)), - itemBuilder: (context, index) { - final item = levels[index]; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: item.enabled ? () => onLevelSelected?.call(index) : null, - child: SizedBox( - width: s(40), - height: s(52), - child: _LevelBadge( - scale: scale, - item: item, - selected: index == selectedLevel, - ), - ), - ); - }, - ), + child: _LevelRail( + scale: scale, + levels: levels, + selectedLevel: selectedLevel, + onLevelSelected: onLevelSelected, ), ); } @@ -1031,9 +1025,12 @@ class _LevelBadge extends StatelessWidget { item.label, fontSize: 10 * scale, color: - item.enabled + selected + ? const Color(0xFFA5FFE3) + : item.enabled ? Colors.white : Colors.white.withValues(alpha: 0.52), + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, ), ), ], @@ -1042,6 +1039,118 @@ class _LevelBadge extends StatelessWidget { } } +class _LevelRail extends StatefulWidget { + const _LevelRail({ + required this.scale, + required this.levels, + required this.selectedLevel, + this.onLevelSelected, + }); + + final double scale; + final List levels; + final int selectedLevel; + final ValueChanged? onLevelSelected; + + @override + State<_LevelRail> createState() => _LevelRailState(); +} + +class _LevelRailState extends State<_LevelRail> { + late final ScrollController _controller; + + double get _itemHeight => 52 * widget.scale; + double get _separatorHeight => 4 * widget.scale; + double get _viewportHeight => 190 * widget.scale; + + @override + void initState() { + super.initState(); + _controller = ScrollController( + initialScrollOffset: _targetOffset(widget.selectedLevel), + ); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToSelected()); + } + + @override + void didUpdateWidget(covariant _LevelRail oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedLevel != widget.selectedLevel || + oldWidget.levels.length != widget.levels.length || + oldWidget.scale != widget.scale) { + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToSelected()); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + double _targetOffset(int selectedLevel) { + if (widget.levels.isEmpty) { + return 0; + } + final index = selectedLevel.clamp(0, widget.levels.length - 1).toInt(); + final totalHeight = + widget.levels.length * _itemHeight + + math.max(0, widget.levels.length - 1) * _separatorHeight; + final maxOffset = math.max(0, totalHeight - _viewportHeight).toDouble(); + final centered = + index * (_itemHeight + _separatorHeight) - + (_viewportHeight - _itemHeight) / 2; + return centered.clamp(0, maxOffset).toDouble(); + } + + void _scrollToSelected() { + if (!mounted || !_controller.hasClients) { + return; + } + final target = _targetOffset(widget.selectedLevel); + if ((_controller.offset - target).abs() < 0.5) { + return; + } + _controller.animateTo( + target, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + return ScrollConfiguration( + behavior: const _NoGlowScrollBehavior(), + child: ListView.separated( + controller: _controller, + padding: EdgeInsets.zero, + primary: false, + physics: const BouncingScrollPhysics(), + itemCount: widget.levels.length, + separatorBuilder: (_, __) => SizedBox(height: _separatorHeight), + itemBuilder: (context, index) { + final item = widget.levels[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: + item.enabled ? () => widget.onLevelSelected?.call(index) : null, + child: SizedBox( + width: 40 * widget.scale, + height: _itemHeight, + child: _LevelBadge( + scale: widget.scale, + item: item, + selected: index == widget.selectedLevel, + ), + ), + ); + }, + ), + ); + } +} + class _CrewAvatar extends StatelessWidget { const _CrewAvatar({ required this.scale, @@ -1399,28 +1508,45 @@ class _LevelBadgePainter extends CustomPainter { size, const Size(40, 52), ); + if (selected) { + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = size.width / 7 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, size.width / 12) + ..color = const Color(0xFF56FFD0).withValues(alpha: 0.38), + ); + } canvas.drawPath( path, Paint() - ..shader = const LinearGradient( + ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFF077C60), Color(0xFF08251E)], + colors: + selected + ? const [Color(0xFF10DFA8), Color(0xFF07392E)] + : const [Color(0xFF077C60), Color(0xFF08251E)], ).createShader(Offset.zero & size), ); canvas.drawPath( path, Paint() ..style = PaintingStyle.stroke - ..strokeWidth = selected ? size.width / 22 : size.width / 40 + ..strokeWidth = selected ? size.width / 14 : size.width / 40 ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - enabled + selected + ? Colors.white + : enabled ? const Color(0xFFB2FBCC) : const Color(0xFFB2FBCC).withValues(alpha: 0.42), - const Color(0xFFB2FBCC).withValues(alpha: 0), + selected + ? const Color(0xFF56FFD0) + : const Color(0xFFB2FBCC).withValues(alpha: 0), ], ).createShader(Offset.zero & size), ); @@ -1554,12 +1680,14 @@ class _RocketText extends StatelessWidget { required this.fontSize, required this.color, this.textAlign = TextAlign.center, + this.fontWeight = FontWeight.w400, }); final String text; final double fontSize; final Color color; final TextAlign textAlign; + final FontWeight fontWeight; @override Widget build(BuildContext context) { @@ -1572,6 +1700,7 @@ class _RocketText extends StatelessWidget { style: TextStyle( color: color, fontSize: fontSize, + fontWeight: fontWeight, height: 1, decoration: TextDecoration.none, ), diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart index 249452b..eed0fd4 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart @@ -34,10 +34,13 @@ class RoomRocketFloatingEntry extends StatelessWidget { return Selector( selector: (_, provider) => provider.roomRocketStatus, builder: (context, status, child) { + if (status == null || !status.isEnabled) { + return const SizedBox.shrink(); + } final level = _currentEntryLevel(status); final percent = _entryPercent(status); final shouldShake = - (status?.shake ?? false) || + (status.shake ?? false) || (level != null && percent > level.shakeThresholdPercent); return SCDebounceWidget( onTap: () => _showRocketDialog(context, status), @@ -46,7 +49,7 @@ class RoomRocketFloatingEntry extends StatelessWidget { progressUrl: level?.progressBarUrl ?? '', progressPercent: percent, shake: shouldShake, - loading: status == null, + loading: false, ), ); }, @@ -108,6 +111,55 @@ class RoomRocketFloatingEntry extends StatelessWidget { } } +@visibleForTesting +int debugResolveRoomRocketSelectedLevelIndex({ + required List? levelItems, + required SCRoomRocketStatusRes? status, + required int selectedLevel, + required bool levelTouched, +}) { + return _resolveRoomRocketSelectedLevelIndex( + levelItems: levelItems, + status: status, + selectedLevel: selectedLevel, + levelTouched: levelTouched, + ); +} + +int _resolveRoomRocketSelectedLevelIndex({ + required List? levelItems, + required SCRoomRocketStatusRes? status, + required int selectedLevel, + required bool levelTouched, +}) { + final items = levelItems ?? const []; + final maxIndex = math.max(0, (items.isEmpty ? 6 : items.length) - 1); + if (levelTouched) { + return selectedLevel.clamp(0, maxIndex).toInt(); + } + final currentLevel = _roomRocketLevelNumberFromStatus(status); + final matchedIndex = items.indexWhere((item) => item.level == currentLevel); + if (matchedIndex >= 0) { + return matchedIndex; + } + return (currentLevel - 1).clamp(0, maxIndex).toInt(); +} + +int _roomRocketLevelNumberFromStatus(SCRoomRocketStatusRes? status) { + return (status?.currentLevel ?? status?.level ?? 1).clamp(1, 99).toInt(); +} + +int _roomRocketLevelNumberForIndex( + List? levelItems, + int index, +) { + final items = levelItems ?? const []; + if (index >= 0 && index < items.length) { + return items[index].level.clamp(1, 99).toInt(); + } + return (index + 1).clamp(1, 99).toInt(); +} + class _RoomRocketEntryButton extends StatefulWidget { const _RoomRocketEntryButton({ required this.iconUrl, @@ -369,9 +421,16 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { super.initState(); _status = widget.initialStatus; _selectedLevel = _levelIndexFromStatus(widget.initialStatus); - _cacheRewardGroupsFromStatus(widget.initialStatus, _selectedLevel + 1); + final initialLevelItems = RoomRocketApiMapper.levelsFromStatus( + widget.initialStatus, + ); + final initialLevelNumber = _levelNumberForIndex( + initialLevelItems, + _selectedLevel, + ); + _cacheRewardGroupsFromStatus(widget.initialStatus, initialLevelNumber); unawaited(_load()); - unawaited(_loadRewardForLevel(_selectedLevel + 1)); + unawaited(_loadRewardForLevel(initialLevelNumber)); unawaited(_loadConfigs()); unawaited(_loadRule()); } @@ -387,16 +446,29 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { if (!mounted) { return; } + if (!res.isEnabled) { + try { + Provider.of( + context, + listen: false, + ).updateRoomRocketConfigurationStatus(res); + } catch (_) {} + widget.onClose?.call(); + return; + } _debugStatusAssets(res); + final levelItems = RoomRocketApiMapper.levelsFromStatus(res); + late final int selectedLevelNumber; setState(() { _status = res; if (!_levelTouched) { _selectedLevel = _levelIndexFromStatus(res); } - _cacheRewardGroupsFromStatus(res, _selectedLevel + 1); + selectedLevelNumber = _levelNumberForIndex(levelItems, _selectedLevel); + _cacheRewardGroupsFromStatus(res, selectedLevelNumber); }); - unawaited(_loadCrewForLevel(_selectedLevel + 1)); - unawaited(_loadRewardForLevel(_selectedLevel + 1)); + unawaited(_loadCrewForLevel(selectedLevelNumber)); + unawaited(_loadRewardForLevel(selectedLevelNumber)); try { Provider.of( context, @@ -430,13 +502,29 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { if (!mounted) { return; } + if (!contents.enabled) { + setState(() { + _levelContents = contents; + _configRewardGroupsByLevel = {}; + }); + _rocketUiDebug('rocket level contents disabled'); + return; + } final groups = RoomRocketApiMapper.rewardGroupsByLevelFromLevelContents( contents, ); + final levelItems = RoomRocketApiMapper.levelsFromLevelContents(contents); + late final int selectedLevelNumber; setState(() { _levelContents = contents; _configRewardGroupsByLevel = groups; + if (!_levelTouched) { + _selectedLevel = _resolvedSelectedLevelIndex(levelItems, _status); + } + selectedLevelNumber = _levelNumberForIndex(levelItems, _selectedLevel); }); + unawaited(_loadCrewForLevel(selectedLevelNumber)); + unawaited(_loadRewardForLevel(selectedLevelNumber)); _rocketUiDebug( 'rocket level contents loaded levels=${contents.levels.length} ' 'rewardLevels=${groups.keys.toList()}', @@ -471,8 +559,11 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { @override Widget build(BuildContext context) { final status = _status; - final selectedLevel = _selectedLevel.clamp(0, 5).toInt(); - final selectedLevelNumber = selectedLevel + 1; + final levelItems = + RoomRocketApiMapper.levelsFromLevelContents(_levelContents) ?? + RoomRocketApiMapper.levelsFromStatus(status); + final selectedLevel = _resolvedSelectedLevelIndex(levelItems, status); + final selectedLevelNumber = _levelNumberForIndex(levelItems, selectedLevel); final currentLevelNumber = _levelNumberFromStatus(status); final statusRewardGroups = RoomRocketApiMapper.rewardGroupsFromStatus( status, @@ -487,9 +578,6 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { : rewardGroupsFromStatus ?? configRewardGroups ?? const RoomRocketRewardGroups(); - final levelItems = - RoomRocketApiMapper.levelsFromLevelContents(_levelContents) ?? - RoomRocketApiMapper.levelsFromStatus(status); return RoomRocketDialog( status: status, levels: levelItems, @@ -536,13 +624,32 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { } int _levelIndexFromStatus(SCRoomRocketStatusRes? status) { - return ((status?.currentLevel ?? status?.level ?? 1) - 1) - .clamp(0, 5) - .toInt(); + return _resolveRoomRocketSelectedLevelIndex( + levelItems: RoomRocketApiMapper.levelsFromStatus(status), + status: status, + selectedLevel: _selectedLevel, + levelTouched: false, + ); } int _levelNumberFromStatus(SCRoomRocketStatusRes? status) { - return (status?.currentLevel ?? status?.level ?? 1).clamp(1, 99).toInt(); + return _roomRocketLevelNumberFromStatus(status); + } + + int _resolvedSelectedLevelIndex( + List? levelItems, + SCRoomRocketStatusRes? status, + ) { + return _resolveRoomRocketSelectedLevelIndex( + levelItems: levelItems, + status: status, + selectedLevel: _selectedLevel, + levelTouched: _levelTouched, + ); + } + + int _levelNumberForIndex(List? levelItems, int index) { + return _roomRocketLevelNumberForIndex(levelItems, index); } void _cacheRewardGroupsFromStatus(SCRoomRocketStatusRes? status, int level) { @@ -576,13 +683,17 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> { void _onLevelSelected(int index) { final selectedIndex = index.clamp(0, 5).toInt(); - _rocketUiDebug('select rocket level=${selectedIndex + 1}'); + final levelItems = + RoomRocketApiMapper.levelsFromLevelContents(_levelContents) ?? + RoomRocketApiMapper.levelsFromStatus(_status); + final selectedLevelNumber = _levelNumberForIndex(levelItems, selectedIndex); + _rocketUiDebug('select rocket level=$selectedLevelNumber'); setState(() { _levelTouched = true; _selectedLevel = selectedIndex; }); - unawaited(_loadRewardForLevel(selectedIndex + 1)); - unawaited(_loadCrewForLevel(selectedIndex + 1)); + unawaited(_loadRewardForLevel(selectedLevelNumber)); + unawaited(_loadCrewForLevel(selectedLevelNumber)); } Future _loadRewardForLevel(int level) async { diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index 26ed376..55d5f1f 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -81,7 +81,7 @@ class _MsgItemState extends State { } ///火箭用户中奖 - if (widget.msg.type == SCRoomMsgType.rocketRewardUser) { + if (SCRoomMsgType.isRocketRewardUserType(widget.msg.type)) { return _buildRocketRewardMsg(context); } @@ -692,10 +692,17 @@ class _MsgItemState extends State { ///火箭用户中奖 _buildRocketRewardMsg(BuildContext context) { List spans = []; + final nickname = Msg._firstNonBlankString([ + widget.msg.user?.userNickname, + widget.msg.user?.account, + widget.msg.user?.id, + SCAppLocalizations.of(context)?.member, + 'Member', + ]); spans.add( TextSpan( - text: "${widget.msg.user?.userNickname} ", + text: "$nickname ", style: TextStyle( fontSize: sp(12), color: SocialChatTheme.primaryColor, @@ -1995,6 +2002,118 @@ class Msg { gift = json['gift'] != null ? SocialChatGiftRes.fromJson(json['gift']) : null; + _hydrateRocketRewardPayload(json); + } + + void _hydrateRocketRewardPayload(dynamic json) { + if (!SCRoomMsgType.isRocketRewardUserType(type)) { + return; + } + final root = _stringKeyMap(json); + final payload = _stringKeyMap(root['data']); + final source = payload.isEmpty ? root : payload; + groupId = _firstNonBlankString([ + groupId, + source['groupId'], + source['roomAccount'], + source['room_account'], + source['roomId'], + source['room_id'], + ]); + role = _firstNonBlankString([ + role, + source['rewardType'], + source['reward_type'], + ]); + rocketIconUrl = _firstNonBlankString([ + rocketIconUrl, + source['rocketIconUrl'], + source['rocket_icon_url'], + source['rocketIcon'], + source['rewardCover'], + source['reward_cover'], + ]); + msg = _firstNonBlankString([ + msg, + source['rewardCover'], + source['reward_cover'], + source['rewardIconUrl'], + source['reward_icon_url'], + source['cover'], + rocketIconUrl, + ]); + user ??= _rocketRewardUserProfile(source); + } + + static SocialChatUserProfile? _rocketRewardUserProfile( + Map source, + ) { + final userId = _firstNonBlankString([ + source['userId'], + source['user_id'], + source['rewardUserId'], + source['reward_user_id'], + source['receiverUserId'], + source['receiver_user_id'], + source['targetUserId'], + source['target_user_id'], + ]); + final nickname = _firstNonBlankString([ + source['userNickname'], + source['user_nickname'], + source['nickname'], + source['receiverNickname'], + source['receiver_nickname'], + source['account'], + userId, + ]); + final account = _firstNonBlankString([ + source['account'], + source['userAccount'], + source['user_account'], + source['actualAccount'], + source['actual_account'], + userId, + ]); + final avatar = _firstNonBlankString([ + source['userAvatar'], + source['user_avatar'], + source['avatar'], + source['receiverAvatar'], + source['receiver_avatar'], + ]); + if (userId.isEmpty && + nickname.isEmpty && + account.isEmpty && + avatar.isEmpty) { + return null; + } + return SocialChatUserProfile( + id: userId, + account: account, + userNickname: nickname, + userAvatar: avatar, + ); + } + + static Map _stringKeyMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, item) => MapEntry(key.toString(), item)); + } + return const {}; + } + + static String _firstNonBlankString(Iterable values) { + for (final value in values) { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty && text.toLowerCase() != 'null') { + return text; + } + } + return ''; } Map toJson() { diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index 4d54854..ca12aab 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -52,8 +52,7 @@ class _RoomUserInfoCardState extends State { static const String _defaultProfileCardBackgroundAsset = "sc_images/room/sc_bg_room_user_profile_card_default.png"; static const double _profileSheetHeightFactor = 0.4; - static const double _dataCardTranslateDownFactor = 0.4; - static const double _defaultBackgroundImageFactor = 0.68; + static const double _dataCardBackgroundTranslateFactor = 0.8; SocialChatUserProfileManager? userProvider; String roomId = ""; @@ -90,8 +89,8 @@ class _RoomUserInfoCardState extends State { @override Widget build(BuildContext context) { - final sheetHeight = - MediaQuery.of(context).size.height * _profileSheetHeightFactor; + final screenHeight = MediaQuery.of(context).size.height; + final defaultSheetHeight = screenHeight * _profileSheetHeightFactor; return Consumer( builder: (context, ref, child) { @@ -106,14 +105,18 @@ class _RoomUserInfoCardState extends State { final canLeaveMic = currentMicIndex > -1; if (ref.userCardInfo == null) { - return _buildLoadingSheet(sheetHeight); + return _buildLoadingSheet(defaultSheetHeight); } + final dataCard = _activeDataCard(ref.userCardInfo); + return _buildProfileSheet( context: context, ref: ref, rtcProvider: rtcProvider, - sheetHeight: sheetHeight, + sheetHeight: defaultSheetHeight, + dataCardBackgroundHeight: screenHeight, + dataCard: dataCard, isSelf: isSelf, currentMicIndex: currentMicIndex, canLeaveMic: canLeaveMic, @@ -154,6 +157,8 @@ class _RoomUserInfoCardState extends State { required SocialChatUserProfileManager ref, required RtcProvider rtcProvider, required double sheetHeight, + required double dataCardBackgroundHeight, + required PropsResources? dataCard, required bool isSelf, required num currentMicIndex, required bool canLeaveMic, @@ -161,7 +166,6 @@ class _RoomUserInfoCardState extends State { }) { final bottomPadding = MediaQuery.of(context).padding.bottom; final profile = ref.userCardInfo?.userProfile; - final dataCard = _activeDataCard(ref.userCardInfo); final canShowReport = currentUserId != widget.userId; final canShowSetting = canShowReport && @@ -199,6 +203,7 @@ class _RoomUserInfoCardState extends State { return _buildDataCardFrame( sheetHeight: sheetHeight, + backgroundHeight: dataCardBackgroundHeight, dataCard: dataCard, child: profileContent, ); @@ -206,6 +211,7 @@ class _RoomUserInfoCardState extends State { Widget _buildDataCardFrame({ required double sheetHeight, + required double backgroundHeight, required PropsResources dataCard, required Widget child, }) { @@ -224,8 +230,10 @@ class _RoomUserInfoCardState extends State { Positioned( left: 0, right: 0, - top: sheetHeight * _dataCardTranslateDownFactor, - height: sheetHeight, + top: + -(backgroundHeight - sheetHeight) + + sheetHeight * _dataCardBackgroundTranslateFactor, + height: backgroundHeight, child: IgnorePointer(child: _buildSheetBackground(dataCard)), ), Positioned.fill( @@ -348,14 +356,10 @@ class _RoomUserInfoCardState extends State { Widget _buildDefaultSheetBackground() { return DecoratedBox( decoration: _sheetDecoration(), - child: Center( - child: FractionallySizedBox( - widthFactor: _defaultBackgroundImageFactor, - heightFactor: _defaultBackgroundImageFactor, - child: Image.asset( - _defaultProfileCardBackgroundAsset, - fit: BoxFit.contain, - ), + child: SizedBox.expand( + child: Image.asset( + _defaultProfileCardBackgroundAsset, + fit: BoxFit.cover, ), ), ); diff --git a/sc_images/room/sc_bg_room_user_profile_card_default.png b/sc_images/room/sc_bg_room_user_profile_card_default.png index e509d89..5d3fc20 100644 Binary files a/sc_images/room/sc_bg_room_user_profile_card_default.png and b/sc_images/room/sc_bg_room_user_profile_card_default.png differ diff --git a/sc_images/room/sc_icon_seat_lock.png b/sc_images/room/sc_icon_seat_lock.png index 8d51b98..81aba44 100644 Binary files a/sc_images/room/sc_icon_seat_lock.png and b/sc_images/room/sc_icon_seat_lock.png differ diff --git a/sc_images/room/sc_icon_seat_open.png b/sc_images/room/sc_icon_seat_open.png index d7e1c2c..0957891 100644 Binary files a/sc_images/room/sc_icon_seat_open.png and b/sc_images/room/sc_icon_seat_open.png differ diff --git a/test/room_rocket_dialog_test.dart b/test/room_rocket_dialog_test.dart new file mode 100644 index 0000000..ee89c0e --- /dev/null +++ b/test/room_rocket_dialog_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_floating_entry.dart'; + +void main() { + test('rocket dialog title displays current level before daily round', () { + expect(debugRoomRocketTitleText(level: 3, round: 1), 'Lv.3 Today'); + expect( + debugRoomRocketTitleText(level: 2, round: 4), + 'Lv.2 · Round 4 Today', + ); + }); + + test( + 'rocket level rail follows current level unless user selected manually', + () { + final status = SCRoomRocketStatusRes.fromJson({'currentLevel': 4}); + const levels = [ + RoomRocketLevelItem(label: 'Lv.2', level: 2), + RoomRocketLevelItem(label: 'Lv.4', level: 4), + RoomRocketLevelItem(label: 'Lv.5', level: 5), + ]; + + expect( + debugResolveRoomRocketSelectedLevelIndex( + levelItems: levels, + status: status, + selectedLevel: 0, + levelTouched: false, + ), + 1, + ); + expect( + debugResolveRoomRocketSelectedLevelIndex( + levelItems: levels, + status: status, + selectedLevel: 2, + levelTouched: true, + ), + 2, + ); + }, + ); +} diff --git a/test/room_rocket_message_test.dart b/test/room_rocket_message_test.dart new file mode 100644 index 0000000..7b4f100 --- /dev/null +++ b/test/room_rocket_message_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/app/constants/sc_room_msg_type.dart'; +import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; + +void main() { + test('hydrates rocket reward user from nested im payload', () { + final msg = Msg.fromJson({ + 'type': SCRoomMsgType.voiceRoomRocketRewardUser, + 'data': { + 'roomId': '9001', + 'userId': '1001', + 'userNickname': 'Winner', + 'account': '223456', + 'userAvatar': 'https://cdn.example.com/avatar.png', + 'rewardType': 'GOLD', + 'rewardCover': 'https://cdn.example.com/reward.png', + 'rocketIconUrl': 'https://cdn.example.com/rocket.png', + }, + }); + + expect(msg.type, SCRoomMsgType.voiceRoomRocketRewardUser); + expect(msg.groupId, '9001'); + expect(msg.user?.id, '1001'); + expect(msg.user?.userNickname, 'Winner'); + expect(msg.user?.account, '223456'); + expect(msg.user?.userAvatar, 'https://cdn.example.com/avatar.png'); + expect(msg.role, 'GOLD'); + expect(msg.msg, 'https://cdn.example.com/reward.png'); + expect(msg.rocketIconUrl, 'https://cdn.example.com/rocket.png'); + }); + + test('rocket reward user nickname falls back to account', () { + final msg = Msg.fromJson({ + 'type': SCRoomMsgType.voiceRoomRocketRewardUser, + 'userId': '1002', + 'account': '334455', + 'rewardType': 'BADGE', + }); + + expect(msg.user?.userNickname, '334455'); + expect(msg.user?.id, '1002'); + }); +} diff --git a/test/room_rocket_status_test.dart b/test/room_rocket_status_test.dart index a336342..23b9b73 100644 --- a/test/room_rocket_status_test.dart +++ b/test/room_rocket_status_test.dart @@ -122,6 +122,22 @@ void main() { expect(levels?.last.progressImageUrl, 'progress-2'); }); + test('parses disabled rocket status from go api', () { + final status = SCRoomRocketStatusRes.fromJson({ + 'configured': true, + 'enabled': false, + 'status': 'DISABLED', + 'roomId': 'room-1', + 'levels': [], + }); + + expect(status.configured, isTrue); + expect(status.enabled, isFalse); + expect(status.isEnabled, isFalse); + expect(status.status, 'DISABLED'); + expect(RoomRocketApiMapper.levelsFromStatus(status), isNull); + }); + test( 'uses top-level reward preview for the response level instead of current level', () {