diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index 48ac919..d61df79 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -726,5 +726,6 @@ "roomRocketNoteSectionBody": "1. في كل مرة يتم فيها شراء مستوى VIP، تكون صلاحيته 30 يومًا، ويمكن تجديده يدويًا بعد انتهاء الصلاحية.", "roomRocketRewardEmptyText": "لم تحصل على مكافآت. حظًا أوفر في المرة القادمة.", "roomRocketRecordEmptyText": "لا توجد سجلات مكافآت", - "launchRocket": "إطلاق الصاروخ" + "launchRocket": "إطلاق الصاروخ", + "roomRocketFuelFullNotice": "اكتمل وقود الصاروخ! سيتم الإطلاق بعد {1} ثانية!" } diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index a7cbbf2..e162d18 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -726,5 +726,6 @@ "roomRocketNoteSectionBody": "1. প্রতিবার VIP লেভেল কেনার পর এটি ৩০ দিনের জন্য বৈধ থাকে এবং মেয়াদ শেষ হলে ম্যানুয়ালি নবায়ন করা যায়।", "roomRocketRewardEmptyText": "কোনো পুরস্কার পাওয়া যায়নি। পরের বার শুভকামনা।", "roomRocketRecordEmptyText": "কোনো পুরস্কারের রেকর্ড নেই", - "launchRocket": "রকেট চালু করুন" + "launchRocket": "রকেট চালু করুন", + "roomRocketFuelFullNotice": "রকেটের জ্বালানি পূর্ণ! {1} সেকেন্ড পরে উৎক্ষেপণ!" } diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index 8913d5f..bca0970 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -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!" } diff --git a/assets/l10n/intl_fr.json b/assets/l10n/intl_fr.json index 52d5f02..c2ff866 100644 --- a/assets/l10n/intl_fr.json +++ b/assets/l10n/intl_fr.json @@ -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 !" } diff --git a/assets/l10n/intl_it.json b/assets/l10n/intl_it.json index 7d05004..7188dbb 100644 --- a/assets/l10n/intl_it.json +++ b/assets/l10n/intl_it.json @@ -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!" } diff --git a/assets/l10n/intl_pt.json b/assets/l10n/intl_pt.json index 0db691d..4c8759f 100644 --- a/assets/l10n/intl_pt.json +++ b/assets/l10n/intl_pt.json @@ -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!" } diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index 7715660..bffb8a7 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -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!" } diff --git a/lib/app/constants/sc_room_msg_type.dart b/lib/app/constants/sc_room_msg_type.dart index 4e3228b..a38aae4 100644 --- a/lib/app/constants/sc_room_msg_type.dart +++ b/lib/app/constants/sc_room_msg_type.dart @@ -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"; diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart index 01f121b..d6ffbb0 100644 --- a/lib/app_localizations.dart +++ b/lib/app_localizations.dart @@ -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'); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 47ae4f9..f224c60 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -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 _sentRoomRocketRewardMessageKeys = {}; 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 _sendRoomRocketRewardPublicMessages( - String roomId, - Map 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 _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> _loadRocketRewardPublicRecords( - String roomId, - String launchNo, - ) async { - if (roomId.trim().isEmpty || launchNo.trim().isEmpty) { - return const []; - } - 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 []; - } - } - - List _rocketRewardRecordsFromPayload( - Map 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 []; - } - - 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 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 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 _rocketRewardUserPayload( - Map 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 {'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 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 _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 _payloadStringList(dynamic value) { if (value is Iterable) { return value diff --git a/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart index 9fbc4c0..a4fc3ca 100644 --- a/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart +++ b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart @@ -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; diff --git a/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart index 7d5046d..0ff31c4 100644 --- a/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart +++ b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart @@ -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 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; +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart index bf40c36..6e7a887 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart @@ -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'; diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart index 227324b..86fa2af 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart @@ -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; } diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart index be89a38..a51c1fa 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart @@ -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 []; + 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; } diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index 9323fdd..aae2a11 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -85,6 +85,11 @@ class _MsgItemState extends State { 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 { 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 spans = []; diff --git a/test/room_rocket_status_test.dart b/test/room_rocket_status_test.dart index e7b531f..81265bf 100644 --- a/test/room_rocket_status_test.dart +++ b/test/room_rocket_status_test.dart @@ -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,