This commit is contained in:
roxy 2026-05-15 22:25:32 +08:00
parent 6e10d57808
commit ea296e461b
17 changed files with 379 additions and 320 deletions

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية.", "roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية.",
"roomRocketRewardEmptyText": "لم تحصل على مكافآت. حظًا أوفر في المرة القادمة.", "roomRocketRewardEmptyText": "لم تحصل على مكافآت. حظًا أوفر في المرة القادمة.",
"roomRocketRecordEmptyText": "لا توجد سجلات مكافآت", "roomRocketRecordEmptyText": "لا توجد سجلات مكافآت",
"launchRocket": "إطلاق الصاروخ" "launchRocket": "إطلاق الصاروخ",
"roomRocketFuelFullNotice": "اكتمل وقود الصاروخ! سيتم الإطلاق بعد {1} ثانية!"
} }

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।", "roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।",
"roomRocketRewardEmptyText": "কোনো পুরস্কার পাওয়া যায়নি। পরের বার শুভকামনা।", "roomRocketRewardEmptyText": "কোনো পুরস্কার পাওয়া যায়নি। পরের বার শুভকামনা।",
"roomRocketRecordEmptyText": "কোনো পুরস্কারের রেকর্ড নেই", "roomRocketRecordEmptyText": "কোনো পুরস্কারের রেকর্ড নেই",
"launchRocket": "রকেট চালু করুন" "launchRocket": "রকেট চালু করুন",
"roomRocketFuelFullNotice": "রকেটের জ্বালানি পূর্ণ! {1} সেকেন্ড পরে উৎক্ষেপণ!"
} }

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. Each time a VIP level is purchased, it is valid for 30 days, and can be manually renewed after expiration.", "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 rewards earned. Better luck next time.",
"roomRocketRecordEmptyText": "No reward records", "roomRocketRecordEmptyText": "No reward records",
"launchRocket": "Launch Rocket" "launchRocket": "Launch Rocket",
"roomRocketFuelFullNotice": "Rocket fuel is full! Launching in {1}s!"
} }

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. Chaque fois qu'un niveau VIP est acheté, il est valable 30 jours et peut être renouvelé manuellement après expiration.", "roomRocketNoteSectionBody": "1. Chaque fois qu'un niveau VIP est acheté, il est valable 30 jours et peut être renouvelé manuellement après expiration.",
"roomRocketRewardEmptyText": "Aucune récompense gagnée. Plus de chance la prochaine fois.", "roomRocketRewardEmptyText": "Aucune récompense gagnée. Plus de chance la prochaine fois.",
"roomRocketRecordEmptyText": "Aucun enregistrement de récompense", "roomRocketRecordEmptyText": "Aucun enregistrement de récompense",
"launchRocket": "Lancement de fusée" "launchRocket": "Lancement de fusée",
"roomRocketFuelFullNotice": "Le carburant de la fusée est plein ! Lancement dans {1} s !"
} }

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. Ogni volta che viene acquistato un livello VIP, è valido per 30 giorni e può essere rinnovato manualmente dopo la scadenza.", "roomRocketNoteSectionBody": "1. Ogni volta che viene acquistato un livello VIP, è valido per 30 giorni e può essere rinnovato manualmente dopo la scadenza.",
"roomRocketRewardEmptyText": "Nessun premio guadagnato. Più fortuna la prossima volta.", "roomRocketRewardEmptyText": "Nessun premio guadagnato. Più fortuna la prossima volta.",
"roomRocketRecordEmptyText": "Nessun record di ricompensa", "roomRocketRecordEmptyText": "Nessun record di ricompensa",
"launchRocket": "Lancia il razzo" "launchRocket": "Lancia il razzo",
"roomRocketFuelFullNotice": "Carburante del razzo pieno! Lancio tra {1} s!"
} }

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. Cada vez que um nível VIP é adquirido, ele é válido por 30 dias, podendo ser renovado manualmente após expirar.", "roomRocketNoteSectionBody": "1. Cada vez que um nível VIP é adquirido, ele é válido por 30 dias, podendo ser renovado manualmente após expirar.",
"roomRocketRewardEmptyText": "Nenhuma recompensa ganha. Melhor sorte da próxima vez.", "roomRocketRewardEmptyText": "Nenhuma recompensa ganha. Melhor sorte da próxima vez.",
"roomRocketRecordEmptyText": "Nenhum registro de recompensa", "roomRocketRecordEmptyText": "Nenhum registro de recompensa",
"launchRocket": "Lançar foguete" "launchRocket": "Lançar foguete",
"roomRocketFuelFullNotice": "Combustível do foguete cheio! Lançamento em {1}s!"
} }

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. Her VIP seviyesi satın alındığında 30 gün geçerlidir ve süresi dolduktan sonra manuel olarak yenilenebilir.", "roomRocketNoteSectionBody": "1. Her VIP seviyesi satın alındığında 30 gün geçerlidir ve süresi dolduktan sonra manuel olarak yenilenebilir.",
"roomRocketRewardEmptyText": "Ödül kazanmadın. Bir dahaki sefere bol şans.", "roomRocketRewardEmptyText": "Ödül kazanmadın. Bir dahaki sefere bol şans.",
"roomRocketRecordEmptyText": "Ödül kaydı yok", "roomRocketRecordEmptyText": "Ödül kaydı yok",
"launchRocket": "Roketi Fırlat" "launchRocket": "Roketi Fırlat",
"roomRocketFuelFullNotice": "Roket yakıtı doldu! {1} saniye sonra fırlatılıyor!"
} }

