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 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية.",
"roomRocketRewardEmptyText": "لم تحصل على مكافآت. حظًا أوفر في المرة القادمة.",
"roomRocketRecordEmptyText": "لا توجد سجلات مكافآت",
"launchRocket": "إطلاق الصاروخ"
"launchRocket": "إطلاق الصاروخ",
"roomRocketFuelFullNotice": "اكتمل وقود الصاروخ! سيتم الإطلاق بعد {1} ثانية!"
}

View File

@ -726,5 +726,6 @@
"roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।",
"roomRocketRewardEmptyText": "কোনো পুরস্কার পাওয়া যায়নি। পরের বার শুভকামনা।",
"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.",
"roomRocketRewardEmptyText": "No rewards earned. Better luck next time.",
"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.",
"roomRocketRewardEmptyText": "Aucune récompense gagnée. Plus de chance la prochaine fois.",
"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.",
"roomRocketRewardEmptyText": "Nessun premio guadagnato. Più fortuna la prossima volta.",
"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.",
"roomRocketRewardEmptyText": "Nenhuma recompensa ganha. Melhor sorte da próxima vez.",
"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.",
"roomRocketRewardEmptyText": "Ödül kazanmadın. Bir dahaki sefere bol şans.",
"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 rocketLaunchNotice = "ROCKET_LAUNCH_NOTICE";
///
static const String voiceRoomRocketStatusUpdate =
"VOICE_ROOM_ROCKET_STATUS_UPDATE";

View File

