完善功能

This commit is contained in:
zhx 2026-05-16 06:48:47 +08:00
parent 8be5c4c334
commit 8bf3d679a7
26 changed files with 958 additions and 186 deletions

View File

@ -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": "منتهي",

View File

@ -267,7 +267,7 @@
"aboutUs": "আমাদের সম্পর্কে",
"theme": "থিম",
"wealthLevel": "সম্পদ স্তর",
"userLevel": "ব্যবহারকারী স্তর",
"userLevel": "আকর্ষণ স্তর",
"goToUpload": "আপলোড করতে যান",
"logout": "লগআউট",
"luck": "ভাগ্য",
@ -600,6 +600,8 @@
"toConsume": "খরচ করার জন্য",
"howToUpgrade": "কীভাবে উন্নত করব?",
"spendCoinsToGainExperiencePoints": "অভিজ্ঞতা পয়েন্ট পাওয়ার জন্য কয়েন খরচ করুন",
"richLevelUpgradeAnswer": "তোমার সম্পদ সবকিছু জয় করুক",
"charmLevelUpgradeAnswer": "অন্যদের তোমার আকর্ষণের কাছে নত করো",
"higherLevelFancierAvatarFrame": "উচ্চতর স্তর, আরও সুন্দর ব্যাজ/প্রোফাইল ফ্রেম",
"all": "সব",
"gift": "গিফট",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