View File

@ -79,6 +79,9 @@ class SCRoomMsgType {
/// ///
static const String rocketRewardUser = "ROCKET_REWARD_USER"; static const String rocketRewardUser = "ROCKET_REWARD_USER";
///
static const String rocketLaunchNotice = "ROCKET_LAUNCH_NOTICE";
/// ///
static const String voiceRoomRocketStatusUpdate = static const String voiceRoomRocketStatusUpdate =
"VOICE_ROOM_ROCKET_STATUS_UPDATE"; "VOICE_ROOM_ROCKET_STATUS_UPDATE";

View File

@ -845,6 +845,9 @@ class SCAppLocalizations {
String get launchRocket => translate('launchRocket'); String get launchRocket => translate('launchRocket');
String roomRocketFuelFullNotice(String seconds) =>
translate('roomRocketFuelFullNotice').replaceAll('{1}', seconds);
String get useCoupontips => translate('useCoupontips'); String get useCoupontips => translate('useCoupontips');
String get joinMemberTips2 => translate('joinMemberTips2'); String get joinMemberTips2 => translate('joinMemberTips2');

View File

@ -54,7 +54,6 @@ import 'package:yumi/shared/business_logic/models/res/broad_cast_mic_change_push
hide Data; hide Data;
import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; import 'package:yumi/shared/business_logic/models/res/gift_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_public_message_page_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_public_message_page_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_theme_list_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_theme_list_res.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/ui_kit/widgets/room/invite/invite_room_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/invite/invite_room_dialog.dart';
@ -119,7 +118,6 @@ class RealTimeMessagingManager extends ChangeNotifier {
static const int _luckyGiftBurstPlaybackWatchdogMs = 8000; static const int _luckyGiftBurstPlaybackWatchdogMs = 8000;
BuildContext? context; BuildContext? context;
final Set<String> _sentRoomRocketRewardMessageKeys = <String>{};
void _giftFxLog(String message) {} void _giftFxLog(String message) {}
@ -1106,6 +1104,8 @@ class RealTimeMessagingManager extends ChangeNotifier {
return true; return true;
case SCRoomMsgType.rocketRewardUser: case SCRoomMsgType.rocketRewardUser:
return true; return true;
case SCRoomMsgType.rocketLaunchNotice:
return true;
case SCRoomMsgType.emoticons: case SCRoomMsgType.emoticons:
return (msg.number ?? -1) < 0; return (msg.number ?? -1) < 0;
default: default:
@ -1139,6 +1139,10 @@ class RealTimeMessagingManager extends ChangeNotifier {
_hasRoomRocketRewardMessage(msg)) { _hasRoomRocketRewardMessage(msg)) {
return; return;
} }
if (msg.type == SCRoomMsgType.rocketLaunchNotice &&
_hasRoomRocketNoticeMessage(msg)) {
return;
}
roomAllMsgList.insert(0, msg); roomAllMsgList.insert(0, msg);
if (roomAllMsgList.length > 250) { if (roomAllMsgList.length > 250) {
@ -1177,6 +1181,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomChatMsgList.removeAt(roomChatMsgList.length - 1); roomChatMsgList.removeAt(roomChatMsgList.length - 1);
} }
msgChatListener?.call(msg); msgChatListener?.call(msg);
} else if (msg.type == SCRoomMsgType.rocketLaunchNotice) {
roomChatMsgList.insert(0, msg);
if (roomChatMsgList.length > 250) {
roomChatMsgList.removeAt(roomChatMsgList.length - 1);
}
msgChatListener?.call(msg);
} else if (msg.type == SCRoomMsgType.gift || } else if (msg.type == SCRoomMsgType.gift ||
msg.type == SCRoomMsgType.luckGiftAnimOther) { msg.type == SCRoomMsgType.luckGiftAnimOther) {
roomGiftMsgList.insert(0, msg); roomGiftMsgList.insert(0, msg);
@ -1296,6 +1306,18 @@ class RealTimeMessagingManager extends ChangeNotifier {
); );
} }
bool _hasRoomRocketNoticeMessage(Msg msg) {
final key = (msg.giftBatchId ?? '').trim();
if (key.isEmpty) {
return false;
}
return roomAllMsgList.any(
(item) =>
item.type == SCRoomMsgType.rocketLaunchNotice &&
(item.giftBatchId ?? '').trim() == key,
);
}
String _roomRocketRewardMessageKey(Msg msg) { String _roomRocketRewardMessageKey(Msg msg) {
final stableKey = (msg.giftBatchId ?? '').trim(); final stableKey = (msg.giftBatchId ?? '').trim();
if (stableKey.isNotEmpty) { if (stableKey.isNotEmpty) {
@ -1891,6 +1913,15 @@ class RealTimeMessagingManager extends ChangeNotifier {
rtcProvider.shouldShowRoomVisualEffects && rtcProvider.shouldShowRoomVisualEffects &&
rtcProvider.isVoiceRoomRouteVisible; rtcProvider.isVoiceRoomRouteVisible;
if (isCurrentVisibleRoom) { if (isCurrentVisibleRoom) {
addMsg(
_buildRoomRocketLaunchNoticeMsg(
_firstNonBlank([
rtcProvider.currenRoom?.roomProfile?.roomProfile?.roomAccount,
launch.roomAccount,
]),
launch,
),
);
rtcProvider.handleRoomRocketLaunchBroadcast(launch); rtcProvider.handleRoomRocketLaunchBroadcast(launch);
} }
} }
@ -1924,260 +1955,6 @@ class RealTimeMessagingManager extends ChangeNotifier {
unawaited(_showRoomRocketRewardPopupDialog(resolvedRoomId)); unawaited(_showRoomRocketRewardPopupDialog(resolvedRoomId));
} }
Future<void> _sendRoomRocketRewardPublicMessages(
String roomId,
Map<String, dynamic> payload,
) async {
final groupId = _currentVoiceRoomGroupId();
if (groupId.isEmpty) {
return;
}
final launchNo = _payloadText(payload['launchNo']);
var records = _rocketRewardRecordsFromPayload(payload);
if (records.isEmpty) {
records = await _loadRocketRewardPublicRecords(roomId, launchNo);
}
if (records.isNotEmpty) {
for (final record in records) {
await _sendRoomRocketRewardMessage(
groupId,
_buildRocketRewardRecordMsg(groupId, record),
);
}
return;
}
final userIds = _payloadStringList(payload['userIds']);
for (final userId in userIds) {
await _sendRoomRocketRewardMessage(
groupId,
_buildRocketRewardFallbackMsg(groupId, payload, userId),
);
}
}
Future<void> _sendRoomRocketRewardMessage(String groupId, Msg msg) async {
final key = _roomRocketRewardMessageKey(msg);
if (key.isEmpty || !_sentRoomRocketRewardMessageKeys.add(key)) {
return;
}
try {
final textMsg = await TencentImSDKPlugin.v2TIMManager
.getMessageManager()
.createCustomMessage(data: jsonEncode(msg.toJson()));
if (textMsg.code != 0 || textMsg.data?.id == null) {
_sentRoomRocketRewardMessageKeys.remove(key);
return;
}
final sendResult = await TencentImSDKPlugin.v2TIMManager
.getMessageManager()
.sendMessage(id: textMsg.data!.id!, groupID: groupId, receiver: '');
if (sendResult.code == 0) {
addMsg(msg);
} else {
_sentRoomRocketRewardMessageKeys.remove(key);
}
} catch (_) {
_sentRoomRocketRewardMessageKeys.remove(key);
}
}
Future<List<SCRoomRocketRewardRecordRes>> _loadRocketRewardPublicRecords(
String roomId,
String launchNo,
) async {
if (roomId.trim().isEmpty || launchNo.trim().isEmpty) {
return const <SCRoomRocketRewardRecordRes>[];
}
try {
final res = await SCChatRoomRepository().roomRocketRewardRecords(
roomId,
cursor: 1,
limit: 100,
);
return res.records
.where((item) => item.launchNo.trim() == launchNo.trim())
.toList(growable: false);
} catch (_) {
return const <SCRoomRocketRewardRecordRes>[];
}
}
List<SCRoomRocketRewardRecordRes> _rocketRewardRecordsFromPayload(
Map<String, dynamic> payload,
) {
final candidates = [
payload['records'],
payload['rewardRecords'],
payload['rewards'],
_broadcastPayloadMap(payload['body'])['records'],
_broadcastPayloadMap(payload['data'])['records'],
];
for (final candidate in candidates) {
if (candidate is Iterable) {
final records = candidate
.map(SCRoomRocketRewardRecordRes.fromJson)
.toList(growable: false);
final usableRecords = records
.where((item) => item.userId.trim().isNotEmpty)
.toList(growable: false);
if (usableRecords.isNotEmpty) {
return usableRecords;
}
}
}
return const <SCRoomRocketRewardRecordRes>[];
}
Msg _buildRocketRewardRecordMsg(
String groupId,
SCRoomRocketRewardRecordRes record,
) {
return Msg(
groupId: groupId,
msg: record.rewardCover,
type: SCRoomMsgType.rocketRewardUser,
user: SocialChatUserProfile(
id: record.userId,
userNickname: record.displayName,
userAvatar: record.userAvatar,
account: record.account,
),
role: _rocketRewardRole(record.rewardType, record.rewardCover),
number: record.level <= 0 ? 1 : record.level,
rocketIconUrl: _firstNonBlank([
record.rocketIconUrl,
_rocketIconUrlForLevel(record.level),
]),
giftBatchId: _rocketRewardRecordKey(record),
);
}
Msg _buildRocketRewardFallbackMsg(
String groupId,
Map<String, dynamic> payload,
String userId,
) {
final rewardCover = _firstNonBlank([
_payloadText(payload['rewardCover']),
_payloadText(payload['rewardIcon']),
_payloadText(payload['rewardUrl']),
_payloadText(payload['giftUrl']),
_payloadText(payload['giftIcon']),
]);
final rewardType = _firstNonBlank([
_payloadText(payload['rewardType']),
_payloadText(payload['type']),
rewardCover.isEmpty ? 'GOLD' : '',
]);
final level = (_payloadNum(payload['level']) ?? 1).toInt();
return Msg(
groupId: groupId,
msg: rewardCover,
type: SCRoomMsgType.rocketRewardUser,
user: _rocketRewardUserFromPayload(payload, userId),
role: _rocketRewardRole(rewardType, rewardCover),
number: level <= 0 ? 1 : level,
rocketIconUrl: _rocketRewardRocketIconUrl(payload, level),
giftBatchId: [
'rocket_reward_fallback',
_payloadText(payload['launchNo']),
userId,
level,
rewardType,
].join('|'),
);
}
SocialChatUserProfile _rocketRewardUserFromPayload(
Map<String, dynamic> payload,
String userId,
) {
final userPayload = _rocketRewardUserPayload(payload, userId);
final nickname = _firstNonBlank([
_payloadText(userPayload['userNickname']),
_payloadText(userPayload['nickname']),
_payloadText(userPayload['nickName']),
_payloadText(userPayload['account']),
userId,
]);
return SocialChatUserProfile(
id: _firstNonBlank([_payloadText(userPayload['userId']), userId]),
userNickname: nickname,
userAvatar: _firstNonBlank([
_payloadText(userPayload['userAvatar']),
_payloadText(userPayload['avatar']),
_payloadText(userPayload['avatarUrl']),
]),
account: _payloadText(userPayload['account']),
);
}
Map<String, dynamic> _rocketRewardUserPayload(
Map<String, dynamic> payload,
String userId,
) {
final candidates = [
payload['users'],
payload['rewardUsers'],
payload['winnerUsers'],
payload['winners'],
];
for (final candidate in candidates) {
if (candidate is Map) {
final item = candidate[userId] ?? candidate[userId.toString()];
final map = _broadcastPayloadMap(item);
if (map.isNotEmpty) {
return map;
}
}
if (candidate is Iterable) {
for (final item in candidate) {
final map = _broadcastPayloadMap(item);
final id = _firstNonBlank([
_payloadText(map['userId']),
_payloadText(map['id']),
_payloadText(map['account']),
]);
if (id == userId) {
return map;
}
}
}
}
return <String, dynamic>{'userId': userId, 'userNickname': userId};
}
String _rocketRewardRole(String rewardType, String rewardCover) {
final normalized = rewardType.trim().toUpperCase();
if (normalized == 'GOLD' || normalized == 'COIN' || normalized == 'COINS') {
return 'GOLD';
}
return rewardCover.trim().isEmpty ? 'GOLD' : normalized;
}
String _rocketRewardRocketIconUrl(Map<String, dynamic> payload, int level) {
final rocket = _broadcastPayloadMap(payload['rocket']);
final levelInfo = _broadcastPayloadMap(payload['levelInfo']);
final config = _broadcastPayloadMap(payload['config']);
return _firstNonBlank([
_payloadText(payload['rocket_icon_url']),
_payloadText(payload['rocketIconUrl']),
_payloadText(payload['rocketIcon']),
_payloadText(payload['iconUrl']),
_payloadText(rocket['rocket_icon_url']),
_payloadText(rocket['rocketIconUrl']),
_payloadText(rocket['rocketIcon']),
_payloadText(levelInfo['rocket_icon_url']),
_payloadText(levelInfo['rocketIconUrl']),
_payloadText(levelInfo['rocketIcon']),
_payloadText(config['rocket_icon_url']),
_payloadText(config['rocketIconUrl']),
_payloadText(config['rocketIcon']),
_rocketIconUrlForLevel(level),
]);
}
String _rocketIconUrlForLevel(int level) { String _rocketIconUrlForLevel(int level) {
final currentContext = context; final currentContext = context;
if (currentContext == null || !currentContext.mounted) { if (currentContext == null || !currentContext.mounted) {
@ -2196,29 +1973,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
return ''; return '';
} }
String _rocketRewardRecordKey(SCRoomRocketRewardRecordRes record) {
return [
'rocket_reward',
record.id,
record.launchNo,
record.userId,
record.rewardScene,
record.rewardType,
record.rewardItemId,
].join('|');
}
Future<void> _showRoomRocketRewardPopupDialog(String roomId) async { Future<void> _showRoomRocketRewardPopupDialog(String roomId) async {
try { try {
final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId); final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId);
if (res.records.isEmpty) { if (res.records.isEmpty) {
return; return;
} }
unawaited(
_sendRoomRocketRewardPublicMessages(roomId, {
'records': res.records.map((item) => item.toJson()).toList(),
}),
);
final recordIds = res.records final recordIds = res.records
.map((item) => item.id) .map((item) => item.id)
.where((item) => item.isNotEmpty) .where((item) => item.isNotEmpty)
@ -2273,6 +2033,29 @@ class RealTimeMessagingManager extends ChangeNotifier {
); );
} }
Msg _buildRoomRocketLaunchNoticeMsg(
String groupId,
RoomRocketLaunchBroadcastMessage launch,
) {
final seconds = launch.durationSeconds <= 0 ? 10 : launch.durationSeconds;
return Msg(
groupId: groupId,
msg: seconds.toString(),
type: SCRoomMsgType.rocketLaunchNotice,
number: seconds,
rocketIconUrl: _firstNonBlank([
launch.rocketIconUrl,
_rocketIconUrlForLevel(launch.safeLevel),
]),
giftBatchId: [
'rocket_launch_notice',
launch.launchNo,
launch.roomId,
launch.safeLevel,
].join('|'),
);
}
List<String> _payloadStringList(dynamic value) { List<String> _payloadStringList(dynamic value) {
if (value is Iterable) { if (value is Iterable) {
return value return value

View File

@ -376,8 +376,8 @@ class SCRoomRocketRewardRes {
_asMap(data['config'])['rocketIconUrl'], _asMap(data['config'])['rocketIconUrl'],
_asMap(data['config'])['rocketIcon'], _asMap(data['config'])['rocketIcon'],
]), ]),
rewardAmount: _numValue(data['rewardAmount']) ?? 0, rewardAmount: _rewardAmountValue(data['rewardAmount']) ?? 0,
expireDays: _intValue(data['expireDays']), expireDays: _dayValue(data['expireDays']),
); );
} }
@ -887,7 +887,7 @@ class SCRoomRocketRewardRecordRes {
rewardItemId: _stringValue(data['rewardItemId']), rewardItemId: _stringValue(data['rewardItemId']),
rewardName: _stringValue(data['rewardName']), rewardName: _stringValue(data['rewardName']),
rewardCover: _stringValue(data['rewardCover']), rewardCover: _stringValue(data['rewardCover']),
rewardAmount: _numValue(data['rewardAmount']) ?? 0, rewardAmount: _rewardAmountValue(data['rewardAmount']) ?? 0,
rocketIconUrl: _firstAssetString([ rocketIconUrl: _firstAssetString([
data['rocket_icon_url'], data['rocket_icon_url'],
data['rocketIconUrl'], data['rocketIconUrl'],
@ -903,7 +903,7 @@ class SCRoomRocketRewardRecordRes {
_asMap(data['config'])['rocketIconUrl'], _asMap(data['config'])['rocketIconUrl'],
_asMap(data['config'])['rocketIcon'], _asMap(data['config'])['rocketIcon'],
]), ]),
expireDays: _intValue(data['expireDays']), expireDays: _dayValue(data['expireDays']),
grantStatus: _stringValue(data['grantStatus']), grantStatus: _stringValue(data['grantStatus']),
popupStatus: _stringValue(data['popupStatus']), popupStatus: _stringValue(data['popupStatus']),
grantTime: _stringValue(data['grantTime']), grantTime: _stringValue(data['grantTime']),
@ -1116,6 +1116,24 @@ num? _numValue(dynamic value) {
return num.tryParse(_stringValue(value)); return num.tryParse(_stringValue(value));
} }
num? _rewardAmountValue(dynamic value) {
return _numValue(value) ?? _dayNumberValue(value);
}
int _dayValue(dynamic value, {int fallback = 0}) {
final numeric = _numValue(value) ?? _dayNumberValue(value);
return numeric?.toInt() ?? fallback;
}
num? _dayNumberValue(dynamic value) {
final text = _stringValue(value);
final match = RegExp(
r'^(\d+(?:\.\d+)?)\s*d$',
caseSensitive: false,
).firstMatch(text);
return match == null ? null : num.tryParse(match.group(1)!);
}
bool _boolValue(dynamic value, {bool fallback = false}) { bool _boolValue(dynamic value, {bool fallback = false}) {
if (value is bool) { if (value is bool) {
return value; return value;

View File

@ -25,7 +25,17 @@ class RoomRocketLaunchBroadcastMessage {
level: _intValue(data['level'], fallback: 1), level: _intValue(data['level'], fallback: 1),
rocketIconUrl: _rocketIconUrl(data), rocketIconUrl: _rocketIconUrl(data),
rocketAnimationUrl: _rocketAnimationUrl(data), rocketAnimationUrl: _rocketAnimationUrl(data),
durationSeconds: _intValue(data['durationSeconds'], fallback: 7), durationSeconds: _firstIntValue([
data['countdown_seconds'],
data['countdownSeconds'],
data['launch_delay_seconds'],
data['launchDelaySeconds'],
data['delaySeconds'],
data['durationSeconds'],
data['broadcastDurationSeconds'],
_asMap(data['config'])['countdownSeconds'],
_asMap(data['config'])['launchDelaySeconds'],
], fallback: 10),
triggerUser: RoomRocketLaunchTriggerUser.fromJson(data['triggerUser']), triggerUser: RoomRocketLaunchTriggerUser.fromJson(data['triggerUser']),
triggerUserId: _stringValue(data['triggerUserId']), triggerUserId: _stringValue(data['triggerUserId']),
launchNo: _stringValue(data['launchNo']), launchNo: _stringValue(data['launchNo']),
@ -59,7 +69,7 @@ class RoomRocketLaunchBroadcastMessage {
Duration get displayDuration { Duration get displayDuration {
if (durationSeconds <= 0) { if (durationSeconds <= 0) {
return const Duration(seconds: 7); return const Duration(seconds: 10);
} }
return Duration(seconds: durationSeconds); return Duration(seconds: durationSeconds);
} }
@ -195,3 +205,18 @@ int _intValue(dynamic value, {int fallback = 0}) {
} }
return int.tryParse(_stringValue(value)) ?? fallback; return int.tryParse(_stringValue(value)) ?? fallback;
} }
int _firstIntValue(Iterable<dynamic> values, {int fallback = 0}) {
for (final value in values) {
final parsed =
value is int
? value
: value is num
? value.toInt()
: int.tryParse(_stringValue(value));
if (parsed != null) {
return parsed;
}
}
return fallback;
}

View File

@ -235,7 +235,28 @@ class RoomRocketRewardItem {
if (isGoldReward) { if (isGoldReward) {
return amount; return amount;
} }
return expireDays > 0 ? '${expireDays}D' : amount; return _formatDayText(expireDays > 0 ? expireDays.toString() : amount);
}
static String _formatDayText(String value) {
final text = value.trim();
if (text.isEmpty) {
return text;
}
final dayMatch = RegExp(
r'^(\d+(?:\.\d+)?)\s*d$',
caseSensitive: false,
).firstMatch(text);
final dayValue = dayMatch?.group(1) ?? _normalizedNumber(text);
return dayValue == null ? text : '${dayValue}d';
}
static String? _normalizedNumber(String value) {
final parsed = num.tryParse(value);
if (parsed == null) {
return null;
}
return parsed % 1 == 0 ? parsed.toInt().toString() : parsed.toString();
} }
} }
@ -380,18 +401,20 @@ class _RocketCanvas extends StatelessWidget {
} }
Widget _buildRocketBody() { Widget _buildRocketBody() {
final previewImageUrl = selectedLevelItem.previewImageUrl?.trim() ?? '';
return Positioned( return Positioned(
left: s(88), left: s(88),
top: s(111), top: s(111),
width: s(200), width: s(200),
height: s(432), height: s(432),
child: _RocketImage( child: _RocketImage(
asset: selectedLevelItem.asset ?? _RocketAssets.rocketBody, imageUrl: previewImageUrl.isEmpty ? null : previewImageUrl,
imageUrl: selectedLevelItem.previewImageUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
preferSvg: true, preferSvg: true,
pagDisplayScale: 2, pagDisplayScale: 2,
pagClipBehavior: Clip.none, pagClipBehavior: Clip.none,
allowStaticFallback: false,
emptyBuilder: (_) => const SCRotatingDotsLoading(),
debugName: debugName:
'centerPreview level=${selectedLevelItem.level} ' 'centerPreview level=${selectedLevelItem.level} '
'preview=${selectedLevelItem.previewImageUrl ?? '-'}', 'preview=${selectedLevelItem.previewImageUrl ?? '-'}',
@ -470,11 +493,6 @@ class _RocketCanvas extends StatelessWidget {
} }
Widget _buildProgress() { Widget _buildProgress() {
final asset = switch (progressStage) {
RoomRocketProgressStage.empty => _RocketAssets.progressEmpty,
RoomRocketProgressStage.charging => _RocketAssets.progressPartial,
RoomRocketProgressStage.full => _RocketAssets.progressFull,
};
final progressImageUrl = selectedLevelItem.progressImageUrl?.trim() ?? ''; final progressImageUrl = selectedLevelItem.progressImageUrl?.trim() ?? '';
return Stack( return Stack(
children: [ children: [
@ -483,10 +501,10 @@ class _RocketCanvas extends StatelessWidget {
top: s(227), top: s(227),
width: s(44), width: s(44),
height: s(134), height: s(134),
child: _RocketImage( child: _RocketProgressBar(
asset: asset, percent: percent,
progressStage: progressStage,
imageUrl: progressImageUrl.isEmpty ? null : progressImageUrl, imageUrl: progressImageUrl.isEmpty ? null : progressImageUrl,
fit: BoxFit.fill,
), ),
), ),
Positioned( Positioned(
@ -807,6 +825,96 @@ class _RocketCanvas extends StatelessWidget {
} }
} }
class _RocketProgressBar extends StatelessWidget {
const _RocketProgressBar({
required this.percent,
required this.progressStage,
this.imageUrl,
});
final double percent;
final RoomRocketProgressStage progressStage;
final String? imageUrl;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
final ratio = _progressRatio();
final fillHeight = height * ratio;
final showFillCap =
fillHeight > 0 &&
ratio < 1 &&
progressStage != RoomRocketProgressStage.full;
final capHeight = math.max(1.0, height * 0.014);
return Stack(
fit: StackFit.expand,
children: [
const _RocketImage(
asset: _RocketAssets.progressEmpty,
fit: BoxFit.fill,
),
if (fillHeight > 0)
Positioned(
left: 0,
bottom: 0,
width: width,
height: fillHeight,
child: ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
width: width,
height: height,
child: _RocketImage(
asset: _RocketAssets.progressFull,
imageUrl: imageUrl,
fit: BoxFit.fill,
),
),
),
),
),
if (showFillCap)
Positioned(
left: width * 0.08,
bottom: math.max(0, fillHeight - capHeight / 2),
width: width * 0.84,
height: capHeight,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(capHeight),
gradient: LinearGradient(
colors: [
const Color(0xFF30FFC0).withValues(alpha: 0),
const Color(0xFFA4FFE3).withValues(alpha: 0.9),
const Color(0xFF30FFC0).withValues(alpha: 0),
],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF2DFFC1).withValues(alpha: 0.7),
blurRadius: capHeight * 2,
),
],
),
),
),
],
);
},
);
}
double _progressRatio() {
final value = percent.isFinite ? percent : 0.0;
return (value / 100).clamp(0.0, 1.0).toDouble();
}
}
class _RewardCard extends StatelessWidget { class _RewardCard extends StatelessWidget {
const _RewardCard({required this.scale, required this.item, this.onTap}); const _RewardCard({required this.scale, required this.item, this.onTap});
@ -978,6 +1086,8 @@ class _RocketImage extends StatelessWidget {
this.preferSvg = false, this.preferSvg = false,
this.pagDisplayScale = 1, this.pagDisplayScale = 1,
this.pagClipBehavior = Clip.hardEdge, this.pagClipBehavior = Clip.hardEdge,
this.allowStaticFallback = true,
this.emptyBuilder,
this.debugName, this.debugName,
}); });
@ -987,6 +1097,8 @@ class _RocketImage extends StatelessWidget {
final bool preferSvg; final bool preferSvg;
final double pagDisplayScale; final double pagDisplayScale;
final Clip pagClipBehavior; final Clip pagClipBehavior;
final bool allowStaticFallback;
final WidgetBuilder? emptyBuilder;
final String? debugName; final String? debugName;
@override @override
@ -1035,14 +1147,23 @@ class _RocketImage extends StatelessWidget {
errorBuilder: (_, error, __) { errorBuilder: (_, error, __) {
_debugImage('network svg failed: $error'); _debugImage('network svg failed: $error');
return preferSvg && !_isSvg(source) return preferSvg && !_isSvg(source)
? CustomCachedImage(imageUrl: source, fit: fit) ? CustomCachedImage(
imageUrl: source,
fit: fit,
errorWidget:
allowStaticFallback ? null : const SizedBox.shrink(),
)
: _imageFallback(); : _imageFallback();
}, },
); );
} }
return CustomCachedImage(imageUrl: source, fit: fit); return CustomCachedImage(
imageUrl: source,
fit: fit,
errorWidget: allowStaticFallback ? null : const SizedBox.shrink(),
);
} }
if (asset != null && asset!.trim().isNotEmpty) { if (allowStaticFallback && asset != null && asset!.trim().isNotEmpty) {
final source = asset!.trim(); final source = asset!.trim();
_debugImage('load asset ${_shortSource(source)}'); _debugImage('load asset ${_shortSource(source)}');
if (SCNetworkSvgaWidget.isSvga(source)) { if (SCNetworkSvgaWidget.isSvga(source)) {
@ -1080,7 +1201,7 @@ class _RocketImage extends StatelessWidget {
} }
return Image.asset(source, fit: fit, gaplessPlayback: true); return Image.asset(source, fit: fit, gaplessPlayback: true);
} }
return const SizedBox.shrink(); return emptyBuilder?.call(context) ?? const SizedBox.shrink();
} }
static bool _isSvg(String value) { static bool _isSvg(String value) {
@ -1123,6 +1244,9 @@ class _RocketImage extends StatelessWidget {
} }
Widget _imageFallback() { Widget _imageFallback() {
if (!allowStaticFallback) {
return const SizedBox.shrink();
}
if (asset != null && if (asset != null &&
asset!.trim().isNotEmpty && asset!.trim().isNotEmpty &&
!_isSvg(asset!) && !_isSvg(asset!) &&
@ -1747,8 +1871,6 @@ class _RocketAssets {
'sc_images/room/rocket/bottom_avatar_ring.png'; 'sc_images/room/rocket/bottom_avatar_ring.png';
static const String pillBg = 'sc_images/room/rocket/pill_bg.png'; static const String pillBg = 'sc_images/room/rocket/pill_bg.png';
static const String progressFull = 'sc_images/room/rocket/progress_full.png'; static const String progressFull = 'sc_images/room/rocket/progress_full.png';
static const String progressPartial =
'sc_images/room/rocket/progress_partial.png';
static const String progressEmpty = static const String progressEmpty =
'sc_images/room/rocket/progress_empty.png'; 'sc_images/room/rocket/progress_empty.png';
static const String rewardTop = 'sc_images/room/rocket/reward_dialog_top.png'; static const String rewardTop = 'sc_images/room/rocket/reward_dialog_top.png';

View File

@ -469,15 +469,6 @@ class _RewardDialogCanvas extends StatelessWidget {
} }
bool _handleHorizontalScrollNotification(ScrollNotification notification) { bool _handleHorizontalScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.horizontal) {
return false;
}
if (!hasMore || isLoadingMore) {
return false;
}
if (notification.metrics.extentAfter <= s(96)) {
onLoadMore?.call();
}
return false; return false;
} }

View File

@ -70,10 +70,12 @@ class _RoomRocketRewardDialogLoaderState
widget.initialPopupRecords.isNotEmpty && widget.initialPopupRecords.isNotEmpty &&
!_usedInitialPopupRecords; !_usedInitialPopupRecords;
final nextPopupRecords = final nextPopupRecords =
useInitialPopupRecords reset
? widget.initialPopupRecords ? useInitialPopupRecords
: await _loadPopupRecords(requestCursor); ? widget.initialPopupRecords
if (useInitialPopupRecords) { : await _loadPopupRecords(requestCursor)
: const <SCRoomRocketRewardRecordRes>[];
if (reset && useInitialPopupRecords) {
_usedInitialPopupRecords = true; _usedInitialPopupRecords = true;
} }
final nextRoomRecords = await _loadRoomRewardRecords(requestCursor); final nextRoomRecords = await _loadRoomRewardRecords(requestCursor);
@ -89,16 +91,13 @@ class _RoomRocketRewardDialogLoaderState
..clear() ..clear()
..addAll(nextRoomRecords); ..addAll(nextRoomRecords);
} else { } else {
_popupRecords.addAll(nextPopupRecords);
_roomRecords.addAll(nextRoomRecords); _roomRecords.addAll(nextRoomRecords);
} }
_rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords); _rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords);
_winners = RoomRocketApiMapper.winnersFromRecords( _winners = RoomRocketApiMapper.winnersFromRecords(
_visibleRoomRewardRecords(), _visibleRoomRewardRecords(),
); );
_hasMore = _hasMore = nextRoomRecords.length >= _rewardDialogPageSize;
nextPopupRecords.length >= _rewardDialogPageSize ||
nextRoomRecords.length >= _rewardDialogPageSize;
if (_hasMore) { if (_hasMore) {
_cursor = requestCursor + 1; _cursor = requestCursor + 1;
} }