@ -845,6 +845,9 @@ class SCAppLocalizations {
String get launchRocket => translate('launchRocket');
String roomRocketFuelFullNotice(String seconds) =>
translate('roomRocketFuelFullNotice').replaceAll('{1}', seconds);
String get useCoupontips => translate('useCoupontips');
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;
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_room_rocket_api_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/ui_kit/widgets/room/invite/invite_room_dialog.dart';
@ -119,7 +118,6 @@ class RealTimeMessagingManager extends ChangeNotifier {
static const int _luckyGiftBurstPlaybackWatchdogMs = 8000;
BuildContext? context;
final Set<String> _sentRoomRocketRewardMessageKeys = <String>{};
void _giftFxLog(String message) {}
@ -1106,6 +1104,8 @@ class RealTimeMessagingManager extends ChangeNotifier {
return true;
case SCRoomMsgType.rocketRewardUser:
return true;
case SCRoomMsgType.rocketLaunchNotice:
return true;
case SCRoomMsgType.emoticons:
return (msg.number ?? -1) < 0;
default:
@ -1139,6 +1139,10 @@ class RealTimeMessagingManager extends ChangeNotifier {
_hasRoomRocketRewardMessage(msg)) {
return;
}
if (msg.type == SCRoomMsgType.rocketLaunchNotice &&
_hasRoomRocketNoticeMessage(msg)) {
return;
}
roomAllMsgList.insert(0, msg);
if (roomAllMsgList.length > 250) {
@ -1177,6 +1181,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomChatMsgList.removeAt(roomChatMsgList.length - 1);
}
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 ||
msg.type == SCRoomMsgType.luckGiftAnimOther) {
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) {
final stableKey = (msg.giftBatchId ?? '').trim();
if (stableKey.isNotEmpty) {
@ -1891,6 +1913,15 @@ class RealTimeMessagingManager extends ChangeNotifier {
rtcProvider.shouldShowRoomVisualEffects &&
rtcProvider.isVoiceRoomRouteVisible;
if (isCurrentVisibleRoom) {
addMsg(
_buildRoomRocketLaunchNoticeMsg(
_firstNonBlank([
rtcProvider.currenRoom?.roomProfile?.roomProfile?.roomAccount,
launch.roomAccount,
]),
launch,
),
);
rtcProvider.handleRoomRocketLaunchBroadcast(launch);
}
}
@ -1924,260 +1955,6 @@ class RealTimeMessagingManager extends ChangeNotifier {
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) {
final currentContext = context;
if (currentContext == null || !currentContext.mounted) {
@ -2196,29 +1973,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
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 {
try {
final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId);
if (res.records.isEmpty) {
return;
}
unawaited(
_sendRoomRocketRewardPublicMessages(roomId, {
'records': res.records.map((item) => item.toJson()).toList(),
}),
);
final recordIds = res.records
.map((item) => item.id)
.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) {
if (value is Iterable) {
return value

View File

@ -376,8 +376,8 @@ class SCRoomRocketRewardRes {
_asMap(data['config'])['rocketIconUrl'],
_asMap(data['config'])['rocketIcon'],
]),
rewardAmount: _numValue(data['rewardAmount']) ?? 0,
expireDays: _intValue(data['expireDays']),
rewardAmount: _rewardAmountValue(data['rewardAmount']) ?? 0,
expireDays: _dayValue(data['expireDays']),
);
}
@ -887,7 +887,7 @@ class SCRoomRocketRewardRecordRes {
rewardItemId: _stringValue(data['rewardItemId']),
rewardName: _stringValue(data['rewardName']),
rewardCover: _stringValue(data['rewardCover']),
rewardAmount: _numValue(data['rewardAmount']) ?? 0,
rewardAmount: _rewardAmountValue(data['rewardAmount']) ?? 0,
rocketIconUrl: _firstAssetString([
data['rocket_icon_url'],
data['rocketIconUrl'],
@ -903,7 +903,7 @@ class SCRoomRocketRewardRecordRes {
_asMap(data['config'])['rocketIconUrl'],
_asMap(data['config'])['rocketIcon'],
]),
expireDays: _intValue(data['expireDays']),
expireDays: _dayValue(data['expireDays']),
grantStatus: _stringValue(data['grantStatus']),
popupStatus: _stringValue(data['popupStatus']),
grantTime: _stringValue(data['grantTime']),
@ -1116,6 +1116,24 @@ num? _numValue(dynamic 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}) {
if (value is bool) {
return value;

View File

@ -25,7 +25,17 @@ class RoomRocketLaunchBroadcastMessage {
level: _intValue(data['level'], fallback: 1),
rocketIconUrl: _rocketIconUrl(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']),
triggerUserId: _stringValue(data['triggerUserId']),
launchNo: _stringValue(data['launchNo']),
@ -59,7 +69,7 @@ class RoomRocketLaunchBroadcastMessage {
Duration get displayDuration {
if (durationSeconds <= 0) {
return const Duration(seconds: 7);
return const Duration(seconds: 10);
}
return Duration(seconds: durationSeconds);
}
@ -195,3 +205,18 @@ int _intValue(dynamic value, {int fallback = 0}) {
}
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) {
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() {
final previewImageUrl = selectedLevelItem.previewImageUrl?.trim() ?? '';
return Positioned(
left: s(88),
top: s(111),
width: s(200),
height: s(432),
child: _RocketImage(
asset: selectedLevelItem.asset ?? _RocketAssets.rocketBody,
imageUrl: selectedLevelItem.previewImageUrl,
imageUrl: previewImageUrl.isEmpty ? null : previewImageUrl,
fit: BoxFit.contain,
preferSvg: true,
pagDisplayScale: 2,
pagClipBehavior: Clip.none,
allowStaticFallback: false,
emptyBuilder: (_) => const SCRotatingDotsLoading(),
debugName:
'centerPreview level=${selectedLevelItem.level} '
'preview=${selectedLevelItem.previewImageUrl ?? '-'}',
@ -470,11 +493,6 @@ class _RocketCanvas extends StatelessWidget {
}
Widget _buildProgress() {
final asset = switch (progressStage) {
RoomRocketProgressStage.empty => _RocketAssets.progressEmpty,
RoomRocketProgressStage.charging => _RocketAssets.progressPartial,
RoomRocketProgressStage.full => _RocketAssets.progressFull,
};
final progressImageUrl = selectedLevelItem.progressImageUrl?.trim() ?? '';
return Stack(
children: [
@ -483,10 +501,10 @@ class _RocketCanvas extends StatelessWidget {
top: s(227),
width: s(44),
height: s(134),
child: _RocketImage(
asset: asset,
child: _RocketProgressBar(
percent: percent,
progressStage: progressStage,
imageUrl: progressImageUrl.isEmpty ? null : progressImageUrl,
fit: BoxFit.fill,
),
),
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 {
const _RewardCard({required this.scale, required this.item, this.onTap});
@ -978,6 +1086,8 @@ class _RocketImage extends StatelessWidget {
this.preferSvg = false,
this.pagDisplayScale = 1,
this.pagClipBehavior = Clip.hardEdge,
this.allowStaticFallback = true,
this.emptyBuilder,
this.debugName,
});
@ -987,6 +1097,8 @@ class _RocketImage extends StatelessWidget {
final bool preferSvg;
final double pagDisplayScale;
final Clip pagClipBehavior;
final bool allowStaticFallback;
final WidgetBuilder? emptyBuilder;
final String? debugName;
@override
@ -1035,14 +1147,23 @@ class _RocketImage extends StatelessWidget {
errorBuilder: (_, error, __) {
_debugImage('network svg failed: $error');
return preferSvg && !_isSvg(source)
? CustomCachedImage(imageUrl: source, fit: fit)
? CustomCachedImage(
imageUrl: source,
fit: fit,
errorWidget:
allowStaticFallback ? null : const SizedBox.shrink(),
)
: _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();
_debugImage('load asset ${_shortSource(source)}');
if (SCNetworkSvgaWidget.isSvga(source)) {
@ -1080,7 +1201,7 @@ class _RocketImage extends StatelessWidget {
}
return Image.asset(source, fit: fit, gaplessPlayback: true);
}
return const SizedBox.shrink();
return emptyBuilder?.call(context) ?? const SizedBox.shrink();
}
static bool _isSvg(String value) {
@ -1123,6 +1244,9 @@ class _RocketImage extends StatelessWidget {
}
Widget _imageFallback() {
if (!allowStaticFallback) {
return const SizedBox.shrink();
}
if (asset != null &&
asset!.trim().isNotEmpty &&
!_isSvg(asset!) &&
@ -1747,8 +1871,6 @@ class _RocketAssets {
'sc_images/room/rocket/bottom_avatar_ring.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 progressPartial =
'sc_images/room/rocket/progress_partial.png';
static const String progressEmpty =
'sc_images/room/rocket/progress_empty.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) {
if (notification.metrics.axis != Axis.horizontal) {
return false;
}
if (!hasMore || isLoadingMore) {
return false;
}
if (notification.metrics.extentAfter <= s(96)) {
onLoadMore?.call();
}
return false;
}

View File

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

View File

@ -85,6 +85,11 @@ class _MsgItemState extends State<MsgItem> {
return _buildRocketRewardMsg(context);
}
///
if (widget.msg.type == SCRoomMsgType.rocketLaunchNotice) {
return _buildRocketLaunchNoticeMsg(context);
}
///
if (widget.msg.type == SCRoomMsgType.gameLuckyGift) {
return _buildGameLuckyGiftMsg(context);
@ -764,6 +769,73 @@ class _MsgItemState extends State<MsgItem> {
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) {
List<InlineSpan> spans = [];

View File

@ -139,6 +139,42 @@ void main() {
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', () {
final contents = SCRoomRocketLevelContentsRes.fromJson({
'configured': true,