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 bca0970..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", @@ -724,7 +726,7 @@ "roomRocketNoteTitle": "Note", "roomRocketNoteSectionTitle": "Each time a VIP", "roomRocketNoteSectionBody": "1. Each time a VIP level is purchased, it is valid for 30 days, and can be manually renewed after expiration.", - "roomRocketRewardEmptyText": "No rewards earned. Better luck next time.", + "roomRocketRewardEmptyText": "No reward", "roomRocketRecordEmptyText": "No reward records", "launchRocket": "Launch Rocket", "roomRocketFuelFullNotice": "Rocket fuel is full! Launching in {1}s!" 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/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index b6145cf..71d62a6 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -560,7 +560,6 @@ class _VoiceRoomPageState extends State ///礼物上飘动画 _floatingGiftListener(Msg msg) { final rtcProvider = Provider.of(context, listen: false); - rtcProvider.scheduleRoomRocketStatusRefreshForGift(); if (!rtcProvider.shouldShowRoomVisualEffects) { return; } diff --git a/lib/modules/store/chatbox/store_chatbox_page.dart b/lib/modules/store/chatbox/store_chatbox_page.dart index 880bb32..15e30e8 100644 --- a/lib/modules/store/chatbox/store_chatbox_page.dart +++ b/lib/modules/store/chatbox/store_chatbox_page.dart @@ -88,13 +88,7 @@ class _StoreChatboxPageState height: 45.w, fit: BoxFit.contain, ), - SizedBox(height: 3.w), - buildStoreBagItemTitle( - res.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 11.sp, - ), - SizedBox(height: 3.w), + SizedBox(height: 10.w), Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/modules/store/data_card/store_data_card_page.dart b/lib/modules/store/data_card/store_data_card_page.dart index cd26c3d..71ffb85 100644 --- a/lib/modules/store/data_card/store_data_card_page.dart +++ b/lib/modules/store/data_card/store_data_card_page.dart @@ -87,13 +87,7 @@ class StoreDataCardPageState fit: BoxFit.cover, ), ), - SizedBox(height: 8.w), - buildStoreBagItemTitle( - res.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 11.sp, - ), - SizedBox(height: 3.w), + SizedBox(height: 12.w), Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/modules/store/headdress/store_headdress_page.dart b/lib/modules/store/headdress/store_headdress_page.dart index b4e8edf..d9c8564 100644 --- a/lib/modules/store/headdress/store_headdress_page.dart +++ b/lib/modules/store/headdress/store_headdress_page.dart @@ -87,13 +87,7 @@ class _StoreHeaddressPageState width: 55.w, height: 55.w, ), - SizedBox(height: 3.w), - buildStoreBagItemTitle( - res.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 11.sp, - ), - SizedBox(height: 3.w), + SizedBox(height: 10.w), Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/modules/store/mountains/store_mountains_page.dart b/lib/modules/store/mountains/store_mountains_page.dart index dd58d3a..c1d8819 100644 --- a/lib/modules/store/mountains/store_mountains_page.dart +++ b/lib/modules/store/mountains/store_mountains_page.dart @@ -91,13 +91,7 @@ class _StoreMountainsPageState width: 55.w, height: 55.w, ), - SizedBox(height: 3.w), - buildStoreBagItemTitle( - res.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 11.sp, - ), - SizedBox(height: 3.w), + SizedBox(height: 10.w), Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/modules/store/theme/store_theme_page.dart b/lib/modules/store/theme/store_theme_page.dart index 2fa5a00..c1da299 100644 --- a/lib/modules/store/theme/store_theme_page.dart +++ b/lib/modules/store/theme/store_theme_page.dart @@ -91,13 +91,7 @@ class _StoreThemePagePageState height: 55.w, borderRadius: BorderRadius.all(Radius.circular(8.w)), ), - SizedBox(height: 3.w), - buildStoreBagItemTitle( - res.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 11.sp, - ), - SizedBox(height: 3.w), + SizedBox(height: 10.w), Row( mainAxisSize: MainAxisSize.min, children: [ 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/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index 286bb38..1cd11b2 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -32,7 +32,6 @@ import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/ui_kit/widgets/badge/sc_user_badge_strip.dart'; -import 'package:yumi/ui_kit/widgets/props/sc_data_card_resource_view.dart'; import '../../../app/constants/sc_screen.dart'; import '../../../shared/business_logic/models/res/sc_user_counter_res.dart'; @@ -580,30 +579,8 @@ class _PersonDetailPageState extends State { ); } - Widget _buildProfileDataCardBackground(PropsResources? dataCard) { - if (dataCard == null) { - return const ColoredBox(color: _profileBg); - } - return Stack( - fit: StackFit.expand, - children: [ - SCDataCardResourceView(resource: dataCard, fit: BoxFit.cover), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.26), - _profileBg.withValues(alpha: 0.54), - _profileBg.withValues(alpha: 0.78), - ], - stops: const [0, 0.45, 1], - ), - ), - ), - ], - ); + Widget _buildProfileDataCardBackground(PropsResources? _) { + return const ColoredBox(color: _profileBg); } PropsResources? _activeDataCard(SocialChatUserProfile? profile) { diff --git a/lib/modules/user/profile/profile_wall_of_honors_page.dart b/lib/modules/user/profile/profile_wall_of_honors_page.dart index e2cae40..043bf82 100644 --- a/lib/modules/user/profile/profile_wall_of_honors_page.dart +++ b/lib/modules/user/profile/profile_wall_of_honors_page.dart @@ -151,7 +151,7 @@ class _ProfileWallOfHonorsPageState extends State { ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, - mainAxisExtent: 132.w, + mainAxisExtent: 96.w, mainAxisSpacing: 14.w, crossAxisSpacing: 12.w, ), @@ -161,39 +161,26 @@ class _ProfileWallOfHonorsPageState extends State { Widget _buildBadgeItem(WearBadge badge) { final url = _badgeUrl(badge); - final name = (badge.badgeName ?? "").trim(); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 86.w, - height: 86.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0xff0E332D), - borderRadius: BorderRadius.circular(8.w), - border: Border.all( - color: const Color(0xff18F2B1).withValues(alpha: 0.24), - ), - ), - child: netImage( - url: url, - width: 68.w, - height: 68.w, - fit: BoxFit.contain, - noDefaultImg: true, + return Center( + child: Container( + width: 86.w, + height: 86.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xff0E332D), + borderRadius: BorderRadius.circular(8.w), + border: Border.all( + color: const Color(0xff18F2B1).withValues(alpha: 0.24), ), ), - SizedBox(height: 8.w), - text( - name.isEmpty ? "--" : name, - fontSize: 12, - textColor: Colors.white, - fontWeight: FontWeight.w400, - maxLines: 2, - textAlign: TextAlign.center, + child: netImage( + url: url, + width: 68.w, + height: 68.w, + fit: BoxFit.contain, + noDefaultImg: true, ), - ], + ), ); } diff --git a/lib/modules/user/vip/vip_detail_page.dart b/lib/modules/user/vip/vip_detail_page.dart index 6e3ddaa..1ab6276 100644 --- a/lib/modules/user/vip/vip_detail_page.dart +++ b/lib/modules/user/vip/vip_detail_page.dart @@ -13,6 +13,7 @@ import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/ui_kit/components/sc_compontent.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/props/sc_data_card_resource_view.dart'; class VipDetailPage extends StatefulWidget { const VipDetailPage({super.key}); @@ -58,7 +59,6 @@ class _VipDetailPageState extends State { final status = results[1] as SCVipStatusRes; final resolvedStatus = _resolveStatus(status, home.state); final selectedLevel = _resolveInitialLevel(home, resolvedStatus); - _logLoadedVipData(home, resolvedStatus, selectedLevel); if (!mounted) return; setState(() { @@ -141,11 +141,6 @@ class _VipDetailPageState extends State { _selectedLevel = level; _preview = null; }); - _logSelectedLevelResources( - level, - config: _levelConfigFor(level), - status: _status, - ); _loadPreviewForLevel(level); } @@ -201,6 +196,7 @@ class _VipDetailPageState extends State { await _loadVipData(showLoading: false); } } catch (error) { + debugPrint('VIP purchase failed: $error'); } finally { SCLoadingManager.hide(); if (mounted) { @@ -214,56 +210,6 @@ class _VipDetailPageState extends State { return 'vip-buy-$userId-${DateTime.now().millisecondsSinceEpoch}'; } - void _logLoadedVipData( - SCVipHomeRes home, - SCVipStatusRes? status, - int selectedLevel, - ) { - if (home.configured != true) {} - if (home.levels.isEmpty) {} - _logSelectedLevelResources( - selectedLevel, - config: _levelConfigFromHome(home, selectedLevel), - status: status, - ); - } - - void _logSelectedLevelResources( - int level, { - required SCVipLevelConfigRes? config, - required SCVipStatusRes? status, - }) { - final state = status?.levelInt == level ? status : null; - if (config == null && state == null) { - return; - } - final badge = _preferResource(config?.badge, state?.badge); - final avatarFrame = _preferResource( - config?.avatarFrame, - state?.avatarFrame, - ); - final entryEffect = _preferResource( - config?.entryEffect, - state?.entryEffect, - ); - final chatBubble = _preferResource(config?.chatBubble, state?.chatBubble); - final floatPicture = _preferResource( - config?.floatPicture, - state?.floatPicture, - ); - } - - String _resourceLogValue(SCVipResourceRes? resource) { - if (!_hasResource(resource)) { - return 'null'; - } - return '{id:${resource?.resourceId},name:${resource?.name},' - 'hasPreviewUrl:${resource?.hasPreviewUrl},' - 'hasSourceUrl:${resource?.hasSourceUrl},' - 'previewUrl:${resource?.previewUrl},' - 'sourceUrl:${resource?.sourceResourceUrl}}'; - } - @override Widget build(BuildContext context) { return Scaffold( @@ -524,6 +470,8 @@ class _VipDetailPageState extends State { Widget _buildLevelCard() { final assetLevel = _assetLevel; + final backgroundCard = _selectedBackgroundCard; + final effectImage = _selectedEffectImage ?? _selectedLongBadge; return Center( child: SizedBox( width: 351.w, @@ -532,18 +480,23 @@ class _VipDetailPageState extends State { clipBehavior: Clip.none, children: [ Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.w), - border: Border.all( - color: const Color(0xFFEBCB76), - width: 1.w, - ), - image: DecorationImage( - image: AssetImage( - 'sc_images/vip/sc_vip_card_bg_$assetLevel.png', + child: ClipRRect( + borderRadius: BorderRadius.circular(8.w), + child: _buildVipCardBackground( + resource: backgroundCard, + assetLevel: assetLevel, + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.w), + border: Border.all( + color: const Color(0xFFEBCB76), + width: 1.w, ), - fit: BoxFit.fill, ), ), ), @@ -575,8 +528,8 @@ class _VipDetailPageState extends State { PositionedDirectional( end: 24.w, top: -36.w, - child: _buildResourceImage( - resource: _selectedBadge, + child: _buildDynamicResourceVisual( + resource: effectImage, width: 135.w, height: 135.w, fallbackIcon: Icons.workspace_premium_outlined, @@ -588,6 +541,27 @@ class _VipDetailPageState extends State { ); } + Widget _buildVipCardBackground({ + required SCVipResourceRes? resource, + required int assetLevel, + }) { + final fallback = Image.asset( + 'sc_images/vip/sc_vip_card_bg_$assetLevel.png', + fit: BoxFit.fill, + width: double.infinity, + height: double.infinity, + ); + if (!_hasResourceUrl(resource)) { + return fallback; + } + return SCDataCardResourceView( + sourceUrl: resource?.sourceResourceUrl, + coverUrl: resource?.previewUrl, + fit: BoxFit.fill, + fallback: fallback, + ); + } + Widget _buildLevelDivider() { final ornamentLayout = _ornamentLayoutForLevel(_assetLevel); return SizedBox( @@ -713,9 +687,14 @@ class _VipDetailPageState extends State { ); } - SCVipResourceRes? get _selectedBadge => _selectedResource( - fromConfig: (config) => config.badge, - fromState: (status) => status.badge, + SCVipResourceRes? get _selectedLongBadge => _selectedResource( + fromConfig: (config) => config.longBadge, + fromState: (status) => status.longBadge, + ); + + SCVipResourceRes? get _selectedShortBadge => _selectedResource( + fromConfig: (config) => config.shortBadge, + fromState: (status) => status.shortBadge, ); SCVipResourceRes? get _selectedAvatarFrame => _selectedResource( @@ -738,6 +717,16 @@ class _VipDetailPageState extends State { fromState: (status) => status.floatPicture, ); + SCVipResourceRes? get _selectedBackgroundCard => _selectedResource( + fromConfig: (config) => config.backgroundCard, + fromState: (status) => status.backgroundCard, + ); + + SCVipResourceRes? get _selectedEffectImage => _selectedResource( + fromConfig: (config) => config.effectImage, + fromState: (status) => status.effectImage, + ); + SCVipResourceRes? _selectedResource({ required SCVipResourceRes? Function(SCVipLevelConfigRes config) fromConfig, required SCVipResourceRes? Function(SCVipStatusRes status) fromState, @@ -768,8 +757,14 @@ class _VipDetailPageState extends State { final items = <_VipFeatureData>[]; _addResourceItem( items, - resource: _selectedBadge, - fallbackTitle: 'VIP Badge', + resource: _selectedLongBadge, + fallbackTitle: 'Long Badge', + fallbackIcon: Icons.workspace_premium_outlined, + ); + _addResourceItem( + items, + resource: _selectedShortBadge, + fallbackTitle: 'Short Badge', fallbackIcon: Icons.workspace_premium_outlined, ); _addResourceItem( @@ -797,12 +792,19 @@ class _VipDetailPageState extends State { fallbackIcon: Icons.airline_seat_recline_extra, wide: true, ); + _addResourceItem( + items, + resource: _selectedBackgroundCard, + fallbackTitle: 'Profile Card', + fallbackIcon: Icons.badge_outlined, + wide: true, + ); return items; } List<_VipFeatureData> get _vipPrivilegeItems { // The VIP home API currently returns resource perks only: - // badge/avatarFrame/entryEffect/chatBubble/floatPicture. + // badges/avatarFrame/entryEffect/chatBubble/floatPicture/backgroundCard. // Do not render demo-only functional privileges without backend data. return const <_VipFeatureData>[]; } @@ -844,6 +846,40 @@ class _VipDetailPageState extends State { return fallbackTitle; } + Widget _buildDynamicResourceVisual({ + required SCVipResourceRes? resource, + required double width, + required double height, + required IconData fallbackIcon, + }) { + final sourceUrl = resource?.sourceResourceUrl?.trim(); + final previewUrl = resource?.previewUrl?.trim(); + final hasSource = sourceUrl != null && sourceUrl.isNotEmpty; + final hasPreview = previewUrl != null && previewUrl.isNotEmpty; + if (hasSource || hasPreview) { + return SCDataCardResourceView( + sourceUrl: sourceUrl, + coverUrl: previewUrl, + width: width, + height: height, + fit: BoxFit.contain, + fallback: _buildResourceImage( + resource: resource, + width: width, + height: height, + fallbackIcon: fallbackIcon, + ), + ); + } + + return _buildResourceImage( + resource: resource, + width: width, + height: height, + fallbackIcon: fallbackIcon, + ); + } + Widget _buildResourceImage({ required SCVipResourceRes? resource, required double width, diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index c2a5041..bdb9d4b 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/app_localizations.dart'; @@ -291,6 +292,20 @@ class _PendingRoomStartupSeatAction { final String? inviterId; } +class _PersistedVoiceRoomCleanupRequest { + const _PersistedVoiceRoomCleanupRequest({ + required this.staleRoomId, + required this.targetRoomId, + required this.restoreOnlineHeartbeatWhenIdle, + this.clearMarkerOnly = false, + }); + + final String staleRoomId; + final String targetRoomId; + final bool restoreOnlineHeartbeatWhenIdle; + final bool clearMarkerOnly; +} + class RealTimeCommunicationManager extends ChangeNotifier { static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog'; static const String _roomRocketPagLaunchDialogTag = @@ -304,6 +319,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { Duration(seconds: 5), Duration(seconds: 10), ]; + static const List _roomRocketEntryReadyFallbackDelays = [ + Duration(milliseconds: 300), + Duration(milliseconds: 900), + Duration(milliseconds: 1800), + Duration(milliseconds: 3200), + ]; static const int _roomPasswordNotTrueErrorCode = 9005; static const Duration _micListPollingInterval = Duration(seconds: 2); @@ -343,8 +364,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { Timer? _onlineUsersPollingTimer; Timer? _roomEntryEffectTimer; Timer? _roomRocketLaunchAnimationTimer; + ValueNotifier? _roomRocketLaunchTopAvatarNotifier; Timer? _roomRocketGiftRefreshTimer; final List _roomRocketPostLaunchRefreshTimers = []; + final List _roomRocketEntryReadyFallbackTimers = []; Timer? _roomRedPacketPresenceTimer; Future? _rtcEnginePrewarmTask; Future? _pendingRoomSwitchRtcLeaveTask; @@ -490,8 +513,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomEntryEffectTimer = null; _roomRocketLaunchAnimationTimer?.cancel(); _roomRocketLaunchAnimationTimer = null; + SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); + _releaseRoomRocketLaunchTopAvatarNotifier(); _roomRocketGiftRefreshTimer?.cancel(); _roomRocketGiftRefreshTimer = null; + _cancelRoomRocketEntryReadyFallbacks(); _cancelRoomRocketPostLaunchStatusRefresh(); } _roomVisualEffectsEnabled = enabled; @@ -500,6 +526,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { void setVoiceRoomRouteVisible(bool visible) { _voiceRoomRouteVisible = visible; + if (visible) { + final roomId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); + if (roomId.isNotEmpty) { + _scheduleRoomRocketEntryReadyFallback(roomId); + } + } } void _setRoomStartupLoading() { @@ -1780,10 +1812,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (!context.mounted) { return; } - await cleanupPersistedVoiceRoomSessionBeforeEntry(targetRoomId: roomId); - if (!context.mounted) { - return; - } + final persistedRoomCleanupRequest = + _preparePersistedVoiceRoomCleanupBeforeEntry( + targetRoomId: roomId, + restoreOnlineHeartbeatWhenIdle: false, + ); if (roomId == currenRoom?.roomProfile?.roomProfile?.id) { if (_currentRoomIsEntryPreview || _roomStartupStatus == RoomStartupStatus.loading) { @@ -1792,6 +1825,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { } _startVoiceRoomForegroundService(); VoiceRoomRoute.openVoiceRoom(context); + _startPersistedVoiceRoomCleanupInBackground( + persistedRoomCleanupRequest, + ); return; } @@ -1830,6 +1866,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _refreshRoomRedPacketListAfterEntry(roomId: roomId); _startVoiceRoomForegroundService(); VoiceRoomRoute.openVoiceRoom(context); + _startPersistedVoiceRoomCleanupInBackground(persistedRoomCleanupRequest); if (shouldRejoinRoomRtc) { unawaited(_rejoinCurrentRoomRtcForSameRoomReuse(roomId)); } @@ -1865,6 +1902,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { ); _startVoiceRoomForegroundService(); VoiceRoomRoute.openVoiceRoom(context); + _startPersistedVoiceRoomCleanupInBackground(persistedRoomCleanupRequest); notifyListeners(); final entryRequestSerial = ++_roomEntryRequestSerial; if (preEnteredRoom != null) { @@ -2487,14 +2525,70 @@ class RealTimeCommunicationManager extends ChangeNotifier { Future cleanupPersistedVoiceRoomSessionBeforeEntry({ String? targetRoomId, + bool restoreOnlineHeartbeatWhenIdle = true, }) { + final cleanupRequest = _preparePersistedVoiceRoomCleanupBeforeEntry( + targetRoomId: targetRoomId, + restoreOnlineHeartbeatWhenIdle: restoreOnlineHeartbeatWhenIdle, + ); + return _runPersistedVoiceRoomCleanup(cleanupRequest); + } + + _PersistedVoiceRoomCleanupRequest? + _preparePersistedVoiceRoomCleanupBeforeEntry({ + String? targetRoomId, + bool restoreOnlineHeartbeatWhenIdle = true, + }) { + final staleRoomId = DataPersistence.getLastTimeRoomId().trim(); + if (staleRoomId.isEmpty) { + return null; + } + + final currentRoomId = + (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); + if (currentRoomId.isNotEmpty) { + return null; + } + + final currentUserId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + if (currentUserId.isEmpty || AccountStorage().getToken().trim().isEmpty) { + return null; + } + + final normalizedTargetRoomId = (targetRoomId ?? "").trim(); + if (normalizedTargetRoomId.isNotEmpty && + normalizedTargetRoomId == staleRoomId) { + return null; + } + + final staleRoomUserId = DataPersistence.getLastTimeRoomUserId().trim(); + return _PersistedVoiceRoomCleanupRequest( + staleRoomId: staleRoomId, + targetRoomId: normalizedTargetRoomId, + restoreOnlineHeartbeatWhenIdle: restoreOnlineHeartbeatWhenIdle, + clearMarkerOnly: + staleRoomUserId.isNotEmpty && staleRoomUserId != currentUserId, + ); + } + + void _startPersistedVoiceRoomCleanupInBackground( + _PersistedVoiceRoomCleanupRequest? cleanupRequest, + ) { + unawaited(_runPersistedVoiceRoomCleanup(cleanupRequest)); + } + + Future _runPersistedVoiceRoomCleanup( + _PersistedVoiceRoomCleanupRequest? cleanupRequest, + ) { + if (cleanupRequest == null) { + return Future.value(); + } final runningTask = _persistedRoomCleanupTask; if (runningTask != null) { return runningTask; } - final task = _cleanupPersistedVoiceRoomSessionBeforeEntry( - targetRoomId: targetRoomId, - ); + final task = _cleanupPersistedVoiceRoomSessionBeforeEntry(cleanupRequest); _persistedRoomCleanupTask = task; return task.whenComplete(() { if (identical(_persistedRoomCleanupTask, task)) { @@ -2503,28 +2597,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { }); } - Future _cleanupPersistedVoiceRoomSessionBeforeEntry({ - String? targetRoomId, - }) async { - final staleRoomId = DataPersistence.getLastTimeRoomId().trim(); - if (staleRoomId.isEmpty) { - return; - } - - final currentRoomId = - (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); - if (currentRoomId.isNotEmpty) { - return; - } - - final currentUserId = - (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); - if (currentUserId.isEmpty || AccountStorage().getToken().trim().isEmpty) { - return; - } - - final staleRoomUserId = DataPersistence.getLastTimeRoomUserId().trim(); - if (staleRoomUserId.isNotEmpty && staleRoomUserId != currentUserId) { + Future _cleanupPersistedVoiceRoomSessionBeforeEntry( + _PersistedVoiceRoomCleanupRequest cleanupRequest, + ) async { + final staleRoomId = cleanupRequest.staleRoomId; + if (cleanupRequest.clearMarkerOnly) { await _clearPersistedRoomMarkerIfMatches(staleRoomId); return; } @@ -2545,7 +2622,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { ); if (didClean) { await _clearPersistedRoomMarkerIfMatches(staleRoomId); - if ((targetRoomId ?? "").trim() != staleRoomId) { + final currentRoomId = + (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); + if (cleanupRequest.restoreOnlineHeartbeatWhenIdle && + cleanupRequest.targetRoomId != staleRoomId && + currentRoomId.isEmpty) { await SCHeartbeatUtils.scheduleHeartbeat( SCHeartbeatStatus.ONLINE.name, false, @@ -2594,6 +2675,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { setRoomVisualEffectsEnabled(true); if (shouldOpenRoomPage) { VoiceRoomRoute.openVoiceRoom(context!); + setVoiceRoomRouteVisible(true); } unawaited( _bootstrapEnteredVoiceRoomSession( @@ -2694,6 +2776,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { roomId: roomId, ); _loadRoomSecondaryData(); + _scheduleRoomRocketEntryReadyFallback(roomId); } catch (error, stackTrace) { if (!_isActiveRoomStartup(entryRequestSerial, roomId)) { return; @@ -2806,18 +2889,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(); @@ -2873,6 +2957,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, @@ -2893,6 +2980,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { } roomRocketStatus = res; notifyListeners(); + if (!res.isEnabled) { + return; + } _scheduleRoomRocketAssetPreload(resolvedRoomId, res); } @@ -2905,6 +2995,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (resolvedRoomId.isEmpty) { return; } + if (!status.isEnabled) { + return; + } if (afterRoomEntry) { RoomRocketAssetPreloader.preloadAfterRoomEntry( roomId: resolvedRoomId, @@ -2937,6 +3030,42 @@ class RealTimeCommunicationManager extends ChangeNotifier { }); } + void _scheduleRoomRocketEntryReadyFallback(String roomId) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + _notifyRoomRocketEntryReadyIfCurrent(normalizedRoomId); + for (final delay in _roomRocketEntryReadyFallbackDelays) { + final timer = Timer(delay, () { + _roomRocketEntryReadyFallbackTimers.removeWhere( + (item) => !item.isActive, + ); + _notifyRoomRocketEntryReadyIfCurrent(normalizedRoomId); + }); + _roomRocketEntryReadyFallbackTimers.add(timer); + } + } + + void _notifyRoomRocketEntryReadyIfCurrent(String roomId) { + final normalizedRoomId = roomId.trim(); + final currentRoomId = + currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; + if (normalizedRoomId.isEmpty || + currentRoomId != normalizedRoomId || + !_voiceRoomRouteVisible) { + return; + } + rtmProvider?.handleVoiceRoomReadyForRocketRewards(normalizedRoomId); + } + + void _cancelRoomRocketEntryReadyFallbacks() { + for (final timer in _roomRocketEntryReadyFallbackTimers) { + timer.cancel(); + } + _roomRocketEntryReadyFallbackTimers.clear(); + } + void scheduleRoomRocketPostLaunchStatusRefresh({String? roomId}) { final resolvedRoomId = (roomId ?? currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); @@ -2973,6 +3102,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } _roomRocketLaunchAnimationTimer?.cancel(); + _releaseRoomRocketLaunchTopAvatarNotifier(); if (shouldShowRoomVisualEffects && isVoiceRoomRouteVisible) { final animationUrl = @@ -2990,6 +3120,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomRocketLaunchAnimationTimer = Timer(launch.displayDuration, () { _roomRocketLaunchAnimationTimer = null; SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); + _releaseRoomRocketLaunchTopAvatarNotifier(); unawaited(refreshRoomRocketStatus(roomId: launch.roomId)); scheduleRoomRocketPostLaunchStatusRefresh(roomId: launch.roomId); }); @@ -3015,6 +3146,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { required RoomRocketLaunchBroadcastMessage launch, }) { if (RoomRocketPagEffectOverlay.isPag(animationUrl)) { + final topAvatarNotifier = ValueNotifier( + _roomRocketTop1AvatarUrl(launch.safeLevel), + ); + _roomRocketLaunchTopAvatarNotifier = topAvatarNotifier; + unawaited(_loadRoomRocketTop1Avatar(launch, topAvatarNotifier)); SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); SmartDialog.show( tag: _roomRocketPagLaunchDialogTag, @@ -3026,11 +3162,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { builder: (_) => RoomRocketPagEffectOverlay( resource: animationUrl, - topAvatarUrl: _roomRocketTop1AvatarUrl(), + topAvatarUrlListenable: topAvatarNotifier, onCompleted: () { SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); _roomRocketLaunchAnimationTimer?.cancel(); _roomRocketLaunchAnimationTimer = null; + _releaseRoomRocketLaunchTopAvatarNotifier(topAvatarNotifier); unawaited(refreshRoomRocketStatus(roomId: launch.roomId)); scheduleRoomRocketPostLaunchStatusRefresh( roomId: launch.roomId, @@ -3047,8 +3184,21 @@ class RealTimeCommunicationManager extends ChangeNotifier { ); } - String? _roomRocketTop1AvatarUrl() { - final kings = roomRocketStatus?.rocketKings ?? const []; + String? _roomRocketTop1AvatarUrl(int level) { + final kings = + roomRocketStatus?.rocketKingsForLevel(level) ?? + const []; + return _roomRocketTop1AvatarUrlFromUsers(kings); + } + + String? _roomRocketTop1AvatarUrlFromKing(SCRoomRocketKingRes king) { + final source = king.podium.isNotEmpty ? king.podium : king.displayRecords; + return _roomRocketTop1AvatarUrlFromUsers(source); + } + + String? _roomRocketTop1AvatarUrlFromUsers( + List kings, + ) { if (kings.isEmpty) { return null; } @@ -3065,6 +3215,90 @@ class RealTimeCommunicationManager extends ChangeNotifier { return null; } + Future _loadRoomRocketTop1Avatar( + RoomRocketLaunchBroadcastMessage launch, + ValueNotifier notifier, + ) async { + final roomId = launch.roomId.trim(); + if (roomId.isEmpty) { + return; + } + try { + _roomRocketDebug( + 'request launch top1 avatar roomId=$roomId ' + 'level=${launch.safeLevel} roundNo=${launch.roundNo}', + ); + var king = await SCChatRoomRepository().roomRocketKing( + roomId, + level: launch.safeLevel, + roundNo: launch.roundNo, + cursor: 1, + limit: 3, + ); + var avatar = _roomRocketTop1AvatarUrlFromKing(king); + if (avatar == null && launch.roundNo > 0) { + _roomRocketDebug( + 'launch top1 avatar retry without roundNo ' + 'level=${launch.safeLevel} roundNo=${launch.roundNo}', + ); + king = await SCChatRoomRepository().roomRocketKing( + roomId, + level: launch.safeLevel, + cursor: 1, + limit: 3, + ); + avatar = _roomRocketTop1AvatarUrlFromKing(king); + } + if (_roomRocketLaunchTopAvatarNotifier != notifier) { + return; + } + _roomRocketDebug( + 'launch top1 avatar loaded level=${launch.safeLevel} ' + 'roundNo=${launch.roundNo} avatar=${_shortRoomRocketDebugUrl(avatar)} ' + 'podium=${king.podium.length} records=${king.records.length}', + ); + if (avatar != null && avatar != notifier.value) { + notifier.value = avatar; + } + } catch (error) { + _roomRocketDebug( + 'launch top1 avatar load failed level=${launch.safeLevel} ' + 'roundNo=${launch.roundNo}: $error', + ); + } + } + + void _releaseRoomRocketLaunchTopAvatarNotifier([ + ValueNotifier? expected, + ]) { + final notifier = _roomRocketLaunchTopAvatarNotifier; + if (notifier == null || (expected != null && notifier != expected)) { + return; + } + _roomRocketLaunchTopAvatarNotifier = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + notifier.dispose(); + }); + } + + void _roomRocketDebug(String message) { + if (!kDebugMode) { + return; + } + debugPrint('[RoomRocket] $message'); + } + + String _shortRoomRocketDebugUrl(String? url) { + final value = url?.trim() ?? ''; + if (value.isEmpty) { + return '-'; + } + if (value.length <= 96) { + return value; + } + return '${value.substring(0, 48)}...${value.substring(value.length - 24)}'; + } + RoomRocketRewardUserProfile? roomRocketRewardUserProfileForRecord( SCRoomRocketRewardRecordRes record, ) { @@ -3367,9 +3601,6 @@ class RealTimeCommunicationManager extends ChangeNotifier { final exitRtmProvider = rtmProvider; final navigationContext = context; try { - exitRtmProvider?.msgAllListener = null; - exitRtmProvider?.msgChatListener = null; - exitRtmProvider?.msgGiftListener = null; SCGiftVapSvgaManager().stopPlayback(); setRoomVisualEffectsEnabled(false); SCHeartbeatUtils.cancelTimer(); @@ -3405,9 +3636,6 @@ class RealTimeCommunicationManager extends ChangeNotifier { final isPreviewOnlyExit = _currentRoomIsEntryPreview; final exitRtmProvider = rtmProvider; try { - exitRtmProvider?.msgAllListener = null; - exitRtmProvider?.msgChatListener = null; - exitRtmProvider?.msgGiftListener = null; SCGiftVapSvgaManager().stopPlayback(); setRoomVisualEffectsEnabled(false); SCHeartbeatUtils.cancelTimer(); @@ -3613,6 +3841,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomEntryEffectTimer = null; _roomRocketLaunchAnimationTimer?.cancel(); _roomRocketLaunchAnimationTimer = null; + SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); + _releaseRoomRocketLaunchTopAvatarNotifier(); _roomRocketGiftRefreshTimer?.cancel(); _roomRocketGiftRefreshTimer = null; _cancelRoomRocketPostLaunchStatusRefresh(); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 11c77f2..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'; @@ -106,6 +107,18 @@ class _LuckGiftPushQueueEntry { } } +class _PendingRoomRocketRewardPopup { + const _PendingRoomRocketRewardPopup({ + required this.roomId, + required this.receivedAt, + this.initialRoomRecords = const [], + }); + + final String roomId; + final int receivedAt; + final List initialRoomRecords; +} + class RealTimeMessagingManager extends ChangeNotifier { static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog'; @@ -121,12 +134,25 @@ class RealTimeMessagingManager extends ChangeNotifier { static const int _roomRocketLaunchRecentTtlMs = 90000; static const int _roomRocketLaunchLooseRecentTtlMs = 12000; static const int _roomRocketRegionBroadcastRecentTtlMs = 90000; + static const int _roomRocketRewardPendingTtlMs = 120000; + static const int _roomRocketRewardProbeTtlMs = 120000; + static const int _roomRocketLaunchCountdownRetainMs = 10000; + static const int _roomRocketLaunchEffectConsumedTtlMs = 120000; static const Duration _createRoomGroupTimeout = Duration(seconds: 12); static const List _roomRocketRewardPopupRetryDelays = [ Duration(milliseconds: 700), Duration(seconds: 2), Duration(seconds: 5), ]; + static const Duration _roomRocketRewardAfterLaunchDelay = Duration( + milliseconds: 350, + ); + static const List _roomRocketRoomReadyRewardRetryDelays = [ + Duration(milliseconds: 300), + Duration(milliseconds: 900), + Duration(seconds: 2), + Duration(seconds: 4), + ]; BuildContext? context; @@ -239,7 +265,21 @@ class RealTimeMessagingManager extends ChangeNotifier { final Map _recentRoomRocketLaunchLooseTimes = {}; final Map _recentRoomRocketRegionBroadcastTimes = {}; + final _pendingRoomRocketRewardPopups = + {}; + final Map _recentRoomRocketRewardProbeTimes = {}; + final Map _roomRocketLaunchCountdownDueTimes = {}; + final Map _roomRocketLaunchCountdownKeysByRoom = + {}; + final _pendingRoomRocketCountdownLaunches = + {}; + final Map _roomRocketLaunchEffectTimers = {}; + final Map _consumedRoomRocketLaunchEffectTimes = {}; + final Set _hiddenCurrentRoomRocketLaunchKeys = {}; + final Map _roomRocketRewardHoldUntilTimes = {}; + final Map _roomRocketRewardHoldTimers = {}; Timer? _roomRocketRewardPopupRetryTimer; + Timer? _roomRocketRoomReadyRewardRetryTimer; int get currentLuckGiftBurstPlaybackToken => _luckGiftPushPlaybackToken; String? roomRedPacketBroadcastGroupId; String? roomRedPacketBroadcastRegionCode; @@ -1128,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; @@ -1139,6 +1180,7 @@ class RealTimeMessagingManager extends ChangeNotifier { } addMsg(Msg msg) { + _scheduleRoomRocketStatusRefreshForGiftMessage(msg); final mergedGiftMsg = _mergeGiftMessageIfNeeded(msg); if (mergedGiftMsg != null) { msgAllListener?.call(mergedGiftMsg); @@ -1160,7 +1202,7 @@ class RealTimeMessagingManager extends ChangeNotifier { _hasRoomRedPacketClaimMessage(msg)) { return; } - if (msg.type == SCRoomMsgType.rocketRewardUser && + if (SCRoomMsgType.isRocketRewardUserType(msg.type) && _hasRoomRocketRewardMessage(msg)) { return; } @@ -1200,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); @@ -1225,6 +1267,33 @@ class RealTimeMessagingManager extends ChangeNotifier { } } + void _scheduleRoomRocketStatusRefreshForGiftMessage(Msg msg) { + if (msg.type != SCRoomMsgType.gift && + msg.type != SCRoomMsgType.luckGiftAnimOther) { + return; + } + final currentContext = context; + if (currentContext == null || !currentContext.mounted) { + return; + } + final rtcProvider = Provider.of( + currentContext, + listen: false, + ); + final currentRoomId = + (rtcProvider.currenRoom?.roomProfile?.roomProfile?.id ?? '').trim(); + if (currentRoomId.isEmpty) { + return; + } + final messageRoomId = msg.type == SCRoomMsgType.gift ? msg.msg?.trim() : ''; + if (messageRoomId != null && + messageRoomId.isNotEmpty && + messageRoomId != currentRoomId) { + return; + } + rtcProvider.scheduleRoomRocketStatusRefreshForGift(roomId: currentRoomId); + } + void _cacheRoomRedPacketMessage(Msg msg) { try { final decoded = jsonDecode(msg.msg ?? ''); @@ -1326,7 +1395,7 @@ class RealTimeMessagingManager extends ChangeNotifier { } return roomAllMsgList.any( (item) => - item.type == SCRoomMsgType.rocketRewardUser && + SCRoomMsgType.isRocketRewardUserType(item.type) && _roomRocketRewardMessageKey(item) == key, ); } @@ -1941,6 +2010,33 @@ class RealTimeMessagingManager extends ChangeNotifier { currentContext, listen: false, ); + final isCurrentVisibleRoom = + rtcProvider.shouldShowRoomVisualEffects && + rtcProvider.isVoiceRoomRouteVisible; + final launch = _buildRoomRocketLaunchFromStatusPayload( + rtcProvider: rtcProvider, + payload: data, + roomId: roomId, + ); + if (_shouldHandleRoomRocketLaunchFromStatusPayload( + rtcProvider: rtcProvider, + payload: data, + launch: launch, + )) { + if (isCurrentVisibleRoom) { + _handleCurrentVisibleRoomRocketLaunch( + rtcProvider: rtcProvider, + launch: launch, + publishRegionBroadcast: true, + ); + } else { + _rememberRoomRocketLaunchCountdown( + launch, + suppressReplayAfterDue: true, + ); + _publishRoomRocketRegionBroadcastIfNeeded(launch); + } + } unawaited(rtcProvider.refreshRoomRocketStatus(roomId: roomId)); } @@ -1954,7 +2050,6 @@ class RealTimeMessagingManager extends ChangeNotifier { if (!launch.isValid) { return; } - var isCurrentRoom = false; var isCurrentVisibleRoom = false; final currentContext = context; @@ -1971,19 +2066,26 @@ class RealTimeMessagingManager extends ChangeNotifier { rtcProvider.shouldShowRoomVisualEffects && rtcProvider.isVoiceRoomRouteVisible; if (isCurrentVisibleRoom) { - if (!isRegionBroadcast) { - _publishRoomRocketRegionBroadcastIfNeeded(launch); - } _handleCurrentVisibleRoomRocketLaunch( rtcProvider: rtcProvider, launch: launch, ); + } else { + _rememberRoomRocketLaunchCountdown( + launch, + suppressReplayAfterDue: true, + ); } } } + if (!isCurrentRoom) { + _rememberRoomRocketLaunchCountdown(launch); + } + if (!isCurrentVisibleRoom && !isCurrentRoom) { + _markRoomRocketRewardProbe(launch.roomId); + } if (isCurrentRoom && !isCurrentVisibleRoom && !isRegionBroadcast) { - _publishRoomRocketRegionBroadcastIfNeeded(launch); return; } OverlayManager().addMessage( @@ -1996,21 +2098,47 @@ 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; - if (resolvedRoomId.isEmpty || - (currentRoomId.isNotEmpty && resolvedRoomId != currentRoomId)) { + if (resolvedRoomId.isEmpty) { + return; + } + final initialRoomRecords = _roomRocketRewardRecordsFromPayload(data); + if (currentRoomId.isNotEmpty && resolvedRoomId != currentRoomId) { + _cachePendingRoomRocketRewardPopup( + resolvedRoomId, + initialRoomRecords: initialRoomRecords, + ); return; } if (!_isCurrentVoiceRoom(resolvedRoomId)) { + _cachePendingRoomRocketRewardPopup( + resolvedRoomId, + initialRoomRecords: initialRoomRecords, + ); return; } if (!_isCurrentVisibleVoiceRoom(resolvedRoomId)) { + _cachePendingRoomRocketRewardPopup( + resolvedRoomId, + initialRoomRecords: initialRoomRecords, + ); + return; + } + if (_isRoomRocketRewardPopupHeld(resolvedRoomId)) { + _scheduleRoomRocketRewardPopupAfterHold( + resolvedRoomId, + initialRoomRecords: initialRoomRecords, + ); return; } - final initialRoomRecords = _roomRocketRewardRecordsFromPayload(data); + _pendingRoomRocketRewardPopups.remove(resolvedRoomId); + _recentRoomRocketRewardProbeTimes.remove(resolvedRoomId); unawaited( _showRoomRocketRewardPopupDialog( resolvedRoomId, @@ -2019,12 +2147,592 @@ 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) { + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + _pruneRoomRocketRewardFallbacks(now); + final pending = _pendingRoomRocketRewardPopups[normalizedRoomId]; + final hasProbe = + _recentRoomRocketRewardProbeTimes[normalizedRoomId] != null; + final hasLaunch = + _pendingRoomRocketCountdownLaunches[normalizedRoomId] != null; + if (pending == null && !hasProbe && !hasLaunch) { + return; + } + if (!_isCurrentVisibleVoiceRoom(normalizedRoomId)) { + _scheduleRoomRocketReadyRewardRetry(normalizedRoomId, attempt); + return; + } + final resumedLaunch = _resumeRoomRocketLaunchForCurrentRoom( + normalizedRoomId, + ); + final pendingAfterResume = _pendingRoomRocketRewardPopups[normalizedRoomId]; + final hasProbeAfterResume = + _recentRoomRocketRewardProbeTimes[normalizedRoomId] != null; + if (pendingAfterResume == null && !hasProbeAfterResume) { + return; + } + if (!resumedLaunch && pendingAfterResume == null && attempt == 0) { + _scheduleRoomRocketReadyRewardRetry(normalizedRoomId, attempt); + return; + } + _pendingRoomRocketRewardPopups.remove(normalizedRoomId); + _recentRoomRocketRewardProbeTimes.remove(normalizedRoomId); + _roomRocketRoomReadyRewardRetryTimer?.cancel(); + _roomRocketRoomReadyRewardRetryTimer = null; + if (_isRoomRocketRewardPopupHeld(normalizedRoomId)) { + _scheduleRoomRocketRewardPopupAfterHold( + normalizedRoomId, + initialRoomRecords: + pending?.initialRoomRecords ?? + const [], + ); + return; + } + unawaited( + _showRoomRocketRewardPopupDialog( + normalizedRoomId, + initialRoomRecords: + pendingAfterResume?.initialRoomRecords ?? + const [], + ), + ); + } + + void _scheduleRoomRocketReadyRewardRetry(String roomId, int attempt) { + if (attempt >= _roomRocketRoomReadyRewardRetryDelays.length) { + return; + } + _roomRocketRoomReadyRewardRetryTimer?.cancel(); + _roomRocketRoomReadyRewardRetryTimer = Timer( + _roomRocketRoomReadyRewardRetryDelays[attempt], + () { + _roomRocketRoomReadyRewardRetryTimer = null; + handleVoiceRoomReadyForRocketRewards(roomId, attempt: attempt + 1); + }, + ); + } + + void _cachePendingRoomRocketRewardPopup( + String roomId, { + List initialRoomRecords = + const [], + }) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + _pruneRoomRocketRewardFallbacks(now); + final existing = _pendingRoomRocketRewardPopups[normalizedRoomId]; + final records = + initialRoomRecords.isNotEmpty + ? List.unmodifiable(initialRoomRecords) + : existing?.initialRoomRecords ?? + const []; + _pendingRoomRocketRewardPopups[normalizedRoomId] = + _PendingRoomRocketRewardPopup( + roomId: normalizedRoomId, + receivedAt: now, + initialRoomRecords: records, + ); + } + + void _markRoomRocketRewardProbe(String roomId) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + _pruneRoomRocketRewardFallbacks(now); + _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, + ); + _recentRoomRocketRewardProbeTimes.removeWhere( + (_, timestamp) => now - timestamp > _roomRocketRewardProbeTtlMs, + ); + } + + void _clearRoomRocketRewardFallbackState() { + _pendingRoomRocketRewardPopups.clear(); + _recentRoomRocketRewardProbeTimes.clear(); + _consumedRoomRocketLaunchEffectTimes.clear(); + _hiddenCurrentRoomRocketLaunchKeys.clear(); + _roomRocketRoomReadyRewardRetryTimer?.cancel(); + _roomRocketRoomReadyRewardRetryTimer = null; + for (final timer in _roomRocketRewardHoldTimers.values) { + timer.cancel(); + } + _roomRocketRewardHoldTimers.clear(); + _roomRocketRewardHoldUntilTimes.clear(); + _clearRoomRocketLaunchCountdownState(); + } + + void _rememberRoomRocketLaunchCountdown( + RoomRocketLaunchBroadcastMessage launch, { + bool suppressReplayAfterDue = false, + }) { + if (!launch.isValid || _hasConsumedRoomRocketLaunchEffect(launch)) { + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + _pruneRoomRocketLaunchCountdowns(now); + final key = _roomRocketLaunchCountdownKey(launch); + final existingDueAt = _roomRocketLaunchCountdownDueTimes[key]; + final dueAt = + existingDueAt != null && existingDueAt > now + ? existingDueAt + : now + _roomRocketLaunchCountdownDelay(launch).inMilliseconds; + _roomRocketLaunchCountdownDueTimes[key] = dueAt; + _roomRocketLaunchCountdownKeysByRoom[launch.roomId] = key; + _pendingRoomRocketCountdownLaunches[launch.roomId] = launch; + if (suppressReplayAfterDue) { + _hiddenCurrentRoomRocketLaunchKeys.add(key); + } + } + + void _scheduleRoomRocketLaunchEffectsAfterCountdown( + RoomRocketLaunchBroadcastMessage launch, + ) { + if (!launch.isValid || _hasConsumedRoomRocketLaunchEffect(launch)) { + return; + } + _rememberRoomRocketLaunchCountdown(launch); + final key = _roomRocketLaunchCountdownKey(launch); + final delay = _roomRocketLaunchCountdownRemaining(launch.roomId); + final dueAt = + _roomRocketLaunchCountdownDueTimes[key] ?? + DateTime.now().millisecondsSinceEpoch; + _roomRocketLaunchEffectTimers[key]?.cancel(); + _roomRocketLaunchEffectTimers[key] = Timer(delay, () { + _roomRocketLaunchEffectTimers.remove(key); + if (_roomRocketLaunchCountdownDueTimes[key] != dueAt) { + return; + } + _roomRocketLaunchCountdownDueTimes.remove(key); + if (_roomRocketLaunchCountdownKeysByRoom[launch.roomId] == key) { + _roomRocketLaunchCountdownKeysByRoom.remove(launch.roomId); + } + final pendingLaunch = + _pendingRoomRocketCountdownLaunches.remove(launch.roomId) ?? launch; + _playRoomRocketLaunchEffectsAndReward(pendingLaunch); + }); + } + + void _playRoomRocketLaunchEffectsAndReward( + RoomRocketLaunchBroadcastMessage launch, + ) { + if (_hasConsumedRoomRocketLaunchEffect(launch)) { + _clearRoomRocketLaunchCountdownForRoom(launch.roomId); + return; + } + _clearRoomRocketLaunchCountdownForRoom(launch.roomId); + if (!_isCurrentVisibleVoiceRoom(launch.roomId)) { + _consumeRoomRocketLaunchWithoutEffects(launch); + return; + } + final currentContext = context; + if (currentContext == null || !currentContext.mounted) { + _consumeRoomRocketLaunchWithoutEffects(launch); + return; + } + final rtcProvider = Provider.of( + currentContext, + listen: false, + ); + final shouldFetchReward = + _pendingRoomRocketRewardPopups.containsKey(launch.roomId) || + _recentRoomRocketRewardProbeTimes.containsKey(launch.roomId); + final pending = _pendingRoomRocketRewardPopups.remove(launch.roomId); + _recentRoomRocketRewardProbeTimes.remove(launch.roomId); + _markRoomRocketLaunchEffectConsumed(launch); + _holdRoomRocketRewardPopupUntilLaunchEnds(launch); + rtcProvider.handleRoomRocketLaunchBroadcast(launch); + if (shouldFetchReward) { + _scheduleRoomRocketRewardPopupAfterHold( + launch.roomId, + initialRoomRecords: + pending?.initialRoomRecords ?? + const [], + ); + } + } + + bool _resumeRoomRocketLaunchForCurrentRoom(String roomId) { + final normalizedRoomId = roomId.trim(); + final launch = _pendingRoomRocketCountdownLaunches[normalizedRoomId]; + if (launch == null || !_isCurrentVisibleVoiceRoom(normalizedRoomId)) { + return false; + } + if (_hasConsumedRoomRocketLaunchEffect(launch)) { + _clearRoomRocketLaunchCountdownForRoom(normalizedRoomId); + return false; + } + final currentContext = context; + if (currentContext == null || !currentContext.mounted) { + return false; + } + final rtcProvider = Provider.of( + currentContext, + listen: false, + ); + final remaining = _roomRocketLaunchCountdownRemaining(normalizedRoomId); + final launchKey = _roomRocketLaunchCountdownKey(launch); + if (remaining > Duration.zero) { + _hiddenCurrentRoomRocketLaunchKeys.remove(launchKey); + _addRoomRocketLaunchNoticeIfNeeded( + rtcProvider: rtcProvider, + launch: launch, + secondsOverride: _roomRocketRemainingSeconds(remaining), + ); + _scheduleRoomRocketLaunchEffectsAfterCountdown(launch); + } else { + if (_hiddenCurrentRoomRocketLaunchKeys.remove(launchKey)) { + _consumeRoomRocketLaunchWithoutEffects(launch); + return false; + } + _playRoomRocketLaunchEffectsAndReward(launch); + } + return true; + } + + int _roomRocketRemainingSeconds(Duration duration) { + final seconds = (duration.inMilliseconds / 1000).ceil(); + return seconds.clamp(1, 60).toInt(); + } + + Duration _roomRocketLaunchCountdownRemaining(String roomId) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty) { + return Duration.zero; + } + final now = DateTime.now().millisecondsSinceEpoch; + _pruneRoomRocketLaunchCountdowns(now); + final key = _roomRocketLaunchCountdownKeysByRoom[normalizedRoomId]; + final dueAt = key == null ? null : _roomRocketLaunchCountdownDueTimes[key]; + if (dueAt == null || dueAt <= now) { + return Duration.zero; + } + return Duration(milliseconds: dueAt - now); + } + + Duration _roomRocketLaunchCountdownDelay( + RoomRocketLaunchBroadcastMessage launch, + ) { + final seconds = launch.durationSeconds <= 0 ? 10 : launch.durationSeconds; + return Duration(seconds: seconds.clamp(0, 60).toInt()); + } + + String _roomRocketLaunchCountdownKey( + RoomRocketLaunchBroadcastMessage launch, + ) { + final launchKey = _roomRocketLaunchNoticeKey(launch); + if (launchKey.isNotEmpty) { + return launchKey; + } + return 'rocket_launch_countdown|${launch.roomId}'; + } + + void _pruneRoomRocketLaunchCountdowns(int now) { + final expiredKeys = {}; + _roomRocketLaunchCountdownDueTimes.removeWhere((key, dueAt) { + final expired = now - dueAt > _roomRocketLaunchCountdownRetainMs; + if (expired) { + expiredKeys.add(key); + } + return expired; + }); + if (expiredKeys.isEmpty) { + return; + } + _roomRocketLaunchCountdownKeysByRoom.removeWhere( + (_, key) => expiredKeys.contains(key), + ); + _pendingRoomRocketCountdownLaunches.removeWhere( + (roomId, _) => !_roomRocketLaunchCountdownKeysByRoom.containsKey(roomId), + ); + } + + void _clearRoomRocketLaunchCountdownState() { + _roomRocketLaunchCountdownDueTimes.clear(); + _roomRocketLaunchCountdownKeysByRoom.clear(); + _pendingRoomRocketCountdownLaunches.clear(); + _hiddenCurrentRoomRocketLaunchKeys.clear(); + for (final timer in _roomRocketLaunchEffectTimers.values) { + timer.cancel(); + } + _roomRocketLaunchEffectTimers.clear(); + } + + void _clearRoomRocketLaunchCountdownForRoom(String roomId) { + final normalizedRoomId = roomId.trim(); + final key = _roomRocketLaunchCountdownKeysByRoom.remove(normalizedRoomId); + if (key != null) { + _roomRocketLaunchCountdownDueTimes.remove(key); + _roomRocketLaunchEffectTimers.remove(key)?.cancel(); + _hiddenCurrentRoomRocketLaunchKeys.remove(key); + } + _pendingRoomRocketCountdownLaunches.remove(normalizedRoomId); + } + + bool _hasConsumedRoomRocketLaunchEffect( + RoomRocketLaunchBroadcastMessage launch, + ) { + final key = _roomRocketLaunchCountdownKey(launch); + if (key.isEmpty) { + return false; + } + _pruneConsumedRoomRocketLaunchEffects( + DateTime.now().millisecondsSinceEpoch, + ); + return _consumedRoomRocketLaunchEffectTimes.containsKey(key); + } + + void _markRoomRocketLaunchEffectConsumed( + RoomRocketLaunchBroadcastMessage launch, + ) { + final key = _roomRocketLaunchCountdownKey(launch); + if (key.isEmpty) { + return; + } + _consumedRoomRocketLaunchEffectTimes[key] = + DateTime.now().millisecondsSinceEpoch; + _hiddenCurrentRoomRocketLaunchKeys.remove(key); + } + + void _consumeRoomRocketLaunchWithoutEffects( + RoomRocketLaunchBroadcastMessage launch, + ) { + _clearRoomRocketLaunchCountdownForRoom(launch.roomId); + _pendingRoomRocketRewardPopups.remove(launch.roomId); + _recentRoomRocketRewardProbeTimes.remove(launch.roomId); + _markRoomRocketLaunchEffectConsumed(launch); + } + + void _pruneConsumedRoomRocketLaunchEffects(int now) { + _consumedRoomRocketLaunchEffectTimes.removeWhere( + (_, timestamp) => now - timestamp > _roomRocketLaunchEffectConsumedTtlMs, + ); + } + void _handleCurrentVisibleRoomRocketLaunch({ required RealTimeCommunicationManager rtcProvider, required RoomRocketLaunchBroadcastMessage launch, + bool publishRegionBroadcast = false, + }) { + if (_addRoomRocketLaunchNoticeIfNeeded( + rtcProvider: rtcProvider, + launch: launch, + publishRegionBroadcast: publishRegionBroadcast, + )) { + _markRoomRocketRewardProbe(launch.roomId); + _scheduleRoomRocketLaunchEffectsAfterCountdown(launch); + } + } + + bool _addRoomRocketLaunchNoticeIfNeeded({ + required RealTimeCommunicationManager rtcProvider, + required RoomRocketLaunchBroadcastMessage launch, + bool publishRegionBroadcast = false, + int? secondsOverride, }) { if (!_isCurrentVisibleVoiceRoom(launch.roomId)) { - return; + return false; } final launchKey = _roomRocketLaunchNoticeKey(launch); final looseLaunchKey = _roomRocketLaunchLooseKey(launch); @@ -2035,23 +2743,27 @@ class RealTimeMessagingManager extends ChangeNotifier { _recentRoomRocketLaunchTimes.containsKey(launchKey)) || (looseLaunchKey.isNotEmpty && _recentRoomRocketLaunchLooseTimes.containsKey(looseLaunchKey)); - if (!alreadyHandled && launchKey.isNotEmpty) { - _recentRoomRocketLaunchTimes[launchKey] = now; - if (looseLaunchKey.isNotEmpty) { - _recentRoomRocketLaunchLooseTimes[looseLaunchKey] = now; - } - addMsg( - _buildRoomRocketLaunchNoticeMsg( - _firstNonBlank([ - rtcProvider.currenRoom?.roomProfile?.roomProfile?.roomAccount, - launch.roomAccount, - ]), - launch, - ), - ); - rtcProvider.handleRoomRocketLaunchBroadcast(launch); + if (alreadyHandled || launchKey.isEmpty) { + return false; } - unawaited(_showRoomRocketRewardPopupDialog(launch.roomId)); + _recentRoomRocketLaunchTimes[launchKey] = now; + if (looseLaunchKey.isNotEmpty) { + _recentRoomRocketLaunchLooseTimes[looseLaunchKey] = now; + } + addMsg( + _buildRoomRocketLaunchNoticeMsg( + _firstNonBlank([ + rtcProvider.currenRoom?.roomProfile?.roomProfile?.roomAccount, + launch.roomAccount, + ]), + launch, + secondsOverride: secondsOverride, + ), + ); + if (publishRegionBroadcast) { + _publishRoomRocketRegionBroadcastIfNeeded(launch); + } + return true; } void _publishRoomRocketRegionBroadcastIfNeeded( @@ -2122,12 +2834,21 @@ 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)) { return; } - if (res.records.isEmpty && initialRoomRecords.isEmpty) { + if (res.records.isEmpty && + initialRoomRecords.isEmpty && + attempt < _roomRocketRewardPopupRetryDelays.length) { _scheduleRoomRocketRewardPopupRetry( roomId, attempt, @@ -2202,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) { @@ -2286,9 +3014,12 @@ class RealTimeMessagingManager extends ChangeNotifier { Msg _buildRoomRocketLaunchNoticeMsg( String groupId, - RoomRocketLaunchBroadcastMessage launch, - ) { - final seconds = launch.durationSeconds <= 0 ? 10 : launch.durationSeconds; + RoomRocketLaunchBroadcastMessage launch, { + int? secondsOverride, + }) { + final seconds = + secondsOverride ?? + (launch.durationSeconds <= 0 ? 10 : launch.durationSeconds); return Msg( groupId: groupId, msg: seconds.toString(), @@ -2318,6 +3049,147 @@ class RealTimeMessagingManager extends ChangeNotifier { return ['rocket_launch_loose', launch.roomId, launch.safeLevel].join('|'); } + bool _shouldHandleRoomRocketLaunchFromStatusPayload({ + required RealTimeCommunicationManager rtcProvider, + required Map payload, + required RoomRocketLaunchBroadcastMessage launch, + }) { + final currentPercent = _roomRocketPayloadPercent(payload); + if (currentPercent < 99.5) { + return false; + } + final previousPercent = _roomRocketStatusPercent( + rtcProvider.roomRocketStatus, + ); + if (previousPercent < 99.5) { + return true; + } + final launchKey = _roomRocketLaunchNoticeKey(launch); + if (launchKey.isEmpty) { + return false; + } + _pruneRecentRoomRocketLaunches(DateTime.now().millisecondsSinceEpoch); + final looseLaunchKey = _roomRocketLaunchLooseKey(launch); + return !_recentRoomRocketLaunchTimes.containsKey(launchKey) && + (looseLaunchKey.isEmpty || + !_recentRoomRocketLaunchLooseTimes.containsKey(looseLaunchKey)); + } + + RoomRocketLaunchBroadcastMessage _buildRoomRocketLaunchFromStatusPayload({ + required RealTimeCommunicationManager rtcProvider, + required Map payload, + required String roomId, + }) { + final roomProfile = rtcProvider.currenRoom?.roomProfile?.roomProfile; + final level = _roomRocketPayloadLevel( + payload, + rtcProvider.roomRocketStatus?.currentLevel ?? + rtcProvider.roomRocketStatus?.level?.toInt() ?? + 1, + ); + return RoomRocketLaunchBroadcastMessage( + roomId: roomId, + roomAccount: _firstNonBlank([ + roomProfile?.roomAccount, + _payloadText(payload['roomAccount'] ?? payload['room_account']), + ]), + roomName: _firstNonBlank([ + roomProfile?.roomName, + _payloadText(payload['roomName'] ?? payload['room_name']), + ]), + roomCoverUrl: _firstNonBlank([ + roomProfile?.roomCover, + _payloadAssetText(payload['roomCoverUrl'] ?? payload['room_cover_url']), + _payloadAssetText(payload['roomCover'] ?? payload['room_cover']), + _payloadAssetText(payload['roomAvatar'] ?? payload['room_avatar']), + _payloadAssetText(payload['roomIcon'] ?? payload['room_icon']), + _payloadAssetText(payload['coverUrl'] ?? payload['cover_url']), + _payloadAssetText(payload['cover']), + _payloadAssetText(_broadcastPayloadMap(payload['room'])['coverUrl']), + _payloadAssetText(_broadcastPayloadMap(payload['room'])['cover']), + _payloadAssetText(_broadcastPayloadMap(payload['room'])['avatar']), + _payloadAssetText( + _broadcastPayloadMap(payload['roomInfo'])['coverUrl'], + ), + _payloadAssetText(_broadcastPayloadMap(payload['roomInfo'])['cover']), + _payloadAssetText(_broadcastPayloadMap(payload['roomInfo'])['avatar']), + ]), + roundNo: + _payloadNum(payload['roundNo'] ?? payload['round_no'])?.toInt() ?? 0, + level: level, + rocketIconUrl: _firstNonBlank([ + _rocketIconFromPayload(payload), + _rocketIconUrlForLevel(level), + ]), + rocketAnimationUrl: _rocketAnimationFromPayload(payload), + durationSeconds: _roomRocketPayloadNoticeSeconds(payload), + triggerUser: const RoomRocketLaunchTriggerUser( + userId: '', + nickname: '', + avatar: '', + account: '', + countryCode: '', + countryName: '', + ), + triggerUserId: _payloadText( + payload['triggerUserId'] ?? payload['trigger_user_id'], + ), + launchNo: _payloadText(payload['launchNo'] ?? payload['launch_no']), + ); + } + + double _roomRocketStatusPercent(dynamic status) { + if (status == null) { + return 0; + } + final displayPercent = _payloadNum(status.displayPercent); + if (displayPercent != null) { + final raw = displayPercent.toDouble(); + final percent = raw > 0 && raw <= 1 ? raw * 100 : raw; + return percent.clamp(0, 100).toDouble(); + } + final energyPercent = _payloadNum(status.energyPercent); + if (energyPercent != null) { + final raw = energyPercent.toDouble(); + final percent = raw > 0 && raw <= 1 ? raw * 100 : raw; + return percent.clamp(0, 100).toDouble(); + } + final currentEnergy = _payloadNum(status.currentEnergy); + final maxEnergy = _payloadNum(status.maxEnergy ?? status.needEnergy); + if (currentEnergy == null || maxEnergy == null || maxEnergy <= 0) { + return 0; + } + return ((currentEnergy / maxEnergy) * 100).clamp(0, 100).toDouble(); + } + + double _roomRocketPayloadPercent(Map payload) { + final value = _payloadNum( + payload['displayPercent'] ?? + payload['display_percent'] ?? + payload['energyPercent'] ?? + payload['energy_percent'] ?? + payload['percent'], + ); + if (value != null) { + final raw = value.toDouble(); + final percent = raw > 0 && raw <= 1 ? raw * 100 : raw; + return percent.clamp(0, 100).toDouble(); + } + final currentEnergy = _payloadNum( + payload['currentEnergy'] ?? payload['current_energy'], + ); + final maxEnergy = _payloadNum( + payload['maxEnergy'] ?? + payload['max_energy'] ?? + payload['needEnergy'] ?? + payload['need_energy'], + ); + if (currentEnergy == null || maxEnergy == null || maxEnergy <= 0) { + return 0; + } + return ((currentEnergy / maxEnergy) * 100).clamp(0, 100).toDouble(); + } + int _roomRocketPayloadLevel(Map payload, int fallback) { final value = _payloadNum( payload['currentLevel'] ?? @@ -2331,6 +3203,25 @@ class RealTimeMessagingManager extends ChangeNotifier { return (value?.toInt() ?? fallback).clamp(1, 99).toInt(); } + int _roomRocketPayloadNoticeSeconds(Map payload) { + final seconds = + _payloadNum( + payload['countdownSeconds'] ?? + payload['countdown_seconds'] ?? + payload['launchDelaySeconds'] ?? + payload['launch_delay_seconds'] ?? + payload['delaySeconds'] ?? + payload['delay_seconds'] ?? + payload['durationSeconds'] ?? + payload['duration_seconds'], + )?.toInt() ?? + 10; + if (seconds > 0 && seconds <= 60) { + return seconds; + } + return 10; + } + String _rocketIconFromPayload(Map payload) { return _firstNonBlank([ _payloadAssetText(payload['rocketIconUrl']), @@ -2342,6 +3233,22 @@ class RealTimeMessagingManager extends ChangeNotifier { ]); } + String _rocketAnimationFromPayload(Map payload) { + return _firstNonBlank([ + _payloadAssetText(payload['rocketAnimationUrl']), + _payloadAssetText(payload['rocket_animation_url']), + _payloadAssetText(payload['rocketSvgaUrl']), + _payloadAssetText(payload['rocket_svga_url']), + _payloadAssetText(payload['animationUrl']), + _payloadAssetText(payload['animation_url']), + _payloadAssetText(payload['launchAnimationUrl']), + _payloadAssetText(payload['launch_animation_url']), + _payloadAssetText(payload['svgaUrl']), + _payloadAssetText(payload['sourceUrl']), + _payloadAssetText(payload['source_url']), + ]); + } + String _payloadRoomId(Map payload) { return _firstNonBlank([ _payloadText(payload['roomId']), @@ -2367,6 +3274,7 @@ class RealTimeMessagingManager extends ChangeNotifier { _recentRoomRocketLaunchLooseTimes.removeWhere( (_, timestamp) => now - timestamp > _roomRocketLaunchLooseRecentTtlMs, ); + _pruneConsumedRoomRocketLaunchEffects(now); } String _roomRocketLaunchNoticeKeyForValues({ @@ -2464,6 +3372,7 @@ class RealTimeMessagingManager extends ChangeNotifier { logout() async { roomRedPacketBroadcastGroupId = null; roomRedPacketBroadcastRegionCode = null; + _clearRoomRocketRewardFallbackState(); RoomRedPacketOpenDialog.clearClaimedState(); RoomRedPacketPendingCache.instance.clear(); V2TimCallback logoutRes = await TencentImSDKPlugin.v2TIMManager.logout(); @@ -2601,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"]; @@ -2699,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"]; @@ -3407,8 +4321,15 @@ class RealTimeMessagingManager extends ChangeNotifier { _recentRoomRocketLaunchTimes.clear(); _recentRoomRocketLaunchLooseTimes.clear(); _recentRoomRocketRegionBroadcastTimes.clear(); + _consumedRoomRocketLaunchEffectTimes.clear(); + _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..a87ab2a 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,17 @@ class SCRoomRocketStatusRes { num? get myContributionRate => _myContributionRate; + bool? get configured => _configured; + + bool? get enabled => _enabled; + + bool get isEnabled { + final normalizedStatus = (_status ?? '').trim().toUpperCase(); + return _configured != false && + _enabled != false && + normalizedStatus != 'DISABLED'; + } + int? get roundNo => _roundNo; bool? get displayRound => _displayRound; @@ -371,6 +397,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/shared/business_logic/models/res/sc_vip_res.dart b/lib/shared/business_logic/models/res/sc_vip_res.dart index ad941a9..56d945a 100644 --- a/lib/shared/business_logic/models/res/sc_vip_res.dart +++ b/lib/shared/business_logic/models/res/sc_vip_res.dart @@ -110,10 +110,14 @@ class SCVipStatusRes { String? startAt, String? expireAt, SCVipResourceRes? badge, + SCVipResourceRes? longBadge, + SCVipResourceRes? shortBadge, SCVipResourceRes? avatarFrame, SCVipResourceRes? entryEffect, SCVipResourceRes? chatBubble, SCVipResourceRes? floatPicture, + SCVipResourceRes? backgroundCard, + SCVipResourceRes? effectImage, }) { _userId = userId; _sysOrigin = sysOrigin; @@ -125,10 +129,14 @@ class SCVipStatusRes { _startAt = startAt; _expireAt = expireAt; _badge = badge; + _longBadge = longBadge; + _shortBadge = shortBadge; _avatarFrame = avatarFrame; _entryEffect = entryEffect; _chatBubble = chatBubble; _floatPicture = floatPicture; + _backgroundCard = backgroundCard; + _effectImage = effectImage; } SCVipStatusRes.fromJson(dynamic json) { @@ -144,10 +152,14 @@ class SCVipStatusRes { _startAt = _stringValue(map['startAt']); _expireAt = _stringValue(map['expireAt']); _badge = _resourceValue(map['badge']); + _longBadge = _resourceValue(map['longBadge']) ?? _badge; + _shortBadge = _resourceValue(map['shortBadge']); _avatarFrame = _resourceValue(map['avatarFrame']); _entryEffect = _resourceValue(map['entryEffect']); _chatBubble = _resourceValue(map['chatBubble']); _floatPicture = _resourceValue(map['floatPicture']); + _backgroundCard = _resourceValue(map['backgroundCard']); + _effectImage = _resourceValue(map['effectImage']); } String? _userId; @@ -160,10 +172,14 @@ class SCVipStatusRes { String? _startAt; String? _expireAt; SCVipResourceRes? _badge; + SCVipResourceRes? _longBadge; + SCVipResourceRes? _shortBadge; SCVipResourceRes? _avatarFrame; SCVipResourceRes? _entryEffect; SCVipResourceRes? _chatBubble; SCVipResourceRes? _floatPicture; + SCVipResourceRes? _backgroundCard; + SCVipResourceRes? _effectImage; String? get userId => _userId; @@ -183,7 +199,11 @@ class SCVipStatusRes { String? get expireAt => _expireAt; - SCVipResourceRes? get badge => _badge; + SCVipResourceRes? get badge => longBadge; + + SCVipResourceRes? get longBadge => _longBadge ?? _badge; + + SCVipResourceRes? get shortBadge => _shortBadge; SCVipResourceRes? get avatarFrame => _avatarFrame; @@ -193,6 +213,10 @@ class SCVipStatusRes { SCVipResourceRes? get floatPicture => _floatPicture; + SCVipResourceRes? get backgroundCard => _backgroundCard; + + SCVipResourceRes? get effectImage => _effectImage; + bool get isActive => _active == true && (_level ?? 0) > 0; int get levelInt => (_level ?? 0).toInt(); @@ -216,11 +240,17 @@ class SCVipStatusRes { map['displayName'] = _displayName; map['startAt'] = _startAt; map['expireAt'] = _expireAt; - if (_badge != null) map['badge'] = _badge?.toJson(); + if (badge != null) map['badge'] = badge?.toJson(); + if (longBadge != null) map['longBadge'] = longBadge?.toJson(); + if (_shortBadge != null) map['shortBadge'] = _shortBadge?.toJson(); if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson(); if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson(); if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson(); if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson(); + if (_backgroundCard != null) { + map['backgroundCard'] = _backgroundCard?.toJson(); + } + if (_effectImage != null) map['effectImage'] = _effectImage?.toJson(); return map; } } @@ -234,10 +264,14 @@ class SCVipLevelConfigRes { num? durationDays, num? priceGold, SCVipResourceRes? badge, + SCVipResourceRes? longBadge, + SCVipResourceRes? shortBadge, SCVipResourceRes? avatarFrame, SCVipResourceRes? entryEffect, SCVipResourceRes? chatBubble, SCVipResourceRes? floatPicture, + SCVipResourceRes? backgroundCard, + SCVipResourceRes? effectImage, bool? canPurchase, num? payableGold, }) { @@ -248,10 +282,14 @@ class SCVipLevelConfigRes { _durationDays = durationDays; _priceGold = priceGold; _badge = badge; + _longBadge = longBadge; + _shortBadge = shortBadge; _avatarFrame = avatarFrame; _entryEffect = entryEffect; _chatBubble = chatBubble; _floatPicture = floatPicture; + _backgroundCard = backgroundCard; + _effectImage = effectImage; _canPurchase = canPurchase; _payableGold = payableGold; } @@ -266,10 +304,14 @@ class SCVipLevelConfigRes { _durationDays = _numValue(map['durationDays']); _priceGold = _numValue(map['priceGold']); _badge = _resourceValue(map['badge']); + _longBadge = _resourceValue(map['longBadge']) ?? _badge; + _shortBadge = _resourceValue(map['shortBadge']); _avatarFrame = _resourceValue(map['avatarFrame']); _entryEffect = _resourceValue(map['entryEffect']); _chatBubble = _resourceValue(map['chatBubble']); _floatPicture = _resourceValue(map['floatPicture']); + _backgroundCard = _resourceValue(map['backgroundCard']); + _effectImage = _resourceValue(map['effectImage']); _canPurchase = _boolValue(map['canPurchase']); _payableGold = _numValue(map['payableGold']); } @@ -281,10 +323,14 @@ class SCVipLevelConfigRes { num? _durationDays; num? _priceGold; SCVipResourceRes? _badge; + SCVipResourceRes? _longBadge; + SCVipResourceRes? _shortBadge; SCVipResourceRes? _avatarFrame; SCVipResourceRes? _entryEffect; SCVipResourceRes? _chatBubble; SCVipResourceRes? _floatPicture; + SCVipResourceRes? _backgroundCard; + SCVipResourceRes? _effectImage; bool? _canPurchase; num? _payableGold; @@ -300,7 +346,11 @@ class SCVipLevelConfigRes { num? get priceGold => _priceGold; - SCVipResourceRes? get badge => _badge; + SCVipResourceRes? get badge => longBadge; + + SCVipResourceRes? get longBadge => _longBadge ?? _badge; + + SCVipResourceRes? get shortBadge => _shortBadge; SCVipResourceRes? get avatarFrame => _avatarFrame; @@ -310,6 +360,10 @@ class SCVipLevelConfigRes { SCVipResourceRes? get floatPicture => _floatPicture; + SCVipResourceRes? get backgroundCard => _backgroundCard; + + SCVipResourceRes? get effectImage => _effectImage; + bool? get canPurchase => _canPurchase; num? get payableGold => _payableGold; @@ -332,11 +386,17 @@ class SCVipLevelConfigRes { map['enabled'] = _enabled; map['durationDays'] = _durationDays; map['priceGold'] = _priceGold; - if (_badge != null) map['badge'] = _badge?.toJson(); + if (badge != null) map['badge'] = badge?.toJson(); + if (longBadge != null) map['longBadge'] = longBadge?.toJson(); + if (_shortBadge != null) map['shortBadge'] = _shortBadge?.toJson(); if (_avatarFrame != null) map['avatarFrame'] = _avatarFrame?.toJson(); if (_entryEffect != null) map['entryEffect'] = _entryEffect?.toJson(); if (_chatBubble != null) map['chatBubble'] = _chatBubble?.toJson(); if (_floatPicture != null) map['floatPicture'] = _floatPicture?.toJson(); + if (_backgroundCard != null) { + map['backgroundCard'] = _backgroundCard?.toJson(); + } + if (_effectImage != null) map['effectImage'] = _effectImage?.toJson(); map['canPurchase'] = _canPurchase; map['payableGold'] = _payableGold; return map; diff --git a/lib/shared/data_sources/models/enum/sc_props_type.dart b/lib/shared/data_sources/models/enum/sc_props_type.dart index 42975a2..adb7a97 100644 --- a/lib/shared/data_sources/models/enum/sc_props_type.dart +++ b/lib/shared/data_sources/models/enum/sc_props_type.dart @@ -1,13 +1,16 @@ -enum SCPropsType { - AVATAR_FRAME, - FLOAT_PICTURE, - DATA_CARD, - CHAT_BUBBLE, - RIDE, - LAYOUT, - THEME, - BADGE, - NOBLE_VIP, - FRAGMENTS, - RED_PACKET, -} +// ignore_for_file: constant_identifier_names + +enum SCPropsType { + AVATAR_FRAME, + FLOAT_PICTURE, + DATA_CARD, + CHAT_BUBBLE, + RIDE, + LAYOUT, + THEME, + BADGE, + NOBLE_VIP, + VIP_EFFECT_IMAGE, + FRAGMENTS, + RED_PACKET, +} 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 0a60271..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 @@ -23,6 +23,8 @@ class SCDataCardResourceView extends StatelessWidget { this.fit = BoxFit.cover, this.loop = true, this.fallback, + this.showCoverFallback = true, + this.allowCoverAsResource = true, }); final PropsResources? resource; @@ -33,6 +35,8 @@ class SCDataCardResourceView extends StatelessWidget { final BoxFit fit; final bool loop; final Widget? fallback; + final bool showCoverFallback; + final bool allowCoverAsResource; static bool isSupported(String? url) { return _isSvga(url) || @@ -50,15 +54,22 @@ class SCDataCardResourceView extends StatelessWidget { final resolvedHeight = _resolvedSize(height, constraints.maxHeight); final cover = _firstNonBlank([coverUrl, resource?.cover]); final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]); - final resourceUrl = source.isNotEmpty ? source : cover; + final resourceUrl = + source.isNotEmpty || !allowCoverAsResource ? source : cover; + final emptyFallback = SizedBox( + width: resolvedWidth, + height: resolvedHeight, + ); final fallbackView = fallback ?? - _buildImage( - cover, - width: resolvedWidth, - height: resolvedHeight, - fit: fit, - ); + (showCoverFallback + ? _buildImage( + cover, + width: resolvedWidth, + height: resolvedHeight, + fit: fit, + ) + : emptyFallback); if (resourceUrl.isEmpty) { return fallbackView; @@ -330,10 +341,12 @@ class _LoopingDataCardVapVideo extends StatefulWidget { class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { static const int _legacyRgbLeftAlphaRightMode = 3; + static const int _longRunningLoopCount = 999999; VapController? _controller; int _playToken = 0; bool _failed = false; + bool _started = false; @override void didUpdateWidget(covariant _LoopingDataCardVapVideo oldWidget) { @@ -343,8 +356,11 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { oldWidget.fit != widget.fit) { _playToken++; _stop(); - if (_failed) { - setState(() => _failed = false); + if (_failed || _started) { + setState(() { + _failed = false; + _started = false; + }); } _play(); } @@ -362,15 +378,32 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { _controller = controller; controller.setAnimListener( onFailed: (_, __, ___) => _markFailed(), + onVideoComplete: _handleVideoComplete, onVideoStart: () { - if (mounted && _failed) { - setState(() => _failed = false); + if (mounted && (_failed || !_started)) { + setState(() { + _failed = false; + _started = true; + }); } }, ); _play(); } + void _handleVideoComplete() { + if (!mounted || !widget.loop || _failed) { + return; + } + unawaited( + Future.delayed(Duration.zero, () { + if (mounted && widget.loop && !_failed) { + return _play(); + } + }), + ); + } + Future _play() async { final controller = _controller; final url = widget.url.trim(); @@ -390,7 +423,7 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { await controller.setMute(true); await controller.enableVersion1(true); await controller.setVideoMode(_legacyRgbLeftAlphaRightMode); - await controller.setLoop(widget.loop ? -1 : 0); + await controller.setLoop(widget.loop ? _longRunningLoopCount : 0); await controller.setScaleType(_scaleTypeForFit(widget.fit).key); if (!_isCurrent(token, controller)) { return; @@ -451,7 +484,10 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { if (!mounted || _failed) { return; } - setState(() => _failed = true); + setState(() { + _failed = true; + _started = false; + }); } ScaleType _scaleTypeForFit(BoxFit fit) { @@ -482,7 +518,7 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { if (!_failed) VapView( scaleType: _scaleTypeForFit(widget.fit), - repeat: widget.loop ? -1 : 0, + repeat: widget.loop ? _longRunningLoopCount : 0, mute: true, onViewCreated: _handleViewCreated, ), 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 e718b09..7381b96 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,19 +34,24 @@ 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); + if (level == null || level.rocketIconUrl.trim().isEmpty) { + return const SizedBox.shrink(); + } final percent = _entryPercent(status); final shouldShake = - (status?.shake ?? false) || - (level != null && percent > level.shakeThresholdPercent); + (status.shake ?? false) || percent > level.shakeThresholdPercent; return SCDebounceWidget( onTap: () => _showRocketDialog(context, status), child: _RoomRocketEntryButton( - iconUrl: level?.rocketIconUrl ?? '', - progressUrl: level?.progressBarUrl ?? '', + iconUrl: level.rocketIconUrl, + progressUrl: level.progressBarUrl, progressPercent: percent, shake: shouldShake, - loading: status == null, + loading: false, ), ); }, @@ -108,6 +113,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 +423,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 +448,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 +504,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 +561,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 +580,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 +626,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 +685,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 { @@ -801,60 +914,75 @@ class _RoomRocketRecordDialogLoaderState @override void initState() { super.initState(); - unawaited(_load(reset: true)); + unawaited(_loadAllRecords()); } - Future _load({bool reset = false}) async { - if (_loadingMore || (!reset && (!_hasMore || _loading))) { + Future _loadAllRecords() async { + if (_loadingMore) { return; } - final requestCursor = reset ? 1 : _cursor; setState(() { - if (reset) { - _loading = true; - } else { - _loadingMore = true; - } + _loading = true; + _loadingMore = false; + _hasMore = true; + _cursor = 1; + _rawRecords.clear(); + _records = null; }); + var requestCursor = 1; + final allRecords = []; try { - _rocketUiDebug( - 'request reward-records roomId=${widget.roomId} ' - 'cursor=$requestCursor limit=$_rocketPageSize', - ); - final res = await SCChatRoomRepository().roomRocketRewardRecords( - widget.roomId, - cursor: requestCursor, - limit: _rocketPageSize, - ); + while (mounted) { + _rocketUiDebug( + 'request reward-records roomId=${widget.roomId} ' + 'cursor=$requestCursor limit=$_rocketPageSize', + ); + final res = await SCChatRoomRepository().roomRocketRewardRecords( + widget.roomId, + cursor: requestCursor, + limit: _rocketPageSize, + ); + if (!mounted) { + return; + } + final nextRecords = res.records; + allRecords.addAll(nextRecords); + _rocketUiDebug( + 'reward-records loaded cursor=$requestCursor ' + 'count=${nextRecords.length} total=${allRecords.length}', + ); + if (nextRecords.length < _rocketPageSize) { + break; + } + requestCursor += 1; + } if (!mounted) { return; } - final nextRecords = res.records; setState(() { - if (reset) { - _rawRecords - ..clear() - ..addAll(nextRecords); - } else { - _rawRecords.addAll(nextRecords); - } + _rawRecords + ..clear() + ..addAll(allRecords); _records = RoomRocketApiMapper.recordItemsFromRecords(_rawRecords); - _hasMore = nextRecords.length >= _rocketPageSize; - if (_hasMore) { - _cursor = requestCursor + 1; - } + _hasMore = false; + _cursor = requestCursor; _loading = false; _loadingMore = false; }); _rocketUiDebug( - 'reward-records loaded cursor=$requestCursor ' - 'count=${nextRecords.length} total=${_rawRecords.length} ' - 'hasMore=$_hasMore nextCursor=$_cursor', + 'reward-records all loaded total=${_rawRecords.length} ' + 'lastCursor=$_cursor', ); } catch (error) { _rocketUiDebug('reward-records request failed: $error'); if (mounted) { setState(() { + if (allRecords.isNotEmpty) { + _rawRecords + ..clear() + ..addAll(allRecords); + _records = RoomRocketApiMapper.recordItemsFromRecords(_rawRecords); + } _loading = false; _loadingMore = false; _hasMore = false; @@ -871,7 +999,7 @@ class _RoomRocketRecordDialogLoaderState loadingText: _loading ? 'Loading data......' : '', isLoadingMore: _loadingMore, hasMore: _hasMore, - onLoadMore: () => unawaited(_load()), + onLoadMore: null, limitText: 'Show only records from the last 35 days', onClose: widget.onClose, ); diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart index 2bd5610..c527af1 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart @@ -11,17 +11,21 @@ import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; const Duration _pagMemoryTtl = Duration(minutes: 5); +const Size _roomRocketFallbackPagRawSize = Size(750, 1624); +const Rect _roomRocketTopAvatarRawRect = Rect.fromLTWH(291, 164, 168, 168); class RoomRocketPagEffectOverlay extends StatefulWidget { const RoomRocketPagEffectOverlay({ super.key, required this.resource, this.topAvatarUrl, + this.topAvatarUrlListenable, this.onCompleted, }); final String resource; final String? topAvatarUrl; + final ValueListenable? topAvatarUrlListenable; final VoidCallback? onCompleted; static bool isPag(String? resource) { @@ -50,6 +54,7 @@ class RoomRocketPagEffectOverlay extends StatefulWidget { class _RoomRocketPagEffectOverlayState extends State { bool _completed = false; + Size? _pagRawSize; @override Widget build(BuildContext context) { @@ -65,10 +70,7 @@ class _RoomRocketPagEffectOverlayState child: Stack( clipBehavior: Clip.none, children: [ - _RoomRocketPagTopAvatar( - avatarUrl: widget.topAvatarUrl, - canvasSize: size, - ), + _buildTopAvatar(size), Positioned.fill( child: _RoomRocketPagView( resource: resource, @@ -79,6 +81,7 @@ class _RoomRocketPagEffectOverlayState displayScale: 1, clipBehavior: Clip.hardEdge, memoryTtl: _pagMemoryTtl, + onReady: _updatePagRawSize, onCompleted: _complete, defaultBuilder: (_) => const Center(child: SCRotatingDotsLoading()), @@ -99,16 +102,48 @@ class _RoomRocketPagEffectOverlayState _completed = true; widget.onCompleted?.call(); } + + void _updatePagRawSize(Size rawSize) { + if (_pagRawSize == rawSize) { + return; + } + setState(() => _pagRawSize = rawSize); + } + + Widget _buildTopAvatar(Size canvasSize) { + final listenable = widget.topAvatarUrlListenable; + if (listenable == null) { + return _RoomRocketPagTopAvatar( + avatarUrl: widget.topAvatarUrl, + canvasSize: canvasSize, + pagRawSize: _pagRawSize, + ); + } + return ValueListenableBuilder( + valueListenable: listenable, + builder: + (context, avatarUrl, _) => _RoomRocketPagTopAvatar( + avatarUrl: _firstNonBlankPagAvatar([ + avatarUrl, + widget.topAvatarUrl, + ]), + canvasSize: canvasSize, + pagRawSize: _pagRawSize, + ), + ); + } } class _RoomRocketPagTopAvatar extends StatelessWidget { const _RoomRocketPagTopAvatar({ required this.avatarUrl, required this.canvasSize, + required this.pagRawSize, }); final String? avatarUrl; final Size canvasSize; + final Size? pagRawSize; @override Widget build(BuildContext context) { @@ -116,12 +151,13 @@ class _RoomRocketPagTopAvatar extends StatelessWidget { if (url.isEmpty) { return const SizedBox.shrink(); } - final size = (canvasSize.width * 0.15).clamp(46.0, 66.0).toDouble(); - return Positioned( - left: (canvasSize.width - size) / 2, - top: canvasSize.height * 0.098, - width: size, - height: size, + final rect = _mapRoomRocketPagRawRectToCoverCanvas( + rawRect: _roomRocketTopAvatarRawRect, + rawSize: pagRawSize ?? _roomRocketFallbackPagRawSize, + canvasSize: canvasSize, + ); + return Positioned.fromRect( + rect: rect, child: ClipOval( child: CustomCachedImage( imageUrl: url, @@ -200,6 +236,7 @@ class _RoomRocketPagView extends StatefulWidget { required this.clipBehavior, required this.memoryTtl, this.underlayBuilder, + this.onReady, this.onCompleted, this.defaultBuilder, }); @@ -213,6 +250,7 @@ class _RoomRocketPagView extends StatefulWidget { final Clip clipBehavior; final Duration memoryTtl; final WidgetBuilder? underlayBuilder; + final ValueChanged? onReady; final VoidCallback? onCompleted; final WidgetBuilder? defaultBuilder; @@ -414,6 +452,7 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { 'viewSize=${widget.width?.toStringAsFixed(1)}x' '${widget.height?.toStringAsFixed(1)} fit=${widget.fit.name}', ); + widget.onReady?.call(Size(rawWidth, rawHeight)); unawaited(_debugLayers(state, rawWidth, rawHeight)); if (!_initialized) { setState(() => _initialized = true); @@ -587,6 +626,55 @@ Timer? _schedulePagMemoryReleaseTimer( return Timer(memoryTtl, onRelease); } +@visibleForTesting +Rect debugMapRoomRocketPagRawRectToCoverCanvas({ + required Rect rawRect, + required Size rawSize, + required Size canvasSize, +}) { + return _mapRoomRocketPagRawRectToCoverCanvas( + rawRect: rawRect, + rawSize: rawSize, + canvasSize: canvasSize, + ); +} + +Rect _mapRoomRocketPagRawRectToCoverCanvas({ + required Rect rawRect, + required Size rawSize, + required Size canvasSize, +}) { + if (rawSize.width <= 0 || + rawSize.height <= 0 || + canvasSize.width <= 0 || + canvasSize.height <= 0) { + return Rect.zero; + } + final scaleX = canvasSize.width / rawSize.width; + final scaleY = canvasSize.height / rawSize.height; + final scale = scaleX > scaleY ? scaleX : scaleY; + final fittedWidth = rawSize.width * scale; + final fittedHeight = rawSize.height * scale; + final offsetX = (canvasSize.width - fittedWidth) / 2; + final offsetY = (canvasSize.height - fittedHeight) / 2; + return Rect.fromLTWH( + offsetX + rawRect.left * scale, + offsetY + rawRect.top * scale, + rawRect.width * scale, + rawRect.height * scale, + ); +} + +String? _firstNonBlankPagAvatar(Iterable values) { + for (final value in values) { + final text = value?.trim() ?? ''; + if (text.isNotEmpty) { + return text; + } + } + return null; +} + @visibleForTesting String debugNormalizeRoomRocketPagResource(String resource) { return _normalizePagResource(resource); diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart index 1e67126..d0bfe6f 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart @@ -35,7 +35,7 @@ class RoomRocketRewardDialog extends StatelessWidget { final emptyText = _translate( localizations, 'roomRocketRewardEmptyText', - 'No rewards earned. Better luck next time.', + 'No reward', ); final contentDirection = Directionality.of(context); final resolvedRewards = _normalizeRewards( @@ -257,7 +257,7 @@ class _RewardDialogCanvas extends StatelessWidget { if (isEmpty) _buildEmptyState() else ...[ - _buildRewardGrid(), + rewards.isEmpty ? _buildRewardEmptyState() : _buildRewardGrid(), _buildWinnerList(), ], _buildLoadingText(), @@ -380,9 +380,6 @@ class _RewardDialogCanvas extends StatelessWidget { } Widget _buildRewardGrid() { - if (rewards.isEmpty) { - return const SizedBox.shrink(); - } final columns = math.max(3, (rewards.length / 2).ceil()); return Positioned( left: s(28), @@ -425,6 +422,45 @@ class _RewardDialogCanvas extends StatelessWidget { ); } + Widget _buildRewardEmptyState() { + return Positioned( + left: s(0), + top: s(268), + width: s(_designWidth), + height: s(178), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + _RewardDialogAssets.empty, + width: s(72), + height: s(72), + fit: BoxFit.contain, + ), + SizedBox(height: s(10)), + SizedBox( + width: s(250), + child: Text( + emptyText, + textDirection: contentDirection, + textScaler: TextScaler.noScaling, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: s(13), + height: 1.25, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ); + } + Widget _buildWinnerList() { return Positioned( left: s(12), 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 b245a78..e2b05ca 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -49,6 +49,10 @@ class RoomUserInfoCard extends StatefulWidget { class _RoomUserInfoCardState extends State { static const String _leaveMicActionIconAsset = "sc_images/room/sc_icon_room_user_card_leave_mic.png"; + static const String _defaultProfileCardBackgroundAsset = + "sc_images/room/sc_bg_room_user_profile_card_default.png"; + static const double _profileSheetHeightFactor = 0.4; + static const double _dataCardBackgroundTranslateFactor = 0.8; SocialChatUserProfileManager? userProvider; String roomId = ""; @@ -85,7 +89,8 @@ class _RoomUserInfoCardState extends State { @override Widget build(BuildContext context) { - final sheetHeight = MediaQuery.of(context).size.height * 0.6; + final screenHeight = MediaQuery.of(context).size.height; + final defaultSheetHeight = screenHeight * _profileSheetHeightFactor; return Consumer( builder: (context, ref, child) { @@ -100,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, @@ -123,19 +132,21 @@ class _RoomUserInfoCardState extends State { height: sheetHeight, child: ClipRRect( borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), - child: DecoratedBox( - decoration: _sheetDecoration(), - child: Center( - child: Container( - width: 55.w, - height: 55.w, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(8.w), + child: Stack( + children: [ + Positioned.fill(child: _buildDefaultSheetBackground()), + Center( + child: Container( + width: 55.w, + height: 55.w, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8.w), + ), + child: CupertinoActivityIndicator(color: Colors.white24), ), - child: CupertinoActivityIndicator(color: Colors.white24), ), - ), + ], ), ), ); @@ -146,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, @@ -153,138 +166,215 @@ 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 && ref.userCardInfo?.roomRole != SCRoomRolesType.HOMEOWNER.name && (rtcProvider.isFz() || rtcProvider.isGL()); + final profileContent = _buildProfileContentLayer( + context: context, + ref: ref, + rtcProvider: rtcProvider, + profile: profile, + bottomPadding: bottomPadding, + isSelf: isSelf, + canLeaveMic: canLeaveMic, + currentMicIndex: currentMicIndex, + canShowReport: canShowReport, + canShowSetting: canShowSetting, + ); + + if (dataCard == null) { + return SizedBox( + width: double.infinity, + height: sheetHeight, + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: Stack( + children: [ + Positioned.fill(child: _buildDefaultSheetBackground()), + Positioned.fill(child: profileContent), + ], + ), + ), + ); + } + + return _buildDataCardFrame( + sheetHeight: sheetHeight, + backgroundHeight: dataCardBackgroundHeight, + dataCard: dataCard, + child: profileContent, + ); + } + + Widget _buildDataCardFrame({ + required double sheetHeight, + required double backgroundHeight, + required PropsResources dataCard, + required Widget child, + }) { return SizedBox( width: double.infinity, height: sheetHeight, - child: ClipRRect( - borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), - child: Stack( - children: [ - Positioned.fill(child: _buildSheetBackground(dataCard)), - Positioned.fill( - child: SingleChildScrollView( - physics: const ClampingScrollPhysics(), - padding: EdgeInsets.fromLTRB( - 18.w, - 16.w, - 18.w, - bottomPadding + 10.w, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildProfileHeader(context, profile, ref), - SizedBox(height: 6.w), - _buildInfoChips(context, profile), - SizedBox(height: 9.w), - _buildBadgeStrip( - profile?.id ?? widget.userId, - ref, - profile, - ), - SizedBox(height: 10.w), - _buildActions( - context: context, - ref: ref, - rtcProvider: rtcProvider, - isSelf: isSelf, - canLeaveMic: canLeaveMic, - currentMicIndex: currentMicIndex, - ), - ], - ), - ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: DecoratedBox(decoration: _sheetDecoration()), ), - if (canShowReport) - Positioned( - top: 14.w, - right: 16.w, - child: _buildHeaderIconButton( - iconAsset: "sc_images/room/sc_icon_user_card_report.png", - onTap: () { - final route = - "${SCMainRoute.report}?type=user&tageId=${widget.userId}"; - Navigator.of(context).pop(); - SCNavigatorUtils.push( - navigatorKey.currentState?.context ?? context, - route, - replace: false, - ); - }, - ), - ), - if (canShowSetting) - Positioned( - top: 14.w, - right: 52.w, - child: _buildHeaderIconButton( - iconAsset: - "sc_images/room/sc_icon_room_user_card_setting.png", - onTap: () { - if (userProvider?.userCardInfo == null) { - return; - } - Navigator.of(context).pop(); - showBottomInBottomDialog( - navigatorKey.currentState?.context ?? context, - RoomUserCardSetting( - roomId: roomId, - userCardInfo: userProvider?.userCardInfo?.copyWith(), - ), - ); - }, - ), - ), - ], - ), + ), + Positioned( + left: 0, + right: 0, + top: + -(backgroundHeight - sheetHeight) + + sheetHeight * _dataCardBackgroundTranslateFactor, + height: backgroundHeight, + child: IgnorePointer(child: _buildSheetBackground(dataCard)), + ), + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: child, + ), + ), + ], ), ); } + Widget _buildProfileContentLayer({ + required BuildContext context, + required SocialChatUserProfileManager ref, + required RtcProvider rtcProvider, + required SocialChatUserProfile? profile, + required double bottomPadding, + required bool isSelf, + required bool canLeaveMic, + required num currentMicIndex, + required bool canShowReport, + required bool canShowSetting, + }) { + return Stack( + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + return Align( + alignment: Alignment.topCenter, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: SizedBox( + width: constraints.maxWidth, + child: Padding( + padding: EdgeInsets.fromLTRB( + 18.w, + 10.w, + 18.w, + bottomPadding > 6.w ? bottomPadding : 6.w, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildProfileHeader(context, profile, ref), + SizedBox(height: 6.w), + _buildBadgeStrip( + profile?.id ?? widget.userId, + ref, + profile, + ), + SizedBox(height: 8.w), + _buildActions( + context: context, + ref: ref, + rtcProvider: rtcProvider, + isSelf: isSelf, + canLeaveMic: canLeaveMic, + currentMicIndex: currentMicIndex, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + if (canShowReport) + Positioned( + top: 14.w, + right: 16.w, + child: _buildHeaderIconButton( + iconAsset: "sc_images/room/sc_icon_user_card_report.png", + onTap: () { + final route = + "${SCMainRoute.report}?type=user&tageId=${widget.userId}"; + Navigator.of(context).pop(); + SCNavigatorUtils.push( + navigatorKey.currentState?.context ?? context, + route, + replace: false, + ); + }, + ), + ), + if (canShowSetting) + Positioned( + top: 14.w, + right: 52.w, + child: _buildHeaderIconButton( + iconAsset: "sc_images/room/sc_icon_room_user_card_setting.png", + onTap: () { + if (userProvider?.userCardInfo == null) { + return; + } + Navigator.of(context).pop(); + showBottomInBottomDialog( + navigatorKey.currentState?.context ?? context, + RoomUserCardSetting( + roomId: roomId, + userCardInfo: userProvider?.userCardInfo?.copyWith(), + ), + ); + }, + ), + ), + ], + ); + } + BoxDecoration _sheetDecoration() { - return const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xff18F2B1), - Color(0xffE4FFF6), - Color(0xffF7FFFC), - Color(0xffFFFFFF), - ], + return const BoxDecoration(color: Color(0xff061F1E)); + } + + Widget _buildDefaultSheetBackground() { + return DecoratedBox( + decoration: _sheetDecoration(), + child: SizedBox.expand( + child: Image.asset( + _defaultProfileCardBackgroundAsset, + fit: BoxFit.cover, + ), ), ); } Widget _buildSheetBackground(PropsResources? dataCard) { if (dataCard == null) { - return DecoratedBox(decoration: _sheetDecoration()); + return _buildDefaultSheetBackground(); } - return Stack( - fit: StackFit.expand, - children: [ - SCDataCardResourceView(resource: dataCard, fit: BoxFit.cover), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.white.withValues(alpha: 0.08), - Colors.white.withValues(alpha: 0.22), - Colors.white.withValues(alpha: 0.58), - ], - ), - ), - ), - ], + + return SCDataCardResourceView( + resource: dataCard, + fit: BoxFit.cover, + showCoverFallback: false, + allowCoverAsResource: false, ); } @@ -325,8 +415,8 @@ class _RoomUserInfoCardState extends State { onTap: () => _openProfile(context), child: head( url: profile?.userAvatar ?? "", - width: 70.w, - border: Border.all(color: Colors.white, width: 2), + width: 105.w, + border: Border.all(color: Colors.white, width: 3), headdress: profile?.getHeaddress()?.sourceUrl, headdressCover: profile?.getHeaddress()?.cover, ), @@ -347,7 +437,7 @@ class _RoomUserInfoCardState extends State { maxWidth: 116.w, profile?.userNickname ?? "", fontSize: 18.sp, - textColor: Colors.black, + textColor: Colors.white, fontWeight: FontWeight.bold, type: "", needScroll: (profile?.userNickname?.characters.length ?? 0) > 12, @@ -381,6 +471,22 @@ class _RoomUserInfoCardState extends State { ], ), SizedBox(height: 2.w), + _buildSpecialIdMetaRow(context, profile), + ], + ); + } + + Widget _buildSpecialIdMetaRow( + BuildContext context, + SocialChatUserProfile? profile, + ) { + final flagUrl = _countryFlagUrl(context, profile); + final hasSex = profile?.userSex != null; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ SCSpecialIdBadge( idText: profile?.getID() ?? "", showAnimated: profile?.hasSpecialId() ?? false, @@ -392,106 +498,62 @@ class _RoomUserInfoCardState extends State { showAnimatedGradientText: profile?.shouldShowColoredSpecialIdText() ?? false, animationTextStyle: TextStyle( - color: Colors.black, + color: Colors.white, fontSize: 12.sp, fontWeight: FontWeight.bold, ), normalTextStyle: TextStyle( - color: Colors.black, + color: Colors.white, fontSize: 12.sp, fontWeight: FontWeight.bold, ), animationFit: BoxFit.contain, ), + if (flagUrl.isNotEmpty) ...[ + SizedBox(width: 8.w), + _buildCountryFlag(flagUrl), + ], + if (hasSex) ...[SizedBox(width: 7.w), _buildSexIcon(profile)], ], ); } - Widget _buildInfoChips(BuildContext context, SocialChatUserProfile? profile) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildCountryChip(context, profile), - SizedBox(width: 7.w), - _buildSexAgeChip(profile), - ], - ); - } - - Widget _buildCountryChip( - BuildContext context, - SocialChatUserProfile? profile, - ) { + String _countryFlagUrl(BuildContext context, SocialChatUserProfile? profile) { final countryName = profile?.countryName ?? ""; - final flagUrl = - Provider.of( - context, - listen: false, - ).findCountryByName(countryName)?.nationalFlag ?? - ""; + try { + return Provider.of( + context, + listen: false, + ).findCountryByName(countryName)?.nationalFlag ?? + ""; + } catch (_) { + return ""; + } + } - return Container( - constraints: BoxConstraints(maxWidth: 145.w), - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(16.w), - border: Border.all( - color: const Color(0xff18F2B1).withValues(alpha: 0.18), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - netImage( - url: flagUrl, - borderRadius: BorderRadius.all(Radius.circular(3.w)), - width: 19.w, - height: 14.w, - ), - SizedBox(width: 5.w), - Flexible( - child: text( - countryName, - maxLines: 1, - textColor: Colors.black, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ), - ], + Widget _buildCountryFlag(String flagUrl) { + final trimmedUrl = flagUrl.trim(); + if (trimmedUrl.isEmpty) { + return const SizedBox.shrink(); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(2.w), + child: netImage( + url: trimmedUrl, + width: 22.w, + height: 15.w, + fit: BoxFit.cover, + noDefaultImg: true, ), ); } - Widget _buildSexAgeChip(SocialChatUserProfile? profile) { - return Container( - height: 26.w, - padding: EdgeInsets.symmetric(horizontal: 10.w), - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - profile?.userSex == 0 - ? "sc_images/login/sc_icon_sex_woman_bg.png" - : "sc_images/login/sc_icon_sex_man_bg.png", - ), - fit: BoxFit.fill, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - xb(profile?.userSex), - SizedBox(width: 3.w), - text( - "${profile?.age ?? 0}", - textColor: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ], - ), - ); + Widget _buildSexIcon(SocialChatUserProfile? profile) { + if (profile?.userSex == null) { + return const SizedBox.shrink(); + } + return xb(profile?.userSex, height: 15.w, color: null); } Widget _buildHeaderLongBadgeStrip( @@ -547,7 +609,7 @@ class _RoomUserInfoCardState extends State { return wearBadges; } return ref.userCardInfo?.useBadge - ?.map(_roomUseBadgeToWearBadge) + ?.map(_roomUseBadgeToWearBadge) .where(_hasBadgeUrl) .toList() ?? const []; @@ -662,7 +724,7 @@ class _RoomUserInfoCardState extends State { label, maxLines: 2, textAlign: TextAlign.center, - textColor: const Color(0xff666666), + textColor: Colors.white, fontWeight: FontWeight.w600, fontSize: 12.sp, lineHeight: 1.1, @@ -717,7 +779,7 @@ class _RoomUserInfoCardState extends State { : SCAppLocalizations.of(context)!.follow, maxLines: 2, textAlign: TextAlign.center, - textColor: const Color(0xff666666), + textColor: Colors.white, fontWeight: FontWeight.w600, fontSize: 12.sp, lineHeight: 1.1, diff --git a/lib/ui_kit/widgets/store/props_store_chatbox_detail_dialog.dart b/lib/ui_kit/widgets/store/props_store_chatbox_detail_dialog.dart index 62f2632..f754f5f 100644 --- a/lib/ui_kit/widgets/store/props_store_chatbox_detail_dialog.dart +++ b/lib/ui_kit/widgets/store/props_store_chatbox_detail_dialog.dart @@ -1,5 +1,3 @@ -import 'package:extended_image/extended_image.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -70,11 +68,6 @@ class _PropsStoreChatboxDetailDialogState mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 15.w), - text( - widget.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 12.sp, - ), Spacer(), Image.asset( "sc_images/general/sc_icon_jb.png", diff --git a/lib/ui_kit/widgets/store/props_store_data_card_detail_dialog.dart b/lib/ui_kit/widgets/store/props_store_data_card_detail_dialog.dart index 913866f..d099466 100644 --- a/lib/ui_kit/widgets/store/props_store_data_card_detail_dialog.dart +++ b/lib/ui_kit/widgets/store/props_store_data_card_detail_dialog.dart @@ -74,14 +74,7 @@ class _PropsStoreDataCardDetailDialogState mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 15.w), - Expanded( - child: text( - widget.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 12.sp, - maxLines: 1, - ), - ), + const Spacer(), Image.asset( "sc_images/general/sc_icon_jb.png", width: 22.w, diff --git a/lib/ui_kit/widgets/store/props_store_headdress_detail_dialog.dart b/lib/ui_kit/widgets/store/props_store_headdress_detail_dialog.dart index 3bf4b13..27b354e 100644 --- a/lib/ui_kit/widgets/store/props_store_headdress_detail_dialog.dart +++ b/lib/ui_kit/widgets/store/props_store_headdress_detail_dialog.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -77,11 +76,6 @@ class _PropsStoreHeaddressDetailDialogState mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 15.w), - text( - widget.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 12.sp, - ), Spacer(), Image.asset( "sc_images/general/sc_icon_jb.png", diff --git a/lib/ui_kit/widgets/store/props_store_mountains_detail_dialog.dart b/lib/ui_kit/widgets/store/props_store_mountains_detail_dialog.dart index 3dc2752..273415c 100644 --- a/lib/ui_kit/widgets/store/props_store_mountains_detail_dialog.dart +++ b/lib/ui_kit/widgets/store/props_store_mountains_detail_dialog.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -16,7 +15,6 @@ import '../../../app/constants/sc_global_config.dart'; import '../../../app/routes/sc_fluro_navigator.dart'; import '../../../shared/data_sources/models/enum/sc_currency_type.dart'; import '../../../shared/data_sources/models/enum/sc_props_type.dart'; -import '../../../shared/data_sources/sources/local/user_manager.dart'; import '../../../modules/wallet/wallet_route.dart'; import '../../../main.dart'; import '../../components/sc_debounce_widget.dart'; @@ -76,11 +74,6 @@ class _PropsStoreMountainsDetailDialogState mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 15.w), - text( - widget.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 12.sp, - ), Spacer(), Image.asset( "sc_images/general/sc_icon_jb.png", diff --git a/lib/ui_kit/widgets/store/props_store_theme_detail_dialog.dart b/lib/ui_kit/widgets/store/props_store_theme_detail_dialog.dart index f887afc..211a05f 100644 --- a/lib/ui_kit/widgets/store/props_store_theme_detail_dialog.dart +++ b/lib/ui_kit/widgets/store/props_store_theme_detail_dialog.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -76,11 +75,6 @@ class _PropsStoreThemeDetailDialogState mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 15.w), - text( - widget.res.propsResources?.name ?? "", - textColor: Colors.white, - fontSize: 12.sp, - ), Spacer(), Image.asset( "sc_images/general/sc_icon_jb.png", 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 new file mode 100644 index 0000000..5d3fc20 Binary files /dev/null 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_pag_resource_test.dart b/test/room_rocket_pag_resource_test.dart index 5c2802a..c1c4a3f 100644 --- a/test/room_rocket_pag_resource_test.dart +++ b/test/room_rocket_pag_resource_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; import 'package:yumi/app/config/app_config.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart'; @@ -60,6 +61,19 @@ void main() { timer?.cancel(); }); + test('maps top avatar frame from pag raw canvas using cover fit', () { + final rect = debugMapRoomRocketPagRawRectToCoverCanvas( + rawRect: const Rect.fromLTWH(291, 164, 168, 168), + rawSize: const Size(750, 1624), + canvasSize: const Size(392.7272727, 829.090909), + ); + + expect(rect.left, closeTo(152.38, 0.01)); + expect(rect.top, closeTo(75.23, 0.01)); + expect(rect.width, closeTo(87.97, 0.01)); + expect(rect.height, closeTo(87.97, 0.01)); + }); + test('extracts launch pag urls from nested resource payloads', () { final launch = RoomRocketLaunchBroadcastMessage.fromJson({ 'rocketAnimationResource': { diff --git a/test/room_rocket_reward_dialog_loader_test.dart b/test/room_rocket_reward_dialog_loader_test.dart index 3fb48f8..bda3aa8 100644 --- a/test/room_rocket_reward_dialog_loader_test.dart +++ b/test/room_rocket_reward_dialog_loader_test.dart @@ -1,5 +1,8 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_dialog.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart'; void main() { @@ -27,6 +30,30 @@ void main() { expect(records.map((item) => item.rewardAmount), [5000, 5200]); }); }); + + group('room rocket reward dialog', () { + testWidgets('shows reward empty state when room winners exist', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: RoomRocketRewardDialog( + rewards: [], + winners: [ + RoomRocketRewardWinner(nickname: 'test man'), + ], + loadingText: '', + useMockFallback: false, + ), + ), + ), + ); + + expect(find.text('No reward'), findsOneWidget); + expect(find.text('test man'), findsOneWidget); + }); + }); } SCRoomRocketRewardRecordRes _rewardRecord({ diff --git a/test/room_rocket_status_test.dart b/test/room_rocket_status_test.dart index a336342..f3fd92d 100644 --- a/test/room_rocket_status_test.dart +++ b/test/room_rocket_status_test.dart @@ -122,6 +122,37 @@ 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('treats unconfigured or disabled-status rocket as unavailable', () { + final unconfigured = SCRoomRocketStatusRes.fromJson({ + 'configured': false, + 'enabled': true, + 'status': 'ENABLED', + }); + final disabledStatus = SCRoomRocketStatusRes.fromJson({ + 'configured': true, + 'status': 'DISABLED', + }); + + expect(unconfigured.isEnabled, isFalse); + expect(disabledStatus.isEnabled, isFalse); + }); + test( 'uses top-level reward preview for the response level instead of current level', () {