diff --git a/lib/modules/chat/message_chat_page.dart b/lib/modules/chat/message_chat_page.dart index 9462608..811b734 100644 --- a/lib/modules/chat/message_chat_page.dart +++ b/lib/modules/chat/message_chat_page.dart @@ -1868,14 +1868,7 @@ class _MessageItem extends StatelessWidget { SCSystemInvitMessageRes? _cpInviteFromMessageData(dynamic data) { try { - final content = data is Map ? data["content"] : null; - if (content is String && content.trim().isNotEmpty) { - final decoded = jsonDecode(content); - return SCSystemInvitMessageRes.fromJson(decoded); - } - if (content is Map) { - return SCSystemInvitMessageRes.fromJson(content); - } + return SCSystemInvitMessageRes.fromMessageData(data); } catch (error) { _cpMessageLog('parse invite failed error=$error'); } diff --git a/lib/modules/chat/system/message_system_page.dart b/lib/modules/chat/system/message_system_page.dart index ead845d..84abd24 100644 --- a/lib/modules/chat/system/message_system_page.dart +++ b/lib/modules/chat/system/message_system_page.dart @@ -1843,14 +1843,7 @@ class _MessageItem extends StatelessWidget { SCSystemInvitMessageRes? _cpInviteFromMessageData(dynamic data) { try { - final content = data is Map ? data["content"] : null; - if (content is String && content.trim().isNotEmpty) { - final decoded = jsonDecode(content); - return SCSystemInvitMessageRes.fromJson(decoded); - } - if (content is Map) { - return SCSystemInvitMessageRes.fromJson(content); - } + return SCSystemInvitMessageRes.fromMessageData(data); } catch (_) {} return null; } diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index 1b8543c..27ecc0f 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -18,10 +18,9 @@ import 'package:yumi/app/config/business_logic_strategy.dart'; import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/remote/net/api.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; -import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_system_invit_message_res.dart'; import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; import 'package:yumi/main.dart'; import 'package:provider/provider.dart'; @@ -84,6 +83,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { TabController? _tabController; List _tabTypes = []; bool _didApplyInitialTab = false; + bool _showFirstRechargeGiftEntry = false; /// 业务逻辑策略访问器 BusinessLogicStrategy get _strategy => SCGlobalConfig.businessLogicStrategy; @@ -240,6 +240,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { listen: false, ).giftBackpack(forceRefresh: true), ); + unawaited(_loadFirstRechargeGiftEntryState()); Provider.of(context, listen: false).balance(); rtcProvider?.roomWheatMap.forEach((k, v) { if (v.user != null) { @@ -275,6 +276,35 @@ class _GiftPageState extends State with TickerProviderStateMixin { _normalizeCpRecipientSelection(notify: true); } + Future _loadFirstRechargeGiftEntryState() async { + if (SCGlobalConfig.isReview || AccountStorage().getToken().isEmpty) { + return; + } + try { + final home = await SCConfigRepositoryImp().firstRechargeRewardHome(); + if (!mounted) { + return; + } + final shouldShow = home.shouldShowRewardDialog; + if (_showFirstRechargeGiftEntry == shouldShow) { + return; + } + setState(() { + _showFirstRechargeGiftEntry = shouldShow; + }); + } catch (error) { + if (kDebugMode) { + debugPrint('[FirstRecharge][gift] load entry failed: $error'); + } + if (!mounted || !_showFirstRechargeGiftEntry) { + return; + } + setState(() { + _showFirstRechargeGiftEntry = false; + }); + } + } + bool get _isCurrentCpGiftTab { final controller = _tabController; if (controller == null || controller.index >= _tabTypes.length) { @@ -287,9 +317,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { bool _isCpGift(SocialChatGiftRes? gift) => scIsCpGift(gift); - String _cpRelationTypeForGift(SocialChatGiftRes gift) => - scCpRelationTypeForGift(gift); - bool get _isCpRecipientSingleMode => _isCurrentCpGiftTab || _isCpGift(checkedGift); @@ -703,10 +730,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { final currentTabType = giftTabs[tabController.index].type; final isCpTab = currentTabType == "CP"; final showFirstRechargeEntry = - !SCGlobalConfig.isReview && - !isCpTab && - (AccountStorage().getCurrentUser()?.userProfile?.firstRecharge ?? - false); + !SCGlobalConfig.isReview && !isCpTab && _showFirstRechargeGiftEntry; _ensureCurrentTabSelection(ref, giftTabs[tabController.index].type); final isCpRecipientSingleMode = isCpTab || _isCpGift(checkedGift); return SafeArea( @@ -1577,7 +1601,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { } await _refreshGiftBackpack(); _refreshCpStateAfterGift(request, profileManager); - _sendCpInviteMessageAfterGift(request); return; } @@ -1640,7 +1663,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { } profileManager?.updateBalance(result); _refreshCpStateAfterGift(request, profileManager); - _sendCpInviteMessageAfterGift(request); } catch (e) { final errorMessage = request.isBackpackGiftRequest && _isBackpackQuantityError(e) @@ -1683,90 +1705,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { unawaited(profileManager?.refreshLoadedCpProfiles()); } - void _sendCpInviteMessageAfterGift(_GiftSendRequest request) { - if (!_isCpGift(request.gift)) { - return; - } - if (request.acceptUserIds.length != 1 || request.acceptUsers.length != 1) { - _giftFxLog( - 'skip relation invite C2C reason=invalid_accept_count ' - 'acceptUserIds=${request.acceptUserIds.join(",")}', - ); - return; - } - unawaited(_sendCpInviteMessageAfterGiftAsync(request)); - } - - Future _sendCpInviteMessageAfterGiftAsync( - _GiftSendRequest request, - ) async { - final relationType = _cpRelationTypeForGift(request.gift); - final receiverProfile = request.acceptUsers.first.user; - final receiverId = - (receiverProfile?.id ?? request.acceptUserIds.first).trim(); - final currentProfile = AccountStorage().getCurrentUser()?.userProfile; - if (receiverId.isEmpty || currentProfile == null) { - _giftFxLog( - 'skip relation invite C2C reason=missing_user ' - 'receiverId=$receiverId relationType=$relationType ' - 'hasCurrent=${currentProfile != null}', - ); - return; - } - final providerContext = navigatorKey.currentState?.context ?? context; - final rtmProvider = Provider.of( - providerContext, - listen: false, - ); - try { - final applyId = await SCAccountRepository().cpRelationshipApplyId( - receiverId, - relationType: relationType, - ); - if ((applyId ?? "").trim().isEmpty) { - _giftFxLog( - 'skip relation invite C2C reason=no_apply_id ' - 'receiverId=$receiverId relationType=$relationType', - ); - return; - } - final expireAtSeconds = - DateTime.now().millisecondsSinceEpoch ~/ 1000 + 86400; - final giftCover = - (request.gift.giftPhoto ?? "").trim().isNotEmpty - ? request.gift.giftPhoto - : request.gift.giftSourceUrl; - final invite = SCSystemInvitMessageRes( - account: currentProfile.account ?? currentProfile.id, - actualAccount: currentProfile.id, - userAvatar: currentProfile.userAvatar ?? "", - userHeaddress: currentProfile.getHeaddress()?.sourceUrl, - userHeaddressCover: currentProfile.getHeaddress()?.cover, - userNickname: currentProfile.userNickname ?? "", - relationType: relationType, - dismissEndTime: expireAtSeconds, - giftCover: giftCover, - giftCount: request.quantity, - ); - _giftFxLog( - 'send relation invite C2C applyId=$applyId ' - 'receiverId=$receiverId relationType=$relationType ' - 'giftId=${request.gift.id}', - ); - await rtmProvider.sendCpInviteBuildMessage( - applyId: applyId!, - invite: invite, - receiverId: receiverId, - receiverProfile: receiverProfile, - ); - } catch (error) { - _giftFxLog( - 'send relation invite C2C failed ' - 'receiverId=$receiverId relationType=$relationType error=$error', - ); - } - } - Future> _giveBackpackGiftToRecipients( SCChatRoomRepository repository, _GiftSendRequest request, diff --git a/lib/modules/index/index_page.dart b/lib/modules/index/index_page.dart index fe26430..f2b0541 100644 --- a/lib/modules/index/index_page.dart +++ b/lib/modules/index/index_page.dart @@ -15,7 +15,9 @@ import 'package:provider/provider.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; +import 'package:yumi/shared/tools/sc_dialog_utils.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; @@ -50,6 +52,7 @@ class _SCIndexPageState extends State Locale? _lastLocale; bool _hasShownEntryDialogs = false; bool _hasShownRegisterRewardDialog = false; + bool _hasRequestedFirstRechargeDialog = false; bool _isShowingDailySignInDialog = false; bool _showRegisterRewardAfterDailySignIn = false; bool _isOpeningFirstRegisterRoomGame = false; @@ -438,14 +441,27 @@ class _SCIndexPageState extends State return; } + await _showDailySignInDialogIfNeeded(); + if (!mounted) { + return; + } + await _showEntryPopupIfNeeded(); if (!mounted) { return; } + await _showFirstRechargeDialogIfNeeded(); + if (!mounted) { + return; + } + + await _openFirstRegisterRoomGameIfNeeded(); + } + + Future _showDailySignInDialogIfNeeded() async { final dialogData = await _loadDailySignInDialogData(); if (!mounted || dialogData == null) { - await _openFirstRegisterRoomGameIfNeeded(); return; } _isShowingDailySignInDialog = true; @@ -461,7 +477,6 @@ class _SCIndexPageState extends State if (!mounted) { return; } - await _openFirstRegisterRoomGameIfNeeded(); } Future _showEntryPopupIfNeeded() async { @@ -474,6 +489,18 @@ class _SCIndexPageState extends State ); } + Future _showFirstRechargeDialogIfNeeded() async { + if (_hasRequestedFirstRechargeDialog || + SCGlobalConfig.isReview || + AccountStorage().getToken().isEmpty || + !mounted || + !(ModalRoute.of(context)?.isCurrent ?? false)) { + return; + } + _hasRequestedFirstRechargeDialog = true; + await SCDialogUtils.showFirstRechargeDialog(context); + } + Future _waitForRegisterRewardSocketIfNeeded() async { if (!DataPersistence.getAwaitRegisterRewardSocket() || DataPersistence.getPendingRegisterRewardDialog()) { diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 81319ba..c456fe0 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -79,6 +79,8 @@ class _VoiceRoomPageState extends State int _shownRoomStartupFailureToken = 0; RtcProvider? _rtcProvider; bool _roomProviderRebuildScheduled = false; + bool _roomRouteVisibleScheduled = false; + bool _roomVisualEffectsEnsureScheduled = false; ModalRoute? _routeObserverRoute; SCRoomTopLayerGuardLease? _coveredRouteGuardLease; bool _roomRouteVisible = false; @@ -87,14 +89,9 @@ class _VoiceRoomPageState extends State void initState() { super.initState(); _tabController = TabController(length: _chatTabCount, vsync: this); - Provider.of( - context, - listen: false, - ).setVoiceRoomRouteVisible(true); SCRoomTopLayerGuard().activeListenable.addListener( _handleRoomTopLayerGuardChanged, ); - _enableRoomVisualEffects(); _tabController.addListener(_handleTabChange); _subscription = eventBus.on().listen(( event, @@ -131,12 +128,12 @@ class _VoiceRoomPageState extends State _routeObserverRoute = route; modalRouteObserver.subscribe(this, route); if (route.isCurrent) { - _handleRoomRouteVisible(); + _scheduleRoomRouteVisible(); } else { _handleRoomRouteCovered(); } } - _ensureRoomVisualEffectsEnabled(); + _scheduleEnsureRoomVisualEffectsEnabled(); } @override @@ -184,6 +181,21 @@ class _VoiceRoomPageState extends State _setRoomRouteVisible(true); } + void _scheduleRoomRouteVisible() { + if (_roomRouteVisibleScheduled) { + return; + } + _roomRouteVisibleScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _roomRouteVisibleScheduled = false; + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) { + return; + } + _handleRoomRouteVisible(); + _ensureRoomVisualEffectsEnabled(); + }); + } + void _handleRoomRouteCovered() { _setRoomRouteVisible(false); _acquireCoveredRouteGuard(); @@ -260,6 +272,20 @@ class _VoiceRoomPageState extends State _enableRoomVisualEffects(); } + void _scheduleEnsureRoomVisualEffectsEnabled() { + if (_roomVisualEffectsEnsureScheduled) { + return; + } + _roomVisualEffectsEnsureScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _roomVisualEffectsEnsureScheduled = false; + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) { + return; + } + _ensureRoomVisualEffectsEnabled(); + }); + } + void _clearTransientRoomVisualEffects({required String reason}) { if (!mounted) { return; diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index baf5366..7c290a8 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -19,6 +19,7 @@ import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/app/routes/sc_routes.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/shared/tools/sc_lk_event_bus.dart'; import 'package:yumi/shared/tools/sc_pick_utils.dart'; @@ -1476,7 +1477,9 @@ class _PersonDetailPageState extends State { } String _profileRelationDays(CPRes relation) { - return _firstNonBlankProfileValue([relation.days, "0"]); + return scCpRelationDisplayDays( + _firstNonBlankProfileValue([relation.days, "0"]), + ); } String _profileRelationLevelDisplayText(String rawText, CPRes relation) { diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 3abc932..4046c67 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -308,6 +308,18 @@ class _PersistedVoiceRoomCleanupRequest { final bool clearMarkerOnly; } +class _RoomMicRelationProfileCache { + const _RoomMicRelationProfileCache({ + required this.profile, + required this.loadedAt, + }); + + final SocialChatUserProfile profile; + final DateTime loadedAt; + + bool isExpired(DateTime now, Duration ttl) => now.difference(loadedAt) > ttl; +} + class RealTimeCommunicationManager extends ChangeNotifier { static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog'; static const String _roomRocketPagLaunchDialogTag = @@ -327,6 +339,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { Duration(milliseconds: 1800), Duration(milliseconds: 3200), ]; + static const List _roomMicRelationTypes = [ + 'CP', + 'BROTHER', + 'SISTERS', + ]; + static const Duration _roomMicRelationCacheTtl = Duration(minutes: 5); static const int _roomPasswordNotTrueErrorCode = 9005; static const Duration _micListPollingInterval = Duration(seconds: 2); @@ -415,6 +433,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { Map roomWheatMap = {}; final Map _seatEmojiEventVersions = {}; int _emojiPlaybackEventVersion = 0; + final Map _roomMicRelationCache = + {}; + final Set _roomMicRelationLoadingUserIds = {}; final Set _roomMusicPublishingUserIds = {}; final Map _roomMusicPublishingClearTimers = {}; @@ -1428,13 +1449,266 @@ class RealTimeCommunicationManager extends ChangeNotifier { }) { final changed = !_sameMicMaps(roomWheatMap, nextMap); roomWheatMap = nextMap; + final relationCacheChanged = _mergeCachedRoomMicRelationProfiles(); + _scheduleRoomMicRelationHydration(); _syncSelfMicRuntimeState(); final roomMusicChanged = _pruneRoomMusicPublishingUsersToMicSeats(); - if (changed || notifyIfUnchanged || roomMusicChanged) { + if (changed || + notifyIfUnchanged || + roomMusicChanged || + relationCacheChanged) { notifyListeners(); } } + bool _mergeCachedRoomMicRelationProfiles() { + final now = DateTime.now(); + _roomMicRelationCache.removeWhere( + (_, cache) => cache.isExpired(now, _roomMicRelationCacheTtl), + ); + var changed = false; + final mergedMap = roomWheatMap.map((index, mic) { + final user = mic.user; + final userId = _roomMicRelationUserId(user); + if (user == null || userId.isEmpty) { + return MapEntry(index, mic); + } + final cache = _roomMicRelationCache[userId]; + if (cache == null || cache.isExpired(now, _roomMicRelationCacheTtl)) { + return MapEntry(index, mic); + } + final mergedUser = _mergeRoomMicRelationProfile(user, cache.profile); + if (identical(mergedUser, user)) { + return MapEntry(index, mic); + } + changed = true; + return MapEntry(index, mic.copyWith(user: mergedUser)); + }); + if (changed) { + roomWheatMap = mergedMap; + } + return changed; + } + + void _scheduleRoomMicRelationHydration() { + final roomId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); + if (roomId.isEmpty || roomWheatMap.isEmpty) { + return; + } + final now = DateTime.now(); + final queuedUserIds = {}; + for (final mic in roomWheatMap.values) { + final user = mic.user; + final userId = _roomMicRelationUserId(user); + if (user == null || userId.isEmpty || !queuedUserIds.add(userId)) { + continue; + } + final cache = _roomMicRelationCache[userId]; + if (cache == null && _hasCompleteRoomMicRelationLists(user)) { + continue; + } + if (cache != null && !cache.isExpired(now, _roomMicRelationCacheTtl)) { + continue; + } + if (!_roomMicRelationLoadingUserIds.add(userId)) { + continue; + } + unawaited(_hydrateRoomMicRelationProfile(roomId, userId, user)); + } + } + + Future _hydrateRoomMicRelationProfile( + String roomId, + String userId, + SocialChatUserProfile profile, + ) async { + try { + final hydratedProfile = await _loadRoomMicRelationProfile( + profile, + userId, + ); + _roomMicRelationCache[userId] = _RoomMicRelationProfileCache( + profile: hydratedProfile, + loadedAt: DateTime.now(), + ); + _applyHydratedRoomMicRelationProfile(roomId, userId, hydratedProfile); + } finally { + _roomMicRelationLoadingUserIds.remove(userId); + } + } + + Future _loadRoomMicRelationProfile( + SocialChatUserProfile profile, + String userId, + ) async { + final cpPairs = []; + final closeFriendPairs = []; + await Future.wait( + _roomMicRelationTypes.map((relationType) async { + final cabin = await _safeLoadRoomMicRelationCabin(userId, relationType); + final pair = cabin?.cpPairUserProfileCO; + if (!_hasRoomMicRelationPair(pair)) { + return; + } + final normalizedType = scNormalizeCpRelationType( + pair?.relationType ?? relationType, + ); + final enrichedPair = pair!.copyWith( + relationType: normalizedType, + cpValue: pair.cpValue ?? cabin?.cpVal?.toDouble(), + levelText: + pair.levelText ?? _roomMicRelationLevelTextFromCabin(cabin), + ); + if (normalizedType == "CP") { + cpPairs.add(enrichedPair); + } else { + closeFriendPairs.add(enrichedPair); + } + }), + ); + return profile.copyWith( + cpList: cpPairs, + closeFriendList: closeFriendPairs, + isCpRelation: cpPairs.isNotEmpty, + ); + } + + Future _safeLoadRoomMicRelationCabin( + String userId, + String relationType, + ) async { + try { + return await SCAccountRepository().cpCabin( + userId, + relationType: relationType, + ); + } catch (error) { + if (kDebugMode) { + debugPrint( + "[CP][MicRelation] cabin failed userId=$userId " + "relationType=$relationType error=$error", + ); + } + return null; + } + } + + String? _roomMicRelationLevelTextFromCabin(SCCpCabinRes? cabin) { + final config = cabin?.currentCpCabinConfigCO; + if (config is! Map) { + return null; + } + final level = _firstRoomMicRelationConfigText([ + config["level"], + config["cpLevel"], + config["cabinLevel"], + config["lv"], + ]); + if (level.isEmpty) { + return null; + } + return "Lv.$level"; + } + + String _firstRoomMicRelationConfigText(Iterable values) { + for (final value in values) { + final text = value?.toString().trim() ?? ""; + if (text.isNotEmpty) { + return text; + } + } + return ""; + } + + void _applyHydratedRoomMicRelationProfile( + String roomId, + String userId, + SocialChatUserProfile hydratedProfile, + ) { + final currentRoomId = + (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); + if (currentRoomId != roomId) { + return; + } + var changed = false; + final mergedMap = roomWheatMap.map((index, mic) { + final user = mic.user; + if (user == null || _roomMicRelationUserId(user) != userId) { + return MapEntry(index, mic); + } + final mergedUser = _mergeRoomMicRelationProfile(user, hydratedProfile); + if (identical(mergedUser, user)) { + return MapEntry(index, mic); + } + changed = true; + return MapEntry(index, mic.copyWith(user: mergedUser)); + }); + if (!changed) { + return; + } + roomWheatMap = mergedMap; + notifyListeners(); + } + + SocialChatUserProfile _mergeRoomMicRelationProfile( + SocialChatUserProfile user, + SocialChatUserProfile relationProfile, + ) { + final nextCpList = relationProfile.cpList ?? const []; + final nextCloseFriendList = + relationProfile.closeFriendList ?? const []; + if (_sameRoomMicRelationLists(user.cpList, nextCpList) && + _sameRoomMicRelationLists(user.closeFriendList, nextCloseFriendList)) { + return user; + } + return user.copyWith( + cpList: nextCpList, + closeFriendList: nextCloseFriendList, + isCpRelation: nextCpList.isNotEmpty, + ); + } + + bool _hasCompleteRoomMicRelationLists(SocialChatUserProfile profile) { + return profile.cpList != null && profile.closeFriendList != null; + } + + bool _hasRoomMicRelationPair(CPRes? pair) { + return (pair?.cpUserId?.trim().isNotEmpty ?? false) || + (pair?.cpAccount?.trim().isNotEmpty ?? false) || + (pair?.meUserId?.trim().isNotEmpty ?? false) || + (pair?.meAccount?.trim().isNotEmpty ?? false); + } + + bool _sameRoomMicRelationLists(List? left, List? right) { + final leftList = left ?? const []; + final rightList = right ?? const []; + if (leftList.length != rightList.length) { + return false; + } + for (var index = 0; index < leftList.length; index += 1) { + if (_roomMicRelationPairKey(leftList[index]) != + _roomMicRelationPairKey(rightList[index])) { + return false; + } + } + return true; + } + + String _roomMicRelationPairKey(CPRes pair) { + return [ + pair.meUserId?.trim() ?? "", + pair.meAccount?.trim() ?? "", + pair.cpUserId?.trim() ?? "", + pair.cpAccount?.trim() ?? "", + pair.relationType?.trim() ?? "", + pair.status?.trim() ?? "", + ].join("|"); + } + + String _roomMicRelationUserId(SocialChatUserProfile? user) { + return user?.id?.trim() ?? ""; + } + bool _pruneRoomMusicPublishingUsersToMicSeats() { if (_roomMusicPublishingUserIds.isEmpty) { return false; @@ -1519,6 +1793,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { await _roomRtcEngineAdapter?.leaveRoom(); } catch (error) {} if (clearMicSeats) { + _roomMicRelationCache.clear(); + _roomMicRelationLoadingUserIds.clear(); roomWheatMap.clear(); notifyListeners(); } diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 15357a2..a7768b6 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -14,6 +14,7 @@ import 'package:yumi/shared/tools/sc_message_utils.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_broadcast_assets.dart'; import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; @@ -293,6 +294,7 @@ class RealTimeMessagingManager extends ChangeNotifier { Timer? _roomRocketRewardPopupRetryTimer; Timer? _roomRocketRoomReadyRewardRetryTimer; final Set _shownCpInviteMessageKeys = {}; + final Set _forwardedCpInviteC2CMessageKeys = {}; int get currentLuckGiftBurstPlaybackToken => _luckGiftPushPlaybackToken; String? roomRedPacketBroadcastGroupId; String? roomRedPacketBroadcastRegionCode; @@ -886,6 +888,13 @@ class RealTimeMessagingManager extends ChangeNotifier { fallback: currentProfile.id, ); final relationType = scNormalizeCpRelationType(invite.relationType); + _forwardCpInviteC2CMessageIfNeeded( + sourceMessage: message, + data: data, + applyId: applyId, + invite: invite, + isSender: isSender, + ); if (isSender) { final receiver = _cpInviteReceiverFromMessageData(data); _cpInviteLog( @@ -968,14 +977,7 @@ class RealTimeMessagingManager extends ChangeNotifier { SCSystemInvitMessageRes? _cpInviteFromMessageData(Map data) { try { - final content = data["content"]; - if (content is String && content.trim().isNotEmpty) { - final decoded = jsonDecode(content); - return SCSystemInvitMessageRes.fromJson(decoded); - } - if (content is Map) { - return SCSystemInvitMessageRes.fromJson(content); - } + return SCSystemInvitMessageRes.fromMessageData(data); } catch (error) { _cpInviteLog('parse CP_BUILD content failed error=$error data=$data'); } @@ -1351,6 +1353,110 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } + void _forwardCpInviteC2CMessageIfNeeded({ + required V2TimMessage? sourceMessage, + required Map data, + required String applyId, + required SCSystemInvitMessageRes invite, + required bool isSender, + }) { + if (!isSender || sourceMessage?.isSelf == true) { + return; + } + final normalizedApplyId = applyId.trim(); + if (normalizedApplyId.isEmpty || + !_forwardedCpInviteC2CMessageKeys.add(normalizedApplyId)) { + return; + } + final receiverId = _cpInviteReceiverIdFromMessageData(data); + if (receiverId.isEmpty) { + _forwardedCpInviteC2CMessageKeys.remove(normalizedApplyId); + _cpInviteLog( + 'skip forwarding CP_BUILD C2C reason=no_receiver ' + 'applyId=$normalizedApplyId', + ); + return; + } + _cpInviteLog( + 'forward backend CP_BUILD to C2C ' + 'receiver=$receiverId applyId=$normalizedApplyId', + ); + unawaited( + sendCpInviteBuildMessage( + applyId: normalizedApplyId, + invite: invite, + receiverId: receiverId, + receiverProfile: _cpInviteReceiverProfileFromMessageData(data), + ), + ); + } + + String _cpInviteReceiverIdFromMessageData(Map data) { + final content = _cpInviteContentMap(data); + return _firstCpInviteValue([ + data["acceptActualAccount"]?.toString(), + data["acceptUserId"]?.toString(), + data["receiverActualAccount"]?.toString(), + data["receiverId"]?.toString(), + data["receiverUserId"]?.toString(), + data["targetUserId"]?.toString(), + data["toUserId"]?.toString(), + content["acceptActualAccount"]?.toString(), + content["acceptUserId"]?.toString(), + content["receiverActualAccount"]?.toString(), + content["receiverId"]?.toString(), + content["receiverUserId"]?.toString(), + content["targetUserId"]?.toString(), + content["toUserId"]?.toString(), + ]) ?? + ""; + } + + SocialChatUserProfile? _cpInviteReceiverProfileFromMessageData( + Map data, + ) { + final content = _cpInviteContentMap(data); + final id = _cpInviteReceiverIdFromMessageData(data); + final account = _firstCpInviteValue([ + data["acceptAccount"]?.toString(), + data["receiverAccount"]?.toString(), + content["acceptAccount"]?.toString(), + content["receiverAccount"]?.toString(), + ]); + final nickname = _firstCpInviteValue([ + data["acceptNickname"]?.toString(), + data["acceptUserNickname"]?.toString(), + data["receiverNickname"]?.toString(), + data["receiverUserNickname"]?.toString(), + content["acceptNickname"]?.toString(), + content["acceptUserNickname"]?.toString(), + content["receiverNickname"]?.toString(), + content["receiverUserNickname"]?.toString(), + ]); + final avatar = _firstCpInviteValue([ + data["acceptUserAvatar"]?.toString(), + data["acceptAvatar"]?.toString(), + data["receiverUserAvatar"]?.toString(), + data["receiverAvatar"]?.toString(), + content["acceptUserAvatar"]?.toString(), + content["acceptAvatar"]?.toString(), + content["receiverUserAvatar"]?.toString(), + content["receiverAvatar"]?.toString(), + ]); + if (id.isEmpty && + (account ?? "").isEmpty && + (nickname ?? "").isEmpty && + (avatar ?? "").isEmpty) { + return null; + } + return SocialChatUserProfile( + id: id.isNotEmpty ? id : null, + account: account, + userNickname: nickname, + userAvatar: avatar, + ); + } + Future sendCpInviteBuildMessage({ required String applyId, required SCSystemInvitMessageRes invite, @@ -2270,6 +2376,7 @@ class RealTimeMessagingManager extends ChangeNotifier { roomAllMsgList.removeAt(roomAllMsgList.length - 1); } msgAllListener?.call(msg); + _enqueueCpRelationBroadcastFloatingIfNeeded(msg); _showCpRelationFormedDialogIfNeeded(msg); if (msg.type == SCRoomMsgType.text) { @@ -2327,6 +2434,71 @@ class RealTimeMessagingManager extends ChangeNotifier { } } + void _enqueueCpRelationBroadcastFloatingIfNeeded(Msg msg) { + if (msg.type != SCRoomMsgType.cpSystemNotice) { + return; + } + final action = msg.noticeAction?.trim() ?? ""; + if (action != "RELATION_ESTABLISHED" && action != "RELATION_JOINED_ROOM") { + return; + } + if (!_isLiveCpRelationBroadcastMessage(msg) || + !_shouldShowCpRelationBroadcastFloating(msg.groupId ?? "")) { + return; + } + final relationType = scNormalizeCpRelationType(msg.role); + final isEntry = action == "RELATION_JOINED_ROOM"; + final firstProfile = isEntry ? msg.user : (msg.toUser ?? msg.user); + final secondProfile = isEntry ? msg.toUser : (msg.user ?? msg.toUser); + OverlayManager().addMessage( + SCFloatingMessage( + type: scCpRelationBroadcastFloatingType, + priority: 970, + roomId: msg.groupId ?? "", + userId: _roomMsgUserId(firstProfile), + toUserId: _roomMsgUserId(secondProfile), + userAvatarUrl: firstProfile?.userAvatar ?? "", + toUserAvatarUrl: secondProfile?.userAvatar ?? "", + userName: _roomMsgUserDisplayName(firstProfile), + toUserName: _roomMsgUserDisplayName(secondProfile), + giftUrl: scCpRelationBroadcastAsset(relationType), + giftId: action, + giftTab: relationType, + displayText: msg.msg ?? "", + durationSeconds: 5, + ), + ); + } + + bool _isLiveCpRelationBroadcastMessage(Msg msg) { + final timestamp = msg.time ?? 0; + if (timestamp <= 0) { + return true; + } + return DateTime.now().millisecondsSinceEpoch - timestamp < 60000; + } + + bool _shouldShowCpRelationBroadcastFloating(String groupId) { + final normalizedGroupId = groupId.trim(); + final currentContext = context; + if (normalizedGroupId.isEmpty || + currentContext == null || + !currentContext.mounted || + !_isCurrentVoiceRoomGroup(normalizedGroupId)) { + return false; + } + final rtcProvider = Provider.of( + currentContext, + listen: false, + ); + return rtcProvider.shouldShowRoomVisualEffects && + rtcProvider.isVoiceRoomRouteVisible; + } + + String _roomMsgUserId(SocialChatUserProfile? profile) { + return _firstCpInviteValue([profile?.id, profile?.account]) ?? ""; + } + void _showCpRelationFormedDialogIfNeeded(Msg msg) { if (msg.type != SCRoomMsgType.cpSystemNotice || msg.noticeAction != "RELATION_ESTABLISHED") { @@ -2792,6 +2964,93 @@ class RealTimeMessagingManager extends ChangeNotifier { OverlayManager().addMessage(_buildLuckyGiftFloatingMessage(broadCastRes)); } + void _handleGameWinBroadcast( + dynamic rawData, { + required bool isRegionBroadcast, + }) { + if (SCGlobalConfig.isReview) { + return; + } + final wrapper = _broadcastPayloadMap(rawData); + final payload = _broadcastPayloadMap(wrapper['data'] ?? rawData); + if (payload.isEmpty) { + return; + } + final winCoins = + _payloadNum(payload['currencyDiff']) ?? + _payloadNum(payload['winAmount']) ?? + _payloadNum(payload['awardAmount']) ?? + 0; + if (winCoins <= 0) { + return; + } + + final roomId = _payloadRoomId(payload); + final userId = _firstNonBlank([ + _payloadText(payload['account']), + _payloadText(payload['actualAccount']), + _payloadText(payload['userId']), + _payloadText(payload['sendUserId']), + _payloadText(payload['senderUserId']), + ]); + final userName = _firstNonBlank([ + _payloadText(payload['userNickname']), + _payloadText(payload['nickname']), + _payloadText(payload['userName']), + userId, + ]); + final gameId = _firstNonBlank([ + _payloadText(payload['currentGameId']), + _payloadText(payload['gameId']), + _payloadText(payload['providerGameId']), + _payloadText(payload['vendorGameId']), + _payloadText(payload['currentProviderGameId']), + _payloadText(payload['currentVendorGameId']), + ]); + final gameName = _firstNonBlank([ + _payloadText(payload['currentGameName']), + _payloadText(payload['gameName']), + _payloadText(payload['name']), + 'game', + ]); + final gameIconUrl = _firstNonBlank([ + _payloadAssetText(payload['gameUrl']), + _payloadAssetText(payload['currentGameCover']), + _payloadAssetText(payload['gameCover']), + _payloadAssetText(payload['cover']), + _payloadAssetText(payload['iconUrl']), + _payloadAssetText(payload['icon']), + ]); + final roundId = _firstNonBlank([ + _payloadText(payload['gameRoundId']), + _payloadText(payload['game_round_id']), + _payloadText(payload['roundId']), + _payloadText(payload['roundNo']), + ]); + + OverlayManager().addMessage( + SCFloatingMessage( + type: 2, + userId: userId, + userAvatarUrl: _payloadAssetText(payload['userAvatar']), + userName: userName, + toUserName: gameName, + giftUrl: gameIconUrl, + giftId: roundId, + gameId: gameId, + roomId: roomId, + coins: winCoins, + multiple: + _payloadNum(payload['multiple']) ?? + _payloadNum(payload['winMultiple']), + displayText: _payloadText(payload['msg']), + broadcastScope: + isRegionBroadcast ? SCFloatingMessage.broadcastScopeRegion : '', + priority: 1000, + ), + ); + } + void _handleRoomLuckyGiftMessage(SCBroadCastLuckGiftPush broadCastRes) { final rewardData = broadCastRes.data; if (rewardData == null) { @@ -4653,25 +4912,10 @@ class RealTimeMessagingManager extends ChangeNotifier { notifyListeners(); } } else if (type == "GAME_BAISHUN_WIN") { - if (SCGlobalConfig.isReview) { - ///审核状态不播放动画 - return; - } - var fdata = data["data"]; - var winCoins = fdata["currencyDiff"]; - if (winCoins > 14999) { - ///达到5000才飘屏 - SCFloatingMessage msg = SCFloatingMessage( - type: 2, - userId: fdata["account"], - userAvatarUrl: fdata["userAvatar"], - userName: fdata["userNickname"], - giftUrl: fdata["gameUrl"], - roomId: fdata["roomId"], - coins: fdata["currencyDiff"], - ); - OverlayManager().addMessage(msg); - } + _handleGameWinBroadcast( + data, + isRegionBroadcast: isRegionBroadcastGroup, + ); } else if (type == "GAME_LUCKY_GIFT") { final broadCastRes = SCBroadCastLuckGiftPush.fromJson(data); _giftFxLog( @@ -4890,6 +5134,10 @@ class RealTimeMessagingManager extends ChangeNotifier { } return; } + if (data["type"] == "GAME_BAISHUN_WIN") { + _handleGameWinBroadcast(data, isRegionBroadcast: false); + return; + } Msg msg = Msg.fromJson(data); if (msg.type == SCRoomMsgType.sendGift || diff --git a/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart b/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart index 893b94a..c2a5983 100644 --- a/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart +++ b/lib/shared/business_logic/models/res/sc_system_invit_message_res.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + // account : "" // countryCode : "" // countryName : "" @@ -62,7 +64,7 @@ class SCSystemInvitMessageRes { _actualAccount = json['actualAccount']; _applyType = json['applyType']; _content = json['content']; - _relationType = json['relationType']; + _relationType = _firstNonBlank(json, _relationTypeKeys); _dismissEndTime = _asInt(json['dismissEndTime']); _giftCover = json['giftCover']; _giftCount = _asInt(json['giftCount']); @@ -126,8 +128,43 @@ class SCSystemInvitMessageRes { map['giftCount'] = _giftCount; return map; } + + static SCSystemInvitMessageRes? fromMessageData(dynamic data) { + final root = _stringKeyMap(data); + if (root.isEmpty) { + return null; + } + final content = _contentMap(root['content']); + if (content.isEmpty && + root.containsKey('content') && + !_hasInviteIdentity(root)) { + return null; + } + final source = {...(content.isNotEmpty ? content : root)}; + final relationType = + _firstNonBlank(source, _relationTypeKeys) ?? + _firstNonBlank(root, _relationTypeKeys) ?? + _relationTypeFromGiftPayload(source) ?? + _relationTypeFromGiftPayload(root); + if (relationType != null && relationType.isNotEmpty) { + source['relationType'] = relationType; + } + return SCSystemInvitMessageRes.fromJson(source); + } } +const List _relationTypeKeys = [ + 'relationType', + 'relation_type', + 'cpRelationType', + 'cp_relation_type', + 'relationshipType', + 'relationship_type', + 'relation', + 'cpType', + 'cp_type', +]; + String? _firstNonBlank(dynamic json, List keys) { if (json is! Map) { return null; @@ -140,3 +177,106 @@ String? _firstNonBlank(dynamic json, List keys) { } return null; } + +bool _hasInviteIdentity(Map source) { + return _firstNonBlank(source, const [ + 'account', + 'actualAccount', + 'userAvatar', + 'userNickname', + 'nickname', + ]) != + null; +} + +Map _stringKeyMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, item) => MapEntry(key.toString(), item)); + } + return const {}; +} + +Map _contentMap(dynamic content) { + if (content is String && content.trim().isNotEmpty) { + try { + final decoded = jsonDecode(content); + return _stringKeyMap(decoded); + } catch (_) { + return const {}; + } + } + return _stringKeyMap(content); +} + +String? _relationTypeFromGiftPayload(Map source) { + for (final key in const ['gift', 'giftInfo', 'giftConfig', 'giftVO']) { + final gift = _stringKeyMap(source[key]); + if (gift.isEmpty) { + continue; + } + final explicit = _firstNonBlank(gift, _relationTypeKeys); + if (explicit != null && explicit.isNotEmpty) { + return explicit; + } + final inferred = _inferRelationTypeFromGift(gift); + if (inferred != null) { + return inferred; + } + } + return null; +} + +String? _inferRelationTypeFromGift(Map gift) { + for (final key in const [ + 'giftCode', + 'giftName', + 'name', + 'special', + 'standardId', + ]) { + final inferred = _inferRelationTypeFromText(gift[key]?.toString()); + if (inferred != null) { + return inferred; + } + } + return null; +} + +String? _inferRelationTypeFromText(String? value) { + final upper = value?.trim().toUpperCase(); + if (upper == null || upper.isEmpty) { + return null; + } + if (_hasRelationToken(upper, const ['SIS', 'SISTER', 'SISTERS'])) { + return 'SISTERS'; + } + if (_hasRelationToken(upper, const [ + 'BRO', + 'BROS', + 'BROTHER', + 'BROTHERS', + 'SWORN_BRO', + 'SWORN_BROS', + 'SWORN_BROTHER', + 'SWORN_BROTHERS', + ])) { + return 'BROTHER'; + } + if (_hasRelationToken(upper, const ['CP', 'COUPLE'])) { + return 'CP'; + } + return null; +} + +bool _hasRelationToken(String upperText, List tokens) { + for (final token in tokens) { + final escaped = RegExp.escape(token); + if (RegExp('(^|[^A-Z0-9])$escaped([^A-Z0-9]|\$)').hasMatch(upperText)) { + return true; + } + } + return false; +} diff --git a/lib/shared/data_sources/models/message/sc_floating_message.dart b/lib/shared/data_sources/models/message/sc_floating_message.dart index 711a66e..e47f3ac 100644 --- a/lib/shared/data_sources/models/message/sc_floating_message.dart +++ b/lib/shared/data_sources/models/message/sc_floating_message.dart @@ -12,6 +12,8 @@ class SCFloatingMessage { String? fallbackUrl; // 兜底资源 String? giftId; // 礼物id String? giftTab; // 礼物分类 + String? gameId; // 游戏id + String? displayText; // 飘窗展示文案 int? type; int? rocketLevel; num? coins; @@ -35,6 +37,8 @@ class SCFloatingMessage { this.fallbackUrl = '', this.giftId = '', this.giftTab = '', + this.gameId = '', + this.displayText = '', this.number = 0, this.coins = 0, this.durationSeconds, @@ -57,6 +61,8 @@ class SCFloatingMessage { fallbackUrl = json['fallbackUrl']; giftId = json['giftId']; giftTab = json['giftTab']; + gameId = json['gameId']?.toString(); + displayText = json['displayText']?.toString(); coins = json['coins']; number = json['number']; durationSeconds = json['durationSeconds']; @@ -82,6 +88,8 @@ class SCFloatingMessage { map['fallbackUrl'] = fallbackUrl; map['giftId'] = giftId; map['giftTab'] = giftTab; + map['gameId'] = gameId; + map['displayText'] = displayText; map['coins'] = coins; map['number'] = number; map['durationSeconds'] = durationSeconds; diff --git a/lib/shared/data_sources/sources/local/floating_screen_manager.dart b/lib/shared/data_sources/sources/local/floating_screen_manager.dart index 443a203..1bcaa10 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -17,6 +17,7 @@ import 'package:yumi/ui_kit/widgets/room/floating/floating_luck_gift_screen_widg import 'package:yumi/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart'; import 'package:yumi/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart'; import 'package:yumi/ui_kit/widgets/room/floating/floating_vip_entry_screen_widget.dart'; +import 'package:yumi/ui_kit/widgets/room/floating/floating_cp_relation_screen_widget.dart'; import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; @@ -30,8 +31,9 @@ class OverlayManager { static const Duration _rocketFloatingFallbackDedupWindow = Duration( seconds: 30, ); + static const Duration _gameWinFloatingDedupWindow = Duration(seconds: 10); static const int _maxStackedFloatingVisibleCount = 2; - static const List _stackedFloatingOrder = [5, 1, 4, 3]; + static const List _stackedFloatingOrder = [6, 5, 1, 4, 3]; static const double _stackedFloatingBaseTop = 70; static const double _stackedFloatingGap = 8; @@ -190,10 +192,45 @@ class OverlayManager { } return 'room_red_packet|$packetId'; } + if (message.type == 2) { + final roundId = message.giftId?.trim() ?? ''; + final userId = message.userId?.trim() ?? ''; + if (roundId.isNotEmpty) { + return 'game_win|$roundId|$userId'; + } + final roomId = message.roomId?.trim() ?? ''; + final gameId = message.gameId?.trim() ?? ''; + final coins = message.coins ?? 0; + if (roomId.isEmpty && userId.isEmpty && gameId.isEmpty && coins == 0) { + return null; + } + return 'game_win_fallback|$roomId|$userId|$gameId|$coins'; + } + if (message.type == 6) { + final roomId = message.roomId?.trim() ?? ''; + final action = message.giftId?.trim() ?? ''; + final relationType = message.giftTab?.trim() ?? ''; + final userId = message.userId?.trim() ?? ''; + final toUserId = message.toUserId?.trim() ?? ''; + if (roomId.isEmpty && + action.isEmpty && + relationType.isEmpty && + userId.isEmpty && + toUserId.isEmpty) { + return null; + } + return 'cp_relation|$roomId|$action|$relationType|$userId|$toUserId'; + } return null; } Duration _floatingDedupWindowFor(SCFloatingMessage message) { + if (message.type == 2) { + return _gameWinFloatingDedupWindow; + } + if (message.type == 6) { + return _gameWinFloatingDedupWindow; + } if (message.type == 3 && (message.giftId?.trim() ?? '').isEmpty) { return _rocketFloatingFallbackDedupWindow; } @@ -245,13 +282,16 @@ class OverlayManager { unawaited( warmImageResource( message.giftUrl ?? "", - logicalWidth: message.type == 5 ? 350 : 96, - logicalHeight: message.type == 5 ? 84 : 96, + logicalWidth: message.type == 5 || message.type == 6 ? 350 : 96, + logicalHeight: message.type == 5 || message.type == 6 ? 84 : 96, ), ); if (message.type == 0) { unawaited(_warmLuckyGiftBannerSvga()); } + if (message.type == 2) { + unawaited(_warmGameWinBannerSvga()); + } } Future _warmLuckyGiftBannerSvga() async { @@ -264,6 +304,16 @@ class OverlayManager { } } + Future _warmGameWinBannerSvga() async { + try { + await SCSvgaAssetWidget.preloadAsset( + FloatingGameScreenWidget.backgroundSvgaAssetPath, + ); + } catch (error) { + // Preload failure is non-fatal; the banner will retry during rendering. + } + } + void _safeScheduleNext() { if (_isSuppressed) return; if (_isProcessing || _messageQueue.isEmpty) return; @@ -470,6 +520,12 @@ class OverlayManager { message: message, onAnimationCompleted: onComplete, ); + case 6: + //CP / 挚友关系飘窗 + return FloatingCpRelationScreenWidget( + message: message, + onAnimationCompleted: onComplete, + ); default: onComplete(); return Container(); @@ -487,6 +543,8 @@ class OverlayManager { return 4; case 5: return 5; + case 6: + return 6; default: return null; } @@ -528,6 +586,8 @@ class OverlayManager { return 66; case 5: return 84; + case 6: + return FloatingCpRelationScreenWidget.bannerHeight; default: return 68; } @@ -679,6 +739,9 @@ class OverlayManager { if (normalizedRoomId.isNotEmpty) { _removeMessagesByType(3, roomId: normalizedRoomId); } + if (normalizedRoomId.isNotEmpty) { + _removeMessagesByType(2, roomId: normalizedRoomId); + } if (normalizedRoomId.isNotEmpty) { _removeMessagesByType(4, roomId: normalizedRoomId); } @@ -734,9 +797,14 @@ class OverlayManager { return message.type == 3 && message.isRegionBroadcast; } + bool _isRegionGameWinMessage(SCFloatingMessage message) { + return message.type == 2 && message.isRegionBroadcast; + } + bool _isCrossRoomRegionFloatingMessage(SCFloatingMessage message) { return _isRegionRedPacketMessage(message) || - _isRegionRoomRocketMessage(message); + _isRegionRoomRocketMessage(message) || + _isRegionGameWinMessage(message); } bool _shouldDeferRegionRedPacketMessage( @@ -856,15 +924,25 @@ class OverlayManager { currentRoomId == messageRoomId; if (isCurrentVisibleRoom) { // 区域红包/火箭在当前房间仍然需要展示;区域礼物当前房间由 RTM 层跳过,避免和房间 IM 礼物飘屏重复。 - return message.type == 4 || message.type == 3; + return message.type == 4 || message.type == 3 || message.type == 2; } final isCurrentRoomMessage = currentRoomId.isNotEmpty && messageRoomId.isNotEmpty && currentRoomId == messageRoomId; - if ((message.type == 4 || message.type == 3) && isCurrentRoomMessage) { + if ((message.type == 4 || message.type == 3 || message.type == 2) && + isCurrentRoomMessage) { return false; } + if (message.type == 2) { + if (SCGlobalConfig.isFloatingBroadcastRoomOnly) { + return false; + } + if (rtcProvider.shouldShowRoomVisualEffects) { + return true; + } + return SCGlobalConfig.isFloatingBroadcastAll && _homeRootTabsVisible; + } if ((message.type == 4 || message.type == 3) && rtcProvider.shouldShowRoomVisualEffects) { return true; @@ -920,6 +998,7 @@ class OverlayManager { if (activeMessage != null && (activeMessage.type == 0 || activeMessage.type == 1 || + activeMessage.type == 2 || activeMessage.type == 3 || activeMessage.type == 5 || (includeRedPacket && activeMessage.type == 4)) && @@ -959,6 +1038,7 @@ class OverlayManager { final isRoomFloatingType = type == 0 || type == 1 || + type == 2 || type == 3 || type == 5 || (includeRedPacket && type == 4); diff --git a/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart index 6f4195c..c6e9509 100644 --- a/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_config_repository_imp.dart @@ -18,7 +18,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_top_four_with_reward_re import 'package:yumi/shared/business_logic/models/res/version_manage_lates_review_res.dart'; import 'package:yumi/shared/business_logic/repositories/config_repository.dart'; import 'package:yumi/shared/data_sources/sources/remote/net/api.dart' - show BaseNetworkClient; + show BaseNetworkClient, DioException; import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart'; import 'package:yumi/shared/tools/sc_deviceId_utils.dart'; @@ -58,23 +58,37 @@ class SCConfigRepositoryImp implements SocialChatConfigRepository { return result; } - ///go/app/popups/entry + ///app/popups/entry @override Future entryPopup() async { final deviceId = await SCDeviceIdUtils.getDeviceId(); - final result = await http.post( - "/go/app/popups/entry", - data: { - "scene": "APP_ENTER", - "platform": Platform.isIOS ? "ios" : "android", - "appVersion": SCGlobalConfig.version, - "deviceId": deviceId, - "language": SCGlobalConfig.lang, - }, + final data = { + "scene": "APP_ENTER", + "platform": Platform.isIOS ? "ios" : "android", + "appVersion": SCGlobalConfig.version, + "deviceId": deviceId, + "language": SCGlobalConfig.lang, + }; + try { + return await _entryPopupByPath("/app/popups/entry", data); + } on DioException catch (error) { + if (error.response?.statusCode != 404) { + rethrow; + } + return _entryPopupByPath("/go/app/popups/entry", data); + } + } + + Future _entryPopupByPath( + String path, + Map data, + ) { + return http.post( + path, + data: data, extra: const {BaseNetworkClient.silentErrorToastKey: true}, fromJson: (json) => SCEntryPopupRes.fromJson(json), ); - return result; } ///sys/config/enum/config diff --git a/lib/shared/tools/sc_cp_relation_broadcast_assets.dart b/lib/shared/tools/sc_cp_relation_broadcast_assets.dart new file mode 100644 index 0000000..42426ea --- /dev/null +++ b/lib/shared/tools/sc_cp_relation_broadcast_assets.dart @@ -0,0 +1,15 @@ +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; + +const int scCpRelationBroadcastFloatingType = 6; + +String scCpRelationBroadcastAsset(String? relationType) { + switch (scNormalizeCpRelationType(relationType)) { + case "BROTHER": + return "sc_images/room/cp_broadcast/brother_broadcast.pag"; + case "SISTERS": + return "sc_images/room/cp_broadcast/sisters_broadcast.pag"; + case "CP": + default: + return "sc_images/room/cp_broadcast/CP_broadcast.pag"; + } +} diff --git a/lib/shared/tools/sc_cp_relation_notice_utils.dart b/lib/shared/tools/sc_cp_relation_notice_utils.dart index cd15ed3..3ffb9bb 100644 --- a/lib/shared/tools/sc_cp_relation_notice_utils.dart +++ b/lib/shared/tools/sc_cp_relation_notice_utils.dart @@ -41,6 +41,17 @@ String scCpRelationPossessiveLabel(String? relationType) { } } +String scCpRelationEntryLabel(String? relationType) { + switch (scNormalizeCpRelationType(relationType)) { + case "BROTHER": + return "brother"; + case "SISTERS": + return "sister"; + default: + return "cp"; + } +} + String scCpRelationDialogTitle(String? relationType) { switch (scNormalizeCpRelationType(relationType)) { case "BROTHER": @@ -52,6 +63,19 @@ String scCpRelationDialogTitle(String? relationType) { } } +String scCpRelationDisplayDays(String? days) { + final text = days?.trim() ?? ""; + final value = num.tryParse(text.replaceAll(",", "")); + if (value == null) { + return text.isEmpty ? "1" : text; + } + final displayValue = value + 1; + if (displayValue % 1 == 0) { + return displayValue.toInt().toString(); + } + return displayValue.toString(); +} + String scBuildCpRelationEstablishedNotice({ required String userName1, required String userName2, @@ -66,7 +90,14 @@ String scBuildCpRelationJoinNotice({ required String joinedName, required String? relationType, }) { - return "[System Notification] $ownerName's " - "${scCpRelationPossessiveLabel(relationType)}, $joinedName, " - "has joined the party."; + return "[System Notification] ${scBuildCpRelationEntryBroadcastText(ownerName: ownerName, joinedName: joinedName, relationType: relationType)}."; +} + +String scBuildCpRelationEntryBroadcastText({ + required String ownerName, + required String joinedName, + required String? relationType, +}) { + return "$ownerName's ${scCpRelationEntryLabel(relationType)} " + "$joinedName enter the room"; } diff --git a/lib/shared/tools/sc_cp_relation_pair_utils.dart b/lib/shared/tools/sc_cp_relation_pair_utils.dart new file mode 100644 index 0000000..27679dc --- /dev/null +++ b/lib/shared/tools/sc_cp_relation_pair_utils.dart @@ -0,0 +1,130 @@ +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; + +class SCCpRelationPairInfo { + const SCCpRelationPairInfo({required this.relationType, this.cpLevel}); + + final String relationType; + final int? cpLevel; +} + +String? scCpRelationTypeBetweenProfiles( + SocialChatUserProfile? left, + SocialChatUserProfile? right, +) { + return scCpRelationInfoBetweenProfiles(left, right)?.relationType; +} + +SCCpRelationPairInfo? scCpRelationInfoBetweenProfiles( + SocialChatUserProfile? left, + SocialChatUserProfile? right, +) { + if (!_hasRelationIdentity(left) || !_hasRelationIdentity(right)) { + return null; + } + SCCpRelationPairInfo? fallbackInfo; + for (final relation in _relationsForProfile(left)) { + final relationInfo = _relationInfoIfContainsBoth(relation, left!, right!); + if (relationInfo != null) { + if (relationInfo.relationType != "CP" || relationInfo.cpLevel != null) { + return relationInfo; + } + fallbackInfo ??= relationInfo; + } + } + for (final relation in _relationsForProfile(right)) { + final relationInfo = _relationInfoIfContainsBoth(relation, left!, right!); + if (relationInfo != null) { + if (relationInfo.relationType != "CP" || relationInfo.cpLevel != null) { + return relationInfo; + } + fallbackInfo ??= relationInfo; + } + } + return fallbackInfo; +} + +int scCpProfileRelationVersion(SocialChatUserProfile? profile) { + if (profile == null) { + return 0; + } + return Object.hashAll( + _relationsForProfile(profile).map( + (relation) => Object.hash( + relation.meUserId ?? "", + relation.meAccount ?? "", + relation.cpUserId ?? "", + relation.cpAccount ?? "", + relation.relationType ?? "", + relation.status ?? "", + relation.levelText ?? "", + relation.cpValue ?? 0, + ), + ), + ); +} + +Iterable _relationsForProfile(SocialChatUserProfile? profile) sync* { + yield* profile?.cpList ?? const []; + yield* profile?.closeFriendList ?? const []; +} + +SCCpRelationPairInfo? _relationInfoIfContainsBoth( + CPRes relation, + SocialChatUserProfile left, + SocialChatUserProfile right, +) { + final leftKeys = _profileKeys(left); + final rightKeys = _profileKeys(right); + if (leftKeys.isEmpty || rightKeys.isEmpty) { + return null; + } + final relationKeys = _relationKeys(relation); + if (!relationKeys.any(leftKeys.contains) || + !relationKeys.any(rightKeys.contains)) { + return null; + } + final relationType = scNormalizeCpRelationType(relation.relationType); + return SCCpRelationPairInfo( + relationType: relationType, + cpLevel: relationType == "CP" ? _cpLevelFromRelation(relation) : null, + ); +} + +bool _hasRelationIdentity(SocialChatUserProfile? profile) => + _profileKeys(profile).isNotEmpty; + +Set _profileKeys(SocialChatUserProfile? profile) { + return { + _normalizedRelationKey(profile?.id), + _normalizedRelationKey(profile?.account), + }..remove(""); +} + +Set _relationKeys(CPRes relation) { + return { + _normalizedRelationKey(relation.meUserId), + _normalizedRelationKey(relation.meAccount), + _normalizedRelationKey(relation.cpUserId), + _normalizedRelationKey(relation.cpAccount), + }..remove(""); +} + +String _normalizedRelationKey(String? value) => value?.trim() ?? ""; + +int? _cpLevelFromRelation(CPRes relation) { + final levelText = relation.levelText?.trim() ?? ""; + if (levelText.isEmpty) { + return null; + } + final levelMatch = RegExp( + r'(?:lv\.?|level|等级|级)\s*([1-5])|([1-5])\s*(?:级|level)', + caseSensitive: false, + ).firstMatch(levelText); + final rawLevel = levelMatch?.group(1) ?? levelMatch?.group(2); + final level = int.tryParse(rawLevel ?? ""); + if (level == null) { + return null; + } + return level.clamp(1, 5); +} diff --git a/lib/shared/tools/sc_entry_popup_coordinator.dart b/lib/shared/tools/sc_entry_popup_coordinator.dart index 5664d95..7d562fa 100644 --- a/lib/shared/tools/sc_entry_popup_coordinator.dart +++ b/lib/shared/tools/sc_entry_popup_coordinator.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:provider/provider.dart'; @@ -68,43 +69,53 @@ class SCEntryPopupCoordinator { 'response requestId=${response.requestId} ' 'queue=${response.queue} popups=${response.popups.keys.toList()}', ); - final popup = _pickDisplayablePopup(response); - if (popup == null) { + final candidates = _displayablePopups(response); + if (candidates.isEmpty) { _log('skip reason=no-displayable-popup'); return; } - final imageUrl = normalizeImageResourceUrl(popup.image); - final imageReady = await warmImageResource( - imageUrl, - logicalWidth: 326, - timeout: const Duration(seconds: 8), - ); - if (!imageReady || !context.mounted) { - _log( - 'skip reason=image-not-ready key=${popup.key} ' - 'image=${_clip(imageUrl)} mounted=${context.mounted}', + for (final popup in candidates) { + final imageUrl = normalizeImageResourceUrl(popup.image); + final imageReady = await warmImageResource( + imageUrl, + logicalWidth: 326, + timeout: const Duration(seconds: 8), + ); + if (!context.mounted) { + _log('skip reason=context-unmounted-after-image key=${popup.key}'); + return; + } + if (!imageReady) { + _log( + 'skip candidate key=${popup.key} reason=image-not-ready ' + 'image=${_clip(imageUrl)}', + ); + continue; + } + _log('show key=${popup.key} popupId=${popup.popupId}'); + await SCEntryPopupDialog.show( + context, + popup: popup, + imageUrl: imageUrl, + onDisplayed: () { + unawaited(_markShown(popup)); + }, + onTap: () { + unawaited(_handlePopupTap(context, popup, onSelectRootTab)); + }, ); return; } - _log('show key=${popup.key} popupId=${popup.popupId}'); - await SCEntryPopupDialog.show( - context, - popup: popup, - imageUrl: imageUrl, - onDisplayed: () { - unawaited(_markShown(popup)); - }, - onTap: () { - unawaited(_handlePopupTap(context, popup, onSelectRootTab)); - }, - ); + _log('skip reason=no-image-ready-candidates'); } catch (error) { + _log('request failed error=$error'); } finally { _isRunning = false; } } - static SCEntryPopupItem? _pickDisplayablePopup(SCEntryPopupRes response) { + static List _displayablePopups(SCEntryPopupRes response) { + final candidates = []; final queuedKeys = {}; for (final popupKey in response.queue) { queuedKeys.add(popupKey); @@ -119,7 +130,7 @@ class SCEntryPopupCoordinator { continue; } _log('candidate key=${popup.key} source=queue'); - return popup; + candidates.add(popup); } final fallbackPopups = @@ -134,9 +145,9 @@ class SCEntryPopupCoordinator { continue; } _log('candidate key=${popup.key} source=priority-fallback'); - return popup; + candidates.add(popup); } - return null; + return candidates; } static String? _cannotShowReason(SCEntryPopupItem popup) { @@ -447,7 +458,9 @@ class SCEntryPopupCoordinator { final room = await SCChatRoomRepository().specific(roomId); resolvedPreviewData = RoomEntryPreviewData.fromMyRoom(room); } - } catch (error) {} + } catch (error) { + _log('open room preview failed roomId=$roomId error=$error'); + } if (!context.mounted) { return false; } @@ -521,7 +534,11 @@ class SCEntryPopupCoordinator { return ''; } - static void _log(String message) {} + static void _log(String message) { + if (kDebugMode) { + debugPrint('[EntryPopup] $message'); + } + } static String _clip(String value, {int maxLength = 180}) { if (value.length <= maxLength) { diff --git a/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart b/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart index 10c0291..75f518a 100644 --- a/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart +++ b/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart @@ -156,36 +156,59 @@ class FirstRechargeDialogData { SCFirstRechargeRewardHomeRes home, ) { final levels = home.availableLevelConfigs.take(2).toList(); - final packages = - levels.map((level) { - return FirstRechargePackage( - priceText: _formatPriceText(level), - discountText: level.discountText, - totalValueText: level.totalValueText, - rechargeAmountCents: level.rechargeAmountCents, - productPackage: level.productPackage, - rewards: - level.rewardItems.map((item) { - return FirstRechargeReward( - title: item.displayTitle, - style: - item.isCoinReward - ? FirstRechargeRewardStyle.coin - : FirstRechargeRewardStyle.prop, - badgeText: item.displayBadgeText, - cover: item.cover, - ); - }).toList(), - ); - }).toList(); + final packages = []; + var selectedPackageIndex = 0; + for (final level in levels) { + final rewards = + level.rewardItems.where(_isDisplayableRewardItem).map((item) { + return FirstRechargeReward( + title: _rewardTitle(item), + style: + item.isCoinReward + ? FirstRechargeRewardStyle.coin + : FirstRechargeRewardStyle.prop, + badgeText: item.displayBadgeText, + cover: item.cover, + ); + }).toList(); + if (rewards.isEmpty) { + continue; + } + if (level.current) { + selectedPackageIndex = packages.length; + } + packages.add( + FirstRechargePackage( + priceText: _formatPriceText(level), + discountText: level.discountText, + totalValueText: level.totalValueText, + rechargeAmountCents: level.rechargeAmountCents, + productPackage: level.productPackage, + rewards: rewards, + ), + ); + } - final currentIndex = levels.indexWhere((level) => level.current); return FirstRechargeDialogData( packages: packages, - initialPackageIndex: currentIndex >= 0 ? currentIndex : 0, + initialPackageIndex: selectedPackageIndex, ); } + static bool _isDisplayableRewardItem(SCFirstRechargeRewardItem item) { + if (item.isCoinReward) { + return item.quantity > 0 || item.displayTitle.trim().isNotEmpty; + } + return item.name.trim().isNotEmpty || item.cover.trim().isNotEmpty; + } + + static String _rewardTitle(SCFirstRechargeRewardItem item) { + if (item.isCoinReward) { + return item.displayTitle; + } + return item.name.trim(); + } + static String _formatPriceText(SCFirstRechargeLevelConfig level) { final amount = level.rechargeAmount.trim(); if (amount.isNotEmpty) { @@ -476,14 +499,9 @@ class _FirstRechargeDialogState extends State { Offset(227, 316), ]; - return List.generate(positions.length, (index) { - final reward = - index < rewards.length - ? rewards[index] - : const FirstRechargeReward( - title: '', - style: FirstRechargeRewardStyle.prop, - ); + final visibleRewards = rewards.take(positions.length).toList(); + return List.generate(visibleRewards.length, (index) { + final reward = visibleRewards[index]; final position = positions[index]; return Positioned( left: position.dx * scale, diff --git a/lib/ui_kit/widgets/room/floating/floating_cp_relation_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_cp_relation_screen_widget.dart new file mode 100644 index 0000000..9c80fdf --- /dev/null +++ b/lib/ui_kit/widgets/room/floating/floating_cp_relation_screen_widget.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_broadcast_assets.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; + +class FloatingCpRelationScreenWidget extends StatefulWidget { + const FloatingCpRelationScreenWidget({ + super.key, + required this.message, + required this.onAnimationCompleted, + }); + + static const double bannerWidth = 350; + static const double bannerHeight = 84; + + final SCFloatingMessage message; + final VoidCallback onAnimationCompleted; + + @override + State createState() => + _FloatingCpRelationScreenWidgetState(); +} + +class _FloatingCpRelationScreenWidgetState + extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _offsetAnimation; + bool _completed = false; + + @override + void initState() { + super.initState(); + final seconds = widget.message.durationSeconds?.toInt() ?? 5; + _controller = AnimationController( + duration: Duration(seconds: seconds <= 0 ? 5 : seconds), + vsync: this, + ); + _offsetAnimation = Tween( + begin: const Offset(1, 0), + end: const Offset(-1, 0), + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _complete(); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _controller.forward(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _complete() { + if (_completed) { + return; + } + _completed = true; + widget.onAnimationCompleted(); + } + + @override + Widget build(BuildContext context) { + return SlideTransition( + position: _offsetAnimation, + child: SizedBox( + width: FloatingCpRelationScreenWidget.bannerWidth.w, + height: FloatingCpRelationScreenWidget.bannerHeight.w, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: _buildPagBackground()), + Positioned.fill(child: _buildText()), + ], + ), + ), + ); + } + + Widget _buildPagBackground() { + final asset = + (widget.message.giftUrl?.trim().isNotEmpty ?? false) + ? widget.message.giftUrl!.trim() + : scCpRelationBroadcastAsset(widget.message.giftTab); + return RoomRocketPagPreview( + resource: asset, + width: FloatingCpRelationScreenWidget.bannerWidth.w, + height: FloatingCpRelationScreenWidget.bannerHeight.w, + fit: BoxFit.cover, + clipBehavior: Clip.none, + defaultBuilder: (_) => _buildFallbackBackground(), + ); + } + + Widget _buildFallbackBackground() { + final colors = _relationColors(widget.message.giftTab); + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(42.w), + gradient: LinearGradient(colors: colors), + ), + ); + } + + Widget _buildText() { + return Padding( + padding: EdgeInsetsDirectional.only(start: 82.w, end: 54.w), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: ClipRect( + child: Text( + _displayText(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: _textFontSize().sp, + height: 1.18, + color: Colors.white, + fontWeight: FontWeight.w800, + decoration: TextDecoration.none, + shadows: [ + Shadow( + color: const Color(0xFF5B0F59).withValues(alpha: 0.86), + blurRadius: 4.w, + offset: Offset(0, 1.w), + ), + Shadow( + color: Colors.black.withValues(alpha: 0.34), + blurRadius: 8.w, + offset: Offset(0, 1.5.w), + ), + ], + ), + ), + ), + ), + ); + } + + String _displayText() { + if (widget.message.giftId == "RELATION_JOINED_ROOM") { + return scBuildCpRelationEntryBroadcastText( + ownerName: _shortName(widget.message.toUserName), + joinedName: _shortName(widget.message.userName), + relationType: widget.message.giftTab, + ); + } + final explicitText = widget.message.displayText?.trim() ?? ""; + if (explicitText.isNotEmpty) { + return _stripSystemNotificationPrefix(explicitText); + } + return _stripSystemNotificationPrefix( + scBuildCpRelationEstablishedNotice( + userName1: _shortName(widget.message.userName), + userName2: _shortName(widget.message.toUserName), + relationType: widget.message.giftTab, + ), + ); + } + + String _shortName(String? value) { + final text = value?.trim() ?? ""; + if (text.length <= 14) { + return text; + } + return "${text.substring(0, 13)}..."; + } + + double _textFontSize() { + final length = _displayText().characters.length; + if (length > 58) { + return 9.5; + } + if (length > 42) { + return 10.5; + } + return 11.5; + } + + String _stripSystemNotificationPrefix(String text) { + return text + .replaceFirst( + RegExp( + r'^\s*[\[\【]\s*sys(?:tem|term)\s+notification\s*[\]\】]\s*', + caseSensitive: false, + ), + '', + ) + .trim(); + } + + List _relationColors(String? relationType) { + switch (scNormalizeCpRelationType(relationType)) { + case "BROTHER": + return const [Color(0xFF0B5DA8), Color(0xFF63B8FF)]; + case "SISTERS": + return const [Color(0xFF8E2BA8), Color(0xFFFF8BF3)]; + case "CP": + default: + return const [Color(0xFFFF59B7), Color(0xFFFF9BD8)]; + } + } +} diff --git a/lib/ui_kit/widgets/room/floating/floating_game_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_game_screen_widget.dart index 54f717e..922482a 100644 --- a/lib/ui_kit/widgets/room/floating/floating_game_screen_widget.dart +++ b/lib/ui_kit/widgets/room/floating/floating_game_screen_widget.dart @@ -1,412 +1,484 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_debouncer/flutter_debouncer.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:yumi/shared/tools/sc_room_utils.dart'; -import 'package:yumi/main.dart'; -import 'package:marquee/marquee.dart'; - -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; - -class FloatingGameScreenWidget extends StatefulWidget { - final SCFloatingMessage message; - final VoidCallback onAnimationCompleted; // 动画完成回调 - - const FloatingGameScreenWidget({ - Key? key, - required this.message, - required this.onAnimationCompleted, - }) : super(key: key); - - @override - _FloatingGameScreenWidgetState createState() => - _FloatingGameScreenWidgetState(); -} - -class _FloatingGameScreenWidgetState extends State - with TickerProviderStateMixin { - Debouncer debouncer = Debouncer(); - late AnimationController _controller; - late Animation _firstAnimation; // 第一段:从右到中 - late Animation _secondAnimation; // 第二段:从中到左 - late AnimationController _swipeController; // 新增:滑动动画控制器 - late Animation _swipeAnimation; // 新增:滑动动画 - - bool _firstPhaseCompleted = false; - bool _isSwipeAnimating = false; // 标记是否正在执行滑动动画 - - ///游戏赢的金币按数量分等级,不同等级特效背景也不一样 - int level = 1; - - @override - void initState() { - super.initState(); - if ((widget.message.coins ?? 0) > 14999 && - (widget.message.coins ?? 0) < 50000) { - level = 1; - } else if ((widget.message.coins ?? 0) > 49999 && - (widget.message.coins ?? 0) < 100000) { - level = 2; - } else if ((widget.message.coins ?? 0) > 99999 && - (widget.message.coins ?? 0) < 300000) { - level = 3; - } else if ((widget.message.coins ?? 0) > 299999 && - (widget.message.coins ?? 0) < 500000) { - level = 4; - } else if ((widget.message.coins ?? 0) > 499999) { - level = 5; - } - - // 主动画控制器 - _controller = AnimationController( - duration: const Duration(milliseconds: 1500), // 每段动画1.5秒 - vsync: this, - ); - - // 滑动动画控制器 - _swipeController = AnimationController( - duration: const Duration(milliseconds: 550), // 滑动动画500ms - vsync: this, - ); - - // 监听滑动动画完成 - _swipeController.addStatusListener((status) { - if (status == AnimationStatus.completed) { - widget.onAnimationCompleted(); - } - }); - - // 第一段动画:从右侧飘到中间 - _firstAnimation = Tween( - begin: const Offset(1.5, 0.0), // 从屏幕右侧外开始 - end: Offset.zero, // 到屏幕中心 - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); - - // 第二段动画:从中间飘到左侧 - _secondAnimation = Tween( - begin: Offset.zero, // 从屏幕中心开始 - end: const Offset(-1.5, 0.0), // 到屏幕左侧外 - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn)); - - // 滑动动画:从当前位置滑动到左侧屏幕外 - _swipeAnimation = Tween( - begin: Offset.zero, // 从当前位置开始 - end: const Offset(-1.5, 0.0), // 到屏幕左侧外 - ).animate(CurvedAnimation( - parent: _swipeController, - curve: Curves.easeIn, - )); - - // 延迟启动动画 - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _startAnimationSequence(); - } - }); - } - - void _startAnimationSequence() async { - // 第一阶段:从右到中 - _controller.reset(); - await _controller.forward(); - - // 停顿2秒(你代码中是3秒,这里保持原样) - await Future.delayed(const Duration(seconds: 3)); - - if (!mounted) return; - - // 第二阶段:从中到左 - setState(() { - _firstPhaseCompleted = true; - }); - _controller.reset(); - await _controller.forward(); - - if (mounted && !_isSwipeAnimating) { - widget.onAnimationCompleted(); - } - } - - // 处理向左滑动 - void _handleSwipeLeft() { - if (_isSwipeAnimating) return; - - setState(() { - _isSwipeAnimating = true; - }); - - // 停止主动画 - _controller.stop(); - - // 启动滑动动画 - _swipeController.reset(); - _swipeController.forward(); - } - - String _coinsVaFormat(num coins) { - int value = coins.toInt(); - if (value > 99999) { - return "${(value / 1000).toStringAsFixed(0)}k"; - } - return "$value"; - } - - @override - void dispose() { - _controller.dispose(); - _swipeController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _buildGameAnimation(); - } - - Widget _buildGameAnimation() { - // 如果正在执行滑动动画,使用滑动动画 - if (_isSwipeAnimating) { - return SlideTransition( - position: _swipeAnimation, - child: _buildContent(), - ); - } - - // 否则使用原来的动画 - return SlideTransition( - position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation, - child: _buildContent(), - ); - } - - Widget _buildContent() { - return GestureDetector( - onTap: () { - debouncer.debounce( - duration: Duration(milliseconds: 350), - onDebounce: () { - if (widget.message.roomId != null && - widget.message.roomId!.isNotEmpty) { - SCRoomUtils.goRoom( - widget.message.roomId!, - navigatorKey.currentState!.context, - fromFloting: true, - ); - } - }, - ); - }, - onHorizontalDragEnd: (details) { - // 获取当前的文本方向 - final textDirection = Directionality.of(context); - - // 定义一个根据方向转换速度符号的辅助函数 - double effectiveVelocity(double velocity) { - // 在RTL模式下,反转速度的正负号 - return textDirection == TextDirection.rtl - ? -velocity - : velocity; - } - - double velocity = effectiveVelocity(details.primaryVelocity ?? 0); - if (velocity < 0) { - ///向左滑动,把当前视图向左平移出去 - _handleSwipeLeft(); - } - }, - child: Align( - alignment: Alignment.topCenter, - child: Directionality( - textDirection: TextDirection.ltr, - child: Container( - alignment: Alignment.topCenter, - height: 120.w, - width: 325.w, - margin: EdgeInsets.only(top: 10.w), - child: Stack( - alignment: - level == 5 - ? AlignmentDirectional.topCenter - : AlignmentDirectional.center, - children: [ - if (widget.message.userAvatarUrl?.isNotEmpty ?? false) - _buildHead(), - Image.asset("sc_images/index/sc_icon_gamebroad_lv${level}_bg.webp"), - Row( - mainAxisSize: MainAxisSize.min, // 宽度由内容决定 - children: [ - SizedBox(width: level < 2 ? 82.w : 95.w), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - height: - level < 2 - ? 22.w - : (level == 5 ? 31.w : 25.w), - ), - Row( - children: [ - Container( - constraints: BoxConstraints( - maxWidth: 55.w, - maxHeight: 18.w, - ), - child: - (widget.message.userName?.length ?? - 0) > - 8 - ? Marquee( - text: - widget.message.userName ?? - "", - style: TextStyle( - fontSize: 12.sp, - color: Colors.orange, - fontWeight: FontWeight.bold, - decoration: - TextDecoration.none, - ), - scrollAxis: Axis.horizontal, - crossAxisAlignment: - CrossAxisAlignment.start, - blankSpace: 20.0, - velocity: 40.0, - pauseAfterRound: Duration( - seconds: 1, - ), - accelerationDuration: Duration( - seconds: 1, - ), - accelerationCurve: - Curves.easeOut, - decelerationDuration: Duration( - milliseconds: 500, - ), - decelerationCurve: - Curves.easeOut, - ) - : Text( - widget.message.userName ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.sp, - color: Colors.orange, - fontWeight: FontWeight.bold, - decoration: - TextDecoration.none, - ), - ), - ), - text( - "ID:${widget.message.userId} ", - textColor: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12.sp, - ), - ], - ), - Transform.translate( - offset: Offset( - 0, - level < 2 ? -5 : (level == 5 ? -5 : 0), - ), - child: Row( - children: [ - text( - SCAppLocalizations.of(context)!.get, - textColor: Colors.orange, - fontWeight: FontWeight.w900, - fontSize: 16.sp, - fontStyle: FontStyle.italic - ), - buildNumForGame( - _coinsVaFormat( - widget.message.coins ?? 0, - ), - size: - (widget.message.coins ?? 0) > 99999 - ? 26.w - : 20.w, - ), - ], - ), - ), - ], - ), - ], - ), - ), - Container( - margin: EdgeInsets.only(top: level < 2 ? 10.w : 21.w), - child: netImage( - url: widget.message.giftUrl ?? "", - width: 28.w, - borderRadius: BorderRadius.all(Radius.circular(3.0)), - ), - ), - SizedBox(width: level < 2 ? 63.w : 65.w), - ], - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildHead() { - if (level == 1) { - return PositionedDirectional( - start: 43.w, - child: Container( - margin: EdgeInsets.only(top: 12.w), - child: head(url: widget.message.userAvatarUrl ?? "", width: 40.w), - ), - ); - } else if (level == 2) { - return PositionedDirectional( - start: 10.w, - child: Container( - margin: EdgeInsets.only(top: 25.w), - child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w), - ), - ); - } else if (level == 3) { - return PositionedDirectional( - start: 10.w, - child: Container( - margin: EdgeInsets.only(top: 25.w), - child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w), - ), - ); - } else if (level == 4) { - return PositionedDirectional( - start: 10.w, - child: Container( - margin: EdgeInsets.only(top: 25.w), - child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w), - ), - ); - } else if (level == 5) { - return PositionedDirectional( - start: 22.w, - top: 20.w, - child: Container( - child: head(url: widget.message.userAvatarUrl ?? "", width: 48.w), - ), - ); - } - return Container(); - } -} \ No newline at end of file +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_debouncer/flutter_debouncer.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svga/flutter_svga.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/main.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; +import 'package:yumi/shared/tools/sc_network_image_utils.dart'; +import 'package:yumi/shared/tools/sc_room_utils.dart'; +import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; + +class FloatingGameScreenWidget extends StatefulWidget { + static const String backgroundSvgaAssetPath = + "sc_images/room/anim/game_win.svga"; + + const FloatingGameScreenWidget({ + super.key, + required this.message, + required this.onAnimationCompleted, + }); + + final SCFloatingMessage message; + final VoidCallback onAnimationCompleted; + + @override + State createState() => + _FloatingGameScreenWidgetState(); +} + +class _FloatingGameScreenWidgetState extends State + with TickerProviderStateMixin { + static const String _avatarDynamicKey = "avatar"; + static const String _gameIconDynamicKey = "gameicon"; + static const String _textLine1DynamicKey = "text1"; + static const String _textLine2DynamicKey = "text2"; + + final Debouncer _debouncer = Debouncer(); + late final AnimationController _controller; + late final Animation _offsetAnimation; + late final AnimationController _swipeController; + late final Animation _swipeAnimation; + + bool _isSwipeAnimating = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 5), + vsync: this, + ); + _swipeController = AnimationController( + duration: const Duration(milliseconds: 550), + vsync: this, + ); + _offsetAnimation = Tween( + begin: const Offset(1.0, 0.0), + end: const Offset(-1.0, 0.0), + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _swipeAnimation = Tween( + begin: Offset.zero, + end: const Offset(-1.5, 0.0), + ).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn)); + + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed && !_isSwipeAnimating) { + widget.onAnimationCompleted(); + } + }); + _swipeController.addStatusListener((status) { + if (status == AnimationStatus.completed) { + widget.onAnimationCompleted(); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _controller.forward(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + _swipeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _handleTap, + onHorizontalDragEnd: _handleHorizontalDragEnd, + child: SlideTransition( + position: _isSwipeAnimating ? _swipeAnimation : _offsetAnimation, + child: SizedBox( + width: 350.w, + height: 128.w, + child: SCSvgaAssetWidget( + assetPath: FloatingGameScreenWidget.backgroundSvgaAssetPath, + width: 350.w, + height: 128.w, + fit: BoxFit.fill, + dynamicIdentity: [ + widget.message.userAvatarUrl ?? "", + widget.message.userName ?? "", + widget.message.userId ?? "", + widget.message.giftUrl ?? "", + widget.message.gameId ?? "", + widget.message.displayText ?? "", + widget.message.coins ?? 0, + widget.message.multiple ?? 0, + ].join("|"), + movieConfigurer: _configureGameWinSvga, + fallback: _buildFallbackBanner(), + ), + ), + ), + ); + } + + void _handleTap() { + _debouncer.debounce( + duration: const Duration(milliseconds: 350), + onDebounce: () { + final currentContext = navigatorKey.currentState?.context; + if (currentContext == null || !currentContext.mounted) { + return; + } + final rtcProvider = Provider.of( + currentContext, + listen: false, + ); + final currentRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; + final messageRoomId = (widget.message.roomId ?? "").trim(); + final isSameVisibleRoom = + rtcProvider.shouldShowRoomVisualEffects && + rtcProvider.isVoiceRoomRouteVisible && + currentRoomId.isNotEmpty && + (messageRoomId.isEmpty || currentRoomId == messageRoomId); + + if (isSameVisibleRoom) { + showRoomGameListSheet( + currentContext, + initialGameId: _resolvedGameId(), + ); + return; + } + if (messageRoomId.isNotEmpty) { + SCRoomUtils.goRoom(messageRoomId, currentContext, fromFloting: true); + } + }, + ); + } + + void _handleHorizontalDragEnd(DragEndDetails details) { + final textDirection = Directionality.of(context); + final velocity = + textDirection == TextDirection.rtl + ? -(details.primaryVelocity ?? 0) + : (details.primaryVelocity ?? 0); + if (velocity < 0) { + _handleSwipeLeft(); + } + } + + void _handleSwipeLeft() { + if (_isSwipeAnimating) { + return; + } + setState(() { + _isSwipeAnimating = true; + }); + _controller.stop(); + _swipeController.reset(); + _swipeController.forward(); + } + + Future _configureGameWinSvga(MovieEntity movieEntity) async { + final avatarUrl = (widget.message.userAvatarUrl ?? "").trim(); + if (avatarUrl.isNotEmpty) { + try { + final avatarImage = await _loadUiImage( + avatarUrl, + logicalWidth: 96, + logicalHeight: 96, + ); + if (avatarImage != null) { + movieEntity.dynamicItem.setImage(avatarImage, _avatarDynamicKey); + } + } catch (_) {} + } + + final gameIconUrl = (widget.message.giftUrl ?? "").trim(); + if (gameIconUrl.isNotEmpty) { + try { + final gameIcon = await _loadUiImage( + gameIconUrl, + logicalWidth: 96, + logicalHeight: 96, + ); + if (gameIcon != null) { + movieEntity.dynamicItem.setImage(gameIcon, _gameIconDynamicKey); + } + } catch (_) {} + } + + final lines = _resolveTextLines(); + try { + movieEntity.dynamicItem.setImage( + await _buildTextLineImage(lines.$1, highlighted: false), + _textLine1DynamicKey, + ); + movieEntity.dynamicItem.setImage( + await _buildTextLineImage(lines.$2, highlighted: true), + _textLine2DynamicKey, + ); + } catch (_) {} + } + + (String, String) _resolveTextLines() { + final rawText = (widget.message.displayText ?? "").trim(); + if (rawText.isNotEmpty) { + final normalized = rawText.replaceAll(RegExp(r"\s+"), " "); + final winIndex = normalized.indexOf(" win "); + if (winIndex > 0 && winIndex < normalized.length - 5) { + return ( + normalized.substring(0, winIndex).trim(), + normalized.substring(winIndex + 1).trim(), + ); + } + return _splitText(normalized); + } + + final userName = _firstNonBlank([ + widget.message.userName, + widget.message.userId, + ]); + final gameName = _firstNonBlank([widget.message.toUserName, "game"]); + final multiple = _formatMultiple(widget.message.multiple); + final coins = _formatCoins(widget.message.coins); + final line1 = + userName.isEmpty + ? "play $gameName get x $multiple" + : "$userName play $gameName get x $multiple"; + return (line1, "win $coins coins"); + } + + (String, String) _splitText(String text) { + const maxLineLength = 32; + if (text.length <= maxLineLength) { + return (text, ""); + } + final midpoint = (text.length / 2).round(); + var splitIndex = text.lastIndexOf(" ", midpoint); + if (splitIndex < 10) { + splitIndex = text.indexOf(" ", midpoint); + } + if (splitIndex < 10 || splitIndex >= text.length - 1) { + splitIndex = midpoint; + } + return ( + text.substring(0, splitIndex).trim(), + text.substring(splitIndex).trim(), + ); + } + + Future _buildTextLineImage( + String value, { + required bool highlighted, + }) async { + const double logicalWidth = 318; + const double logicalHeight = 24; + const double pixelRatio = 3; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(pixelRatio, pixelRatio); + canvas.clipRect(const Rect.fromLTWH(0, 0, logicalWidth, logicalHeight)); + + final painter = TextPainter( + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: "...", + text: TextSpan( + text: value, + style: TextStyle( + fontSize: highlighted ? 18 : 16, + height: 1, + color: + highlighted ? const Color(0xFFFFE95A) : const Color(0xFFFFFFFF), + fontWeight: highlighted ? FontWeight.w900 : FontWeight.w800, + decoration: TextDecoration.none, + ), + ), + )..layout(maxWidth: logicalWidth - 8); + + painter.paint(canvas, Offset(4, (logicalHeight - painter.height) / 2)); + final picture = recorder.endRecording(); + return picture.toImage( + (logicalWidth * pixelRatio).round(), + (logicalHeight * pixelRatio).round(), + ); + } + + Future _loadUiImage( + String resource, { + double? logicalWidth, + double? logicalHeight, + }) async { + final target = resource.trim(); + if (target.isEmpty) { + return null; + } + final provider = buildCachedImageProvider( + target, + logicalWidth: logicalWidth, + logicalHeight: logicalHeight, + ); + final stream = provider.resolve(ImageConfiguration.empty); + final completer = Completer(); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (imageInfo, synchronousCall) { + if (!completer.isCompleted) { + completer.complete(imageInfo.image); + } + stream.removeListener(listener); + }, + onError: (Object error, StackTrace? stackTrace) { + if (!completer.isCompleted) { + completer.complete(null); + } + stream.removeListener(listener); + }, + ); + stream.addListener(listener); + return completer.future.timeout( + const Duration(seconds: 2), + onTimeout: () { + stream.removeListener(listener); + return null; + }, + ); + } + + Widget _buildFallbackBanner() { + return Container( + width: 350.w, + height: 128.w, + padding: EdgeInsets.symmetric(horizontal: 26.w), + decoration: BoxDecoration( + color: const Color(0xDD291436), + borderRadius: BorderRadius.circular(8.w), + border: Border.all(color: const Color(0xFFFFD35A), width: 1.w), + ), + child: Row( + children: [ + _buildFallbackImage( + widget.message.userAvatarUrl, + size: 46.w, + circular: true, + ), + SizedBox(width: 10.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _resolveTextLines().$1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 13.sp, + fontWeight: FontWeight.w800, + decoration: TextDecoration.none, + ), + ), + SizedBox(height: 3.w), + Text( + _resolveTextLines().$2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color(0xFFFFE95A), + fontSize: 14.sp, + fontWeight: FontWeight.w900, + decoration: TextDecoration.none, + ), + ), + ], + ), + ), + SizedBox(width: 8.w), + _buildFallbackImage(widget.message.giftUrl, size: 40.w), + ], + ), + ); + } + + Widget _buildFallbackImage( + String? resource, { + required double size, + bool circular = false, + }) { + final target = (resource ?? "").trim(); + final fallbackAsset = + circular + ? "sc_images/general/sc_icon_avar_defalt.png" + : "sc_images/room/sc_icon_botton_game.png"; + final child = + target.isEmpty + ? Image.asset( + fallbackAsset, + width: size, + height: size, + fit: BoxFit.cover, + ) + : Image( + image: buildCachedImageProvider( + target, + logicalWidth: size, + logicalHeight: size, + ), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => Image.asset( + fallbackAsset, + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + if (!circular) { + return ClipRRect(borderRadius: BorderRadius.circular(6.w), child: child); + } + return ClipOval(child: child); + } + + String? _resolvedGameId() { + final gameId = (widget.message.gameId ?? "").trim(); + return gameId.isEmpty ? null : gameId; + } + + String _formatCoins(num? value) { + final coins = value ?? 0; + if (coins >= 100000) { + return "${(coins / 1000).toStringAsFixed(0)}k"; + } + if (coins == coins.roundToDouble()) { + return coins.toInt().toString(); + } + return coins.toStringAsFixed(2); + } + + String _formatMultiple(num? value) { + final multiple = value ?? 0; + if (multiple == multiple.roundToDouble()) { + return multiple.toInt().toString(); + } + return multiple.toStringAsFixed(2); + } + + String _firstNonBlank(Iterable values) { + for (final value in values) { + final text = value?.trim() ?? ""; + if (text.isNotEmpty) { + return text; + } + } + return ""; + } +} diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart index b421e16..2abaef7 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart @@ -368,6 +368,14 @@ class _RocketRuleHtmlText extends StatelessWidget { } } +String debugRoomRocketRuleHtmlPlainText(String html) { + final spans = _RocketRuleHtmlParser( + baseStyle: const TextStyle(), + scale: 1, + ).parse(html); + return TextSpan(children: spans).toPlainText(); +} + class _RocketRuleHtmlParser { _RocketRuleHtmlParser({required this.baseStyle, required this.scale}); @@ -446,9 +454,12 @@ class _RocketRuleHtmlParser { case 'li': _appendBlockBreak(); final list = _listStack.isEmpty ? null : _listStack.last; - final prefix = - list == null || !list.ordered ? '- ' : '${list.next()}. '; - _appendRaw(prefix, _currentStyle); + if (list == null) { + _appendRaw('- ', _currentStyle); + } else { + final prefix = list.ordered ? '${list.next()}. ' : '- '; + list.beginItem(prefix); + } break; case 'p': case 'div': @@ -477,7 +488,10 @@ class _RocketRuleHtmlParser { _appendBlockBreak(); return; } - if (_isBlockTag(tagName) || tagName == 'li') { + if (tagName == 'li') { + _endCurrentListItem(); + _appendBlockBreak(); + } else if (_isBlockTag(tagName)) { _appendBlockBreak(); } for (var index = _styleStack.length - 1; index >= 0; index -= 1) { @@ -588,7 +602,18 @@ class _RocketRuleHtmlParser { if (normalized.isEmpty) { return; } - _appendRaw(normalized, _currentStyle); + if (normalized.trim().isEmpty) { + if (_segments.isEmpty || + _segments.last.text.endsWith('\n') || + _pendingListFrame != null) { + return; + } + _appendRaw(' ', _currentStyle); + return; + } + final style = _currentStyle; + final textAfterMarker = _flushPendingListMarker(normalized, style); + _appendRaw(textAfterMarker, style); } void _appendRaw(String text, TextStyle style) { @@ -612,6 +637,9 @@ class _RocketRuleHtmlParser { } void _appendBlockBreak() { + if (_pendingListFrame != null) { + return; + } if (_segments.isEmpty) { return; } @@ -622,6 +650,55 @@ class _RocketRuleHtmlParser { _appendRaw('\n', _currentStyle); } + String _flushPendingListMarker(String text, TextStyle style) { + final list = _pendingListFrame; + final marker = list?.pendingMarker; + if (list == null || marker == null) { + return text; + } + final leading = RegExp(r'^\s*').firstMatch(text)?.group(0) ?? ''; + final visibleText = text.substring(leading.length); + if (visibleText.isEmpty) { + return text; + } + list.suppressGeneratedMarkers ??= _hasManualListMarker(visibleText); + list.pendingMarker = null; + if (list.suppressGeneratedMarkers! || + _startsWithGeneratedMarker(visibleText, marker)) { + return text; + } + _appendRaw(marker, style); + return visibleText; + } + + _HtmlListFrame? get _pendingListFrame { + for (var index = _listStack.length - 1; index >= 0; index -= 1) { + if (_listStack[index].pendingMarker != null) { + return _listStack[index]; + } + } + return null; + } + + void _endCurrentListItem() { + for (var index = _listStack.length - 1; index >= 0; index -= 1) { + if (_listStack[index].isInItem) { + _listStack[index].endItem(); + return; + } + } + } + + bool _hasManualListMarker(String text) { + return RegExp(r'^(?:\d+|[a-zA-Z])[.)](?:\s|$)').hasMatch(text) || + RegExp(r'^(?:[-*•‣◦])(?:\s|$)').hasMatch(text); + } + + bool _startsWithGeneratedMarker(String text, String marker) { + return text.startsWith(marker) || + text.startsWith(marker.trimRight()) && text.length == marker.length - 1; + } + void _trimOuterWhitespace() { while (_segments.isNotEmpty) { final trimmed = _segments.first.text.replaceFirst(RegExp(r'^\s+'), ''); @@ -791,6 +868,19 @@ class _HtmlListFrame { final bool ordered; int _index = 1; + String? pendingMarker; + bool? suppressGeneratedMarkers; + bool isInItem = false; + + void beginItem(String marker) { + isInItem = true; + pendingMarker = marker; + } + + void endItem() { + isInItem = false; + pendingMarker = null; + } int next() { final value = _index; diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart index c527af1..442eaee 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart @@ -7,12 +7,13 @@ import 'package:flutter/services.dart'; import 'package:pag/pag.dart'; import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart'; -import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; const Duration _pagMemoryTtl = Duration(minutes: 5); -const Size _roomRocketFallbackPagRawSize = Size(750, 1624); -const Rect _roomRocketTopAvatarRawRect = Rect.fromLTWH(291, 164, 168, 168); +const Duration _roomRocketTopAvatarResolveGracePeriod = Duration( + milliseconds: 700, +); +const String _roomRocketTopAvatarLayerName = 'avatar_tip.png'; class RoomRocketPagEffectOverlay extends StatefulWidget { const RoomRocketPagEffectOverlay({ @@ -54,7 +55,40 @@ class RoomRocketPagEffectOverlay extends StatefulWidget { class _RoomRocketPagEffectOverlayState extends State { bool _completed = false; - Size? _pagRawSize; + bool _topAvatarLocked = false; + String? _lockedTopAvatarUrl; + Timer? _topAvatarLockTimer; + ValueListenable? _observedTopAvatarListenable; + + @override + void initState() { + super.initState(); + _observeTopAvatarListenable(widget.topAvatarUrlListenable); + _resolveTopAvatarLock(notify: false); + } + + @override + void didUpdateWidget(covariant RoomRocketPagEffectOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + final listenableChanged = + oldWidget.topAvatarUrlListenable != widget.topAvatarUrlListenable; + final fallbackAvatarChanged = oldWidget.topAvatarUrl != widget.topAvatarUrl; + final resourceChanged = oldWidget.resource.trim() != widget.resource.trim(); + if (listenableChanged) { + _observeTopAvatarListenable(widget.topAvatarUrlListenable); + } + if (resourceChanged || listenableChanged || fallbackAvatarChanged) { + _resetTopAvatarLock(); + _resolveTopAvatarLock(); + } + } + + @override + void dispose() { + _topAvatarLockTimer?.cancel(); + _observedTopAvatarListenable?.removeListener(_handleTopAvatarChanged); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -67,28 +101,14 @@ class _RoomRocketPagEffectOverlayState child: SizedBox( width: size.width, height: size.height, - child: Stack( - clipBehavior: Clip.none, - children: [ - _buildTopAvatar(size), - Positioned.fill( - child: _RoomRocketPagView( - resource: resource, - width: size.width, - height: size.height, - loop: false, - fit: BoxFit.cover, - displayScale: 1, - clipBehavior: Clip.hardEdge, - memoryTtl: _pagMemoryTtl, - onReady: _updatePagRawSize, - onCompleted: _complete, - defaultBuilder: - (_) => const Center(child: SCRotatingDotsLoading()), - ), - ), - ], - ), + child: + _topAvatarLocked + ? _buildPagViewWithAvatar( + resource: resource, + size: size, + avatarUrl: _lockedTopAvatarUrl, + ) + : const Center(child: SCRotatingDotsLoading()), ), ), ), @@ -103,69 +123,77 @@ class _RoomRocketPagEffectOverlayState widget.onCompleted?.call(); } - void _updatePagRawSize(Size rawSize) { - if (_pagRawSize == rawSize) { + void _observeTopAvatarListenable(ValueListenable? listenable) { + if (_observedTopAvatarListenable == listenable) { return; } - setState(() => _pagRawSize = rawSize); + _observedTopAvatarListenable?.removeListener(_handleTopAvatarChanged); + _observedTopAvatarListenable = listenable; + listenable?.addListener(_handleTopAvatarChanged); } - Widget _buildTopAvatar(Size canvasSize) { - final listenable = widget.topAvatarUrlListenable; - if (listenable == null) { - return _RoomRocketPagTopAvatar( - avatarUrl: widget.topAvatarUrl, - canvasSize: canvasSize, - pagRawSize: _pagRawSize, - ); + void _handleTopAvatarChanged() { + if (_topAvatarLocked) { + return; + } + final avatarUrl = _currentTopAvatarUrl(); + if (avatarUrl != null) { + _lockTopAvatar(avatarUrl); } - return ValueListenableBuilder( - valueListenable: listenable, - builder: - (context, avatarUrl, _) => _RoomRocketPagTopAvatar( - avatarUrl: _firstNonBlankPagAvatar([ - avatarUrl, - widget.topAvatarUrl, - ]), - canvasSize: canvasSize, - pagRawSize: _pagRawSize, - ), - ); } -} -class _RoomRocketPagTopAvatar extends StatelessWidget { - const _RoomRocketPagTopAvatar({ - required this.avatarUrl, - required this.canvasSize, - required this.pagRawSize, - }); - - final String? avatarUrl; - final Size canvasSize; - final Size? pagRawSize; - - @override - Widget build(BuildContext context) { - final url = avatarUrl?.trim() ?? ''; - if (url.isEmpty) { - return const SizedBox.shrink(); + void _resolveTopAvatarLock({bool notify = true}) { + final avatarUrl = _currentTopAvatarUrl(); + if (avatarUrl != null || widget.topAvatarUrlListenable == null) { + _lockTopAvatar(avatarUrl, notify: notify); + return; } - final rect = _mapRoomRocketPagRawRectToCoverCanvas( - rawRect: _roomRocketTopAvatarRawRect, - rawSize: pagRawSize ?? _roomRocketFallbackPagRawSize, - canvasSize: canvasSize, - ); - return Positioned.fromRect( - rect: rect, - child: ClipOval( - child: CustomCachedImage( - imageUrl: url, - fit: BoxFit.cover, - placeholder: const SizedBox.shrink(), - errorWidget: const SizedBox.shrink(), - ), - ), + _topAvatarLockTimer?.cancel(); + _topAvatarLockTimer = Timer(_roomRocketTopAvatarResolveGracePeriod, () { + _lockTopAvatar(_currentTopAvatarUrl()); + }); + } + + void _lockTopAvatar(String? avatarUrl, {bool notify = true}) { + _topAvatarLockTimer?.cancel(); + final normalized = _firstNonBlankPagAvatar([avatarUrl]); + _topAvatarLocked = true; + _lockedTopAvatarUrl = normalized; + if (notify && mounted) { + setState(() {}); + } + } + + void _resetTopAvatarLock() { + _topAvatarLockTimer?.cancel(); + _topAvatarLocked = false; + _lockedTopAvatarUrl = null; + } + + String? _currentTopAvatarUrl() { + return _firstNonBlankPagAvatar([ + widget.topAvatarUrlListenable?.value, + widget.topAvatarUrl, + ]); + } + + Widget _buildPagViewWithAvatar({ + required String resource, + required Size size, + required String? avatarUrl, + }) { + return _RoomRocketPagView( + resource: resource, + width: size.width, + height: size.height, + loop: false, + fit: BoxFit.cover, + displayScale: 1, + clipBehavior: Clip.hardEdge, + memoryTtl: _pagMemoryTtl, + imageReplacementUrlsByName: _topAvatarReplacementUrls(avatarUrl), + onCompleted: _complete, + defaultBuilder: (_) => const Center(child: SCRotatingDotsLoading()), ); } } @@ -236,7 +264,7 @@ class _RoomRocketPagView extends StatefulWidget { required this.clipBehavior, required this.memoryTtl, this.underlayBuilder, - this.onReady, + this.imageReplacementUrlsByName = const {}, this.onCompleted, this.defaultBuilder, }); @@ -250,7 +278,7 @@ class _RoomRocketPagView extends StatefulWidget { final Clip clipBehavior; final Duration memoryTtl; final WidgetBuilder? underlayBuilder; - final ValueChanged? onReady; + final Map imageReplacementUrlsByName; final VoidCallback? onCompleted; final WidgetBuilder? defaultBuilder; @@ -264,6 +292,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { final int _debugId = _nextDebugId++; late GlobalKey _pagKey; Uint8List? _pagBytes; + String? _assetName; + Map? _imageReplacementsByName; + late String _imageReplacementSignature; bool _initialized = false; bool _loadFailed = false; bool _releasedFromMemory = false; @@ -274,6 +305,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { void initState() { super.initState(); _pagKey = GlobalKey(); + _imageReplacementSignature = _imageReplacementUrlsSignature( + widget.imageReplacementUrlsByName, + ); _debug('mount'); _prepareResource(); } @@ -281,9 +315,16 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { @override void didUpdateWidget(covariant _RoomRocketPagView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.resource.trim() != widget.resource.trim()) { + final replacementSignature = _imageReplacementUrlsSignature( + widget.imageReplacementUrlsByName, + ); + if (oldWidget.resource.trim() != widget.resource.trim() || + replacementSignature != _imageReplacementSignature) { _pagKey = GlobalKey(); _pagBytes = null; + _assetName = null; + _imageReplacementsByName = null; + _imageReplacementSignature = replacementSignature; _initialized = false; _loadFailed = false; _releasedFromMemory = false; @@ -327,23 +368,53 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { return _withUnderlay(underlay, defaultChild); } final bytes = _pagBytes; - if (bytes == null) { + final assetName = _assetName; + final imageReplacementsReady = + !_hasImageReplacementUrls(widget.imageReplacementUrlsByName) || + _imageReplacementsByName != null; + if ((bytes == null && assetName == null) || !imageReplacementsReady) { return _withUnderlay(underlay, defaultChild); } - final pagView = PAGView.bytes( - bytes, - key: _pagKey, - width: null, - height: null, - autoPlay: false, - repeatCount: repeatCount, - onInit: _handleInit, - onAnimationStart: _handleAnimationStart, - onAnimationEnd: _handleAnimationEnd, - onAnimationCancel: _handleAnimationCancel, - onAnimationRepeat: _handleAnimationRepeat, - defaultBuilder: (_) => const SizedBox(width: 1, height: 1), - ); + final imageReplacementsByName = + _imageReplacementsByName ?? const {}; + final useNativeAssetView = + assetName != null && Platform.isIOS && widget.loop; + final pagView = + assetName != null + ? PAGView.asset( + assetName, + key: _pagKey, + width: useNativeAssetView ? widget.width : null, + height: useNativeAssetView ? widget.height : null, + autoPlay: useNativeAssetView, + usePlatformView: useNativeAssetView, + repeatCount: repeatCount, + onInit: useNativeAssetView ? null : _handleInit, + onAnimationStart: + useNativeAssetView ? null : _handleAnimationStart, + onAnimationEnd: useNativeAssetView ? null : _handleAnimationEnd, + onAnimationCancel: + useNativeAssetView ? null : _handleAnimationCancel, + onAnimationRepeat: + useNativeAssetView ? null : _handleAnimationRepeat, + defaultBuilder: (_) => const SizedBox(width: 1, height: 1), + imageReplacementsByName: imageReplacementsByName, + ) + : PAGView.bytes( + bytes, + key: _pagKey, + width: null, + height: null, + autoPlay: false, + repeatCount: repeatCount, + onInit: _handleInit, + onAnimationStart: _handleAnimationStart, + onAnimationEnd: _handleAnimationEnd, + onAnimationCancel: _handleAnimationCancel, + onAnimationRepeat: _handleAnimationRepeat, + defaultBuilder: (_) => const SizedBox(width: 1, height: 1), + imageReplacementsByName: imageReplacementsByName, + ); final visibleUnderlay = _initialized ? null : underlay; final content = Stack( fit: StackFit.expand, @@ -351,7 +422,10 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { widget.clipBehavior == Clip.none ? Clip.none : Clip.hardEdge, children: [ if (visibleUnderlay != null) visibleUnderlay, - _scaledPagView(pagView), + if (useNativeAssetView) + Positioned.fill(child: pagView) + else + _scaledPagView(pagView), if (!_initialized) Positioned.fill(child: defaultChild), ], ); @@ -379,25 +453,68 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { final path = widget.resource.trim(); _releasedFromMemory = false; _memoryReleaseTimer?.cancel(); + _assetName = null; + _imageReplacementsByName = null; _debug( 'prepare kind=${_pagSourceKind(path)} ' 'isPag=${RoomRocketPagEffectOverlay.isPag(path)}', ); _restartWatchdog(); - unawaited(_loadBytes(path)); + final assetName = _preferNativePagAsset(path); + final imageReplacementUrls = _validImageReplacementUrls( + widget.imageReplacementUrlsByName, + ); + final imageReplacementSignature = _imageReplacementSignature; + if (assetName != null) { + _debug('use native asset loader asset=$assetName'); + _assetName = assetName; + unawaited( + _loadImageReplacements( + path: path, + signature: imageReplacementSignature, + urlsByName: imageReplacementUrls, + ), + ); + if (widget.loop) { + _startWatchdog?.cancel(); + _initialized = true; + } + return; + } + unawaited( + _loadBytes( + path: path, + signature: imageReplacementSignature, + imageReplacementUrlsByName: imageReplacementUrls, + ), + ); } - Future _loadBytes(String path) async { + Future _loadBytes({ + required String path, + required String signature, + required Map imageReplacementUrlsByName, + }) async { final stopwatch = Stopwatch()..start(); try { _debug( '${_pagLoadAction(path)} start ' 'host=${_pagHost(path)}', ); + final imageReplacementsFuture = _readImageReplacementsByName( + imageReplacementUrlsByName, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + _debug('image replacements timeout'); + return const {}; + }, + ); final bytes = await _readPagBytes( path, ).timeout(const Duration(seconds: 15)); - if (!mounted || widget.resource.trim() != path) { + final imageReplacements = await imageReplacementsFuture; + if (!_isCurrentResource(path, signature)) { _debug( 'bytes ignored stale mounted=$mounted ' 'elapsedMs=${stopwatch.elapsedMilliseconds}', @@ -415,12 +532,13 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { ); setState(() { _pagBytes = bytes; + _imageReplacementsByName = imageReplacements; _releasedFromMemory = false; }); _scheduleMemoryRelease(path); _restartWatchdog(); } catch (error) { - if (!mounted || widget.resource.trim() != path) { + if (!_isCurrentResource(path, signature)) { return; } _markLoadFailed( @@ -429,6 +547,31 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { } } + Future _loadImageReplacements({ + required String path, + required String signature, + required Map urlsByName, + }) async { + if (urlsByName.isEmpty) { + _imageReplacementsByName = const {}; + return; + } + try { + final replacements = await _readImageReplacementsByName( + urlsByName, + ).timeout(const Duration(seconds: 10)); + if (!_isCurrentResource(path, signature)) { + return; + } + setState(() => _imageReplacementsByName = replacements); + } catch (error) { + _debug('image replacements failed: $error'); + if (_isCurrentResource(path, signature)) { + setState(() => _imageReplacementsByName = const {}); + } + } + } + void _handleInit() { _debug('init'); _startWatchdog?.cancel(); @@ -452,7 +595,6 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { 'viewSize=${widget.width?.toStringAsFixed(1)}x' '${widget.height?.toStringAsFixed(1)} fit=${widget.fit.name}', ); - widget.onReady?.call(Size(rawWidth, rawHeight)); unawaited(_debugLayers(state, rawWidth, rawHeight)); if (!_initialized) { setState(() => _initialized = true); @@ -541,6 +683,8 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { setState(() { _pagKey = GlobalKey(); _pagBytes = null; + _assetName = null; + _imageReplacementsByName = null; _initialized = false; _releasedFromMemory = true; }); @@ -586,6 +730,35 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { } } + bool _isCurrentResource(String path, String signature) { + return mounted && + widget.resource.trim() == path && + _imageReplacementSignature == signature; + } + + Future> _readImageReplacementsByName( + Map urlsByName, + ) async { + if (urlsByName.isEmpty) { + return const {}; + } + final result = {}; + for (final entry in urlsByName.entries) { + try { + final bytes = await _readPagBytes(entry.value); + if (bytes.isNotEmpty) { + result[entry.key] = bytes; + } + } catch (error) { + _debug( + 'image replacement load failed layer=${entry.key} ' + 'resource=${_shortPagResource(entry.value)} error=$error', + ); + } + } + return result; + } + void _debug(String event) { if (!kDebugMode) { return; @@ -675,6 +848,45 @@ String? _firstNonBlankPagAvatar(Iterable values) { return null; } +Map _topAvatarReplacementUrls(String? avatarUrl) { + final text = avatarUrl?.trim() ?? ''; + if (text.isEmpty) { + return const {}; + } + return {_roomRocketTopAvatarLayerName: text}; +} + +bool _hasImageReplacementUrls(Map urlsByName) { + return _validImageReplacementUrls(urlsByName).isNotEmpty; +} + +Map _validImageReplacementUrls(Map urlsByName) { + if (urlsByName.isEmpty) { + return const {}; + } + final result = {}; + urlsByName.forEach((key, value) { + final layerName = key.trim(); + final resource = value.trim(); + if (layerName.isNotEmpty && resource.isNotEmpty) { + result[layerName] = resource; + } + }); + return result; +} + +String _imageReplacementUrlsSignature(Map urlsByName) { + final entries = + _validImageReplacementUrls(urlsByName).entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + if (entries.isEmpty) { + return ''; + } + return entries + .map((entry) => '${entry.key}\u0000${entry.value}') + .join('\u0001'); +} + @visibleForTesting String debugNormalizeRoomRocketPagResource(String resource) { return _normalizePagResource(resource); @@ -752,6 +964,22 @@ String _pagLoadAction(String value) { }; } +String? _preferNativePagAsset(String value) { + if (!Platform.isIOS) { + return null; + } + final normalized = _normalizePagResource(value); + if (!RoomRocketPagEffectOverlay.isPag(normalized) || + _pagNetworkUri(normalized) != null || + _pagFilePath(normalized) != null) { + return null; + } + if (normalized.startsWith('sc_images/') || normalized.startsWith('assets/')) { + return normalized; + } + return null; +} + String _normalizePagResource(String value) { final normalized = normalizeImageResourceUrl(value.trim()); return _pagNetworkUri(normalized)?.toString() ?? normalized; diff --git a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart index e28ac8a..10dd16a 100644 --- a/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart +++ b/lib/ui_kit/widgets/room/room_game_bottom_sheet.dart @@ -8,6 +8,26 @@ import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; +void showRoomGameListSheet(BuildContext context, {String? initialGameId}) { + if (!context.mounted) { + return; + } + final guardLease = SCRoomTopLayerGuard().acquire( + reason: 'room_game_list_sheet', + ); + try { + unawaited( + showBottomInBottomDialog( + context, + RoomGameBottomSheet(roomContext: context, initialGameId: initialGameId), + barrierColor: Colors.black54, + ).whenComplete(guardLease.release), + ); + } catch (_) { + guardLease.release(); + } +} + class RoomGameEntryButton extends StatelessWidget { const RoomGameEntryButton({super.key}); @@ -15,20 +35,7 @@ class RoomGameEntryButton extends StatelessWidget { Widget build(BuildContext context) { return SCDebounceWidget( onTap: () { - final guardLease = SCRoomTopLayerGuard().acquire( - reason: 'room_game_list_sheet', - ); - try { - unawaited( - showBottomInBottomDialog( - context, - RoomGameBottomSheet(roomContext: context), - barrierColor: Colors.black54, - ).whenComplete(guardLease.release), - ); - } catch (_) { - guardLease.release(); - } + showRoomGameListSheet(context); }, child: SCSvgaAssetWidget( assetPath: "sc_images/room/sc_icon_room_game_entry_anim.svga", diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index 71158a7..6493628 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -669,8 +669,9 @@ class _MsgItemState extends State { return Container( alignment: AlignmentDirectional.topStart, child: Container( - constraints: BoxConstraints(minHeight: 49.w, maxWidth: 315.w), - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 6.w), + width: 315.w, + constraints: BoxConstraints(minHeight: 49.w), + padding: EdgeInsetsDirectional.fromSTEB(28.w, 6.w, 18.w, 6.w), margin: EdgeInsets.symmetric(horizontal: 10.w).copyWith(bottom: 8.w), decoration: const BoxDecoration( image: DecorationImage( @@ -679,18 +680,21 @@ class _MsgItemState extends State { centerSlice: _cpSystemNoticeBubbleCenterSlice, ), ), - child: Text( - widget.msg.msg ?? '', - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: sp(12), - height: 1.35, - color: Color(_msgColor(widget.msg.type ?? "")), - fontWeight: FontWeight.w500, - decoration: TextDecoration.none, + child: Align( + alignment: Alignment.center, + child: Text( + widget.msg.msg ?? '', + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: sp(12), + height: 1.35, + color: Color(_msgColor(widget.msg.type ?? "")), + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.left, ), ), ); diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index a7c6439..345f8a7 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -24,6 +24,7 @@ import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/tools/sc_string_utils.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; import 'package:yumi/shared/data_sources/models/enum/sc_props_type.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; @@ -751,7 +752,7 @@ class _RoomUserInfoCardState extends State { currentProfile?.id, ]), rightUserId: _firstNonBlank([partner.userId]), - days: _firstNonBlank([cp?.days, "0"]), + days: scCpRelationDisplayDays(_firstNonBlank([cp?.days, "0"])), relationType: _normalizeCpRelationType(cp?.relationType), dismissCost: cp?.dismissCost, cpValue: cp?.cpValue ?? 0, @@ -1638,7 +1639,7 @@ class _RoomUserInfoCardState extends State { child: text( SCAppLocalizations.of( context, - )!.timeSpentTogether(item.days ?? ""), + )!.timeSpentTogether(scCpRelationDisplayDays(item.days)), fontWeight: FontWeight.w600, fontSize: 12.sp, textColor: Color(0xFFFF79A1), @@ -1830,7 +1831,9 @@ class _RoomCpProgressData { currentValue: currentValue, targetValue: currentValue > 0 ? currentValue : 1, expAwayText: SCStringUtils.formatCompactNumber(0), - nextLevelText: _levelShortText(_levelIndex(cp.levelText)), + nextLevelText: _levelShortText( + _nextLevelIndex(_levelIndex(cp.levelText)), + ), ); } @@ -1900,7 +1903,9 @@ class _RoomCpProgressData { nextLevelText: nextLevel == null ? _levelShortText( - currentLevel?.level ?? _levelIndex(cp.levelText), + _nextLevelIndex( + currentLevel?.level ?? _levelIndex(cp.levelText), + ), ) : _levelShortText(nextLevel.level, fallback: nextLevel.levelName), ); @@ -1941,13 +1946,13 @@ class _RoomCpProgressData { } static String _levelShortText(int? level, {String? fallback}) { + if (level != null) { + return "Lv. $level"; + } final fallbackText = fallback?.trim() ?? ""; if (fallbackText.isNotEmpty) { return fallbackText; } - if (level != null) { - return "Lv. $level"; - } return "Lv. 1"; } @@ -1962,6 +1967,10 @@ class _RoomCpProgressData { ) ?? 1; } + + static int _nextLevelIndex(int level) { + return (level + 1).clamp(1, 5).toInt(); + } } class _CpProgressLevel { diff --git a/lib/ui_kit/widgets/room/seat/room_mic_relation_effect.dart b/lib/ui_kit/widgets/room/seat/room_mic_relation_effect.dart new file mode 100644 index 0000000..4d90203 --- /dev/null +++ b/lib/ui_kit/widgets/room/seat/room_mic_relation_effect.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; + +class RoomMicRelationEffect extends StatefulWidget { + const RoomMicRelationEffect({ + super.key, + required this.relationType, + this.cpLevel, + this.width = 108, + this.height = 54, + }); + + static const String assetBase = "sc_images/room/mic"; + + final String relationType; + final int? cpLevel; + final double width; + final double height; + + @override + State createState() => _RoomMicRelationEffectState(); +} + +class _RoomMicRelationEffectState extends State { + static const Duration _cycleDuration = Duration(seconds: 4); + static const Duration _pagMemoryTtl = Duration(hours: 1); + + Timer? _cycleTimer; + int _assetIndex = 0; + List<_RoomMicRelationAsset> _assets = const []; + + @override + void initState() { + super.initState(); + _syncAssets(restart: true); + } + + @override + void didUpdateWidget(covariant RoomMicRelationEffect oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.relationType != widget.relationType || + oldWidget.cpLevel != widget.cpLevel) { + _syncAssets(restart: true); + return; + } + _syncAssets(); + } + + @override + void dispose() { + _cycleTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_assets.isEmpty) { + return const SizedBox.shrink(); + } + final asset = _assets[_assetIndex % _assets.length]; + final width = widget.width.w; + final height = widget.height.w; + return SizedBox( + width: width, + height: height, + child: _buildAsset(asset, width: width, height: height), + ); + } + + Widget _buildAsset( + _RoomMicRelationAsset asset, { + required double width, + required double height, + }) { + switch (asset.kind) { + case _RoomMicRelationAssetKind.svga: + return SCSvgaAssetWidget( + assetPath: asset.path, + width: width, + height: height, + active: true, + loop: true, + fit: BoxFit.contain, + allowDrawingOverflow: true, + ); + case _RoomMicRelationAssetKind.pag: + return RoomRocketPagPreview( + resource: asset.path, + width: width, + height: height, + loop: true, + fit: BoxFit.contain, + clipBehavior: Clip.none, + memoryTtl: _pagMemoryTtl, + defaultBuilder: (_) => SizedBox(width: width, height: height), + ); + } + } + + void _syncAssets({bool restart = false}) { + final nextAssets = _assetsForRelation( + widget.relationType, + cpLevel: widget.cpLevel, + ); + final changed = !_sameAssetList(_assets, nextAssets); + if (!changed && !restart) { + return; + } + _cycleTimer?.cancel(); + _assets = nextAssets; + _assetIndex = 0; + if (mounted && changed) { + setState(() {}); + } + if (_assets.length > 1) { + _cycleTimer = Timer.periodic(_cycleDuration, (_) { + if (!mounted || _assets.length <= 1) { + return; + } + setState(() => _assetIndex = (_assetIndex + 1) % _assets.length); + }); + } + } + + bool _sameAssetList( + List<_RoomMicRelationAsset> left, + List<_RoomMicRelationAsset> right, + ) { + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) { + return false; + } + } + return true; + } + + List<_RoomMicRelationAsset> _assetsForRelation( + String relationType, { + required int? cpLevel, + }) { + switch (scNormalizeCpRelationType(relationType)) { + case "BROTHER": + return const [ + _RoomMicRelationAsset.pag( + "${RoomMicRelationEffect.assetBase}/brother_mic.pag", + ), + ]; + case "SISTERS": + return const [ + _RoomMicRelationAsset.pag( + "${RoomMicRelationEffect.assetBase}/sisters_mic.pag", + ), + ]; + default: + return _cpAssetsForLevel(cpLevel); + } + } + + List<_RoomMicRelationAsset> _cpAssetsForLevel(int? cpLevel) { + final level = (cpLevel ?? 1).clamp(1, 5).toInt(); + switch (level) { + case 2: + return const [ + _RoomMicRelationAsset.svga( + "${RoomMicRelationEffect.assetBase}/CP_mic_2.svga", + ), + ]; + case 3: + return const [ + _RoomMicRelationAsset.svga( + "${RoomMicRelationEffect.assetBase}/CP_mic_3.svga", + ), + ]; + case 4: + return const [ + _RoomMicRelationAsset.pag( + "${RoomMicRelationEffect.assetBase}/CP_mic_4.pag", + ), + ]; + case 5: + return const [ + _RoomMicRelationAsset.pag( + "${RoomMicRelationEffect.assetBase}/CP_mic_5.pag", + ), + ]; + case 1: + default: + return const [ + _RoomMicRelationAsset.pag( + "${RoomMicRelationEffect.assetBase}/CP_mic_1.pag", + ), + ]; + } + } +} + +enum _RoomMicRelationAssetKind { svga, pag } + +class _RoomMicRelationAsset { + const _RoomMicRelationAsset._({required this.path, required this.kind}); + + const _RoomMicRelationAsset.svga(String path) + : this._(path: path, kind: _RoomMicRelationAssetKind.svga); + + const _RoomMicRelationAsset.pag(String path) + : this._(path: path, kind: _RoomMicRelationAssetKind.pag); + + final String path; + final _RoomMicRelationAssetKind kind; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _RoomMicRelationAsset && + other.path == path && + other.kind == kind; + + @override + int get hashCode => Object.hash(path, kind); +} diff --git a/lib/ui_kit/widgets/room/seat/room_seat_widget.dart b/lib/ui_kit/widgets/room/seat/room_seat_widget.dart index 2094a57..25029c2 100644 --- a/lib/ui_kit/widgets/room/seat/room_seat_widget.dart +++ b/lib/ui_kit/widgets/room/seat/room_seat_widget.dart @@ -1,296 +1,302 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:provider/provider.dart'; -import 'package:yumi/services/audio/rtc_manager.dart'; - -import '../../../../modules/room/seat/sc_seat_item.dart'; - -class RoomSeatWidget extends StatefulWidget { - const RoomSeatWidget({super.key}); - - @override - State createState() => _RoomSeatWidgetState(); -} - -class _RoomSeatWidgetState extends State { - static const int _defaultVoiceRoomSeatCount = 10; - - int _lastSeatCount = 0; - - int _normalizeSeatCount(int? seatCount) { - switch (seatCount) { - case 5: - case 10: - case 15: - case 20: - return seatCount ?? 0; - default: - return 0; - } - } - - int _resolvedSeatCount(_RoomSeatLayoutSnapshot snapshot) { - final int liveSeatCount = _normalizeSeatCount(snapshot.seatCount); - final int configuredSeatCount = _normalizeSeatCount( - snapshot.configuredSeatCount, - ); - final int seatCount = - configuredSeatCount > 0 ? configuredSeatCount : liveSeatCount; - if (!snapshot.isExitingCurrentVoiceRoomSession && seatCount > 0) { - _lastSeatCount = seatCount; - } - if (snapshot.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) { - return _lastSeatCount; - } - if (snapshot.hasCurrentRoom && seatCount == 0) { - return _lastSeatCount > 0 ? _lastSeatCount : _defaultVoiceRoomSeatCount; - } - return seatCount; - } - - int _seatCountFromMicIndexes(Iterable micIndexes) { - var maxIndex = -1; - var count = 0; - for (final micIndex in micIndexes) { - count += 1; - final resolvedIndex = micIndex.toInt(); - if (resolvedIndex > maxIndex) { - maxIndex = resolvedIndex; - } - } - final indexedSeatCount = _normalizeSeatCount(maxIndex + 1); - if (indexedSeatCount > 0) { - return indexedSeatCount; - } - return _normalizeSeatCount(count); - } - - int _seatContentVersion(RtcProvider provider) { - var version = provider.roomWheatMap.length; - final micIndexes = - provider.roomWheatMap.keys.toList()..sort((a, b) => a.compareTo(b)); - for (final micIndex in micIndexes) { - final seat = provider.micAtIndexForDisplay(micIndex); - final user = seat?.user; - version = Object.hash( - version, - micIndex, - seat?.micLock ?? false, - seat?.micMute ?? false, - user?.id ?? "", - user?.userAvatar ?? "", - user?.userNickname ?? "", - user?.roles ?? "", - user?.getHeaddress()?.sourceUrl ?? "", - user?.getHeaddress()?.cover ?? "", - user?.getVIP()?.name ?? "", - ); - } - return version; - } - - @override - Widget build(BuildContext context) { - return Selector( - selector: - (context, provider) => _RoomSeatLayoutSnapshot( - seatCount: _seatCountFromMicIndexes(provider.roomWheatMap.keys), - seatContentVersion: _seatContentVersion(provider), - configuredSeatCount: - provider.currenRoom?.roomProfile?.roomSetting?.mikeSize - ?.toInt() ?? - provider.previewRoomSeatCount ?? - 0, - hasCurrentRoom: provider.currenRoom != null, - isExitingCurrentVoiceRoomSession: - provider.isExitingCurrentVoiceRoomSession, - ), - builder: (context, snapshot, child) { - final int seatCount = _resolvedSeatCount(snapshot); - return seatCount == 5 - ? _buildSeat5() - : (seatCount == 10 - ? _buildSeat10() - : (seatCount == 15 - ? _buildSeat15() - : (seatCount == 20 - ? _buildSeat20() - : Container(height: 180.w)))); - }, - ); - } - - _buildSeat5() { - return Container( - margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 0)), - Expanded(flex: 1, child: SCSeatItem(index: 1)), - Expanded(flex: 1, child: SCSeatItem(index: 2)), - Expanded(flex: 1, child: SCSeatItem(index: 3)), - Expanded(flex: 1, child: SCSeatItem(index: 4)), - ], - ), - ], - ), - ); - } - - _buildSeat10() { - return Container( - margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 0)), - Expanded(flex: 1, child: SCSeatItem(index: 1)), - Expanded(flex: 1, child: SCSeatItem(index: 2)), - Expanded(flex: 1, child: SCSeatItem(index: 3)), - Expanded(flex: 1, child: SCSeatItem(index: 4)), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 5)), - Expanded(flex: 1, child: SCSeatItem(index: 6)), - Expanded(flex: 1, child: SCSeatItem(index: 7)), - Expanded(flex: 1, child: SCSeatItem(index: 8)), - Expanded(flex: 1, child: SCSeatItem(index: 9)), - ], - ), - ], - ), - ); - } - - _buildSeat15() { - return Container( - margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 0)), - Expanded(flex: 1, child: SCSeatItem(index: 1)), - Expanded(flex: 1, child: SCSeatItem(index: 2)), - Expanded(flex: 1, child: SCSeatItem(index: 3)), - Expanded(flex: 1, child: SCSeatItem(index: 4)), - ], - ), - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 5)), - Expanded(flex: 1, child: SCSeatItem(index: 6)), - Expanded(flex: 1, child: SCSeatItem(index: 7)), - Expanded(flex: 1, child: SCSeatItem(index: 8)), - Expanded(flex: 1, child: SCSeatItem(index: 9)), - ], - ), - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 10)), - Expanded(flex: 1, child: SCSeatItem(index: 11)), - Expanded(flex: 1, child: SCSeatItem(index: 12)), - Expanded(flex: 1, child: SCSeatItem(index: 13)), - Expanded(flex: 1, child: SCSeatItem(index: 14)), - ], - ), - ], - ), - ); - } - - _buildSeat20() { - return Container( - margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 0)), - Expanded(flex: 1, child: SCSeatItem(index: 1)), - Expanded(flex: 1, child: SCSeatItem(index: 2)), - Expanded(flex: 1, child: SCSeatItem(index: 3)), - Expanded(flex: 1, child: SCSeatItem(index: 4)), - ], - ), - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 5)), - Expanded(flex: 1, child: SCSeatItem(index: 6)), - Expanded(flex: 1, child: SCSeatItem(index: 7)), - Expanded(flex: 1, child: SCSeatItem(index: 8)), - Expanded(flex: 1, child: SCSeatItem(index: 9)), - ], - ), - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 10)), - Expanded(flex: 1, child: SCSeatItem(index: 11)), - Expanded(flex: 1, child: SCSeatItem(index: 12)), - Expanded(flex: 1, child: SCSeatItem(index: 13)), - Expanded(flex: 1, child: SCSeatItem(index: 14)), - ], - ), - Row( - children: [ - Expanded(flex: 1, child: SCSeatItem(index: 15)), - Expanded(flex: 1, child: SCSeatItem(index: 16)), - Expanded(flex: 1, child: SCSeatItem(index: 17)), - Expanded(flex: 1, child: SCSeatItem(index: 18)), - Expanded(flex: 1, child: SCSeatItem(index: 19)), - ], - ), - ], - ), - ); - } -} - -class _RoomSeatLayoutSnapshot { - const _RoomSeatLayoutSnapshot({ - required this.seatCount, - required this.seatContentVersion, - required this.configuredSeatCount, - required this.hasCurrentRoom, - required this.isExitingCurrentVoiceRoomSession, - }); - - final int seatCount; - final int seatContentVersion; - final int configuredSeatCount; - final bool hasCurrentRoom; - final bool isExitingCurrentVoiceRoomSession; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is _RoomSeatLayoutSnapshot && - other.seatCount == seatCount && - other.seatContentVersion == seatContentVersion && - other.configuredSeatCount == configuredSeatCount && - other.hasCurrentRoom == hasCurrentRoom && - other.isExitingCurrentVoiceRoomSession == - isExitingCurrentVoiceRoomSession; - } - - @override - int get hashCode => Object.hash( - seatCount, - seatContentVersion, - configuredSeatCount, - hasCurrentRoom, - isExitingCurrentVoiceRoomSession, - ); -} +import 'package:flutter/cupertino.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_pair_utils.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/ui_kit/widgets/room/seat/room_mic_relation_effect.dart'; + +import '../../../../modules/room/seat/sc_seat_item.dart'; + +class RoomSeatWidget extends StatefulWidget { + const RoomSeatWidget({super.key}); + + @override + State createState() => _RoomSeatWidgetState(); +} + +class _RoomSeatWidgetState extends State { + static const int _defaultVoiceRoomSeatCount = 10; + static const int _seatColumnCount = 5; + static const double _relationEffectWidth = 108; + static const double _relationEffectHeight = 54; + static const double _relationEffectTop = -2; + + int _lastSeatCount = 0; + + int _normalizeSeatCount(int? seatCount) { + switch (seatCount) { + case 5: + case 10: + case 15: + case 20: + return seatCount ?? 0; + default: + return 0; + } + } + + int _resolvedSeatCount(_RoomSeatLayoutSnapshot snapshot) { + final int liveSeatCount = _normalizeSeatCount(snapshot.seatCount); + final int configuredSeatCount = _normalizeSeatCount( + snapshot.configuredSeatCount, + ); + final int seatCount = + configuredSeatCount > 0 ? configuredSeatCount : liveSeatCount; + if (!snapshot.isExitingCurrentVoiceRoomSession && seatCount > 0) { + _lastSeatCount = seatCount; + } + if (snapshot.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) { + return _lastSeatCount; + } + if (snapshot.hasCurrentRoom && seatCount == 0) { + return _lastSeatCount > 0 ? _lastSeatCount : _defaultVoiceRoomSeatCount; + } + return seatCount; + } + + int _seatCountFromMicIndexes(Iterable micIndexes) { + var maxIndex = -1; + var count = 0; + for (final micIndex in micIndexes) { + count += 1; + final resolvedIndex = micIndex.toInt(); + if (resolvedIndex > maxIndex) { + maxIndex = resolvedIndex; + } + } + final indexedSeatCount = _normalizeSeatCount(maxIndex + 1); + if (indexedSeatCount > 0) { + return indexedSeatCount; + } + return _normalizeSeatCount(count); + } + + int _seatContentVersion(RtcProvider provider) { + var version = provider.roomWheatMap.length; + final micIndexes = + provider.roomWheatMap.keys.toList()..sort((a, b) => a.compareTo(b)); + for (final micIndex in micIndexes) { + final seat = provider.micAtIndexForDisplay(micIndex); + final user = seat?.user; + version = Object.hash( + version, + micIndex, + seat?.micLock ?? false, + seat?.micMute ?? false, + user?.id ?? "", + user?.userAvatar ?? "", + user?.userNickname ?? "", + user?.roles ?? "", + user?.getHeaddress()?.sourceUrl ?? "", + user?.getHeaddress()?.cover ?? "", + user?.getVIP()?.name ?? "", + scCpProfileRelationVersion(user), + ); + } + return version; + } + + @override + Widget build(BuildContext context) { + return Selector( + selector: + (context, provider) => _RoomSeatLayoutSnapshot( + seatCount: _seatCountFromMicIndexes(provider.roomWheatMap.keys), + seatContentVersion: _seatContentVersion(provider), + showRoomVisualEffects: provider.shouldShowRoomVisualEffects, + configuredSeatCount: + provider.currenRoom?.roomProfile?.roomSetting?.mikeSize + ?.toInt() ?? + provider.previewRoomSeatCount ?? + 0, + hasCurrentRoom: provider.currenRoom != null, + isExitingCurrentVoiceRoomSession: + provider.isExitingCurrentVoiceRoomSession, + ), + builder: (context, snapshot, child) { + final int seatCount = _resolvedSeatCount(snapshot); + return seatCount == 5 + ? _buildSeat5() + : (seatCount == 10 + ? _buildSeat10() + : (seatCount == 15 + ? _buildSeat15() + : (seatCount == 20 + ? _buildSeat20() + : Container(height: 180.w)))); + }, + ); + } + + _buildSeat5() { + return Container( + margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildSeatRow(const [0, 1, 2, 3, 4]), + ], + ), + ); + } + + _buildSeat10() { + return Container( + margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildSeatRow(const [0, 1, 2, 3, 4]), + _buildSeatRow(const [5, 6, 7, 8, 9]), + ], + ), + ); + } + + _buildSeat15() { + return Container( + margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildSeatRow(const [0, 1, 2, 3, 4]), + _buildSeatRow(const [5, 6, 7, 8, 9]), + _buildSeatRow(const [10, 11, 12, 13, 14]), + ], + ), + ); + } + + _buildSeat20() { + return Container( + margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildSeatRow(const [0, 1, 2, 3, 4]), + _buildSeatRow(const [5, 6, 7, 8, 9]), + _buildSeatRow(const [10, 11, 12, 13, 14]), + _buildSeatRow(const [15, 16, 17, 18, 19]), + ], + ), + ); + } + + Widget _buildSeatRow(List indexes) { + return LayoutBuilder( + builder: (context, constraints) { + final rowWidth = constraints.maxWidth; + final columnWidth = rowWidth / _seatColumnCount; + return Stack( + clipBehavior: Clip.none, + children: [ + Row( + children: [ + for (final index in indexes) + Expanded(flex: 1, child: SCSeatItem(index: index)), + ], + ), + if (rowWidth.isFinite) + ..._buildRelationEffectsForRow( + context, + indexes: indexes, + columnWidth: columnWidth, + ), + ], + ); + }, + ); + } + + List _buildRelationEffectsForRow( + BuildContext context, { + required List indexes, + required double columnWidth, + }) { + final provider = context.read(); + if (!provider.shouldShowRoomVisualEffects) { + return const []; + } + final effects = []; + for (var slot = 0; slot < indexes.length - 1; slot += 1) { + final relationInfo = _relationInfoBetweenSeats( + provider, + indexes[slot], + indexes[slot + 1], + ); + if (relationInfo == null) { + continue; + } + final left = ((slot + 1) * columnWidth - (_relationEffectWidth.w / 2)) + .clamp(0.0, double.infinity); + effects.add( + Positioned( + left: left, + top: _relationEffectTop.w, + width: _relationEffectWidth.w, + height: _relationEffectHeight.w, + child: IgnorePointer( + child: RoomMicRelationEffect( + relationType: relationInfo.relationType, + cpLevel: relationInfo.cpLevel, + ), + ), + ), + ); + } + return effects; + } + + SCCpRelationPairInfo? _relationInfoBetweenSeats( + RtcProvider provider, + int leftIndex, + int rightIndex, + ) { + final leftUser = provider.micAtIndexForDisplay(leftIndex)?.user; + final rightUser = provider.micAtIndexForDisplay(rightIndex)?.user; + return scCpRelationInfoBetweenProfiles(leftUser, rightUser); + } +} + +class _RoomSeatLayoutSnapshot { + const _RoomSeatLayoutSnapshot({ + required this.seatCount, + required this.seatContentVersion, + required this.showRoomVisualEffects, + required this.configuredSeatCount, + required this.hasCurrentRoom, + required this.isExitingCurrentVoiceRoomSession, + }); + + final int seatCount; + final int seatContentVersion; + final bool showRoomVisualEffects; + final int configuredSeatCount; + final bool hasCurrentRoom; + final bool isExitingCurrentVoiceRoomSession; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is _RoomSeatLayoutSnapshot && + other.seatCount == seatCount && + other.seatContentVersion == seatContentVersion && + other.showRoomVisualEffects == showRoomVisualEffects && + other.configuredSeatCount == configuredSeatCount && + other.hasCurrentRoom == hasCurrentRoom && + other.isExitingCurrentVoiceRoomSession == + isExitingCurrentVoiceRoomSession; + } + + @override + int get hashCode => Object.hash( + seatCount, + seatContentVersion, + showRoomVisualEffects, + configuredSeatCount, + hasCurrentRoom, + isExitingCurrentVoiceRoomSession, + ); +} diff --git a/local_packages/pag-1.0.7-patched/android/src/main/java/com/example/flutter_pag_plugin/FlutterPagPlugin.java b/local_packages/pag-1.0.7-patched/android/src/main/java/com/example/flutter_pag_plugin/FlutterPagPlugin.java index 52eb3da..116f078 100644 --- a/local_packages/pag-1.0.7-patched/android/src/main/java/com/example/flutter_pag_plugin/FlutterPagPlugin.java +++ b/local_packages/pag-1.0.7-patched/android/src/main/java/com/example/flutter_pag_plugin/FlutterPagPlugin.java @@ -10,13 +10,16 @@ import android.view.Surface; import androidx.annotation.NonNull; import org.libpag.PAGFile; +import org.libpag.PAGImage; import org.libpag.PAGLayer; +import org.libpag.PAGScaleMode; import org.libpag.PAGSurface; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.plugin.common.MethodCall; @@ -79,6 +82,7 @@ public class FlutterPagPlugin implements FlutterPlugin, MethodCallHandler { final static String _argumentCacheEnabled = "cacheEnabled"; final static String _argumentCacheSize = "cacheSize"; final static String _argumentMultiThreadEnabled = "multiThreadEnabled"; + final static String _argumentImageReplacementsByName = "imageReplacementsByName"; // 回调 final static String _playCallback = "PAGCallback"; @@ -224,6 +228,7 @@ public class FlutterPagPlugin implements FlutterPlugin, MethodCallHandler { result.error("-1100", "load composition is null! ", null); return; } + applyImageReplacementsByName(composition, call); final int repeatCount = call.argument(_argumentRepeatCount); final double initProgress = call.argument(_argumentInitProgress); @@ -276,6 +281,34 @@ public class FlutterPagPlugin implements FlutterPlugin, MethodCallHandler { }); } + private void applyImageReplacementsByName(PAGFile composition, MethodCall call) { + Map replacements = call.argument(_argumentImageReplacementsByName); + if (composition == null || replacements == null || replacements.isEmpty()) { + return; + } + for (Map.Entry entry : replacements.entrySet()) { + String layerName = entry.getKey(); + Object value = entry.getValue(); + if (layerName == null) { + continue; + } + layerName = layerName.trim(); + if (layerName.isEmpty() || !(value instanceof byte[])) { + continue; + } + byte[] bytes = (byte[]) value; + if (bytes.length == 0) { + continue; + } + PAGImage image = PAGImage.FromBytes(bytes); + if (image == null) { + continue; + } + image.setScaleMode(PAGScaleMode.Zoom); + composition.replaceImageByName(layerName, image); + } + } + void start(MethodCall call) { FlutterPagPlayer flutterPagPlayer = getFlutterPagPlayer(call); if (flutterPagPlayer != null) { diff --git a/local_packages/pag-1.0.7-patched/ios/Classes/FlutterPagPlugin.m b/local_packages/pag-1.0.7-patched/ios/Classes/FlutterPagPlugin.m index 7429a4e..99d2001 100644 --- a/local_packages/pag-1.0.7-patched/ios/Classes/FlutterPagPlugin.m +++ b/local_packages/pag-1.0.7-patched/ios/Classes/FlutterPagPlugin.m @@ -8,6 +8,7 @@ #import "FlutterPagPlugin.h" #import "TGFlutterPagRender.h" #import "TGFlutterPagDownloadManager.h" +#import "TGFlutterPagPlatformView.h" /** FlutterPagPlugin,处理flutter MethodChannel约定的方法 @@ -33,6 +34,15 @@ /// 用于通信的channel @property (nonatomic, strong)FlutterMethodChannel* channel; +- (NSDictionary*)imageReplacementsByNameFromArguments:(id)arguments; + +- (void)pagRenderWithPagData:(NSData*)pagData + progress:(double)progress + repeatCount:(int)repeatCount + autoPlay:(BOOL)autoPlay + imageReplacementsByName:(NSDictionary*)imageReplacementsByName + result:(FlutterResult)result; + @end @implementation FlutterPagPlugin @@ -45,6 +55,8 @@ instance.registrar = registrar; instance.channel = channel; [registrar addMethodCallDelegate:instance channel:channel]; + [registrar registerViewFactory:[[TGFlutterPagPlatformViewFactory alloc] initWithRegistrar:registrar] + withId:@"flutter_pag_plugin_view"]; } - (void)getLayersUnderPoint:(id)arguments result:(FlutterResult _Nonnull)result { @@ -136,6 +148,7 @@ if(arguments[@"autoPlay"]){ autoPlay = [[arguments objectForKey:@"autoPlay"] boolValue]; } + NSDictionary* imageReplacementsByName = [self imageReplacementsByNameFromArguments:arguments]; NSString* assetName = arguments[@"assetName"]; NSData *pagData = nil; @@ -157,26 +170,24 @@ [self setCacheData:key data:pagData]; } - [self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result]; + [self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result]; } NSString* url = arguments[@"url"]; if ([url isKindOfClass:NSString.class] && url.length > 0) { - NSURLSessionDownloadTask *task; - [task resume]; NSString *key = url; pagData = [self getCacheData:key]; if (!pagData) { __weak typeof(self) weak_self = self; [TGFlutterPagDownloadManager download:url completionHandler:^(NSData * _Nonnull data, NSError * _Nonnull error) { if (data) { - [weak_self setCacheData:key data:pagData]; - [weak_self pagRenderWithPagData:data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result]; + [weak_self setCacheData:key data:data]; + [weak_self pagRenderWithPagData:data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result]; }else{ result(@-1); } }]; }else{ - [self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result]; + [self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result]; } } @@ -184,13 +195,47 @@ if(bytesData != nil && [bytesData isKindOfClass:FlutterStandardTypedData.class]){ FlutterStandardTypedData *typedData = bytesData; if(typedData.type == FlutterStandardDataTypeUInt8 && typedData.data != nil){ - [self pagRenderWithPagData:typedData.data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result]; + [self pagRenderWithPagData:typedData.data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result]; }else{ result(@-1); } } } +- (NSDictionary*)imageReplacementsByNameFromArguments:(id)arguments { + if (![arguments isKindOfClass:NSDictionary.class]) { + return @{}; + } + NSDictionary* params = arguments; + id raw = params[@"imageReplacementsByName"]; + if (![raw isKindOfClass:NSDictionary.class]) { + return @{}; + } + NSMutableDictionary* result = [[NSMutableDictionary alloc] init]; + [(NSDictionary*)raw enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL* stop) { + if (![key isKindOfClass:NSString.class]) { + return; + } + NSString* layerName = [(NSString*)key stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (layerName.length == 0) { + return; + } + NSData* data = nil; + if ([value isKindOfClass:FlutterStandardTypedData.class]) { + FlutterStandardTypedData* typedData = value; + if (typedData.type == FlutterStandardDataTypeUInt8) { + data = typedData.data; + } + } else if ([value isKindOfClass:NSData.class]) { + data = value; + } + if (data.length > 0) { + result[layerName] = data; + } + }]; + return result; +} + - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { id arguments = call.arguments; @@ -215,21 +260,32 @@ } } --(void)pagRenderWithPagData:(NSData *)pagData progress:(double)progress repeatCount:(int)repeatCount autoPlay:(BOOL)autoPlay result:(FlutterResult)result{ +-(void)pagRenderWithPagData:(NSData *)pagData progress:(double)progress repeatCount:(int)repeatCount autoPlay:(BOOL)autoPlay imageReplacementsByName:(NSDictionary*)imageReplacementsByName result:(FlutterResult)result{ + if (pagData == nil || pagData.length == 0) { + result(@-1); + return; + } __block int64_t textureId = -1; __weak typeof(self) weakSelf = self; - TGFlutterPagRender *render = [[TGFlutterPagRender alloc] initWithPagData:pagData progress:progress frameUpdateCallback:^{ + TGFlutterPagRender *render = [[TGFlutterPagRender alloc] initWithPagData:pagData progress:progress imageReplacementsByName:imageReplacementsByName frameUpdateCallback:^{ [weakSelf.textures textureFrameAvailable:textureId]; } eventCallback:^(NSString * event) { - [weakSelf.channel invokeMethod:PlayCallback arguments:@{ArgumentTextureId:@(textureId), ArgumentEvent:event}]; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.channel invokeMethod:PlayCallback arguments:@{ArgumentTextureId:@(textureId), ArgumentEvent:event}]; + }); }]; + CGSize renderSize = [render size]; + if (render == nil || renderSize.width <= 0 || renderSize.height <= 0) { + result(@-1); + return; + } [render setRepeatCount:repeatCount]; textureId = [self.textures registerTexture:render]; if(_renderMap == nil){ _renderMap = [[NSMutableDictionary alloc] init]; } [_renderMap setObject:render forKey:@(textureId)]; - result(@{@"textureId":@(textureId), @"width":@([render size].width), @"height":@([render size].height)}); + result(@{@"textureId":@(textureId), @"width":@(renderSize.width), @"height":@(renderSize.height)}); if(autoPlay){ [render startRender]; } diff --git a/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagPlatformView.h b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagPlatformView.h new file mode 100644 index 0000000..8649e60 --- /dev/null +++ b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagPlatformView.h @@ -0,0 +1,5 @@ +#import + +@interface TGFlutterPagPlatformViewFactory : NSObject +- (instancetype)initWithRegistrar:(NSObject*)registrar; +@end diff --git a/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagPlatformView.mm b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagPlatformView.mm new file mode 100644 index 0000000..42685bb --- /dev/null +++ b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagPlatformView.mm @@ -0,0 +1,234 @@ +#import "TGFlutterPagPlatformView.h" +#import "TGFlutterPagDownloadManager.h" + +#import +#import +#import +#import +#import + +@interface TGFlutterPagPlatformView : NSObject +@property(nonatomic, strong) PAGView* pagView; +@property(nonatomic, weak) NSObject* registrar; +@end + +@implementation TGFlutterPagPlatformView + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id)arguments + registrar:(NSObject*)registrar { + self = [super init]; + if (self) { + _registrar = registrar; + _pagView = [[PAGView alloc] initWithFrame:frame]; + _pagView.backgroundColor = UIColor.clearColor; + _pagView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [_pagView setScaleMode:PAGScaleModeLetterBox]; + [_pagView addListener:self]; + [self configureWithArguments:arguments]; + } + return self; +} + +- (UIView*)view { + return _pagView; +} + +- (void)configureWithArguments:(id)arguments { + if (![arguments isKindOfClass:NSDictionary.class]) { + return; + } + NSDictionary* params = arguments; + int repeatCount = 1; + id repeatValue = params[@"repeatCount"]; + if ([repeatValue respondsToSelector:@selector(intValue)]) { + repeatCount = [repeatValue intValue]; + } + double initProgress = 0.0; + id progressValue = params[@"initProgress"]; + if ([progressValue respondsToSelector:@selector(doubleValue)]) { + initProgress = [progressValue doubleValue]; + } + BOOL autoPlay = NO; + id autoPlayValue = params[@"autoPlay"]; + if ([autoPlayValue respondsToSelector:@selector(boolValue)]) { + autoPlay = [autoPlayValue boolValue]; + } + NSDictionary* imageReplacementsByName = [self imageReplacementsByNameFromParams:params]; + + [_pagView setRepeatCount:repeatCount]; + [_pagView setProgress:initProgress]; + + NSString* assetName = [self nonEmptyString:params[@"assetName"]]; + if (assetName.length > 0) { + NSString* package = [self nonEmptyString:params[@"package"]]; + NSString* assetPath = [self assetPathForName:assetName package:package]; + [self loadPath:assetPath initProgress:initProgress autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName]; + return; + } + + NSString* url = [self nonEmptyString:params[@"url"]]; + if (url.length > 0) { + __weak typeof(self) weakSelf = self; + [TGFlutterPagDownloadManager download:url completionHandler:^(NSData* data, NSError* error) { + if (data == nil || data.length == 0) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf loadData:data initProgress:initProgress autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName]; + }); + }]; + return; + } + + id bytesData = params[@"bytesData"]; + if ([bytesData isKindOfClass:FlutterStandardTypedData.class]) { + FlutterStandardTypedData* typedData = bytesData; + if (typedData.type == FlutterStandardDataTypeUInt8) { + [self loadData:typedData.data initProgress:initProgress autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName]; + } + } +} + +- (NSString*)nonEmptyString:(id)value { + if (![value isKindOfClass:NSString.class]) { + return nil; + } + NSString* text = [(NSString*)value stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + return text.length > 0 ? text : nil; +} + +- (NSString*)assetPathForName:(NSString*)assetName package:(NSString*)package { + NSString* resourcePath = nil; + if (package.length > 0) { + resourcePath = [_registrar lookupKeyForAsset:assetName fromPackage:package]; + } else { + resourcePath = [_registrar lookupKeyForAsset:assetName]; + } + return [[NSBundle mainBundle] pathForResource:resourcePath ofType:nil]; +} + +- (void)loadPath:(NSString*)path initProgress:(double)initProgress autoPlay:(BOOL)autoPlay imageReplacementsByName:(NSDictionary*)imageReplacementsByName { + if (path.length == 0) { + return; + } + if (imageReplacementsByName.count > 0) { + PAGFile* file = [PAGFile Load:path]; + [self applyImageReplacementsByName:imageReplacementsByName toFile:file]; + [self setFile:file initProgress:initProgress autoPlay:autoPlay]; + return; + } + if (![_pagView setPath:path]) { + return; + } + [_pagView setProgress:initProgress]; + [_pagView flush]; + if (autoPlay) { + [_pagView play]; + } +} + +- (void)loadData:(NSData*)data initProgress:(double)initProgress autoPlay:(BOOL)autoPlay imageReplacementsByName:(NSDictionary*)imageReplacementsByName { + if (data == nil || data.length == 0) { + return; + } + PAGFile* file = [PAGFile Load:data.bytes size:data.length]; + [self applyImageReplacementsByName:imageReplacementsByName toFile:file]; + [self setFile:file initProgress:initProgress autoPlay:autoPlay]; +} + +- (void)setFile:(PAGFile*)file initProgress:(double)initProgress autoPlay:(BOOL)autoPlay { + if (file == nil || file.width <= 0 || file.height <= 0) { + return; + } + [_pagView setComposition:file]; + [_pagView setProgress:initProgress]; + [_pagView flush]; + if (autoPlay) { + [_pagView play]; + } +} + +- (NSDictionary*)imageReplacementsByNameFromParams:(NSDictionary*)params { + id raw = params[@"imageReplacementsByName"]; + if (![raw isKindOfClass:NSDictionary.class]) { + return @{}; + } + NSMutableDictionary* result = [[NSMutableDictionary alloc] init]; + [(NSDictionary*)raw enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL* stop) { + if (![key isKindOfClass:NSString.class]) { + return; + } + NSString* layerName = [(NSString*)key stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (layerName.length == 0) { + return; + } + NSData* data = nil; + if ([value isKindOfClass:FlutterStandardTypedData.class]) { + FlutterStandardTypedData* typedData = value; + if (typedData.type == FlutterStandardDataTypeUInt8) { + data = typedData.data; + } + } else if ([value isKindOfClass:NSData.class]) { + data = value; + } + if (data.length > 0) { + result[layerName] = data; + } + }]; + return result; +} + +- (void)applyImageReplacementsByName:(NSDictionary*)imageReplacementsByName toFile:(PAGFile*)file { + if (file == nil || imageReplacementsByName == nil || imageReplacementsByName.count == 0) { + return; + } + [imageReplacementsByName enumerateKeysAndObjectsUsingBlock:^(NSString* layerName, NSData* data, BOOL* stop) { + if (![layerName isKindOfClass:NSString.class] || layerName.length == 0 || + ![data isKindOfClass:NSData.class] || data.length == 0) { + return; + } + PAGImage* image = [PAGImage FromBytes:data.bytes size:data.length]; + if (image == nil) { + return; + } + [image setScaleMode:PAGScaleModeZoom]; + [file replaceImageByName:layerName data:image]; + }]; +} + +- (void)dealloc { + [_pagView removeListener:self]; + [_pagView stop]; + [_pagView setComposition:nil]; +} + +@end + +@implementation TGFlutterPagPlatformViewFactory { + __weak NSObject* _registrar; +} + +- (instancetype)initWithRegistrar:(NSObject*)registrar { + self = [super init]; + if (self) { + _registrar = registrar; + } + return self; +} + +- (NSObject*)createArgsCodec { + return FlutterStandardMessageCodec.sharedInstance; +} + +- (NSObject*)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id)args { + return [[TGFlutterPagPlatformView alloc] initWithFrame:frame + viewIdentifier:viewId + arguments:args + registrar:_registrar]; +} + +@end diff --git a/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.h b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.h index 5f13fc4..6b8d08a 100644 --- a/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.h +++ b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.h @@ -30,6 +30,7 @@ typedef void(^PAGEventCallback)(NSString *); - (instancetype)initWithPagData:(NSData*)pagData progress:(double)initProgress + imageReplacementsByName:(NSDictionary*)imageReplacementsByName frameUpdateCallback:(FrameUpdateCallback)frameUpdateCallback eventCallback:(PAGEventCallback)eventCallback; diff --git a/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.mm b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.mm index 35fc9aa..57611da 100644 --- a/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.mm +++ b/local_packages/pag-1.0.7-patched/ios/Classes/TGFlutterPagRender.mm @@ -12,7 +12,10 @@ #import #import #import +#include +#include #include +#include #include #include @@ -86,6 +89,7 @@ static int64_t GetCurrentTimeUS() { - (instancetype)initWithPagData:(NSData*)pagData progress:(double)initProgress + imageReplacementsByName:(NSDictionary*)imageReplacementsByName frameUpdateCallback:(FrameUpdateCallback)frameUpdateCallback eventCallback:(PAGEventCallback)eventCallback { @@ -95,6 +99,10 @@ static int64_t GetCurrentTimeUS() { _initProgress = initProgress; if(pagData){ _pagFile = [PAGFile Load:pagData.bytes size:pagData.length]; + if (_pagFile == nil || _pagFile.width <= 0 || _pagFile.height <= 0) { + return self; + } + [self applyImageReplacementsByName:imageReplacementsByName]; _player = [[PAGPlayer alloc] init]; [_player setComposition:_pagFile]; _surface = [PAGSurface MakeFromGPU:CGSizeMake(_pagFile.width, _pagFile.height)]; @@ -107,8 +115,30 @@ static int64_t GetCurrentTimeUS() { return self; } +- (void)applyImageReplacementsByName:(NSDictionary*)imageReplacementsByName +{ + if (_pagFile == nil || imageReplacementsByName == nil || imageReplacementsByName.count == 0) { + return; + } + [imageReplacementsByName enumerateKeysAndObjectsUsingBlock:^(NSString* layerName, NSData* data, BOOL* stop) { + if (![layerName isKindOfClass:NSString.class] || layerName.length == 0 || + ![data isKindOfClass:NSData.class] || data.length == 0) { + return; + } + PAGImage* image = [PAGImage FromBytes:data.bytes size:data.length]; + if (image == nil) { + return; + } + [image setScaleMode:PAGScaleModeZoom]; + [_pagFile replaceImageByName:layerName data:image]; + }]; +} + - (void)startRender { + if (_player == nil || _surface == nil) { + return; + } if (!_displayLink) { _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(update)]; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; @@ -121,6 +151,9 @@ static int64_t GetCurrentTimeUS() { - (void)stopRender { + if (_player == nil) { + return; + } if (_displayLink) { [_displayLink invalidate]; _displayLink = nil; @@ -136,6 +169,9 @@ static int64_t GetCurrentTimeUS() { } - (void)pauseRender{ + if (_player == nil) { + return; + } if (_displayLink) { [_displayLink invalidate]; _displayLink = nil; @@ -146,12 +182,18 @@ static int64_t GetCurrentTimeUS() { } - (void)setProgress:(double)progress{ + if (_player == nil) { + return; + } [_player setProgress:progress]; [_player flush]; _frameUpdateCallback(); } - (NSArray *)getLayersUnderPoint:(CGPoint)point{ + if (_player == nil) { + return @[]; + } NSArray* layers = [_player getLayersUnderPoint:point]; NSMutableArray *layerNames = [[NSMutableArray alloc] init]; for (PAGLayer *layer in layers) { diff --git a/local_packages/pag-1.0.7-patched/lib/pag.dart b/local_packages/pag-1.0.7-patched/lib/pag.dart index 2588a53..a5494b7 100644 --- a/local_packages/pag-1.0.7-patched/lib/pag.dart +++ b/local_packages/pag-1.0.7-patched/lib/pag.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; class PAGView extends StatefulWidget { @@ -45,6 +46,15 @@ class PAGView extends StatefulWidget { /// 加载失败时的默认控件构造器 final Widget Function(BuildContext context)? defaultBuilder; + /// Replace editable PAG image layers by layer name before playback starts. + final Map imageReplacementsByName; + + /// Uses the native iOS PAGView instead of the texture renderer. + /// + /// This is intended for local PAG assets on iOS where the texture renderer can + /// report playback events while presenting transparent frames. + final bool usePlatformView; + static const int REPEAT_COUNT_LOOP = -1; //无限循环 static const int REPEAT_COUNT_DEFAULT = 1; //默认仅播放一次 @@ -61,6 +71,8 @@ class PAGView extends StatefulWidget { this.onAnimationCancel, this.onAnimationRepeat, this.defaultBuilder, + this.imageReplacementsByName = const {}, + this.usePlatformView = false, Key? key, }) : this.bytesData = null, this.assetName = null, @@ -81,6 +93,8 @@ class PAGView extends StatefulWidget { this.onAnimationCancel, this.onAnimationRepeat, this.defaultBuilder, + this.imageReplacementsByName = const {}, + this.usePlatformView = false, Key? key, }) : this.bytesData = null, this.url = null, @@ -100,6 +114,8 @@ class PAGView extends StatefulWidget { this.onAnimationCancel, this.onAnimationRepeat, this.defaultBuilder, + this.imageReplacementsByName = const {}, + this.usePlatformView = false, Key? key, }) : this.url = null, this.assetName = null, @@ -148,6 +164,9 @@ class PAGViewState extends State { static const String _argumentCacheEnabled = "cacheEnabled"; static const String _argumentCacheSize = "cacheSize"; static const String _argumentMultiThreadEnabled = "multiThreadEnabled"; + static const String _argumentImageReplacementsByName = + 'imageReplacementsByName'; + static const String _platformViewType = 'flutter_pag_plugin_view'; // 监听该函数 static const String _playCallback = 'PAGCallback'; @@ -161,7 +180,8 @@ class PAGViewState extends State { static MethodChannel _channel = (const MethodChannel('flutter_pag_plugin') ..setMethodCallHandler((result) { if (result.method == _playCallback) { - callbackHandlers[result.arguments[_argumentTextureId]]?.call(result.arguments[_argumentEvent]); + callbackHandlers[result.arguments[_argumentTextureId]] + ?.call(result.arguments[_argumentEvent]); } return Future.value(); @@ -172,21 +192,60 @@ class PAGViewState extends State { @override void initState() { super.initState(); - newTexture(); + if (!_usesPlatformView) { + newTexture(); + } + } + + bool get _usesPlatformView => + widget.usePlatformView && defaultTargetPlatform == TargetPlatform.iOS; + + Map get _creationParams { + final repeatCount = widget.repeatCount <= 0 && + widget.repeatCount != PAGView.REPEAT_COUNT_LOOP + ? PAGView.REPEAT_COUNT_DEFAULT + : widget.repeatCount; + final initProcess = widget.initProgress < 0 ? 0 : widget.initProgress; + return { + _argumentAssetName: widget.assetName, + _argumentPackage: widget.package, + _argumentUrl: widget.url, + _argumentBytes: widget.bytesData, + _argumentRepeatCount: repeatCount, + _argumentInitProgress: initProcess, + _argumentAutoPlay: widget.autoPlay, + _argumentImageReplacementsByName: + _validImageReplacements(widget.imageReplacementsByName), + }; + } + + Map _validImageReplacements( + Map replacements, + ) { + if (replacements.isEmpty) { + return const {}; + } + final result = {}; + replacements.forEach((key, value) { + final layerName = key.trim(); + if (layerName.isNotEmpty && value.isNotEmpty) { + result[layerName] = value; + } + }); + return result; } // 初始化 void newTexture() async { - int repeatCount = widget.repeatCount <= 0 && widget.repeatCount != PAGView.REPEAT_COUNT_LOOP ? PAGView.REPEAT_COUNT_DEFAULT : widget.repeatCount; - double initProcess = widget.initProgress < 0 ? 0 : widget.initProgress; - try { dynamic result = - await _channel.invokeMethod(_nativeInit, {_argumentAssetName: widget.assetName, _argumentPackage: widget.package, _argumentUrl: widget.url, _argumentBytes: widget.bytesData, _argumentRepeatCount: repeatCount, _argumentInitProgress: initProcess, _argumentAutoPlay: widget.autoPlay}); + await _channel.invokeMethod(_nativeInit, _creationParams); if (result is Map) { _textureId = result[_argumentTextureId]; rawWidth = result[_argumentWidth] ?? 0; rawHeight = result[_argumentHeight] ?? 0; + } else { + throw StateError('initPag failed: $result'); } if (mounted) { setState(() { @@ -216,6 +275,9 @@ class PAGViewState extends State { /// 开始 void start() { + if (_usesPlatformView) { + return; + } if (!_hasLoadTexture) { return; } @@ -224,6 +286,9 @@ class PAGViewState extends State { /// 停止 void stop() { + if (_usesPlatformView) { + return; + } if (!_hasLoadTexture) { return; } @@ -232,6 +297,9 @@ class PAGViewState extends State { /// 暂停 void pause() { + if (_usesPlatformView) { + return; + } if (!_hasLoadTexture) { return; } @@ -240,22 +308,43 @@ class PAGViewState extends State { /// 设置进度 void setProgress(double progress) { + if (_usesPlatformView) { + return; + } if (!_hasLoadTexture) { return; } - _channel.invokeMethod(_nativeSetProgress, {_argumentTextureId: _textureId, _argumentProgress: progress}); + _channel.invokeMethod(_nativeSetProgress, + {_argumentTextureId: _textureId, _argumentProgress: progress}); } /// 获取某一位置的图层 Future> getLayersUnderPoint(double x, double y) async { - if (!_hasLoadTexture) { + if (_usesPlatformView || !_hasLoadTexture) { return []; } - return (await _channel.invokeMethod(_nativeGetPointLayer, {_argumentTextureId: _textureId, _argumentPointX: x, _argumentPointY: y}) as List).map((e) => e.toString()).toList(); + return (await _channel.invokeMethod(_nativeGetPointLayer, { + _argumentTextureId: _textureId, + _argumentPointX: x, + _argumentPointY: y + }) as List) + .map((e) => e.toString()) + .toList(); } @override Widget build(BuildContext context) { + if (_usesPlatformView) { + return SizedBox( + width: widget.width ?? defaultSize, + height: widget.height ?? defaultSize, + child: UiKitView( + viewType: _platformViewType, + creationParams: _creationParams, + creationParamsCodec: const StandardMessageCodec(), + ), + ); + } if (_hasLoadTexture) { return SizedBox( width: widget.width ?? (rawWidth / 2), @@ -274,8 +363,10 @@ class PAGViewState extends State { @override void dispose() { super.dispose(); - _channel.invokeMethod(_nativeRelease, {_argumentTextureId: _textureId}); - callbackHandlers.remove(_textureId); + if (!_usesPlatformView) { + _channel.invokeMethod(_nativeRelease, {_argumentTextureId: _textureId}); + callbackHandlers.remove(_textureId); + } } } @@ -285,16 +376,19 @@ typedef PAGCallback = void Function(); class PAG { // 是否开启缓存,默认true static void enableCache(bool enable) { - PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableCache, {PAGViewState._argumentCacheEnabled: enable}); + PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableCache, + {PAGViewState._argumentCacheEnabled: enable}); } // 是否多线程加载和释放资源,默认true static void enableMultiThread(bool enable) { - PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableMultiThread, {PAGViewState._argumentMultiThreadEnabled: enable}); + PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableMultiThread, + {PAGViewState._argumentMultiThreadEnabled: enable}); } // 设置缓存数量,默认10 static void setCacheSize(int size) { - PAGViewState._channel.invokeMethod(PAGViewState._nativeSetCacheSize, {PAGViewState._argumentCacheSize: size}); + PAGViewState._channel.invokeMethod(PAGViewState._nativeSetCacheSize, + {PAGViewState._argumentCacheSize: size}); } } diff --git a/pubspec.yaml b/pubspec.yaml index 018e2d9..e917567 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -175,8 +175,10 @@ flutter: - sc_images/room/cp_progress/ - sc_images/room/cp_invite/ - sc_images/room/background_examples/ + - sc_images/room/cp_broadcast/ - sc_images/room/emoji/ - sc_images/room/entrance/ + - sc_images/room/mic/ - sc_images/room/red_packet/ - sc_images/room/rocket/ - sc_images/room/anim/ diff --git a/sc_images/room/anim/game_win.svga b/sc_images/room/anim/game_win.svga new file mode 100644 index 0000000..c6bcbc2 Binary files /dev/null and b/sc_images/room/anim/game_win.svga differ diff --git a/sc_images/room/cp_broadcast/CP_broadcast.pag b/sc_images/room/cp_broadcast/CP_broadcast.pag new file mode 100644 index 0000000..25b5de1 Binary files /dev/null and b/sc_images/room/cp_broadcast/CP_broadcast.pag differ diff --git a/sc_images/room/cp_broadcast/brother_broadcast.pag b/sc_images/room/cp_broadcast/brother_broadcast.pag new file mode 100644 index 0000000..9049e38 Binary files /dev/null and b/sc_images/room/cp_broadcast/brother_broadcast.pag differ diff --git a/sc_images/room/cp_broadcast/sisters_broadcast.pag b/sc_images/room/cp_broadcast/sisters_broadcast.pag new file mode 100644 index 0000000..583e50d Binary files /dev/null and b/sc_images/room/cp_broadcast/sisters_broadcast.pag differ diff --git a/sc_images/room/mic/CP_mic_1.pag b/sc_images/room/mic/CP_mic_1.pag new file mode 100644 index 0000000..3f2290b Binary files /dev/null and b/sc_images/room/mic/CP_mic_1.pag differ diff --git a/sc_images/room/mic/CP_mic_2.svga b/sc_images/room/mic/CP_mic_2.svga new file mode 100644 index 0000000..f247d22 Binary files /dev/null and b/sc_images/room/mic/CP_mic_2.svga differ diff --git a/sc_images/room/mic/CP_mic_3.svga b/sc_images/room/mic/CP_mic_3.svga new file mode 100644 index 0000000..0a36596 Binary files /dev/null and b/sc_images/room/mic/CP_mic_3.svga differ diff --git a/sc_images/room/mic/CP_mic_4.pag b/sc_images/room/mic/CP_mic_4.pag new file mode 100644 index 0000000..aba40cc Binary files /dev/null and b/sc_images/room/mic/CP_mic_4.pag differ diff --git a/sc_images/room/mic/CP_mic_5.pag b/sc_images/room/mic/CP_mic_5.pag new file mode 100644 index 0000000..683e26e Binary files /dev/null and b/sc_images/room/mic/CP_mic_5.pag differ diff --git a/sc_images/room/mic/brother_mic.pag b/sc_images/room/mic/brother_mic.pag new file mode 100644 index 0000000..2820cd2 Binary files /dev/null and b/sc_images/room/mic/brother_mic.pag differ diff --git a/sc_images/room/mic/sisters_mic.pag b/sc_images/room/mic/sisters_mic.pag new file mode 100644 index 0000000..3a174fb Binary files /dev/null and b/sc_images/room/mic/sisters_mic.pag differ diff --git a/test/gift_res_cp_relation_type_test.dart b/test/gift_res_cp_relation_type_test.dart index de238e3..81ef88f 100644 --- a/test/gift_res_cp_relation_type_test.dart +++ b/test/gift_res_cp_relation_type_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_system_invit_message_res.dart'; @@ -32,6 +34,12 @@ void main() { expect(scNormalizeCpRelationType('bro'), 'BROTHER'); expect(scNormalizeCpRelationType('sis'), 'SISTERS'); }); + + test('relation days display starts from day one', () { + expect(scCpRelationDisplayDays('0'), '1'); + expect(scCpRelationDisplayDays('12'), '13'); + expect(scCpRelationDisplayDays(''), '1'); + }); }); group('scCpRelationTypeForGift', () { @@ -68,4 +76,26 @@ void main() { expect(invite.toJson()['userHeaddress'], 'frame.svga'); }); }); + + group('SCSystemInvitMessageRes CP_BUILD relation type', () { + test('uses top-level relation type when content omits it', () { + final invite = SCSystemInvitMessageRes.fromMessageData({ + 'relationType': 'BROTHER', + 'content': jsonEncode({'userNickname': 'Alex'}), + }); + + expect(invite?.relationType, 'BROTHER'); + }); + + test('infers relation type from gift payload when needed', () { + final invite = SCSystemInvitMessageRes.fromMessageData({ + 'content': { + 'userNickname': 'Sam', + 'gift': {'giftCode': 'close_friend_sister_ring'}, + }, + }); + + expect(invite?.relationType, 'SISTERS'); + }); + }); } diff --git a/test/room_rocket_status_test.dart b/test/room_rocket_status_test.dart index 31a4881..4352a85 100644 --- a/test/room_rocket_status_test.dart +++ b/test/room_rocket_status_test.dart @@ -4,6 +4,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dar import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_config_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart'; import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart'; void main() { test('maps localized rocket rule html from go rule response', () { @@ -41,6 +42,42 @@ void main() { } }); + test('formats rocket rule html lists without detached markers', () { + final text = debugRoomRocketRuleHtmlPlainText( + '

How to Launch a Rocket

' + '
    ' + '
  1. Sending gifts in the room will add fuel.

  2. ' + '
  3. Once the rocket fuel is full, it will launch.

  4. ' + '
', + ); + + expect(text, contains('How to Launch a Rocket\n1. Sending gifts')); + expect(text, contains('\n2. Once the rocket fuel is full')); + expect(text, isNot(contains('1. \n'))); + expect(text, isNot(contains('2. \n'))); + }); + + test('keeps manually numbered rocket rule html from duplicating markers', () { + final text = debugRoomRocketRuleHtmlPlainText( + '

How to Launch a Rocket

' + '
    ' + '
  1. 1. Sending gifts in the room will add fuel.

  2. ' + '
  3. 2. Once the rocket fuel is full, it will launch.

  4. ' + '
  5. 3. The higher the rocket level, the more rewards.

  6. ' + '
  7. Leaderboard and Rewards

  8. ' + '
' + '
    ' + '
  1. 1. The Top 1 user will receive an exclusive reward.

  2. ' + '
', + ); + + expect(text, contains('How to Launch a Rocket\n1. Sending gifts')); + expect(text, contains('\nLeaderboard and Rewards\n1. The Top 1 user')); + expect(text, isNot(contains('1. \n1.'))); + expect(text, isNot(contains('4. \nLeaderboard'))); + expect(text, isNot(contains('4. Leaderboard'))); + }); + test( 'parses level-specific reward previews and rocket kings from levels', () { diff --git a/test/sc_cp_relation_pair_utils_test.dart b/test/sc_cp_relation_pair_utils_test.dart new file mode 100644 index 0000000..c51a6e4 --- /dev/null +++ b/test/sc_cp_relation_pair_utils_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_pair_utils.dart'; + +void main() { + group('scCpRelationTypeBetweenProfiles', () { + test('detects cp relation from either adjacent profile', () { + final left = SocialChatUserProfile( + id: '100', + account: 'left', + cpList: [ + CPRes( + meUserId: '100', + meAccount: 'left', + cpUserId: '200', + cpAccount: 'right', + relationType: 'CP', + levelText: 'Lv.3 Sweet', + ), + ], + ); + final right = SocialChatUserProfile(id: '200', account: 'right'); + + expect(scCpRelationTypeBetweenProfiles(left, right), 'CP'); + expect(scCpRelationTypeBetweenProfiles(right, left), 'CP'); + expect(scCpRelationInfoBetweenProfiles(left, right)?.cpLevel, 3); + }); + + test('detects brother and sister close-friend relations', () { + final brotherLeft = SocialChatUserProfile( + id: '100', + closeFriendList: [ + CPRes(meUserId: '100', cpUserId: '200', relationType: 'bro'), + ], + ); + final brotherRight = SocialChatUserProfile(id: '200'); + final sisterLeft = SocialChatUserProfile(id: '300'); + final sisterRight = SocialChatUserProfile( + id: '400', + closeFriendList: [ + CPRes(meUserId: '300', cpUserId: '400', relationType: 'sis'), + ], + ); + + expect( + scCpRelationTypeBetweenProfiles(brotherLeft, brotherRight), + 'BROTHER', + ); + expect( + scCpRelationTypeBetweenProfiles(sisterLeft, sisterRight), + 'SISTERS', + ); + }); + + test('does not match unrelated profiles', () { + final left = SocialChatUserProfile( + id: '100', + cpList: [CPRes(meUserId: '100', cpUserId: '200', relationType: 'CP')], + ); + final right = SocialChatUserProfile(id: '300'); + + expect(scCpRelationTypeBetweenProfiles(left, right), isNull); + }); + }); +} diff --git a/需求进度.md b/需求进度.md index 1bbf532..ea733c6 100644 --- a/需求进度.md +++ b/需求进度.md @@ -1,5 +1,6 @@ # 首充弹窗需求进度(2026-05-21) +- [ ] 2026-05-22 新增问题:后台首充已配置,但前端未显示首充弹窗,房间礼物面板也未显示首充悬浮板;本轮需用 iPhone 17 Pro 模拟器实际排查并修复。 - [x] 2026-05-22 用户反馈:礼物弹窗在 iPhone 17 Pro Max 仍报 `A Stack requires bounded constraints from its parent`;本轮已实际运行 iPhone 17 Pro Max 复现,并依据运行日志修到不再报错。 - [x] 2026-05-22 已确认 iPhone 17 Pro Max 模拟器在线,设备 ID:`8D2D87EB-4769-43B7-8DC2-580D46A76B6C`。 - [x] 2026-05-22 已在 iPhone 17 Pro Max 启动主 App;控制台先出现 SmartDialog overlay 运行期异常:`RenderAnimatedOpacity ... SmartDialogWidget ... size: MISSING`,说明问题发生在弹窗 overlay 内容尺寸约束层。 @@ -916,3 +917,66 @@ - 如果需要正式 iOS 分发包,为 `scripts/build_release.py` 补入 `--ios-codesign` 所需签名参数并导出 `.ipa`。 - 继续评估 Agora 扩展库是否都需要,优先核查 `lip sync`、`spatial audio`、`clear vision`、`segmentation`、`face capture` 等扩展 so 能否裁剪。 - 决定是否处理剩余 12 张特殊 `webp`:改格式、换压缩工具,或维持现状。 +- [ ] 2026-05-22 16:08:31 已确认 iPhone 17 Pro 模拟器在线,并开始检查首充弹窗/礼物悬浮板显示条件。 +- [ ] 2026-05-22 16:08:53 已读取首充接口模型、弹窗加载逻辑、礼物面板悬浮板条件和首页首充入口,准备定位显示断点。 +- [ ] 2026-05-22 16:09:12 已确认礼物面板悬浮板仍依赖登录资料 firstRecharge,本地还需接入首充奖励 home 接口状态。 +- [ ] 2026-05-22 16:12:00 已确认首页入场弹层队列目前只串注册奖励、运营弹窗、每日签到,没有首充弹窗主动展示步骤。 +- [ ] 2026-05-22 16:12:44 准备用 iPhone 17 Pro 启动当前工程现状,复现首充弹窗和礼物面板悬浮板不显示问题。 +- [ ] 2026-05-22 16:15:24 iPhone 17 Pro 当前正在编译启动 lib/main.dart,等待进入应用后对比现状。 +- [ ] 2026-05-22 16:16:56 已截取 iPhone 17 Pro 修复前首页截图,用于确认当前没有首充弹窗。 +- [ ] 2026-05-22 16:17:15 iPhone 17 Pro 修复前截图显示每日签到弹窗正常展示,但没有首充弹窗,运行日志也未出现首充 home 接口请求。 +- [ ] 2026-05-22 16:17:34 开始读取首充奖励 Flutter 接口文档,核对 configured/enabled/activityStatus/rewarded 等字段含义。 +- [ ] 2026-05-22 16:18:13 准备修改首页入场弹层队列和礼物面板首充悬浮板判断,统一由 first-recharge-reward/home 接口驱动。 +- [ ] 2026-05-22 16:19:01 已完成首充入口代码修改:首页入场队列新增首充远程弹窗,礼物面板悬浮板改为读取首充 home 接口状态。 +- [ ] 2026-05-22 16:19:18 开始格式化本轮修改的 gift_page.dart 和 index_page.dart。 +- [ ] 2026-05-22 16:19:28 开始对首充相关修改文件执行 flutter analyze。 +- [ ] 2026-05-22 16:20:32 flutter analyze 发现 index_page.dart 缺少 AccountStorage 导入,准备补齐后复查。 +- [ ] 2026-05-22 16:21:15 已补齐 AccountStorage 导入,重新格式化并执行 flutter analyze。 +- [ ] 2026-05-22 16:22:10 flutter analyze 通过,首充入口修改当前无静态错误。 +- [ ] 2026-05-22 16:22:37 准备对 iPhone 17 Pro 当前 flutter run 会话执行 hot restart 验证首页首充弹窗。 +- [ ] 2026-05-22 16:23:24 已截取 iPhone 17 Pro 首页热重启后截图,检查首充弹窗是否展示。 +- [ ] 2026-05-22 16:24:03 iPhone 17 Pro 热重启后已看到首充弹窗,首页入场首充接口展示链路验证通过;继续验证房间礼物面板悬浮板。 +- [ ] 2026-05-22 16:26:26 已在 iPhone 17 Pro 进入房间并打开礼物面板,非 CP 礼物 tab 上方已显示首充悬浮板并完成截图。 +- [ ] 2026-05-22 16:27:08 已切到 CP tab,确认顶部为 CP 专属悬浮板,不显示首充悬浮板,并保存截图。 +- [ ] 2026-05-22 16:30:42 已点击礼物面板首充悬浮板,确认会关闭礼物面板并打开首充弹窗,已保存截图。 +- [ ] 2026-05-22 16:31:26 验证礼物悬浮板点击可打开首充弹窗;同时发现进房时有既有 Provider build 期 notify 异常,开始确认是否需一并修复。 +- [ ] 2026-05-22 16:33:39 准备修复 voice_room_page 在 build 阶段同步通知 RtcProvider 导致的 setState/markNeedsBuild 异常。 +- [ ] 2026-05-22 16:34:37 已将房间页 Provider 通知延后到首帧后,开始格式化并重新执行 flutter analyze。 +- [ ] 2026-05-22 16:36:03 flutter analyze 再次通过,包含首充入口和房间页 Provider 通知修复。 +- [ ] 2026-05-22 16:36:23 准备再次 hot restart iPhone 17 Pro,用干净日志复验首页弹窗、进房、礼物悬浮板和 CP 悬浮板。 +- [ ] 2026-05-22 16:38:24 最终复验:iPhone 17 Pro hot restart 后首页首充弹窗再次正常显示并截图。 +- [ ] 2026-05-22 16:40:39 新增反馈:首充奖励 item 不能固定占六个,空数据不显示;启动需补接口决定的进场弹窗;弹窗顺序调整为注册奖励 > 每日签到 > 进场弹窗 > 首充弹窗。 +- [ ] 2026-05-22 16:41:07 开始读取 APP 进场弹窗接口文档、首页启动弹窗队列、进场弹窗 coordinator 和响应模型。 +- [ ] 2026-05-22 16:41:43 已定位进场弹窗路径和启动顺序问题,继续检查首充弹窗 rewards grid 是否固定占位。 +- [ ] 2026-05-22 16:44:01 准备修改进场接口路径、首页启动弹窗顺序,以及首充奖励 item 的空数据过滤/动态数量渲染。 +- [ ] 2026-05-22 16:46:18 已修改首充 rewards 过滤/动态渲染、首页弹窗顺序和进场弹窗接口路径。 +- [ ] 2026-05-22 16:46:39 开始格式化并分析首充弹窗、首页启动队列、进场接口仓库和相关页面。 +- [ ] 2026-05-22 16:48:20 flutter analyze 通过,首充动态 item、进场接口路径和弹窗顺序修改无静态错误。 +- [ ] 2026-05-22 16:48:51 准备为进场弹窗 coordinator 开启 debug 日志,便于模拟器验证接口队列和本地 limit 判断。 +- [ ] 2026-05-22 16:49:39 已开启 EntryPopup debug 日志,开始格式化并重新分析相关文件。 +- [ ] 2026-05-22 16:50:21 flutter analyze 发现 entry popup coordinator 存在空 catch info,准备改为 debug 日志。 +- [ ] 2026-05-22 16:50:51 已补齐 entry popup 异常 debug 日志,重新执行格式化和分析。 +- [ ] 2026-05-22 16:51:45 flutter analyze 最终通过,进场弹窗 debug 日志改动无静态问题。 +- [ ] 2026-05-22 16:52:50 已复查代码:启动顺序为注册奖励 > 每日签到 > 进场弹窗 > 首充弹窗,首充奖励只生成有效 item,进场接口路径已对齐文档。 +- [ ] 2026-05-22 16:53:25 准备用 iPhone 17 Pro 重新启动验证:检查弹窗顺序、进场弹窗接口和首充动态 item 显示。 +- [ ] 2026-05-22 16:54:59 iPhone 17 Pro 正在编译启动 lib/main.dart,等待进入首页验证弹窗顺序。 +- [ ] 2026-05-22 16:55:57 已截取 iPhone 17 Pro 启动后第一阶段弹窗截图。 +- [ ] 2026-05-22 16:56:28 iPhone 17 Pro 启动第一阶段确认展示每日签到弹窗,符合注册奖励之后、进场弹窗之前的顺序要求。 +- [ ] 2026-05-22 17:01:20 iPhone 17 Pro 日志显示 /app/popups/entry 当前环境返回 404,准备增加 /go/app/popups/entry 兼容降级。 +- [ ] 2026-05-22 17:03:08 已为进场弹窗接口增加 404 降级到 /go/app/popups/entry,开始格式化并分析。 +- [ ] 2026-05-22 17:04:34 flutter analyze 通过,进场弹窗接口降级兼容无静态问题。 +- [ ] 2026-05-22 17:05:08 准备 hot restart iPhone 17 Pro,复验进场接口兼容降级和弹窗队列。 +- [ ] 2026-05-22 17:07:03 hot restart 后已重新截取启动队列第一阶段截图。 +- [ ] 2026-05-22 17:13:01 EntryPopup 日志确认接口已返回 queue=[invite, game],但当前 invite 图片为 cdn.example.com 示例地址,预加载失败所以按规则跳过展示;准备修正金币 item 过滤。 +- [ ] 2026-05-22 17:14:16 已放宽金币奖励有效判断,金币金额在 content/displayTitle 时也保留;开始格式化和分析。 +- [ ] 2026-05-22 17:15:21 flutter analyze 通过,金币奖励 content/displayTitle 保留逻辑无静态错误。 +- [ ] 2026-05-22 17:15:52 准备再次 hot restart 验证首充弹窗:金币奖励应保留,空 item 不显示。 +- [ ] 2026-05-22 17:18:02 已等待启动队列推进并截取首充弹窗动态 item 截图。 +- [ ] 2026-05-22 17:19:23 hot restart 后 9 秒仍停留在每日签到,确认启动队列会等待前一个弹窗结束再继续。 +- [ ] 2026-05-22 17:20:46 已截取最终首充弹窗截图:金币奖励保留,两个空占位 item 已不显示。 +- [ ] 2026-05-22 17:22:05 准备优化进场弹窗候选选择:队列中某个弹窗图片预加载失败时,继续尝试下一个可展示候选。 +- [ ] 2026-05-22 17:23:10 已让进场弹窗图片失败时继续尝试下一个候选,开始格式化并分析。 +- [ ] 2026-05-22 17:24:18 flutter analyze 通过,进场弹窗候选兜底逻辑无静态问题。 +- [ ] 2026-05-22 17:26:58 准备热加载进场弹窗候选兜底逻辑,并通过前后台切换触发一次 entry popup 验证日志。 +- [ ] 2026-05-22 17:31:21 当前 iPhone 17 Pro flutter run 会话已停止,避免后台继续占用;候选兜底逻辑已通过 flutter analyze,未再做前后台日志复验。 +- [x] 2026-05-22 17:32:46 本轮完成:首充奖励按有效接口 item 动态渲染;启动弹窗顺序调整为注册奖励 > 每日签到 > 进场弹窗 > 首充弹窗;进场弹窗接口已接入并支持路径降级与候选图片兜底。