View File

@ -85,6 +85,11 @@ class _MsgItemState extends State<MsgItem> {
return _buildRocketRewardMsg(context); return _buildRocketRewardMsg(context);
} }
///
if (widget.msg.type == SCRoomMsgType.rocketLaunchNotice) {
return _buildRocketLaunchNoticeMsg(context);
}
/// ///
if (widget.msg.type == SCRoomMsgType.gameLuckyGift) { if (widget.msg.type == SCRoomMsgType.gameLuckyGift) {
return _buildGameLuckyGiftMsg(context); return _buildGameLuckyGiftMsg(context);
@ -764,6 +769,73 @@ class _MsgItemState extends State<MsgItem> {
return netImage(url: iconUrl, width: 18.w); return netImage(url: iconUrl, width: 18.w);
} }
Widget _buildRocketLaunchNoticeMsg(BuildContext context) {
final seconds =
(widget.msg.number ?? num.tryParse(widget.msg.msg ?? '') ?? 10).toInt();
final template =
SCAppLocalizations.of(
context,
)?.roomRocketFuelFullNotice(seconds.toString()) ??
'Rocket fuel is full! Launching in ${seconds}s!';
final iconUrl = widget.msg.rocketIconUrl?.trim() ?? '';
return Container(
alignment: AlignmentDirectional.topStart,
child: Container(
constraints: BoxConstraints(minHeight: 42.w, maxWidth: 304.w),
padding: EdgeInsetsDirectional.only(
start: 12.w,
end: 10.w,
top: 8.w,
bottom: 8.w,
),
margin: EdgeInsetsDirectional.only(start: 10.w, end: 36.w, bottom: 8.w),
decoration: BoxDecoration(
color: const Color(0xFFF1F2FF).withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(9.w),
border: Border.all(color: const Color(0xFF6672FF), width: 1.w),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Text(
template,
textScaler: TextScaler.noScaling,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFF6672FF),
fontSize: sp(13),
height: 1.2,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 8.w),
SizedBox(
width: 42.w,
height: 28.w,
child:
iconUrl.isEmpty
? Icon(
Icons.rocket_launch_rounded,
color: const Color(0xFFB8BEFA),
size: 28.w,
)
: netImage(
url: iconUrl,
width: 42.w,
height: 28.w,
fit: BoxFit.contain,
noDefaultImg: true,
),
),
],
),
),
);
}
/// ///
_buildGameLuckyGiftMsg(BuildContext context) { _buildGameLuckyGiftMsg(BuildContext context) {
List<InlineSpan> spans = []; List<InlineSpan> spans = [];

View File

@ -139,6 +139,42 @@ void main() {
expect(groups[2]?.inRoom.single.expireDays, 7); expect(groups[2]?.inRoom.single.expireDays, 7);
}); });
test('normalizes non-gold rocket reward days display text', () {
final rewards = [
SCRoomRocketRewardRes.fromJson({
'rewardType': 'PROPS',
'rewardAmount': '7d',
}),
SCRoomRocketRewardRes.fromJson({
'rewardType': 'PROPS',
'rewardAmount': 1,
}),
SCRoomRocketRewardRes.fromJson({
'rewardType': 'PROPS',
'rewardAmount': 1,
'expireDays': '30D',
}),
SCRoomRocketRewardRes.fromJson({
'rewardType': 'GOLD',
'rewardAmount': 100,
}),
].map(RoomRocketApiMapper.rewardItemFromPreview).toList(growable: false);
expect(rewards[0].displayText, '7d');
expect(rewards[1].displayText, '1d');
expect(rewards[2].displayText, '30d');
expect(rewards[3].displayText, '100');
final recordRewards = RoomRocketApiMapper.rewardItemsFromRecords([
SCRoomRocketRewardRecordRes.fromJson({
'rewardType': 'PROPS',
'rewardAmount': '7d',
}),
]);
expect(recordRewards.single.displayText, '7d');
});
test('parses rocket level contents response from go api', () { test('parses rocket level contents response from go api', () {
final contents = SCRoomRocketLevelContentsRes.fromJson({ final contents = SCRoomRocketLevelContentsRes.fromJson({
'configured': true, 'configured': true,