@ -110,18 +110,26 @@ class _UserLevelPageState extends State<UserLevelPage> {
],
),
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<UserLevelPage> {
],
),
),
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<UserLevelPage> {
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<UserLevelPage> {
: 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");

View File

@ -110,18 +110,26 @@ class _WealthLevelPageState extends State<WealthLevelPage> {
],
),
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<WealthLevelPage> {
],
),
),
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<WealthLevelPage> {
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<WealthLevelPage> {
: 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");

View File

@ -2826,19 +2826,19 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void _loadRoomSecondaryData() {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
SCChatRoomRepository().rocketClaim(roomId).catchError((e) {
return true; //
});
SCChatRoomRepository()
.rocketStatus(roomId)
.then((res) {
roomRocketStatus = res;
notifyListeners();
if (!res.isEnabled) {
return;
}
_scheduleRoomRocketAssetPreload(roomId, res, afterRoomEntry: true);
_scheduleRoomRocketEntryReadyFallback(roomId);
_loadRoomRocketRewardPopups(roomId);
})
.catchError((e) {});
_loadRoomRocketRewardPopups(roomId);
_refreshRoomRedPacketListAfterEntry();
fetchRoomTaskClaimableCount();
@ -2894,6 +2894,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void updateRoomRocketConfigurationStatus(SCRoomRocketStatusRes res) {
roomRocketStatus = res;
notifyListeners();
if (!res.isEnabled) {
return;
}
_scheduleRoomRocketAssetPreload(
(res.roomId ?? currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(),
res,
@ -2914,6 +2917,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
roomRocketStatus = res;
notifyListeners();
if (!res.isEnabled) {
return;
}
_scheduleRoomRocketAssetPreload(resolvedRoomId, res);
}
@ -2926,6 +2932,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (resolvedRoomId.isEmpty) {
return;
}
if (!status.isEnabled) {
return;
}
if (afterRoomEntry) {
RoomRocketAssetPreloader.preloadAfterRoomEntry(
roomId: resolvedRoomId,

View File

@ -4,6 +4,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_debouncer/flutter_debouncer.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
@ -143,6 +144,9 @@ class RealTimeMessagingManager extends ChangeNotifier {
Duration(seconds: 2),
Duration(seconds: 5),
];
static const Duration _roomRocketRewardAfterLaunchDelay = Duration(
milliseconds: 350,
);
static const List<Duration> _roomRocketRoomReadyRewardRetryDelays = [
Duration(milliseconds: 300),
Duration(milliseconds: 900),
@ -272,6 +276,8 @@ class RealTimeMessagingManager extends ChangeNotifier {
final Map<String, Timer> _roomRocketLaunchEffectTimers = <String, Timer>{};
final Map<String, int> _consumedRoomRocketLaunchEffectTimes = <String, int>{};
final Set<String> _hiddenCurrentRoomRocketLaunchKeys = <String>{};
final Map<String, int> _roomRocketRewardHoldUntilTimes = <String, int>{};
final Map<String, Timer> _roomRocketRewardHoldTimers = <String, Timer>{};
Timer? _roomRocketRewardPopupRetryTimer;
Timer? _roomRocketRoomReadyRewardRetryTimer;
int get currentLuckGiftBurstPlaybackToken => _luckGiftPushPlaybackToken;
@ -1162,6 +1168,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
case SCRoomMsgType.roomRedPacketClaim:
return true;
case SCRoomMsgType.rocketRewardUser:
case SCRoomMsgType.voiceRoomRocketRewardUser:
return true;
case SCRoomMsgType.rocketLaunchNotice:
return true;
@ -1195,7 +1202,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
_hasRoomRedPacketClaimMessage(msg)) {
return;
}
if (msg.type == SCRoomMsgType.rocketRewardUser &&
if (SCRoomMsgType.isRocketRewardUserType(msg.type) &&
_hasRoomRocketRewardMessage(msg)) {
return;
}
@ -1235,7 +1242,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomChatMsgList.removeAt(roomChatMsgList.length - 1);
}
msgChatListener?.call(msg);
} else if (msg.type == SCRoomMsgType.rocketRewardUser) {
} else if (SCRoomMsgType.isRocketRewardUserType(msg.type)) {
roomChatMsgList.insert(0, msg);
if (roomChatMsgList.length > 250) {
roomChatMsgList.removeAt(roomChatMsgList.length - 1);
@ -1388,7 +1395,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
return roomAllMsgList.any(
(item) =>
item.type == SCRoomMsgType.rocketRewardUser &&
SCRoomMsgType.isRocketRewardUserType(item.type) &&
_roomRocketRewardMessageKey(item) == key,
);
}
@ -2091,6 +2098,9 @@ class RealTimeMessagingManager extends ChangeNotifier {
void _handleVoiceRoomRocketRewardPopup(dynamic payload) {
final data = _broadcastPayloadMap(payload);
if (!_isCurrentUserInRoomRocketRewardPopup(data)) {
return;
}
final roomId = _payloadRoomId(data);
final currentRoomId = _currentVoiceRoomId();
final resolvedRoomId = roomId.isEmpty ? currentRoomId : roomId;
@ -2119,6 +2129,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
);
return;
}
if (_isRoomRocketRewardPopupHeld(resolvedRoomId)) {
_scheduleRoomRocketRewardPopupAfterHold(
resolvedRoomId,
initialRoomRecords: initialRoomRecords,
);
return;
}
_pendingRoomRocketRewardPopups.remove(resolvedRoomId);
_recentRoomRocketRewardProbeTimes.remove(resolvedRoomId);
@ -2130,6 +2147,87 @@ class RealTimeMessagingManager extends ChangeNotifier {
);
}
bool _isCurrentUserInRoomRocketRewardPopup(Map<String, dynamic> payload) {
final userIds = _roomRocketRewardPopupUserIds(payload);
if (userIds == null) {
return true;
}
if (userIds.isEmpty) {
return false;
}
final profile = AccountStorage().getCurrentUser()?.userProfile;
final currentKeys = <String>{
profile?.id?.trim() ?? '',
profile?.account?.trim() ?? '',
}..removeWhere((item) => item.isEmpty);
if (currentKeys.isEmpty) {
return false;
}
return currentKeys.any(userIds.contains);
}
Set<String>? _roomRocketRewardPopupUserIds(Map<String, dynamic> 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<String> _payloadStringSet(dynamic value) {
if (value == null) {
return <String>{};
}
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 <String>{};
}
if (raw.startsWith('[') || raw.startsWith('{')) {
try {
return _payloadStringSet(jsonDecode(raw));
} catch (_) {
return <String>{};
}
}
return raw
.split(',')
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toSet();
}
void handleVoiceRoomReadyForRocketRewards(String roomId, {int attempt = 0}) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
@ -2166,6 +2264,15 @@ class RealTimeMessagingManager extends ChangeNotifier {
_recentRoomRocketRewardProbeTimes.remove(normalizedRoomId);
_roomRocketRoomReadyRewardRetryTimer?.cancel();
_roomRocketRoomReadyRewardRetryTimer = null;
if (_isRoomRocketRewardPopupHeld(normalizedRoomId)) {
_scheduleRoomRocketRewardPopupAfterHold(
normalizedRoomId,
initialRoomRecords:
pending?.initialRoomRecords ??
const <SCRoomRocketRewardRecordRes>[],
);
return;
}
unawaited(
_showRoomRocketRewardPopupDialog(
normalizedRoomId,
@ -2225,6 +2332,113 @@ class RealTimeMessagingManager extends ChangeNotifier {
_recentRoomRocketRewardProbeTimes[normalizedRoomId] = now;
}
void _holdRoomRocketRewardPopupUntilLaunchEnds(
RoomRocketLaunchBroadcastMessage launch,
) {
if (!launch.isValid) {
return;
}
final normalizedRoomId = launch.roomId.trim();
if (normalizedRoomId.isEmpty) {
return;
}
final now = DateTime.now().millisecondsSinceEpoch;
final holdUntil =
now +
launch.displayDuration.inMilliseconds +
_roomRocketRewardAfterLaunchDelay.inMilliseconds;
final existing = _roomRocketRewardHoldUntilTimes[normalizedRoomId] ?? 0;
if (holdUntil > existing) {
_roomRocketRewardHoldUntilTimes[normalizedRoomId] = holdUntil;
}
}
bool _isRoomRocketRewardPopupHeld(String roomId) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
return false;
}
final now = DateTime.now().millisecondsSinceEpoch;
final holdUntil = _roomRocketRewardHoldUntilTimes[normalizedRoomId] ?? 0;
if (holdUntil > now) {
return true;
}
if (holdUntil > 0) {
_roomRocketRewardHoldUntilTimes.remove(normalizedRoomId);
}
return _pendingRoomRocketCountdownLaunches.containsKey(normalizedRoomId);
}
int _roomRocketRewardHoldUntil(String roomId) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
return 0;
}
final now = DateTime.now().millisecondsSinceEpoch;
var holdUntil = _roomRocketRewardHoldUntilTimes[normalizedRoomId] ?? 0;
final launch = _pendingRoomRocketCountdownLaunches[normalizedRoomId];
if (launch != null) {
final key = _roomRocketLaunchCountdownKeysByRoom[normalizedRoomId];
final launchDueAt =
key == null ? now : _roomRocketLaunchCountdownDueTimes[key] ?? now;
final countdownHoldUntil =
launchDueAt +
launch.displayDuration.inMilliseconds +
_roomRocketRewardAfterLaunchDelay.inMilliseconds;
if (countdownHoldUntil > holdUntil) {
holdUntil = countdownHoldUntil;
}
}
return holdUntil;
}
void _scheduleRoomRocketRewardPopupAfterHold(
String roomId, {
List<SCRoomRocketRewardRecordRes> initialRoomRecords =
const <SCRoomRocketRewardRecordRes>[],
}) {
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 <SCRoomRocketRewardRecordRes>[],
),
);
},
);
_roomRocketRewardDebug(
'defer popup roomId=$normalizedRoomId delayMs=$delayMs '
'initialRoomRecords=${initialRoomRecords.length}',
);
}
void _pruneRoomRocketRewardFallbacks(int now) {
_pendingRoomRocketRewardPopups.removeWhere(
(_, pending) => now - pending.receivedAt > _roomRocketRewardPendingTtlMs,
@ -2241,6 +2455,11 @@ class RealTimeMessagingManager extends ChangeNotifier {
_hiddenCurrentRoomRocketLaunchKeys.clear();
_roomRocketRoomReadyRewardRetryTimer?.cancel();
_roomRocketRoomReadyRewardRetryTimer = null;
for (final timer in _roomRocketRewardHoldTimers.values) {
timer.cancel();
}
_roomRocketRewardHoldTimers.clear();
_roomRocketRewardHoldUntilTimes.clear();
_clearRoomRocketLaunchCountdownState();
}
@ -2322,15 +2541,14 @@ class RealTimeMessagingManager extends ChangeNotifier {
final pending = _pendingRoomRocketRewardPopups.remove(launch.roomId);
_recentRoomRocketRewardProbeTimes.remove(launch.roomId);
_markRoomRocketLaunchEffectConsumed(launch);
_holdRoomRocketRewardPopupUntilLaunchEnds(launch);
rtcProvider.handleRoomRocketLaunchBroadcast(launch);
if (shouldFetchReward) {
unawaited(
_showRoomRocketRewardPopupDialog(
launch.roomId,
initialRoomRecords:
pending?.initialRoomRecords ??
const <SCRoomRocketRewardRecordRes>[],
),
_scheduleRoomRocketRewardPopupAfterHold(
launch.roomId,
initialRoomRecords:
pending?.initialRoomRecords ??
const <SCRoomRocketRewardRecordRes>[],
);
}
}
@ -2616,6 +2834,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
if (!_isCurrentVisibleVoiceRoom(roomId)) {
return;
}
if (_isRoomRocketRewardPopupHeld(roomId)) {
_scheduleRoomRocketRewardPopupAfterHold(
roomId,
initialRoomRecords: initialRoomRecords,
);
return;
}
try {
final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId);
if (!_isCurrentVisibleVoiceRoom(roomId)) {
@ -2698,6 +2923,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
);
}
void _roomRocketRewardDebug(String message) {
if (!kDebugMode) {
return;
}
debugPrint('[RoomRocketRewardPopup] $message');
}
RoomRocketRewardUserProfileResolver? _roomRocketRewardUserProfileResolver() {
final currentContext = context;
if (currentContext == null || !currentContext.mounted) {
@ -3278,7 +3510,9 @@ class RealTimeMessagingManager extends ChangeNotifier {
} else if (type == SCRoomMsgType.voiceRoomRocketStatusUpdate) {
_handleVoiceRoomRocketStatusUpdate(data["data"]);
} else if (type == SCRoomMsgType.voiceRoomRocketRewardPopup) {
_handleVoiceRoomRocketRewardPopup(data["data"]);
_handleVoiceRoomRocketRewardPopup(data["data"] ?? data);
} else if (SCRoomMsgType.isRocketRewardUserType(type?.toString())) {
_handleVoiceRoomRocketRewardPopup(data["data"] ?? data);
} else if (type == SCRoomMsgType.roomRedPacket) {
///
var fData = data["data"];
@ -3376,9 +3610,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
return;
}
if (data["type"] == SCRoomMsgType.voiceRoomRocketRewardPopup) {
_handleVoiceRoomRocketRewardPopup(data["data"]);
_handleVoiceRoomRocketRewardPopup(data["data"] ?? data);
return;
}
if (SCRoomMsgType.isRocketRewardUserType(data["type"]?.toString())) {
_handleVoiceRoomRocketRewardPopup(data["data"] ?? data);
}
if (data["type"] == SCRoomMsgType.roomRedPacket) {
///
var fData = data["data"];
@ -4088,6 +4325,11 @@ class RealTimeMessagingManager extends ChangeNotifier {
_hiddenCurrentRoomRocketLaunchKeys.clear();
_roomRocketRewardPopupRetryTimer?.cancel();
_roomRocketRewardPopupRetryTimer = null;
for (final timer in _roomRocketRewardHoldTimers.values) {
timer.cancel();
}
_roomRocketRewardHoldTimers.clear();
_roomRocketRewardHoldUntilTimes.clear();
onNewMessageListenerGroupMap.forEach((k, v) {
v = null;
});

View File

@ -37,6 +37,8 @@ class SCRoomRocketStatusRes {
bool? hasClaimed,
num? myContribution,
num? myContributionRate,
bool? configured,
bool? enabled,
int? roundNo,
bool? displayRound,
int? currentLevel,
@ -69,6 +71,8 @@ class SCRoomRocketStatusRes {
_hasClaimed = hasClaimed;
_myContribution = myContribution;
_myContributionRate = myContributionRate;
_configured = configured;
_enabled = enabled;
_roundNo = roundNo;
_displayRound = displayRound;
_currentLevel = currentLevel;
@ -87,6 +91,15 @@ class SCRoomRocketStatusRes {
}
SCRoomRocketStatusRes.fromJson(dynamic json) {
_configured = _boolValue(json['configured']);
_enabled =
_boolValue(
json['enabled'] ??
json['enable'] ??
json['rocketEnabled'] ??
json['rocket_enabled'],
) ??
true;
_roomId = json['roomId'] ?? json['room_id'];
_date = json['date'] ?? json['dayKey'] ?? json['day_key'];
_roundNo = _intValue(json['roundNo'] ?? json['round_no']);
@ -219,6 +232,8 @@ class SCRoomRocketStatusRes {
bool? _hasClaimed;
num? _myContribution;
num? _myContributionRate;
bool? _configured;
bool? _enabled;
int? _roundNo;
bool? _displayRound;
int? _currentLevel;
@ -267,6 +282,12 @@ class SCRoomRocketStatusRes {
num? get myContributionRate => _myContributionRate;
bool? get configured => _configured;
bool? get enabled => _enabled;
bool get isEnabled => _enabled != false;
int? get roundNo => _roundNo;
bool? get displayRound => _displayRound;
@ -371,6 +392,8 @@ class SCRoomRocketStatusRes {
map['hasClaimed'] = _hasClaimed;
map['myContribution'] = _myContribution;
map['myContributionRate'] = _myContributionRate;
map['configured'] = _configured;
map['enabled'] = _enabled;
map['roundNo'] = _roundNo;
map['displayRound'] = _displayRound;
map['currentLevel'] = _currentLevel;

View File

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

View File

@ -514,10 +514,7 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> {
child: Stack(
fit: StackFit.expand,
children: [
if (_failed || !_started)
widget.fallback
else
const ColoredBox(color: Colors.black),
widget.fallback,
if (!_failed)
VapView(
scaleType: _scaleTypeForFit(widget.fit),

View File

@ -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<RoomRocketLevelItem> levels;
final int selectedLevel;
final ValueChanged<int>? 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,
),

View File

@ -34,10 +34,13 @@ class RoomRocketFloatingEntry extends StatelessWidget {
return Selector<RtcProvider, SCRoomRocketStatusRes?>(
selector: (_, provider) => provider.roomRocketStatus,
builder: (context, status, child) {
if (status == null || !status.isEnabled) {
return const SizedBox.shrink();
}
final level = _currentEntryLevel(status);
final percent = _entryPercent(status);
final shouldShake =
(status?.shake ?? false) ||
(status.shake ?? false) ||
(level != null && percent > level.shakeThresholdPercent);
return SCDebounceWidget(
onTap: () => _showRocketDialog(context, status),
@ -46,7 +49,7 @@ class RoomRocketFloatingEntry extends StatelessWidget {
progressUrl: level?.progressBarUrl ?? '',
progressPercent: percent,
shake: shouldShake,
loading: status == null,
loading: false,
),
);
},
@ -108,6 +111,55 @@ class RoomRocketFloatingEntry extends StatelessWidget {
}
}
@visibleForTesting
int debugResolveRoomRocketSelectedLevelIndex({
required List<RoomRocketLevelItem>? levelItems,
required SCRoomRocketStatusRes? status,
required int selectedLevel,
required bool levelTouched,
}) {
return _resolveRoomRocketSelectedLevelIndex(
levelItems: levelItems,
status: status,
selectedLevel: selectedLevel,
levelTouched: levelTouched,
);
}
int _resolveRoomRocketSelectedLevelIndex({
required List<RoomRocketLevelItem>? levelItems,
required SCRoomRocketStatusRes? status,
required int selectedLevel,
required bool levelTouched,
}) {
final items = levelItems ?? const <RoomRocketLevelItem>[];
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<RoomRocketLevelItem>? levelItems,
int index,
) {
final items = levelItems ?? const <RoomRocketLevelItem>[];
if (index >= 0 && index < items.length) {
return items[index].level.clamp(1, 99).toInt();
}
return (index + 1).clamp(1, 99).toInt();
}
class _RoomRocketEntryButton extends StatefulWidget {
const _RoomRocketEntryButton({
required this.iconUrl,
@ -369,9 +421,16 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
super.initState();
_status = widget.initialStatus;
_selectedLevel = _levelIndexFromStatus(widget.initialStatus);
_cacheRewardGroupsFromStatus(widget.initialStatus, _selectedLevel + 1);
final initialLevelItems = RoomRocketApiMapper.levelsFromStatus(
widget.initialStatus,
);
final initialLevelNumber = _levelNumberForIndex(
initialLevelItems,
_selectedLevel,
);
_cacheRewardGroupsFromStatus(widget.initialStatus, initialLevelNumber);
unawaited(_load());
unawaited(_loadRewardForLevel(_selectedLevel + 1));
unawaited(_loadRewardForLevel(initialLevelNumber));
unawaited(_loadConfigs());
unawaited(_loadRule());
}
@ -387,16 +446,29 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
if (!mounted) {
return;
}
if (!res.isEnabled) {
try {
Provider.of<RtcProvider>(
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<RtcProvider>(
context,
@ -430,13 +502,29 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
if (!mounted) {
return;
}
if (!contents.enabled) {
setState(() {
_levelContents = contents;
_configRewardGroupsByLevel = {};
});
_rocketUiDebug('rocket level contents disabled');
return;
}
final groups = RoomRocketApiMapper.rewardGroupsByLevelFromLevelContents(
contents,
);
final levelItems = RoomRocketApiMapper.levelsFromLevelContents(contents);
late final int selectedLevelNumber;
setState(() {
_levelContents = contents;
_configRewardGroupsByLevel = groups;
if (!_levelTouched) {
_selectedLevel = _resolvedSelectedLevelIndex(levelItems, _status);
}
selectedLevelNumber = _levelNumberForIndex(levelItems, _selectedLevel);
});
unawaited(_loadCrewForLevel(selectedLevelNumber));
unawaited(_loadRewardForLevel(selectedLevelNumber));
_rocketUiDebug(
'rocket level contents loaded levels=${contents.levels.length} '
'rewardLevels=${groups.keys.toList()}',
@ -471,8 +559,11 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
@override
Widget build(BuildContext context) {
final status = _status;
final selectedLevel = _selectedLevel.clamp(0, 5).toInt();
final selectedLevelNumber = selectedLevel + 1;
final levelItems =
RoomRocketApiMapper.levelsFromLevelContents(_levelContents) ??
RoomRocketApiMapper.levelsFromStatus(status);
final selectedLevel = _resolvedSelectedLevelIndex(levelItems, status);
final selectedLevelNumber = _levelNumberForIndex(levelItems, selectedLevel);
final currentLevelNumber = _levelNumberFromStatus(status);
final statusRewardGroups = RoomRocketApiMapper.rewardGroupsFromStatus(
status,
@ -487,9 +578,6 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
: rewardGroupsFromStatus ??
configRewardGroups ??
const RoomRocketRewardGroups();
final levelItems =
RoomRocketApiMapper.levelsFromLevelContents(_levelContents) ??
RoomRocketApiMapper.levelsFromStatus(status);
return RoomRocketDialog(
status: status,
levels: levelItems,
@ -536,13 +624,32 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
}
int _levelIndexFromStatus(SCRoomRocketStatusRes? status) {
return ((status?.currentLevel ?? status?.level ?? 1) - 1)
.clamp(0, 5)
.toInt();
return _resolveRoomRocketSelectedLevelIndex(
levelItems: RoomRocketApiMapper.levelsFromStatus(status),
status: status,
selectedLevel: _selectedLevel,
levelTouched: false,
);
}
int _levelNumberFromStatus(SCRoomRocketStatusRes? status) {
return (status?.currentLevel ?? status?.level ?? 1).clamp(1, 99).toInt();
return _roomRocketLevelNumberFromStatus(status);
}
int _resolvedSelectedLevelIndex(
List<RoomRocketLevelItem>? levelItems,
SCRoomRocketStatusRes? status,
) {
return _resolveRoomRocketSelectedLevelIndex(
levelItems: levelItems,
status: status,
selectedLevel: _selectedLevel,
levelTouched: _levelTouched,
);
}
int _levelNumberForIndex(List<RoomRocketLevelItem>? levelItems, int index) {
return _roomRocketLevelNumberForIndex(levelItems, index);
}
void _cacheRewardGroupsFromStatus(SCRoomRocketStatusRes? status, int level) {
@ -576,13 +683,17 @@ class _RoomRocketDialogLoaderState extends State<_RoomRocketDialogLoader> {
void _onLevelSelected(int index) {
final selectedIndex = index.clamp(0, 5).toInt();
_rocketUiDebug('select rocket level=${selectedIndex + 1}');
final levelItems =
RoomRocketApiMapper.levelsFromLevelContents(_levelContents) ??
RoomRocketApiMapper.levelsFromStatus(_status);
final selectedLevelNumber = _levelNumberForIndex(levelItems, selectedIndex);
_rocketUiDebug('select rocket level=$selectedLevelNumber');
setState(() {
_levelTouched = true;
_selectedLevel = selectedIndex;
});
unawaited(_loadRewardForLevel(selectedIndex + 1));
unawaited(_loadCrewForLevel(selectedIndex + 1));
unawaited(_loadRewardForLevel(selectedLevelNumber));
unawaited(_loadCrewForLevel(selectedLevelNumber));
}
Future<void> _loadRewardForLevel(int level) async {

View File

@ -81,7 +81,7 @@ class _MsgItemState extends State<MsgItem> {
}
///
if (widget.msg.type == SCRoomMsgType.rocketRewardUser) {
if (SCRoomMsgType.isRocketRewardUserType(widget.msg.type)) {
return _buildRocketRewardMsg(context);
}
@ -692,10 +692,17 @@ class _MsgItemState extends State<MsgItem> {
///
_buildRocketRewardMsg(BuildContext context) {
List<InlineSpan> 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<String, dynamic> 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<String, dynamic> _stringKeyMap(dynamic value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map((key, item) => MapEntry(key.toString(), item));
}
return const <String, dynamic>{};
}
static String _firstNonBlankString(Iterable<dynamic> values) {
for (final value in values) {
final text = value?.toString().trim() ?? '';
if (text.isNotEmpty && text.toLowerCase() != 'null') {
return text;
}
}
return '';
}
Map<String, dynamic> toJson() {

View File

@ -52,8 +52,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
static const String _defaultProfileCardBackgroundAsset =
"sc_images/room/sc_bg_room_user_profile_card_default.png";
static const double _profileSheetHeightFactor = 0.4;
static const double _dataCardTranslateDownFactor = 0.4;
static const double _defaultBackgroundImageFactor = 0.68;
static const double _dataCardBackgroundTranslateFactor = 0.8;
SocialChatUserProfileManager? userProvider;
String roomId = "";
@ -90,8 +89,8 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
@override
Widget build(BuildContext context) {
final sheetHeight =
MediaQuery.of(context).size.height * _profileSheetHeightFactor;
final screenHeight = MediaQuery.of(context).size.height;
final defaultSheetHeight = screenHeight * _profileSheetHeightFactor;
return Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) {
@ -106,14 +105,18 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
final canLeaveMic = currentMicIndex > -1;
if (ref.userCardInfo == null) {
return _buildLoadingSheet(sheetHeight);
return _buildLoadingSheet(defaultSheetHeight);
}
final dataCard = _activeDataCard(ref.userCardInfo);
return _buildProfileSheet(
context: context,
ref: ref,
rtcProvider: rtcProvider,
sheetHeight: sheetHeight,
sheetHeight: defaultSheetHeight,
dataCardBackgroundHeight: screenHeight,
dataCard: dataCard,
isSelf: isSelf,
currentMicIndex: currentMicIndex,
canLeaveMic: canLeaveMic,
@ -154,6 +157,8 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
required SocialChatUserProfileManager ref,
required RtcProvider rtcProvider,
required double sheetHeight,
required double dataCardBackgroundHeight,
required PropsResources? dataCard,
required bool isSelf,
required num currentMicIndex,
required bool canLeaveMic,
@ -161,7 +166,6 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
}) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final profile = ref.userCardInfo?.userProfile;
final dataCard = _activeDataCard(ref.userCardInfo);
final canShowReport = currentUserId != widget.userId;
final canShowSetting =
canShowReport &&
@ -199,6 +203,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
return _buildDataCardFrame(
sheetHeight: sheetHeight,
backgroundHeight: dataCardBackgroundHeight,
dataCard: dataCard,
child: profileContent,
);
@ -206,6 +211,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
Widget _buildDataCardFrame({
required double sheetHeight,
required double backgroundHeight,
required PropsResources dataCard,
required Widget child,
}) {
@ -224,8 +230,10 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
Positioned(
left: 0,
right: 0,
top: sheetHeight * _dataCardTranslateDownFactor,
height: sheetHeight,
top:
-(backgroundHeight - sheetHeight) +
sheetHeight * _dataCardBackgroundTranslateFactor,
height: backgroundHeight,
child: IgnorePointer(child: _buildSheetBackground(dataCard)),
),
Positioned.fill(
@ -348,14 +356,10 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
Widget _buildDefaultSheetBackground() {
return DecoratedBox(
decoration: _sheetDecoration(),
child: Center(
child: FractionallySizedBox(
widthFactor: _defaultBackgroundImageFactor,
heightFactor: _defaultBackgroundImageFactor,
child: Image.asset(
_defaultProfileCardBackgroundAsset,
fit: BoxFit.contain,
),
child: SizedBox.expand(
child: Image.asset(
_defaultProfileCardBackgroundAsset,
fit: BoxFit.cover,
),
),
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

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

View File

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

View File

@ -122,6 +122,22 @@ void main() {
expect(levels?.last.progressImageUrl, 'progress-2');
});
test('parses disabled rocket status from go api', () {
final status = SCRoomRocketStatusRes.fromJson({
'configured': true,
'enabled': false,
'status': 'DISABLED',
'roomId': 'room-1',
'levels': [],
});
expect(status.configured, isTrue);
expect(status.enabled, isFalse);
expect(status.isEnabled, isFalse);
expect(status.status, 'DISABLED');
expect(RoomRocketApiMapper.levelsFromStatus(status), isNull);
});
test(
'uses top-level reward preview for the response level instead of current level',
() {