From 0e2f4cf4bf44917ccff65d5641b0276d7d6dfe8b Mon Sep 17 00:00:00 2001 From: roxy Date: Fri, 22 May 2026 19:32:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=88=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/gift/gift_page.dart | 83 +-- lib/modules/index/index_page.dart | 2 +- .../user/profile/person_detail_page.dart | 504 +++++++++++++++- lib/services/audio/rtm_manager.dart | 544 +++++++++++++++++- lib/services/auth/user_profile_manager.dart | 94 +-- .../general/sc_app_general_manager.dart | 34 ++ .../payment/apple_payment_manager.dart | 37 +- .../payment/google_payment_manager.dart | 21 + .../business_logic/models/res/login_res.dart | 4 + .../models/res/sc_cp_rights_config_res.dart | 7 + .../res/sc_system_invit_message_res.dart | 36 +- .../repositories/room_repository.dart | 3 + .../repositories/sc_room_repository_imp.dart | 23 + .../tools/sc_cp_gift_relation_utils.dart | 43 +- .../tools/sc_cp_relation_level_utils.dart | 227 ++++++++ .../tools/sc_cp_relation_pair_utils.dart | 16 +- lib/shared/tools/sc_dialog_utils.dart | 10 +- .../first_recharge/first_recharge_dialog.dart | 14 +- .../msg/message_conversation_list_page.dart | 161 +++++- .../widgets/room/room_user_info_card.dart | 67 +-- test/gift_res_cp_relation_type_test.dart | 21 + test/sc_cp_relation_pair_utils_test.dart | 17 + test/sc_cp_rights_config_res_test.dart | 4 + 需求进度.md | 34 ++ 24 files changed, 1815 insertions(+), 191 deletions(-) create mode 100644 lib/shared/tools/sc_cp_relation_level_utils.dart diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index 27ecc0f..a0d6214 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -18,7 +18,6 @@ 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/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; @@ -83,7 +82,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { TabController? _tabController; List _tabTypes = []; bool _didApplyInitialTab = false; - bool _showFirstRechargeGiftEntry = false; /// 业务逻辑策略访问器 BusinessLogicStrategy get _strategy => SCGlobalConfig.businessLogicStrategy; @@ -240,7 +238,6 @@ 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) { @@ -276,35 +273,6 @@ 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) { @@ -730,7 +698,9 @@ class _GiftPageState extends State with TickerProviderStateMixin { final currentTabType = giftTabs[tabController.index].type; final isCpTab = currentTabType == "CP"; final showFirstRechargeEntry = - !SCGlobalConfig.isReview && !isCpTab && _showFirstRechargeGiftEntry; + !SCGlobalConfig.isReview && + !isCpTab && + ref.shouldShowFirstRechargeReward; _ensureCurrentTabSelection(ref, giftTabs[tabController.index].type); final isCpRecipientSingleMode = isCpTab || _isCpGift(checkedGift); return SafeArea( @@ -1599,6 +1569,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { animationCount: request.clickCount, ); } + _syncLocalCpInviteAfterGift(request); await _refreshGiftBackpack(); _refreshCpStateAfterGift(request, profileManager); return; @@ -1612,6 +1583,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { request.quantity, false, roomId: request.roomId, + relationType: request.cpRelationType, ) : await repository.giveGift( request.acceptUserIds, @@ -1619,6 +1591,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { request.quantity, false, roomId: request.roomId, + relationType: request.cpRelationType, ); _giftFxLog( '$requestName success trigger=$trigger ' @@ -1662,6 +1635,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { ); } profileManager?.updateBalance(result); + _syncLocalCpInviteAfterGift(request); _refreshCpStateAfterGift(request, profileManager); } catch (e) { final errorMessage = @@ -1705,6 +1679,41 @@ class _GiftPageState extends State with TickerProviderStateMixin { unawaited(profileManager?.refreshLoadedCpProfiles()); } + void _syncLocalCpInviteAfterGift(_GiftSendRequest request) { + final relationType = request.cpRelationType; + if (relationType == null || request.acceptUserIds.isEmpty) { + return; + } + final context = navigatorKey.currentState?.context; + if (context == null) { + return; + } + final receiverId = request.acceptUserIds.first.trim(); + if (receiverId.isEmpty) { + return; + } + final receiverProfile = + request.acceptUsers.isNotEmpty + ? request.acceptUsers.first.user + : SocialChatUserProfile(id: receiverId, account: receiverId); + _giftFxLog( + 'sync local CP invite after gift ' + 'receiver=$receiverId relationType=$relationType giftId=${request.gift.id}', + ); + unawaited( + Provider.of( + context, + listen: false, + ).insertLocalOutgoingCpGiftInvite( + receiverId: receiverId, + receiverProfile: receiverProfile, + relationType: relationType, + gift: request.gift, + giftCount: request.quantity, + ), + ); + } + Future> _giveBackpackGiftToRecipients( SCChatRoomRepository repository, _GiftSendRequest request, @@ -1726,6 +1735,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { roomId: request.roomId, seatIndex: acceptUser?.micIndex ?? _resolveSeatIndexForUserId(acceptUserId), + relationType: request.cpRelationType, ); if (acceptUser != null) { sentUsers.add(acceptUser); @@ -2309,6 +2319,13 @@ class _GiftSendRequest { return isLuckyGiftRequest ? 'giveLuckyGift' : 'giveGift'; } + String? get cpRelationType { + if (!scIsCpGift(gift)) { + return null; + } + return scCpRelationTypeForGift(gift); + } + String get batchKey { final sortedAcceptUserIds = List.from(acceptUserIds)..sort(); final type = diff --git a/lib/modules/index/index_page.dart b/lib/modules/index/index_page.dart index f2b0541..1a29c29 100644 --- a/lib/modules/index/index_page.dart +++ b/lib/modules/index/index_page.dart @@ -498,7 +498,7 @@ class _SCIndexPageState extends State return; } _hasRequestedFirstRechargeDialog = true; - await SCDialogUtils.showFirstRechargeDialog(context); + await SCDialogUtils.showFirstRechargeDialog(context, forceRefresh: true); } Future _waitForRegisterRewardSocketIfNeeded() async { diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index 7c290a8..7723454 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -20,9 +20,11 @@ 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_cp_relation_level_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'; +import 'package:yumi/shared/tools/sc_string_utils.dart'; import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/models/enum/sc_props_type.dart'; @@ -33,12 +35,14 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/business_logic/models/res/my_room_res.dart'; import 'package:yumi/shared/business_logic/models/res/room_res.dart' hide WearBadge, UseProps, PropsResources; +import 'package:yumi/shared/business_logic/models/res/sc_cp_rights_config_res.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/ui_kit/widgets/badge/sc_user_badge_strip.dart'; import 'package:yumi/ui_kit/widgets/room/cp/sc_cp_corner_label.dart'; +import 'package:yumi/ui_kit/widgets/room/cp/room_cp_progress_dialog.dart'; import '../../../app/constants/sc_screen.dart'; import '../../../shared/business_logic/models/res/sc_user_counter_res.dart'; @@ -969,7 +973,7 @@ class _PersonDetailPageState extends State { ) { final owner = _profileRelationOwner(profile, relation); final partner = _profileRelationPartner(profile, relation); - return SizedBox( + final card = SizedBox( height: 114.w, width: double.infinity, child: Stack( @@ -1037,6 +1041,11 @@ class _PersonDetailPageState extends State { ], ), ); + return _buildProfileRelationProgressTap( + profile: profile, + relation: relation, + child: card, + ); } Widget _buildProfileCpCardText( @@ -1093,7 +1102,7 @@ class _PersonDetailPageState extends State { final type = _normalizeProfileRelationType(relation.relationType); final friend = _profileRelationPartner(profile, relation); final isSister = type == "SISTERS"; - return SizedBox( + final card = SizedBox( width: 113.w, height: 100.w, child: Stack( @@ -1180,6 +1189,26 @@ class _PersonDetailPageState extends State { ], ), ); + return _buildProfileRelationProgressTap( + profile: profile, + relation: relation, + child: card, + ); + } + + Widget _buildProfileRelationProgressTap({ + required SocialChatUserProfile? profile, + required CPRes relation, + required Widget child, + }) { + if (!_isOwnProfile(profile) || + !_profileRelationHasPartner(profile, relation)) { + return child; + } + return SCDebounceWidget( + onTap: () => _openProfileRelationProgress(profile, relation), + child: child, + ); } Widget _buildProfileRelationAvatar(String avatarUrl, double size) { @@ -1448,21 +1477,186 @@ class _PersonDetailPageState extends State { } } + bool _isOwnProfile(SocialChatUserProfile? profile) { + if (widget.isMe == "true") { + return true; + } + final currentUserId = + AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? ""; + final profileId = (profile?.id ?? widget.tageId).trim(); + return currentUserId.isNotEmpty && currentUserId == profileId; + } + + _ProfileRelationProgressPayload _profileRelationProgressPayload( + SocialChatUserProfile? profile, + CPRes relation, + ) { + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + final owner = _profileRelationOwner(profile, relation); + final partner = _profileRelationPartner(profile, relation); + return _ProfileRelationProgressPayload( + leftAvatarUrl: _firstNonBlankProfileValue([ + owner.avatarUrl, + profile?.userAvatar, + currentProfile?.userAvatar, + ]), + rightAvatarUrl: partner.avatarUrl?.trim() ?? "", + leftName: _firstNonBlankProfileValue([ + owner.nickname, + profile?.userNickname, + currentProfile?.userNickname, + ]), + rightName: _firstNonBlankProfileValue([ + partner.nickname, + partner.account, + ]), + leftUserId: _firstNonBlankProfileValue([ + owner.userId, + profile?.id, + currentProfile?.id, + ]), + rightUserId: _firstNonBlankProfileValue([partner.userId]), + days: _profileRelationDays(relation), + levelText: _profileRelationLevelText(relation), + relationType: _normalizeProfileRelationType(relation.relationType), + cpValue: relation.cpValue ?? 0, + dismissCost: relation.dismissCost, + ); + } + + Future _openProfileRelationProgress( + SocialChatUserProfile? profile, + CPRes relation, + ) async { + final payload = _profileRelationProgressPayload(profile, relation); + if (payload.rightUserId.trim().isEmpty) { + return; + } + final cancelText = SCAppLocalizations.of(context)!.cancel; + SCLoadingManager.show(context: context); + late final _ProfileRelationProgressData progress; + try { + progress = await _loadProfileRelationProgressData(payload); + } finally { + SCLoadingManager.hide(); + } + if (!mounted) { + return; + } + RoomCpProgressDialog.show( + context, + leftUser: RoomCpProgressDialogUser( + nickname: payload.leftName, + userId: payload.leftUserId, + avatarUrl: payload.leftAvatarUrl, + ), + rightUser: RoomCpProgressDialogUser( + nickname: payload.rightName, + userId: payload.rightUserId, + avatarUrl: payload.rightAvatarUrl, + ), + daysValue: payload.days, + levelText: progress.levelText, + currentValue: progress.currentValue, + targetValue: progress.targetValue, + expAwayText: progress.expAwayText, + nextLevelText: progress.nextLevelText, + cancelText: cancelText, + onCancel: + () => _showProfileRelationDismissConfirm( + payload, + dismissCost: progress.dismissCost, + ), + ); + } + + Future<_ProfileRelationProgressData> _loadProfileRelationProgressData( + _ProfileRelationProgressPayload payload, + ) async { + final partnerUserId = payload.rightUserId.trim(); + final fallback = _ProfileRelationProgressData.fallback(payload); + if (partnerUserId.isEmpty) { + return fallback; + } + var latestFallback = fallback; + try { + final config = await SCAccountRepository().cpRightsConfig( + relationType: payload.relationType, + cpUserId: partnerUserId, + ); + final progress = _ProfileRelationProgressData.fromRightsConfig( + config, + payload, + ); + latestFallback = progress; + if (config.levels.isNotEmpty) { + return progress; + } + } catch (_) { + // Fall back to the cabin endpoint below. + } + try { + final cabin = await SCAccountRepository().cpCabin( + payload.leftUserId, + relationType: payload.relationType, + ); + return _ProfileRelationProgressData.fromCabin( + cabin, + payload, + fallbackDismissCost: latestFallback.dismissCost, + ); + } catch (_) { + return latestFallback; + } + } + + Future _showProfileRelationDismissConfirm( + _ProfileRelationProgressPayload payload, { + int? dismissCost, + }) async { + final partnerUserId = payload.rightUserId.trim(); + if (partnerUserId.isEmpty) { + return; + } + final l10n = SCAppLocalizations.of(context)!; + await showRoomCpDismissConfirmDialog( + context, + message: l10n.cpDismissConfirmMessage( + dismissCost == null ? "--" : "$dismissCost", + ), + cancelText: l10n.cancel, + confirmText: l10n.confirm, + onConfirm: () async { + SCLoadingManager.show(context: context); + try { + await SCAccountRepository().cpRelationshipDismissApply( + partnerUserId, + relationType: payload.relationType, + ); + if (!mounted) { + return; + } + await Provider.of( + context, + listen: false, + ).refreshLoadedCpProfiles(); + await SmartDialog.dismiss(tag: RoomCpProgressDialog.dialogTag); + SCTts.show(l10n.operationSuccessful); + } catch (_) { + // Repository/network layer surfaces backend errors. + } finally { + SCLoadingManager.hide(); + } + }, + ); + } + String _profileCpLevelAsset(CPRes relation) { return "$_cpProfileAssetBase/sc_cp_profile_cp_level_${_profileRelationLevel(relation)}.png"; } int _profileRelationLevel(CPRes relation) { - final levelText = relation.levelText ?? ""; - final levelMatch = RegExp( - r'(?:Lv\.?|Level)\s*(\d+)', - caseSensitive: false, - ).firstMatch(levelText); - final fallbackMatch = RegExp(r'\d+').firstMatch(levelText); - final rawLevel = - int.tryParse(levelMatch?.group(1) ?? fallbackMatch?.group(0) ?? "") ?? - 1; - return rawLevel.clamp(1, 5).toInt(); + return scCpRelationLevelIndex(relation.levelText); } String _profileRelationLevelText(CPRes relation) { @@ -1483,19 +1677,7 @@ class _PersonDetailPageState extends State { } String _profileRelationLevelDisplayText(String rawText, CPRes relation) { - final text = rawText.trim(); - final level = _profileRelationLevel(relation); - if (text.isEmpty) { - return "Lv.1 Simple Love"; - } - final compactLevelPattern = RegExp( - r'^(?:Lv\.?|Level)?\s*\d+$', - caseSensitive: false, - ); - if (compactLevelPattern.hasMatch(text) && level == 1) { - return "Lv.1 Simple Love"; - } - return text; + return scCpRelationLevelDisplayText(rawText); } String _firstNonBlankProfileValue(List values) { @@ -2455,6 +2637,278 @@ class _PersonDetailPageState extends State { } } +class _ProfileRelationProgressData { + const _ProfileRelationProgressData({ + required this.levelText, + required this.currentValue, + required this.targetValue, + required this.expAwayText, + required this.nextLevelText, + this.dismissCost, + }); + + factory _ProfileRelationProgressData.fallback( + _ProfileRelationProgressPayload payload, + ) { + final currentValue = payload.cpValue; + return _ProfileRelationProgressData( + levelText: + payload.levelText.trim().isEmpty + ? "Lv.1 Simple Love" + : payload.levelText, + currentValue: currentValue, + targetValue: currentValue > 0 ? currentValue : 1, + expAwayText: SCStringUtils.formatCompactNumber(0), + nextLevelText: _levelShortText( + _nextLevelIndex(_levelIndex(payload.levelText)), + ), + dismissCost: payload.dismissCost, + ); + } + + factory _ProfileRelationProgressData.fromRightsConfig( + SCCpRightsConfigRes config, + _ProfileRelationProgressPayload payload, + ) { + final currentValue = (config.intimacyValue ?? payload.cpValue).toDouble(); + final levels = + config.levels + .map(_ProfileRelationProgressLevel.fromRightsLevel) + .toList(); + return _ProfileRelationProgressData.fromLevels( + currentValue: currentValue, + levels: levels, + payload: payload, + dismissCost: config.dismissConsumeGold ?? payload.dismissCost, + ); + } + + factory _ProfileRelationProgressData.fromCabin( + SCCpCabinRes cabin, + _ProfileRelationProgressPayload payload, { + int? fallbackDismissCost, + }) { + final currentValue = + cabin.cpVal?.toDouble() ?? + cabin.cpPairUserProfileCO?.cpValue ?? + payload.cpValue; + final levels = + (cabin.cpCabinConfigCO ?? const []) + .map(_ProfileRelationProgressLevel.fromDynamic) + .toList(); + return _ProfileRelationProgressData.fromLevels( + currentValue: currentValue, + levels: levels, + payload: payload, + dismissCost: + cabin.dismissCost ?? fallbackDismissCost ?? payload.dismissCost, + ); + } + + factory _ProfileRelationProgressData.fromLevels({ + required double currentValue, + required List<_ProfileRelationProgressLevel> levels, + required _ProfileRelationProgressPayload payload, + int? dismissCost, + }) { + final sortedLevels = + levels.where((level) => level.requiredValue != null).toList()..sort( + (left, right) => left.requiredValue!.compareTo(right.requiredValue!), + ); + _ProfileRelationProgressLevel? nextLevel; + for (final level in sortedLevels) { + if ((level.requiredValue ?? 0) > currentValue) { + nextLevel = level; + break; + } + } + + final targetValue = + nextLevel?.requiredValue?.toDouble() ?? + (currentValue > 0 ? currentValue : 1); + final expAway = + nextLevel == null + ? 0 + : (targetValue - currentValue).ceil().clamp(0, 1 << 62).toInt(); + final currentLevel = _currentLevelFromConfig(sortedLevels, currentValue); + + return _ProfileRelationProgressData( + levelText: _levelDisplayText(currentLevel, payload.levelText), + currentValue: currentValue, + targetValue: targetValue > 0 ? targetValue : 1, + expAwayText: SCStringUtils.formatCompactNumber(expAway), + nextLevelText: + nextLevel == null + ? _levelShortText( + _nextLevelIndex( + currentLevel?.level ?? _levelIndex(payload.levelText), + ), + ) + : _levelShortText(nextLevel.level, fallback: nextLevel.levelName), + dismissCost: dismissCost, + ); + } + + final String levelText; + final double currentValue; + final double targetValue; + final String expAwayText; + final String nextLevelText; + final int? dismissCost; + + static _ProfileRelationProgressLevel? _currentLevelFromConfig( + List<_ProfileRelationProgressLevel> levels, + double currentValue, + ) { + _ProfileRelationProgressLevel? currentLevel; + for (final level in levels) { + if ((level.requiredValue ?? 0) <= currentValue) { + currentLevel = level; + } + } + return currentLevel; + } + + static String _levelDisplayText( + _ProfileRelationProgressLevel? level, + String fallback, + ) { + final fallbackText = fallback.trim(); + if (level == null) { + return fallbackText.isEmpty ? "Lv.1 Simple Love" : fallbackText; + } + final levelName = level.levelName?.trim(); + if (levelName != null && levelName.isNotEmpty) { + return levelName; + } + if (level.level != null) { + return "Lv.${level.level}"; + } + return fallbackText.isEmpty ? "Lv.1 Simple Love" : fallbackText; + } + + static String _levelShortText(int? level, {String? fallback}) { + if (level != null) { + return "Lv. $level"; + } + final fallbackText = fallback?.trim() ?? ""; + if (fallbackText.isNotEmpty) { + return fallbackText; + } + return "Lv. 1"; + } + + static int _levelIndex(String levelText) { + return scCpRelationLevelIndex(levelText); + } + + static int _nextLevelIndex(int level) { + return (level + 1).clamp(1, 5).toInt(); + } +} + +class _ProfileRelationProgressLevel { + const _ProfileRelationProgressLevel({ + this.level, + this.levelName, + this.requiredValue, + }); + + factory _ProfileRelationProgressLevel.fromRightsLevel( + SCCpRightsLevelRes level, + ) { + return _ProfileRelationProgressLevel( + level: level.level, + levelName: level.levelName, + requiredValue: level.requiredIntimacyValue, + ); + } + + factory _ProfileRelationProgressLevel.fromDynamic(dynamic value) { + final source = value is Map ? value : const {}; + return _ProfileRelationProgressLevel( + level: _asInt( + _firstValue(source, const ["level", "cpLevel", "cabinLevel", "lv"]), + ), + levelName: _asString( + _firstValue(source, const [ + "levelName", + "name", + "title", + "cabinName", + "cpName", + ]), + ), + requiredValue: _asInt( + _firstValue(source, const [ + "requiredIntimacyValue", + "requiredCpValue", + "requiredCpVal", + "cpValue", + "cpVal", + "intimacyValue", + ]), + ), + ); + } + + final int? level; + final String? levelName; + final int? requiredValue; +} + +dynamic _firstValue(Map source, List keys) { + for (final key in keys) { + if (source.containsKey(key) && source[key] != null) { + return source[key]; + } + } + return null; +} + +String? _asString(dynamic value) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? null : text; +} + +int? _asInt(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ""); +} + +class _ProfileRelationProgressPayload { + const _ProfileRelationProgressPayload({ + required this.leftAvatarUrl, + required this.rightAvatarUrl, + required this.leftName, + required this.rightName, + required this.leftUserId, + required this.rightUserId, + required this.days, + required this.levelText, + required this.relationType, + required this.cpValue, + this.dismissCost, + }); + + final String leftAvatarUrl; + final String rightAvatarUrl; + final String leftName; + final String rightName; + final String leftUserId; + final String rightUserId; + final String days; + final String levelText; + final String relationType; + final double cpValue; + final int? dismissCost; +} + class _ProfileRelationUser { const _ProfileRelationUser({ this.userId, diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index a7768b6..1465bb0 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -28,11 +28,13 @@ import 'package:tencent_cloud_chat_sdk/enum/V2TimSDKListener.dart'; import 'package:tencent_cloud_chat_sdk/enum/conversation_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/group_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/log_level_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; import 'package:tencent_cloud_chat_sdk/manager/v2_tim_group_manager.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_custom_elem.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_image_elem.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; @@ -295,6 +297,7 @@ class RealTimeMessagingManager extends ChangeNotifier { Timer? _roomRocketRoomReadyRewardRetryTimer; final Set _shownCpInviteMessageKeys = {}; final Set _forwardedCpInviteC2CMessageKeys = {}; + final Set _echoedCpInviteC2CMessageKeys = {}; int get currentLuckGiftBurstPlaybackToken => _luckGiftPushPlaybackToken; String? roomRedPacketBroadcastGroupId; String? roomRedPacketBroadcastRegionCode; @@ -683,10 +686,13 @@ class RealTimeMessagingManager extends ChangeNotifier { String? msgId, String? fallbackUserId, }) async { + final cpData = _decodeCpSystemMessageData(message); unawaited(_handleIncomingCpBuildMessage(message)); + final cpPeerUserId = _cpInviteConversationPeerIdFromData(cpData); final didSyncConversation = _upsertC2CConversationFromMessage( message, - fallbackUserId: fallbackUserId, + fallbackUserId: fallbackUserId ?? cpPeerUserId, + preferFallbackUserId: cpPeerUserId != null, ); if (!didSyncConversation) { systemUnReadCount = _systemUnreadCount(); @@ -700,11 +706,16 @@ class RealTimeMessagingManager extends ChangeNotifier { bool _upsertC2CConversationFromMessage( V2TimMessage? message, { String? fallbackUserId, + bool preferFallbackUserId = false, }) { if (message == null || message.groupID != null) { return false; } - final peerId = _messagePeerId(message, fallbackUserId); + final peerId = _messagePeerId( + message, + fallbackUserId, + preferFallbackUserId: preferFallbackUserId, + ); if (peerId.isEmpty) { final cpData = _decodeCpSystemMessageData(message); if (cpData != null) { @@ -766,12 +777,21 @@ class RealTimeMessagingManager extends ChangeNotifier { return true; } - String _messagePeerId(V2TimMessage message, String? fallbackUserId) { + String _messagePeerId( + V2TimMessage message, + String? fallbackUserId, { + bool preferFallbackUserId = false, + }) { final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? ""; final userId = message.userID?.trim() ?? ""; final sender = message.sender?.trim() ?? ""; final fallback = fallbackUserId?.trim() ?? ""; + if (preferFallbackUserId && + fallback.isNotEmpty && + fallback != currentUserId) { + return fallback; + } if (message.isSelf == true) { if (userId.isNotEmpty) { return userId; @@ -799,6 +819,21 @@ class RealTimeMessagingManager extends ChangeNotifier { return ""; } + String? _cpInviteConversationPeerIdFromData(Map? data) { + if (data == null) { + return null; + } + final invite = _cpInviteFromMessageData(data); + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + if (invite == null || currentProfile == null) { + return null; + } + if (_cpInviteSenderMatchesCurrent(invite, currentProfile)) { + return _cpInviteReceiverIdFromMessageData(data); + } + return _cpInviteInviterIdFromMessageData(data, invite); + } + String _c2cConversationDisplayName( V2TimConversation conversation, V2TimMessage message, @@ -850,7 +885,7 @@ class RealTimeMessagingManager extends ChangeNotifier { if (noticeType != SCSysytemMessageType.CP_BUILD.name) { return; } - final applyId = data["expand"]?.toString().trim() ?? ""; + final applyId = _cpInviteApplyIdFromData(data); final invite = _cpInviteFromMessageData(data); final messageKey = applyId.isNotEmpty ? applyId : message?.msgID ?? ""; if (messageKey.isNotEmpty && !_shownCpInviteMessageKeys.add(messageKey)) { @@ -888,6 +923,14 @@ class RealTimeMessagingManager extends ChangeNotifier { fallback: currentProfile.id, ); final relationType = scNormalizeCpRelationType(invite.relationType); + _persistBackendCpInviteToC2CConversationIfNeeded( + sourceMessage: message, + data: data, + applyId: applyId, + invite: invite, + currentProfile: currentProfile, + isSender: isSender, + ); _forwardCpInviteC2CMessageIfNeeded( sourceMessage: message, data: data, @@ -895,6 +938,14 @@ class RealTimeMessagingManager extends ChangeNotifier { invite: invite, isSender: isSender, ); + _echoCpInviteC2CMessageToSenderIfNeeded( + sourceMessage: message, + data: data, + applyId: applyId, + invite: invite, + currentProfile: currentProfile, + isSender: isSender, + ); if (isSender) { final receiver = _cpInviteReceiverFromMessageData(data); _cpInviteLog( @@ -984,6 +1035,21 @@ class RealTimeMessagingManager extends ChangeNotifier { return null; } + String _cpInviteApplyIdFromData(Map data) { + final content = _cpInviteContentMap(data); + return _firstCpInviteValue([ + data["expand"]?.toString(), + data["applyId"]?.toString(), + data["apply_id"]?.toString(), + data["id"]?.toString(), + content["expand"]?.toString(), + content["applyId"]?.toString(), + content["apply_id"]?.toString(), + content["id"]?.toString(), + ]) ?? + ""; + } + bool _handleCpInviteResultMessage(Map data) { final result = _cpInviteResultFromMessageData(data); if (result == null) { @@ -1363,6 +1429,10 @@ class RealTimeMessagingManager extends ChangeNotifier { if (!isSender || sourceMessage?.isSelf == true) { return; } + final noticeSource = data["noticeSource"]?.toString() ?? ""; + if (noticeSource == "RECEIVER_ECHO" || noticeSource == "LOCAL_OUTGOING") { + return; + } final normalizedApplyId = applyId.trim(); if (normalizedApplyId.isEmpty || !_forwardedCpInviteC2CMessageKeys.add(normalizedApplyId)) { @@ -1391,6 +1461,98 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } + void _echoCpInviteC2CMessageToSenderIfNeeded({ + required V2TimMessage? sourceMessage, + required Map data, + required String applyId, + required SCSystemInvitMessageRes invite, + required SocialChatUserProfile currentProfile, + required bool isSender, + }) { + if (isSender || sourceMessage?.isSelf == true) { + return; + } + final normalizedApplyId = applyId.trim(); + if (normalizedApplyId.isEmpty || + !_echoedCpInviteC2CMessageKeys.add(normalizedApplyId)) { + return; + } + final inviterId = _cpInviteInviterIdFromMessageData(data, invite); + if (inviterId.isEmpty) { + _echoedCpInviteC2CMessageKeys.remove(normalizedApplyId); + _cpInviteLog( + 'skip echoing CP_BUILD C2C reason=no_inviter ' + 'applyId=$normalizedApplyId', + ); + return; + } + final relationType = scNormalizeCpRelationType(invite.relationType); + final echoData = {...data}; + echoData["noticeType"] = SCSysytemMessageType.CP_BUILD.name; + echoData["noticeSource"] = "RECEIVER_ECHO"; + echoData["expand"] = normalizedApplyId; + echoData["applyId"] = normalizedApplyId; + echoData["relationType"] = relationType; + echoData["acceptUserId"] ??= currentProfile.id; + echoData["acceptAccount"] ??= currentProfile.account; + echoData["acceptActualAccount"] ??= currentProfile.id; + echoData["acceptNickname"] ??= _cpInviteDisplayName( + nickname: currentProfile.userNickname, + account: currentProfile.account, + fallback: currentProfile.id, + ); + echoData["acceptUserNickname"] ??= currentProfile.userNickname; + echoData["acceptUserAvatar"] ??= currentProfile.userAvatar; + echoData["acceptHeaddress"] ??= currentProfile.getHeaddress()?.sourceUrl; + echoData["acceptHeaddressCover"] ??= currentProfile.getHeaddress()?.cover; + _cpInviteLog( + 'echo backend CP_BUILD to sender C2C ' + 'sender=$inviterId applyId=$normalizedApplyId relationType=$relationType', + ); + unawaited( + _sendCpBuildCustomMessage( + receiverId: inviterId, + data: echoData, + debugName: 'invite-echo', + applyId: normalizedApplyId, + ), + ); + } + + String _cpInviteInviterIdFromMessageData( + Map data, + SCSystemInvitMessageRes invite, + ) { + final content = _cpInviteContentMap(data); + return _firstCpInviteValue([ + data["actualAccount"]?.toString(), + data["applyActualAccount"]?.toString(), + data["applyUserActualAccount"]?.toString(), + data["inviterActualAccount"]?.toString(), + data["inviterUserId"]?.toString(), + data["inviterId"]?.toString(), + data["senderUserId"]?.toString(), + data["senderId"]?.toString(), + data["fromUserId"]?.toString(), + data["userId"]?.toString(), + content["actualAccount"]?.toString(), + content["applyActualAccount"]?.toString(), + content["applyUserActualAccount"]?.toString(), + content["inviterActualAccount"]?.toString(), + content["inviterUserId"]?.toString(), + content["inviterId"]?.toString(), + content["senderUserId"]?.toString(), + content["senderId"]?.toString(), + content["fromUserId"]?.toString(), + content["userId"]?.toString(), + invite.actualAccount, + data["applyUserId"]?.toString(), + content["applyUserId"]?.toString(), + invite.account, + ]) ?? + ""; + } + String _cpInviteReceiverIdFromMessageData(Map data) { final content = _cpInviteContentMap(data); return _firstCpInviteValue([ @@ -1512,6 +1674,158 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } + Future insertLocalOutgoingCpGiftInvite({ + required String receiverId, + required String relationType, + required SocialChatGiftRes gift, + required int giftCount, + SocialChatUserProfile? receiverProfile, + }) async { + final targetId = receiverId.trim(); + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + final senderId = currentProfile?.id?.trim() ?? ""; + if (targetId.isEmpty || senderId.isEmpty) { + _cpInviteLog( + 'skip local outgoing invite reason=missing_user ' + 'sender=$senderId receiver=$targetId', + ); + return; + } + final normalizedRelationType = scNormalizeCpRelationType(relationType); + final applyId = + 'local_${normalizedRelationType}_${senderId}_${targetId}_' + '${DateTime.now().microsecondsSinceEpoch}'; + final receiverName = _cpInviteDisplayName( + nickname: receiverProfile?.userNickname, + account: receiverProfile?.account, + fallback: receiverProfile?.id ?? targetId, + ); + final invite = SCSystemInvitMessageRes( + account: currentProfile?.account, + actualAccount: senderId, + userNickname: currentProfile?.userNickname, + userAvatar: currentProfile?.userAvatar, + userHeaddress: currentProfile?.getHeaddress()?.sourceUrl, + userHeaddressCover: currentProfile?.getHeaddress()?.cover, + relationType: normalizedRelationType, + dismissEndTime: DateTime.now().millisecondsSinceEpoch + 86400000, + giftCover: gift.giftPhoto, + giftCount: giftCount, + ); + final inviteJson = invite.toJson(); + inviteJson["gift"] = gift.toJson(); + final data = { + "noticeType": SCSysytemMessageType.CP_BUILD.name, + "noticeAction": "INVITE_PENDING", + "noticeSource": "LOCAL_OUTGOING", + "action": "INVITE_PENDING", + "status": "PENDING", + "result": "PENDING", + "state": "PENDING", + "expand": applyId, + "applyId": applyId, + "relationType": normalizedRelationType, + "account": currentProfile?.account, + "actualAccount": senderId, + "applyUserId": senderId, + "applyActualAccount": senderId, + "userNickname": currentProfile?.userNickname, + "userAvatar": currentProfile?.userAvatar, + "userHeaddress": currentProfile?.getHeaddress()?.sourceUrl, + "userHeaddressCover": currentProfile?.getHeaddress()?.cover, + "acceptUserId": receiverProfile?.id ?? targetId, + "acceptAccount": receiverProfile?.account ?? targetId, + "acceptActualAccount": receiverProfile?.id ?? targetId, + "acceptNickname": receiverName, + "acceptUserNickname": receiverProfile?.userNickname, + "acceptUserAvatar": receiverProfile?.userAvatar, + "acceptHeaddress": receiverProfile?.getHeaddress()?.sourceUrl, + "acceptHeaddressCover": receiverProfile?.getHeaddress()?.cover, + "gift": gift.toJson(), + "giftCover": gift.giftPhoto, + "giftCount": giftCount, + "content": jsonEncode(inviteJson), + }; + _cpInviteLog( + 'insert local outgoing CP_BUILD ' + 'receiver=$targetId applyId=$applyId relationType=$normalizedRelationType', + ); + if (_shownCpInviteMessageKeys.add(applyId)) { + unawaited( + _showOutgoingCpInviteWaitingDialog( + applyId: applyId, + invite: invite, + currentProfile: currentProfile, + receiverProfile: receiverProfile, + receiverId: targetId, + relationType: normalizedRelationType, + ), + ); + } + await _insertCpBuildC2CMessageToLocalStorage( + peerId: targetId, + senderId: senderId, + data: data, + debugName: 'local-outgoing-invite', + applyId: applyId, + ); + } + + Future _showOutgoingCpInviteWaitingDialog({ + required String applyId, + required SCSystemInvitMessageRes invite, + required SocialChatUserProfile? currentProfile, + required SocialChatUserProfile? receiverProfile, + required String receiverId, + required String relationType, + }) async { + final context = navigatorKey.currentState?.context; + if (context == null || currentProfile == null) { + _cpInviteLog( + 'skip local waiting dialog reason=no_context applyId=$applyId', + ); + return; + } + final currentName = _cpInviteDisplayName( + nickname: currentProfile.userNickname, + account: currentProfile.account, + fallback: currentProfile.id, + ); + final receiverName = _cpInviteDisplayName( + nickname: receiverProfile?.userNickname, + account: receiverProfile?.account, + fallback: receiverProfile?.id ?? receiverId, + ); + _cpInviteLog( + 'show local waiting CP_BUILD applyId=$applyId ' + 'relationType=$relationType receiver=$receiverId', + ); + await RoomCpInviteDialog.show( + context, + inviter: RoomCpInviteDialogUser( + name: currentName, + avatarUrl: currentProfile.userAvatar ?? "", + headdress: currentProfile.getHeaddress()?.sourceUrl ?? "", + headdressCover: currentProfile.getHeaddress()?.cover ?? "", + ), + receiver: RoomCpInviteDialogUser( + name: receiverName, + avatarUrl: receiverProfile?.userAvatar ?? "", + headdress: receiverProfile?.getHeaddress()?.sourceUrl ?? "", + headdressCover: receiverProfile?.getHeaddress()?.cover ?? "", + ), + style: RoomCpInviteDialogStyle.waiting, + countdownSeconds: _cpInviteCountdownSeconds(invite), + descriptionText: _cpInviteWaitingDescriptionText(relationType), + onClose: () { + _cpInviteLog('close local waiting CP_BUILD applyId=$applyId'); + }, + onTimeout: () { + _cpInviteLog('timeout local waiting CP_BUILD applyId=$applyId'); + }, + ); + } + Future _sendCpBuildCustomMessage({ required String receiverId, required Map data, @@ -1572,6 +1886,228 @@ class RealTimeMessagingManager extends ChangeNotifier { } } + void _persistBackendCpInviteToC2CConversationIfNeeded({ + required V2TimMessage? sourceMessage, + required Map data, + required String applyId, + required SCSystemInvitMessageRes invite, + required SocialChatUserProfile currentProfile, + required bool isSender, + }) { + final noticeSource = data["noticeSource"]?.toString() ?? ""; + if (noticeSource == "LOCAL_OUTGOING") { + return; + } + final peerId = + isSender + ? _cpInviteReceiverIdFromMessageData(data) + : _cpInviteInviterIdFromMessageData(data, invite); + if (peerId.isEmpty || + _isCpBuildMessageAlreadyInPeerConversation(sourceMessage, peerId)) { + return; + } + final senderId = isSender ? (currentProfile.id?.trim() ?? "") : peerId; + if (senderId.isEmpty) { + return; + } + _cpInviteLog( + 'persist backend CP_BUILD to local C2C ' + 'peer=$peerId sender=$senderId applyId=$applyId relationType=${invite.relationType}', + ); + unawaited( + _insertCpBuildC2CMessageToLocalStorage( + peerId: peerId, + senderId: senderId, + data: data, + debugName: 'backend-invite', + applyId: applyId, + ), + ); + } + + bool _isCpBuildMessageAlreadyInPeerConversation( + V2TimMessage? message, + String peerId, + ) { + if (message == null || message.groupID != null) { + return false; + } + final normalizedPeerId = peerId.trim(); + if (normalizedPeerId.isEmpty) { + return false; + } + return (message.userID?.trim() == normalizedPeerId || + message.sender?.trim() == normalizedPeerId) && + !SCGlobalConfig.isSystemUserId(message.userID) && + !SCGlobalConfig.isSystemUserId(message.sender); + } + + Future _insertCpBuildC2CMessageToLocalStorage({ + required String peerId, + required String senderId, + required Map data, + required String debugName, + required String applyId, + }) async { + final normalizedPeerId = peerId.trim(); + final normalizedSenderId = senderId.trim(); + if (normalizedPeerId.isEmpty || normalizedSenderId.isEmpty) { + return null; + } + try { + final messageManager = + TencentImSDKPlugin.v2TIMManager.getMessageManager(); + final createCustomMessageRes = await messageManager.createCustomMessage( + data: jsonEncode(data), + extension: SCSysytemMessageType.CP_BUILD.name, + desc: SCSysytemMessageType.CP_BUILD.name, + ); + if (createCustomMessageRes.code != 0) { + _cpInviteLog( + 'create $debugName local C2C failed ' + 'code=${createCustomMessageRes.code} ' + 'desc=${createCustomMessageRes.desc}', + ); + return _upsertSyntheticCpBuildC2CMessage( + peerId: normalizedPeerId, + senderId: normalizedSenderId, + data: data, + debugName: debugName, + applyId: applyId, + ); + } + final createdMsgId = createCustomMessageRes.data?.id ?? ""; + if (createdMsgId.isEmpty) { + _cpInviteLog('create $debugName local C2C failed reason=no_id'); + return _upsertSyntheticCpBuildC2CMessage( + peerId: normalizedPeerId, + senderId: normalizedSenderId, + data: data, + debugName: debugName, + applyId: applyId, + ); + } + var insertMessageRes = await messageManager + .insertC2CMessageToLocalStorageV2( + userID: normalizedPeerId, + senderID: normalizedSenderId, + createdMsgID: createdMsgId, + ); + if (insertMessageRes.code != 0) { + _cpInviteLog( + 'insert $debugName local C2C v2 failed ' + 'peer=$normalizedPeerId sender=$normalizedSenderId ' + 'applyId=$applyId code=${insertMessageRes.code} ' + 'desc=${insertMessageRes.desc}', + ); + // ignore: deprecated_member_use + insertMessageRes = await messageManager.insertC2CMessageToLocalStorage( + data: jsonEncode(data), + userID: normalizedPeerId, + sender: normalizedSenderId, + isExcludedFromLastMessage: false, + ); + } + if (insertMessageRes.code != 0) { + _cpInviteLog( + 'insert $debugName local C2C failed ' + 'peer=$normalizedPeerId sender=$normalizedSenderId ' + 'applyId=$applyId code=${insertMessageRes.code} ' + 'desc=${insertMessageRes.desc}', + ); + return _upsertSyntheticCpBuildC2CMessage( + peerId: normalizedPeerId, + senderId: normalizedSenderId, + data: data, + debugName: debugName, + applyId: applyId, + ); + } + final message = + insertMessageRes.data ?? createCustomMessageRes.data?.messageInfo; + if (message == null) { + _cpInviteLog('insert $debugName local C2C failed reason=no_message'); + return _upsertSyntheticCpBuildC2CMessage( + peerId: normalizedPeerId, + senderId: normalizedSenderId, + data: data, + debugName: debugName, + applyId: applyId, + ); + } + message.userID = normalizedPeerId; + message.sender ??= normalizedSenderId; + message.groupID = null; + message.timestamp ??= DateTime.now().millisecondsSinceEpoch ~/ 1000; + message.customElem?.data ??= jsonEncode(data); + _upsertC2CConversationFromMessage( + message, + fallbackUserId: normalizedPeerId, + preferFallbackUserId: true, + ); + onNewMessageCurrentConversationListener?.call(message); + _cpInviteLog( + 'insert $debugName local C2C success ' + 'peer=$normalizedPeerId sender=$normalizedSenderId ' + 'applyId=$applyId msgId=${message.msgID} self=${message.isSelf}', + ); + return message; + } catch (error) { + _cpInviteLog( + 'insert $debugName local C2C error ' + 'peer=$normalizedPeerId sender=$normalizedSenderId ' + 'applyId=$applyId error=$error', + ); + return _upsertSyntheticCpBuildC2CMessage( + peerId: normalizedPeerId, + senderId: normalizedSenderId, + data: data, + debugName: debugName, + applyId: applyId, + ); + } + } + + V2TimMessage _upsertSyntheticCpBuildC2CMessage({ + required String peerId, + required String senderId, + required Map data, + required String debugName, + required String applyId, + }) { + final currentUserId = + AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? ""; + final encodedData = jsonEncode(data); + final message = V2TimMessage( + msgID: 'synthetic_${debugName}_$applyId', + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + sender: senderId, + userID: peerId, + status: MessageStatus.V2TIM_MSG_STATUS_SEND_SUCC, + elemType: MessageElemType.V2TIM_ELEM_TYPE_CUSTOM, + customElem: V2TimCustomElem( + data: encodedData, + desc: SCSysytemMessageType.CP_BUILD.name, + extension: SCSysytemMessageType.CP_BUILD.name, + ), + isSelf: currentUserId.isNotEmpty && senderId == currentUserId, + isExcludedFromUnreadCount: senderId == currentUserId, + isExcludedFromLastMessage: false, + isSupportMessageExtension: true, + ); + _upsertC2CConversationFromMessage( + message, + fallbackUserId: peerId, + preferFallbackUserId: true, + ); + onNewMessageCurrentConversationListener?.call(message); + _cpInviteLog( + 'upsert $debugName synthetic C2C ' + 'peer=$peerId sender=$senderId applyId=$applyId', + ); + return message; + } + void _sendIncomingCpInviteRejectedNotice( SCSystemInvitMessageRes invite, RtcProvider rtcProvider, diff --git a/lib/services/auth/user_profile_manager.dart b/lib/services/auth/user_profile_manager.dart index 0586b5a..57ff770 100644 --- a/lib/services/auth/user_profile_manager.dart +++ b/lib/services/auth/user_profile_manager.dart @@ -12,6 +12,7 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/business_logic/models/res/room_user_card_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_identity_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_level_utils.dart'; class SocialChatUserProfileManager extends ChangeNotifier { static const List _cpProfileRelationTypes = [ @@ -188,10 +189,20 @@ class SocialChatUserProfileManager extends ChangeNotifier { final normalizedType = _normalizeCpRelationType( cpPair.relationType ?? relationType, ); + final cabinLevelText = scCpRelationLevelTextFromCabin( + cabin, + fallbackText: cpPair.levelText, + ); + final rightsLevelText = await _safeLoadCurrentUserCpLevelText( + userId: userId, + cpPair: cpPair, + relationType: normalizedType, + fallbackText: cabinLevelText, + ); final enrichedPair = cpPair.copyWith( relationType: normalizedType, cpValue: cpPair.cpValue ?? cabin?.cpVal?.toDouble(), - levelText: _cpLevelTextFromCabin(cabin), + levelText: rightsLevelText ?? cabinLevelText, dismissCost: cpPair.dismissCost ?? _cpDismissCostFromCabin(cabin), ); if (normalizedType == 'CP') { @@ -241,6 +252,50 @@ class SocialChatUserProfileManager extends ChangeNotifier { } } + Future _safeLoadCurrentUserCpLevelText({ + required String userId, + required CPRes cpPair, + required String relationType, + String? fallbackText, + }) async { + if (!_isCurrentUserId(userId)) { + return null; + } + final partnerUserId = _cpPartnerUserId(userId, cpPair); + if (partnerUserId.isEmpty) { + return null; + } + try { + final config = await SCAccountRepository().cpRightsConfig( + relationType: relationType, + cpUserId: partnerUserId, + ); + return scCpRelationLevelTextFromRightsConfig( + config, + fallbackText: fallbackText, + ); + } catch (error) { + if (kDebugMode) { + debugPrint( + '[CP][Profile] rightsConfig failed userId=$userId ' + 'partnerUserId=$partnerUserId relationType=$relationType ' + 'error=$error', + ); + } + return null; + } + } + + String _cpPartnerUserId(String userId, CPRes cpPair) { + final currentId = userId.trim(); + final meUserId = cpPair.meUserId?.trim() ?? ""; + final cpUserId = cpPair.cpUserId?.trim() ?? ""; + if (currentId.isNotEmpty && currentId == cpUserId) { + return meUserId; + } + return cpUserId; + } + bool _hasCpPair(CPRes? pair) { return (pair?.cpUserId?.trim().isNotEmpty ?? false) || (pair?.cpUserAvatar?.trim().isNotEmpty ?? false) || @@ -266,33 +321,6 @@ class SocialChatUserProfileManager extends ChangeNotifier { } } - String? _cpLevelTextFromCabin(SCCpCabinRes? cabin) { - final config = cabin?.currentCpCabinConfigCO; - if (config is! Map) { - return null; - } - final level = _firstCpConfigText([ - config["level"], - config["cpLevel"], - config["cabinLevel"], - config["lv"], - ]); - final name = _firstCpConfigText([ - config["levelName"], - config["name"], - config["title"], - config["cabinName"], - config["cpName"], - ]); - if (level.isNotEmpty && name.isNotEmpty) { - return "Lv.$level $name"; - } - if (level.isNotEmpty) { - return "Lv.$level"; - } - return name.isNotEmpty ? name : null; - } - int? _cpDismissCostFromCabin(SCCpCabinRes? cabin) { final directCost = cabin?.dismissCost; if (directCost != null) { @@ -340,16 +368,6 @@ class SocialChatUserProfileManager extends ChangeNotifier { return int.tryParse(value?.toString() ?? ""); } - String _firstCpConfigText(Iterable values) { - for (final value in values) { - final text = value?.toString().trim() ?? ""; - if (text.isNotEmpty && text.toLowerCase() != "null") { - return text; - } - } - return ""; - } - Future _withCurrentVipLevel( SocialChatUserProfile profile, String userId, diff --git a/lib/services/general/sc_app_general_manager.dart b/lib/services/general/sc_app_general_manager.dart index bd69f00..653b2c7 100644 --- a/lib/services/general/sc_app_general_manager.dart +++ b/lib/services/general/sc_app_general_manager.dart @@ -11,6 +11,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_banner_leaderboard_res. import 'package:yumi/shared/business_logic/models/res/country_res.dart'; import 'package:yumi/shared/business_logic/models/res/gift_backpack_res.dart'; import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_first_recharge_reward_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_emoji_res.dart'; @@ -36,6 +37,9 @@ class SCAppGeneralManager extends ChangeNotifier { bool _hasGiftListLoaded = false; bool _isFetchingGiftBackpack = false; bool _hasGiftBackpackLoaded = false; + bool _isFetchingFirstRechargeRewardHome = false; + bool _hasFirstRechargeRewardHomeLoaded = false; + SCFirstRechargeRewardHomeRes? _firstRechargeRewardHome; Map> giftByTab = {}; Map> activityGiftByTab = {}; @@ -59,10 +63,40 @@ class SCAppGeneralManager extends ChangeNotifier { bool get isGiftBackpackLoading => _isFetchingGiftBackpack && !_hasGiftBackpackLoaded; + bool get shouldShowFirstRechargeReward => + _firstRechargeRewardHome?.shouldShowRewardDialog ?? false; + + SCFirstRechargeRewardHomeRes? get firstRechargeRewardHome => + _firstRechargeRewardHome; + bool isGiftTabLoading(String type) { return type == backpackGiftTab ? isGiftBackpackLoading : isGiftListLoading; } + Future refreshFirstRechargeRewardHome({ + bool forceRefresh = false, + }) async { + if (_isFetchingFirstRechargeRewardHome) { + return _firstRechargeRewardHome; + } + if (_hasFirstRechargeRewardHomeLoaded && !forceRefresh) { + return _firstRechargeRewardHome; + } + + _isFetchingFirstRechargeRewardHome = true; + try { + final home = await SCConfigRepositoryImp().firstRechargeRewardHome(); + _firstRechargeRewardHome = home; + _hasFirstRechargeRewardHomeLoaded = true; + return home; + } catch (_) { + return _firstRechargeRewardHome; + } finally { + _isFetchingFirstRechargeRewardHome = false; + notifyListeners(); + } + } + ///加载国家列表 Future fetchCountryList() async { if (_isFetchingCountryList) { diff --git a/lib/services/payment/apple_payment_manager.dart b/lib/services/payment/apple_payment_manager.dart index 81757a3..2dbd03d 100644 --- a/lib/services/payment/apple_payment_manager.dart +++ b/lib/services/payment/apple_payment_manager.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:provider/provider.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -23,7 +24,7 @@ class IOSPaymentProcessor extends ChangeNotifier { // 只复制必要的状态变量 bool _isAvailable = false; String _errorMessage = ''; - List _products = []; + final List _products = []; late BuildContext context; Map productMap = {}; @@ -125,11 +126,11 @@ class IOSPaymentProcessor extends ChangeNotifier { // 发起iOS购买 Future processPurchase() async { ProductDetails? product; - _products.forEach((d) { + for (final d in _products) { if (d.isSelecte) { product = d.produc; } - }); + } if (product == null) { SCTts.show(SCAppLocalizations.of(context)!.pleaseSelectaItem); @@ -226,6 +227,14 @@ class IOSPaymentProcessor extends ChangeNotifier { // 验证iOS支付 Future _verifyIOSPayment(PurchaseDetails purchase) async { + final profileManager = Provider.of( + context, + listen: false, + ); + final firstRechargeManager = Provider.of( + context, + listen: false, + ); SCLoadingManager.show(); try { String receiptData = purchase.verificationData.serverVerificationData; @@ -242,14 +251,9 @@ class IOSPaymentProcessor extends ChangeNotifier { // 🔥 iOS关键:必须完成交易 await iap.completePurchase(purchase); // 更新用户信息 - Provider.of( - context, - listen: false, - ).fetchUserProfileData(); - Provider.of( - context, - listen: false, - ).balance(); + profileManager.fetchUserProfileData(); + profileManager.balance(); + _refreshFirstRechargeRewardState(firstRechargeManager); SCTts.show('Purchase is successful!'); } catch (e) { if (e.toString().endsWith("${SCErroCode.orderExistsCreated.code}")) { @@ -270,9 +274,14 @@ class IOSPaymentProcessor extends ChangeNotifier { } } - // 交付商品(与安卓相同) - Future _deliverProduct(String productId) async { - // 使用相同的业务逻辑 + void _refreshFirstRechargeRewardState(SCAppGeneralManager manager) { + try { + unawaited(manager.refreshFirstRechargeRewardHome(forceRefresh: true)); + } catch (error) { + if (kDebugMode) { + debugPrint('[FirstRecharge][ios_payment] refresh state failed: $error'); + } + } } // iOS错误处理 diff --git a/lib/services/payment/google_payment_manager.dart b/lib/services/payment/google_payment_manager.dart index d479d71..22fc38e 100644 --- a/lib/services/payment/google_payment_manager.dart +++ b/lib/services/payment/google_payment_manager.dart @@ -6,6 +6,7 @@ import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:provider/provider.dart'; import 'package:yumi/shared/tools/sc_string_utils.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; @@ -298,12 +299,17 @@ class AndroidPaymentProcessor extends ChangeNotifier { context, listen: false, ); + final firstRechargeManager = Provider.of( + context, + listen: false, + ); try { // 如果是消耗型商品,尝试消耗 if (_getProductType(purchase.productID) == 'consumable') { await consumePurchase(purchase); // 消耗后重新获取余额 profileManager.balance(); + _refreshFirstRechargeRewardState(firstRechargeManager); SCTts.show('outstanding purchases have been reinstated'); } } catch (e) { @@ -317,6 +323,10 @@ class AndroidPaymentProcessor extends ChangeNotifier { context, listen: false, ); + final firstRechargeManager = Provider.of( + context, + listen: false, + ); try { // 1. 基本验证 if (purchase.verificationData.serverVerificationData.isEmpty) { @@ -359,6 +369,7 @@ class AndroidPaymentProcessor extends ChangeNotifier { // 8. 更新用户余额 profileManager.fetchUserProfileData(); profileManager.balance(); + _refreshFirstRechargeRewardState(firstRechargeManager); SCTts.show('purchase successful'); } catch (e) { @@ -376,6 +387,16 @@ class AndroidPaymentProcessor extends ChangeNotifier { // 例如:增加用户余额、解锁功能等 } + void _refreshFirstRechargeRewardState(SCAppGeneralManager manager) { + try { + unawaited(manager.refreshFirstRechargeRewardHome(forceRefresh: true)); + } catch (error) { + if (kDebugMode) { + debugPrint('[FirstRecharge][payment] refresh state failed: $error'); + } + } + } + // 新增:确保购买被消耗 Future _ensurePurchaseConsumed(PurchaseDetails purchase) async { try { diff --git a/lib/shared/business_logic/models/res/login_res.dart b/lib/shared/business_logic/models/res/login_res.dart index 9f53263..46f384b 100644 --- a/lib/shared/business_logic/models/res/login_res.dart +++ b/lib/shared/business_logic/models/res/login_res.dart @@ -1162,6 +1162,10 @@ class CPRes { _levelText = _asString( source['levelText'] ?? source['levelName'] ?? + source['level'] ?? + source['cpLevel'] ?? + source['cabinLevel'] ?? + source['lv'] ?? source['name'] ?? source['title'], ); diff --git a/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart b/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart index 8e7fc86..ee44691 100644 --- a/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart +++ b/lib/shared/business_logic/models/res/sc_cp_rights_config_res.dart @@ -4,6 +4,7 @@ class SCCpRightsConfigRes { this.cpUserId, this.relationshipStatus, this.intimacyValue, + this.dismissConsumeGold, List? levels, }) : levels = levels ?? const []; @@ -18,6 +19,11 @@ class SCCpRightsConfigRes { cpUserId: _asString(source['cpUserId']), relationshipStatus: _asString(source['relationshipStatus']), intimacyValue: _asInt(source['intimacyValue']), + dismissConsumeGold: _asInt( + source['dismissConsumeGold'] ?? + source['dismissCost'] ?? + source['dismissGold'], + ), levels: rawLevels is List ? rawLevels @@ -31,6 +37,7 @@ class SCCpRightsConfigRes { final String? cpUserId; final String? relationshipStatus; final int? intimacyValue; + final int? dismissConsumeGold; final List levels; static String? _asString(dynamic value) { 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 c2a5983..be3f43f 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 @@ -41,7 +41,7 @@ class SCSystemInvitMessageRes { } SCSystemInvitMessageRes.fromJson(dynamic json) { - _account = json['account']; + _account = _firstNonBlank(json, const ['account', 'applyUserAccount']); _countryCode = json['countryCode']; _countryName = json['countryName']; _userAvatar = json['userAvatar']; @@ -61,9 +61,20 @@ class SCSystemInvitMessageRes { 'avatarFrameCover', ]); _userNickname = json['userNickname']; - _actualAccount = json['actualAccount']; + _actualAccount = _firstNonBlank(json, const [ + 'actualAccount', + 'applyActualAccount', + 'applyUserActualAccount', + 'inviterActualAccount', + 'inviterUserId', + 'inviterId', + 'senderUserId', + 'senderId', + 'fromUserId', + 'userId', + ]); _applyType = json['applyType']; - _content = json['content']; + _content = _contentString(json['content']); _relationType = _firstNonBlank(json, _relationTypeKeys); _dismissEndTime = _asInt(json['dismissEndTime']); _giftCover = json['giftCover']; @@ -80,6 +91,20 @@ class SCSystemInvitMessageRes { return int.tryParse(value?.toString() ?? ''); } + static String? _contentString(dynamic value) { + if (value == null) { + return null; + } + if (value is String) { + return value; + } + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } + } + String? _account; String? _countryCode; String? _countryName; @@ -140,7 +165,10 @@ class SCSystemInvitMessageRes { !_hasInviteIdentity(root)) { return null; } - final source = {...(content.isNotEmpty ? content : root)}; + final source = { + ...root, + if (content.isNotEmpty) ...content, + }; final relationType = _firstNonBlank(source, _relationTypeKeys) ?? _firstNonBlank(root, _relationTypeKeys) ?? diff --git a/lib/shared/business_logic/repositories/room_repository.dart b/lib/shared/business_logic/repositories/room_repository.dart index 35b077c..f4baef4 100644 --- a/lib/shared/business_logic/repositories/room_repository.dart +++ b/lib/shared/business_logic/repositories/room_repository.dart @@ -94,6 +94,7 @@ abstract class SocialChatRoomRepository { String? roomId, SCGiveAwayGiftRoomAcceptsCmd? accepts, String? dynamicContentId, + String? relationType, }); ///举报 @@ -182,6 +183,7 @@ abstract class SocialChatRoomRepository { Object? gameId, String? dynamicContentId, String? songId, + String? relationType, }); ///查询榜单前三名 @@ -199,6 +201,7 @@ abstract class SocialChatRoomRepository { String? roomId, num? seatIndex, String? songId, + String? relationType, }); ///查询房间火箭状态 diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 3e8cffa..64d8470 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -610,6 +610,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { String? roomId, SCGiveAwayGiftRoomAcceptsCmd? accepts, String? dynamicContentId, + String? relationType, }) async { Map params = {}; if (roomId != null) { @@ -625,6 +626,9 @@ class SCChatRoomRepository implements SocialChatRoomRepository { if (dynamicContentId != null) { params["dynamicContentId"] = dynamicContentId; } + if (relationType != null && relationType.trim().isNotEmpty) { + params["relationType"] = relationType.trim(); + } _giftRepoLog( 'request giveGift endpoint=/gift/batch ' @@ -632,6 +636,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { 'roomId=$roomId ' 'quantity=$quantity ' 'checkCombo=$checkCombo ' + 'relationType=$relationType ' 'dynamicContentId=$dynamicContentId ' 'acceptUserIds=${acceptUserIds.join(",")} ' 'payload=${_stringifyGiftDebug(params)}', @@ -953,6 +958,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { Object? gameId, String? dynamicContentId, String? songId, + String? relationType, }) async { Map params = {}; if (roomId != null) { @@ -969,6 +975,9 @@ class SCChatRoomRepository implements SocialChatRoomRepository { .toList(); params["dynamicContentId"] = dynamicContentId; params["songId"] = songId; + if (relationType != null && relationType.trim().isNotEmpty) { + params["relationType"] = relationType.trim(); + } _giftRepoLog( 'request giveLuckyGift endpoint=/gift/give/lucky-gift ' @@ -976,6 +985,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { 'roomId=$roomId ' 'quantity=$quantity ' 'checkCombo=$checkCombo ' + 'relationType=$relationType ' 'gameId=$gameId ' 'dynamicContentId=$dynamicContentId ' 'songId=$songId ' @@ -1042,6 +1052,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { String? roomId, num? seatIndex, String? songId, + String? relationType, }) async { final params = { "id": id, @@ -1058,6 +1069,18 @@ class SCChatRoomRepository implements SocialChatRoomRepository { if (songId != null && songId.trim().isNotEmpty) { params["songId"] = songId; } + if (relationType != null && relationType.trim().isNotEmpty) { + params["relationType"] = relationType.trim(); + } + _giftRepoLog( + 'request giveBackpackGift endpoint=/gift/backpack/give-away ' + 'giftId=$giftId ' + 'roomId=$roomId ' + 'quantity=$quantity ' + 'relationType=$relationType ' + 'acceptUserId=$acceptUserId ' + 'payload=${_stringifyGiftDebug(params)}', + ); final result = await http.post( "/gift/backpack/give-away", data: params, diff --git a/lib/shared/tools/sc_cp_gift_relation_utils.dart b/lib/shared/tools/sc_cp_gift_relation_utils.dart index eecddee..89660c4 100644 --- a/lib/shared/tools/sc_cp_gift_relation_utils.dart +++ b/lib/shared/tools/sc_cp_gift_relation_utils.dart @@ -2,10 +2,22 @@ import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart'; import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; -bool scIsCpGiftTab(String? tabType) => - tabType?.trim().toUpperCase() == SCGiftType.CP.name; +bool scIsCpGiftTab(String? tabType) { + final text = tabType?.trim(); + if (text == null || text.isEmpty) { + return false; + } + return text.toUpperCase() == SCGiftType.CP.name || + _inferCpRelationTypeFromGiftText(text) != null; +} -bool scIsCpGift(SocialChatGiftRes? gift) => scIsCpGiftTab(gift?.giftTab); +bool scIsCpGift(SocialChatGiftRes? gift) { + if (gift == null) { + return false; + } + return scIsCpGiftTab(gift.giftTab) || + _normalizeExplicitGiftRelationType(gift.relationType) != null; +} String scCpRelationTypeForGift(SocialChatGiftRes gift) { final explicit = _normalizeExplicitGiftRelationType(gift.relationType); @@ -18,6 +30,7 @@ String scCpRelationTypeForGift(SocialChatGiftRes gift) { gift.giftName, gift.special, gift.standardId, + gift.giftTab, ]) { final inferred = _inferCpRelationTypeFromGiftText(value); if (inferred != null) { @@ -33,10 +46,13 @@ String? _normalizeExplicitGiftRelationType(String? relationType) { return null; } final normalized = scNormalizeCpRelationType(raw); - if (normalized != "CP" || _isCpRelationText(raw)) { + if (normalized != "CP") { return normalized; } - return _inferCpRelationTypeFromGiftText(raw) ?? normalized; + if (_isCpRelationText(raw)) { + return normalized; + } + return _inferCpRelationTypeFromGiftText(raw); } bool _isCpRelationText(String value) { @@ -49,7 +65,14 @@ String? _inferCpRelationTypeFromGiftText(String? value) { if (upper == null || upper.isEmpty) { return null; } - if (_hasRelationToken(upper, const ["SIS", "SISTER", "SISTERS"])) { + if (_hasRelationToken(upper, const [ + "SIS", + "SISTER", + "SISTERS", + "姐妹", + "姊妹", + "闺蜜", + ])) { return "SISTERS"; } if (_hasRelationToken(upper, const [ @@ -61,10 +84,13 @@ String? _inferCpRelationTypeFromGiftText(String? value) { "SWORN_BROS", "SWORN_BROTHER", "SWORN_BROTHERS", + "兄弟", + "哥们", + "兄妹", ])) { return "BROTHER"; } - if (_hasRelationToken(upper, const ["CP", "COUPLE"])) { + if (_hasRelationToken(upper, const ["CP", "COUPLE", "情侣", "恋人", "伴侣"])) { return "CP"; } return null; @@ -73,6 +99,9 @@ String? _inferCpRelationTypeFromGiftText(String? value) { bool _hasRelationToken(String upperText, List tokens) { for (final token in tokens) { final escaped = RegExp.escape(token); + if (RegExp(r'[^\x00-\x7F]').hasMatch(token) && upperText.contains(token)) { + return true; + } if (RegExp('(^|[^A-Z0-9])$escaped([^A-Z0-9]|\$)').hasMatch(upperText)) { return true; } diff --git a/lib/shared/tools/sc_cp_relation_level_utils.dart b/lib/shared/tools/sc_cp_relation_level_utils.dart new file mode 100644 index 0000000..12c6355 --- /dev/null +++ b/lib/shared/tools/sc_cp_relation_level_utils.dart @@ -0,0 +1,227 @@ +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_cp_rights_config_res.dart'; + +String? scCpRelationLevelTextFromCabin( + SCCpCabinRes? cabin, { + String? fallbackText, +}) { + if (cabin == null) { + return _blankAsNull(fallbackText); + } + + final currentValue = + cabin.cpVal?.toDouble() ?? cabin.cpPairUserProfileCO?.cpValue; + final computedLevel = _currentLevelFromDynamicConfigs( + cabin.cpCabinConfigCO ?? const [], + currentValue, + ); + final computedText = _formattedLevelText( + level: computedLevel?.level, + name: computedLevel?.levelName, + ); + if (computedText != null) { + return computedText; + } + + final currentConfig = _CpRelationLevelSource.fromDynamic( + cabin.currentCpCabinConfigCO, + ); + final currentText = _formattedLevelText( + level: currentConfig.level, + name: currentConfig.levelName, + ); + if (currentText != null) { + return currentText; + } + + return _blankAsNull(cabin.cpPairUserProfileCO?.levelText) ?? + _blankAsNull(fallbackText); +} + +String? scCpRelationLevelTextFromRightsConfig( + SCCpRightsConfigRes config, { + String? fallbackText, +}) { + final currentValue = config.intimacyValue?.toDouble(); + final computedLevel = _currentLevelFromRightsLevels( + config.levels, + currentValue, + ); + final computedText = _formattedLevelText( + level: computedLevel?.level, + name: computedLevel?.levelName, + ); + return computedText ?? _blankAsNull(fallbackText); +} + +String scCpRelationLevelDisplayText(String? rawText) { + final text = rawText?.trim() ?? ''; + final level = scCpRelationLevelIndexOrNull(text); + if (text.isEmpty) { + return 'Lv.1 Simple Love'; + } + final compactLevelPattern = RegExp( + r'^(?:Lv\.?|Level)?\s*\d+$', + caseSensitive: false, + ); + if (compactLevelPattern.hasMatch(text) && level == 1) { + return 'Lv.1 Simple Love'; + } + return text; +} + +int scCpRelationLevelIndex(String? levelText) { + return scCpRelationLevelIndexOrNull(levelText) ?? 1; +} + +int? scCpRelationLevelIndexOrNull(String? levelText) { + final text = levelText?.trim() ?? ''; + if (text.isEmpty) { + return null; + } + final levelMatch = RegExp( + r'(?:Lv\.?|Level|等级|级)\s*([1-5])|([1-5])\s*(?:级|Level)', + caseSensitive: false, + ).firstMatch(text); + final fallbackMatch = RegExp(r'[1-5]').firstMatch(text); + final rawLevel = + levelMatch?.group(1) ?? levelMatch?.group(2) ?? fallbackMatch?.group(0); + final level = int.tryParse(rawLevel ?? ''); + return level?.clamp(1, 5).toInt(); +} + +_CpRelationLevelSource? _currentLevelFromDynamicConfigs( + List configs, + double? currentValue, +) { + final levels = + configs.map(_CpRelationLevelSource.fromDynamic).where((level) { + return level.requiredValue != null; + }).toList() + ..sort( + (left, right) => left.requiredValue!.compareTo(right.requiredValue!), + ); + return _currentLevelFromSources(levels, currentValue); +} + +_CpRelationLevelSource? _currentLevelFromRightsLevels( + List levels, + double? currentValue, +) { + final sources = + levels.map(_CpRelationLevelSource.fromRightsLevel).where((level) { + return level.requiredValue != null; + }).toList() + ..sort( + (left, right) => left.requiredValue!.compareTo(right.requiredValue!), + ); + return _currentLevelFromSources(sources, currentValue); +} + +_CpRelationLevelSource? _currentLevelFromSources( + List<_CpRelationLevelSource> levels, + double? currentValue, +) { + if (levels.isEmpty || currentValue == null) { + return null; + } + _CpRelationLevelSource? currentLevel; + for (final level in levels) { + if ((level.requiredValue ?? 0) <= currentValue) { + currentLevel = level; + } + } + return currentLevel; +} + +String? _formattedLevelText({int? level, String? name}) { + final levelName = name?.trim() ?? ''; + if (level != null && levelName.isNotEmpty) { + final nameLevel = scCpRelationLevelIndexOrNull(levelName); + if (nameLevel == level) { + return levelName; + } + return 'Lv.$level $levelName'; + } + if (level != null) { + return 'Lv.$level'; + } + return _blankAsNull(levelName); +} + +String? _blankAsNull(String? value) { + final text = value?.trim() ?? ''; + return text.isEmpty ? null : text; +} + +class _CpRelationLevelSource { + const _CpRelationLevelSource({ + this.level, + this.levelName, + this.requiredValue, + }); + + factory _CpRelationLevelSource.fromRightsLevel(SCCpRightsLevelRes level) { + return _CpRelationLevelSource( + level: level.level, + levelName: level.levelName, + requiredValue: level.requiredIntimacyValue, + ); + } + + factory _CpRelationLevelSource.fromDynamic(dynamic value) { + final source = value is Map ? value : const {}; + return _CpRelationLevelSource( + level: _asInt( + _firstValue(source, const ['level', 'cpLevel', 'cabinLevel', 'lv']), + ), + levelName: _asString( + _firstValue(source, const [ + 'levelName', + 'name', + 'title', + 'cabinName', + 'cpName', + ]), + ), + requiredValue: _asInt( + _firstValue(source, const [ + 'requiredIntimacyValue', + 'requiredCpValue', + 'requiredCpVal', + 'cpValue', + 'cpVal', + 'intimacyValue', + ]), + ), + ); + } + + final int? level; + final String? levelName; + final int? requiredValue; +} + +dynamic _firstValue(Map source, List keys) { + for (final key in keys) { + if (source.containsKey(key) && source[key] != null) { + return source[key]; + } + } + return null; +} + +String? _asString(dynamic value) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? null : text; +} + +int? _asInt(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ''); +} diff --git a/lib/shared/tools/sc_cp_relation_pair_utils.dart b/lib/shared/tools/sc_cp_relation_pair_utils.dart index 27679dc..7d095a8 100644 --- a/lib/shared/tools/sc_cp_relation_pair_utils.dart +++ b/lib/shared/tools/sc_cp_relation_pair_utils.dart @@ -1,4 +1,5 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/tools/sc_cp_relation_level_utils.dart'; import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; class SCCpRelationPairInfo { @@ -113,18 +114,5 @@ Set _relationKeys(CPRes relation) { 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); + return scCpRelationLevelIndexOrNull(relation.levelText); } diff --git a/lib/shared/tools/sc_dialog_utils.dart b/lib/shared/tools/sc_dialog_utils.dart index e95700e..64b8f79 100644 --- a/lib/shared/tools/sc_dialog_utils.dart +++ b/lib/shared/tools/sc_dialog_utils.dart @@ -13,8 +13,14 @@ import 'package:yumi/ui_kit/widgets/first_recharge/first_recharge_dialog.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; class SCDialogUtils { - static Future showFirstRechargeDialog(BuildContext context) { - return FirstRechargeDialog.showFromRemote(context); + static Future showFirstRechargeDialog( + BuildContext context, { + bool forceRefresh = false, + }) { + return FirstRechargeDialog.showFromRemote( + context, + forceRefresh: forceRefresh, + ); } static void showDynamicCommentOptDialog( 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 75f518a..1e81e09 100644 --- a/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart +++ b/lib/ui_kit/widgets/first_recharge/first_recharge_dialog.dart @@ -6,9 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:provider/provider.dart'; +import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/services/payment/google_payment_manager.dart'; import 'package:yumi/shared/business_logic/models/res/sc_first_recharge_reward_res.dart'; -import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; @@ -247,14 +247,20 @@ class FirstRechargeDialog extends StatefulWidget { ); } - static Future showFromRemote(BuildContext context) async { + static Future showFromRemote( + BuildContext context, { + bool forceRefresh = false, + }) async { try { SCLoadingManager.show(context: context); - final home = await SCConfigRepositoryImp().firstRechargeRewardHome(); + final home = await Provider.of( + context, + listen: false, + ).refreshFirstRechargeRewardHome(forceRefresh: forceRefresh); if (!context.mounted) { return; } - if (!home.shouldShowRewardDialog) { + if (home == null || !home.shouldShowRewardDialog) { SmartDialog.dismiss(tag: dialogTag); return; } diff --git a/lib/ui_kit/widgets/msg/message_conversation_list_page.dart b/lib/ui_kit/widgets/msg/message_conversation_list_page.dart index 1e6eac4..1852592 100644 --- a/lib/ui_kit/widgets/msg/message_conversation_list_page.dart +++ b/lib/ui_kit/widgets/msg/message_conversation_list_page.dart @@ -10,6 +10,7 @@ import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_custom_elem.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_text_elem.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/dialog/dialog_base.dart'; @@ -17,6 +18,7 @@ import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/shared/data_sources/models/enum/sc_sysytem_message_type.dart'; import 'package:yumi/shared/tools/sc_date_utils.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'; @@ -406,7 +408,15 @@ class _ConversationItemState extends State { !_isSystemConversation && conversation.conversationID != "customer" && conversation.conversationID != "article" && - (conversation.userID?.isNotEmpty ?? false); + _effectiveUserId.isNotEmpty; + + String get _effectiveUserId { + final cpInvitePeerId = _cpInvitePeerUserIdFromLastMessage(); + if (cpInvitePeerId.isNotEmpty) { + return cpInvitePeerId; + } + return conversation.userID?.trim() ?? ""; + } String get _displayAvatar { final userAvatar = user?.userAvatar ?? ""; @@ -490,20 +500,21 @@ class _ConversationItemState extends State { }*/ return GestureDetector( onTap: () async { - if (_isC2CConversation && (conversation.userID?.isEmpty ?? true)) { + final routeConversation = _routeConversation(); + if (_isC2CConversation && (routeConversation.userID?.isEmpty ?? true)) { return; } - if (conversation.type == ConversationType.V2TIM_GROUP && - (conversation.groupID?.isEmpty ?? true)) { + if (routeConversation.type == ConversationType.V2TIM_GROUP && + (routeConversation.groupID?.isEmpty ?? true)) { return; } conversation.unreadCount = 0; var bool = await Provider.of( context, listen: false, - ).startConversation(conversation); + ).startConversation(routeConversation); if (!bool) return; - var json = jsonEncode(widget.conversation.toJson()); + var json = jsonEncode(routeConversation.toJson()); final route = _isSystemConversation ? SCChatRouter.systemChat : SCChatRouter.chat; SCNavigatorUtils.push( @@ -662,10 +673,146 @@ class _ConversationItemState extends State { void loadUserInfo() { if (!_shouldLoadUserInfo || user != null) return; - SCAccountRepository().loadUserInfo(conversation.userID!).then((value) { + final userId = _effectiveUserId; + if (userId.isEmpty) return; + SCAccountRepository().loadUserInfo(userId).then((value) { if (!mounted) return; user = value; setState(() {}); }); } + + V2TimConversation _routeConversation() { + final peerId = _effectiveUserId; + if (!_isC2CConversation || + peerId.isEmpty || + peerId == (conversation.userID?.trim() ?? "")) { + return conversation; + } + final json = conversation.toJson(); + json["userID"] = peerId; + json["conversationID"] = "c2c_$peerId"; + return V2TimConversation.fromJson(json); + } + + String _cpInvitePeerUserIdFromLastMessage() { + if (!_isC2CConversation) { + return ""; + } + final data = _cpInviteCustomData(conversation.lastMessage); + if (data == null) { + return ""; + } + final content = _cpInviteContentMap(data); + final noticeType = + _firstCpInviteValue([ + data["noticeType"]?.toString(), + data["type"]?.toString(), + content["noticeType"]?.toString(), + content["type"]?.toString(), + ])?.toUpperCase() ?? + ""; + if (!noticeType.contains(SCSysytemMessageType.CP_BUILD.name)) { + return ""; + } + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + final currentIds = + [currentProfile?.id, currentProfile?.account] + .map((value) => value?.trim() ?? "") + .where((value) => value.isNotEmpty) + .toSet(); + final inviterIds = _compactCpInviteValues([ + data["actualAccount"]?.toString(), + data["applyActualAccount"]?.toString(), + data["applyUserActualAccount"]?.toString(), + data["inviterActualAccount"]?.toString(), + data["inviterUserId"]?.toString(), + data["inviterId"]?.toString(), + data["senderUserId"]?.toString(), + data["senderId"]?.toString(), + data["fromUserId"]?.toString(), + data["userId"]?.toString(), + content["actualAccount"]?.toString(), + content["applyActualAccount"]?.toString(), + content["applyUserActualAccount"]?.toString(), + content["inviterActualAccount"]?.toString(), + content["inviterUserId"]?.toString(), + content["inviterId"]?.toString(), + content["senderUserId"]?.toString(), + content["senderId"]?.toString(), + content["fromUserId"]?.toString(), + content["userId"]?.toString(), + data["applyUserId"]?.toString(), + content["applyUserId"]?.toString(), + data["account"]?.toString(), + content["account"]?.toString(), + ]); + final isCurrentInviter = currentIds.any(inviterIds.contains); + final values = + isCurrentInviter + ? [ + data["acceptActualAccount"]?.toString(), + data["acceptUserId"]?.toString(), + data["receiverActualAccount"]?.toString(), + data["receiverId"]?.toString(), + data["receiverUserId"]?.toString(), + content["acceptActualAccount"]?.toString(), + content["acceptUserId"]?.toString(), + content["receiverActualAccount"]?.toString(), + content["receiverId"]?.toString(), + content["receiverUserId"]?.toString(), + ] + : inviterIds; + return _firstCpInviteValue(values) ?? ""; + } + + Map? _cpInviteCustomData(V2TimMessage? message) { + if (message?.elemType != MessageElemType.V2TIM_ELEM_TYPE_CUSTOM) { + return null; + } + final raw = message?.customElem?.data; + if (raw == null || raw.trim().isEmpty) { + return null; + } + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + } catch (_) {} + return null; + } + + Map _cpInviteContentMap(Map data) { + final content = data["content"]; + if (content is String && content.trim().isNotEmpty) { + try { + final decoded = jsonDecode(content); + if (decoded is Map) { + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + } catch (_) {} + } + if (content is Map) { + return content.map((key, value) => MapEntry(key.toString(), value)); + } + return const {}; + } + + List _compactCpInviteValues(Iterable values) { + return values + .map((value) => value?.trim() ?? "") + .where((value) => value.isNotEmpty) + .toList(); + } + + String? _firstCpInviteValue(Iterable values) { + for (final value in values) { + final text = value?.trim() ?? ""; + if (text.isNotEmpty) { + return text; + } + } + return null; + } } 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 345f8a7..4ff6c1d 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_level_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'; @@ -838,31 +839,11 @@ class _RoomUserInfoCardState extends State { } int _cpLevelIndex(String levelText) { - final levelMatch = RegExp( - r'(?:Lv\.?|Level)\s*(\d+)', - caseSensitive: false, - ).firstMatch(levelText); - final fallbackMatch = RegExp(r'\d+').firstMatch(levelText); - final rawLevel = - int.tryParse(levelMatch?.group(1) ?? fallbackMatch?.group(0) ?? "") ?? - 1; - return rawLevel.clamp(1, 5).toInt(); + return scCpRelationLevelIndex(levelText); } String _cpLevelDisplayText(String rawText) { - final text = rawText.trim(); - final level = _cpLevelIndex(text); - if (text.isEmpty) { - return "Lv.1 Simple Love"; - } - final compactLevelPattern = RegExp( - r'^(?:Lv\.?|Level)?\s*\d+$', - caseSensitive: false, - ); - if (compactLevelPattern.hasMatch(text) && level == 1) { - return "Lv.1 Simple Love"; - } - return text; + return scCpRelationLevelDisplayText(rawText); } bool _relationHasPartner(SocialChatUserProfile? profile, CPRes relation) { @@ -1485,7 +1466,12 @@ class _RoomUserInfoCardState extends State { expAwayText: progress.expAwayText, nextLevelText: progress.nextLevelText, cancelText: cancelText, - onCancel: () => _showCpDismissConfirm(context, cp), + onCancel: + () => _showCpDismissConfirm( + context, + cp, + dismissCost: progress.dismissCost, + ), ); } @@ -1514,7 +1500,11 @@ class _RoomUserInfoCardState extends State { cp.leftUserId, relationType: cp.relationType, ); - return _RoomCpProgressData.fromCabin(cabin, cp); + return _RoomCpProgressData.fromCabin( + cabin, + cp, + fallbackDismissCost: latestFallback.dismissCost, + ); } catch (_) { return latestFallback; } @@ -1522,8 +1512,9 @@ class _RoomUserInfoCardState extends State { Future _showCpDismissConfirm( BuildContext context, - _RoomProfileCpData cp, - ) async { + _RoomProfileCpData cp, { + int? dismissCost, + }) async { final l10n = SCAppLocalizations.of(context)!; final partnerUserId = cp.rightUserId.trim(); if (partnerUserId.isEmpty) { @@ -1532,7 +1523,7 @@ class _RoomUserInfoCardState extends State { await showRoomCpDismissConfirmDialog( context, message: l10n.cpDismissConfirmMessage( - cp.dismissCost == null ? "--" : "${cp.dismissCost}", + dismissCost == null ? "--" : "$dismissCost", ), cancelText: l10n.cancel, confirmText: l10n.confirm, @@ -1821,6 +1812,7 @@ class _RoomCpProgressData { required this.targetValue, required this.expAwayText, required this.nextLevelText, + this.dismissCost, }); factory _RoomCpProgressData.fallback(_RoomProfileCpData cp) { @@ -1834,6 +1826,7 @@ class _RoomCpProgressData { nextLevelText: _levelShortText( _nextLevelIndex(_levelIndex(cp.levelText)), ), + dismissCost: cp.dismissCost, ); } @@ -1847,13 +1840,15 @@ class _RoomCpProgressData { currentValue: currentValue, levels: levels, cp: cp, + dismissCost: config.dismissConsumeGold ?? cp.dismissCost, ); } factory _RoomCpProgressData.fromCabin( SCCpCabinRes cabin, - _RoomProfileCpData cp, - ) { + _RoomProfileCpData cp, { + int? fallbackDismissCost, + }) { final currentValue = (cabin.cpVal?.toDouble() ?? cabin.cpPairUserProfileCO?.cpValue ?? @@ -1866,6 +1861,7 @@ class _RoomCpProgressData { currentValue: currentValue, levels: levels, cp: cp, + dismissCost: cabin.dismissCost ?? fallbackDismissCost ?? cp.dismissCost, ); } @@ -1873,6 +1869,7 @@ class _RoomCpProgressData { required double currentValue, required List<_CpProgressLevel> levels, required _RoomProfileCpData cp, + int? dismissCost, }) { final sortedLevels = levels.where((level) => level.requiredValue != null).toList()..sort( @@ -1908,6 +1905,7 @@ class _RoomCpProgressData { ), ) : _levelShortText(nextLevel.level, fallback: nextLevel.levelName), + dismissCost: dismissCost, ); } @@ -1916,6 +1914,7 @@ class _RoomCpProgressData { final double targetValue; final String expAwayText; final String nextLevelText; + final int? dismissCost; static _CpProgressLevel? _currentLevelFromConfig( List<_CpProgressLevel> levels, @@ -1957,15 +1956,7 @@ class _RoomCpProgressData { } static int _levelIndex(String levelText) { - final levelMatch = RegExp( - r'(?:Lv\.?|Level)\s*(\d+)', - caseSensitive: false, - ).firstMatch(levelText); - final fallbackMatch = RegExp(r'\d+').firstMatch(levelText); - return int.tryParse( - levelMatch?.group(1) ?? fallbackMatch?.group(0) ?? "", - ) ?? - 1; + return scCpRelationLevelIndex(levelText); } static int _nextLevelIndex(int level) { diff --git a/test/gift_res_cp_relation_type_test.dart b/test/gift_res_cp_relation_type_test.dart index 81ef88f..f31f8ce 100644 --- a/test/gift_res_cp_relation_type_test.dart +++ b/test/gift_res_cp_relation_type_test.dart @@ -61,6 +61,15 @@ void main() { expect(scCpRelationTypeForGift(cpGift), 'CP'); expect(scIsCpGift(sisterGift), isTrue); }); + + test('infers brother and sister relation from tab and localized names', () { + final brotherGift = SocialChatGiftRes(giftTab: 'BROTHER'); + final sisterGift = SocialChatGiftRes(giftTab: 'CP', giftName: '闺蜜皇冠'); + + expect(scIsCpGift(brotherGift), isTrue); + expect(scCpRelationTypeForGift(brotherGift), 'BROTHER'); + expect(scCpRelationTypeForGift(sisterGift), 'SISTERS'); + }); }); group('SCSystemInvitMessageRes avatar frame', () { @@ -87,6 +96,18 @@ void main() { expect(invite?.relationType, 'BROTHER'); }); + test('keeps long actual account when content only has short account', () { + final invite = SCSystemInvitMessageRes.fromMessageData({ + 'account': '99004', + 'actualAccount': '2057731345099841537', + 'relationType': 'BROTHER', + 'content': jsonEncode({'account': '99004', 'userNickname': 'Alex'}), + }); + + expect(invite?.account, '99004'); + expect(invite?.actualAccount, '2057731345099841537'); + }); + test('infers relation type from gift payload when needed', () { final invite = SCSystemInvitMessageRes.fromMessageData({ 'content': { diff --git a/test/sc_cp_relation_pair_utils_test.dart b/test/sc_cp_relation_pair_utils_test.dart index c51a6e4..a5f96b0 100644 --- a/test/sc_cp_relation_pair_utils_test.dart +++ b/test/sc_cp_relation_pair_utils_test.dart @@ -1,5 +1,6 @@ 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_level_utils.dart'; import 'package:yumi/shared/tools/sc_cp_relation_pair_utils.dart'; void main() { @@ -62,4 +63,20 @@ void main() { expect(scCpRelationTypeBetweenProfiles(left, right), isNull); }); }); + + group('scCpRelationLevelTextFromCabin', () { + test('uses cp value and config thresholds for current level text', () { + final cabin = SCCpCabinRes( + cpVal: 1200, + cpCabinConfigCO: const [ + {'level': 1, 'levelName': 'Simple Love', 'requiredCpValue': 0}, + {'level': 2, 'levelName': 'Sweet Love', 'requiredCpValue': 1000}, + {'level': 3, 'levelName': 'Deep Love', 'requiredCpValue': 3000}, + ], + currentCpCabinConfigCO: const {'level': 1, 'levelName': 'Simple Love'}, + ); + + expect(scCpRelationLevelTextFromCabin(cabin), 'Lv.2 Sweet Love'); + }); + }); } diff --git a/test/sc_cp_rights_config_res_test.dart b/test/sc_cp_rights_config_res_test.dart index 491fa57..8ad4630 100644 --- a/test/sc_cp_rights_config_res_test.dart +++ b/test/sc_cp_rights_config_res_test.dart @@ -9,6 +9,7 @@ void main() { 'cpUserId': null, 'relationshipStatus': 'NONE', 'intimacyValue': 0, + 'dismissConsumeGold': 88, 'levels': [ { 'level': 1, @@ -32,6 +33,7 @@ void main() { expect(config.cpUserId, isNull); expect(config.relationshipStatus, 'NONE'); expect(config.intimacyValue, 0); + expect(config.dismissConsumeGold, 88); expect(config.levels, hasLength(1)); expect(config.levels.first.levelName, 'CP 1级'); expect(config.levels.first.requiredIntimacyValue, 0); @@ -49,6 +51,7 @@ void main() { 'cpUserId': 123, 'relationshipStatus': 'ACTIVE', 'intimacyValue': 1200, + 'dismissConsumeGold': 120, 'levels': [ { 'level': 2, @@ -65,6 +68,7 @@ void main() { expect(config.cpUserId, '123'); expect(config.relationshipStatus, 'ACTIVE'); expect(config.intimacyValue, 1200); + expect(config.dismissConsumeGold, 120); expect(config.levels.first.level, 2); expect(config.levels.first.unlocked, true); }); diff --git a/需求进度.md b/需求进度.md index ea733c6..814265e 100644 --- a/需求进度.md +++ b/需求进度.md @@ -980,3 +980,37 @@ - [ ] 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 动态渲染;启动弹窗顺序调整为注册奖励 > 每日签到 > 进场弹窗 > 首充弹窗;进场弹窗接口已接入并支持路径降级与候选图片兜底。 +- [ ] 2026-05-22 17:52:18 新增性能问题:礼物弹窗每次打开都会请求首充 home 接口导致慢和卡;需要改为进房预请求缓存,房内充值成功后再刷新状态。 +- [ ] 2026-05-22 17:52:43 开始检查礼物面板首充请求、房间进入生命周期和支付成功刷新入口。 +- [ ] 2026-05-22 17:55:06 新增方案确认:启动时首充弹窗请求结果缓存到全局状态;礼物弹窗只读缓存不再请求;用户充值成功后刷新该状态。 +- [ ] 2026-05-22 17:55:29 开始读取首充远程弹窗、礼物弹窗缓存读取点和支付成功刷新点。 +- [ ] 2026-05-22 17:56:21 准备在 SCAppGeneralManager 增加首充 home 全局缓存,礼物面板改为只读缓存,支付成功后强制刷新。 +- [ ] 2026-05-22 17:57:23 开始编码:新增 SCAppGeneralManager 首充缓存,移除 GiftPage 首充接口请求,支付成功后刷新缓存。 + +- [ ] 18:00:55 继续处理首充状态缓存:复核当前改动,并补充支付成功后刷新全局首充状态。 + +- [x] 18:01:20 已确认 GiftPage 打开时的首充接口请求已移除,当前只需要补齐支付成功后的全局状态刷新。 + +- [ ] 18:01:37 开始修改支付成功回调:Google/iOS 验证成功后强制刷新全局首充弹窗状态。 + +- [x] 18:01:59 已补充 Google/iOS 支付成功后的首充全局状态强制刷新。 + +- [ ] 18:02:37 调整首充弹窗入口:App 进场时强制请求并写入缓存,礼物悬浮板打开时复用缓存。 + +- [x] 18:02:59 已区分首充弹窗请求来源:首页进场强刷缓存,礼物入口打开复用缓存。 + +- [ ] 18:03:29 开始格式化本次涉及的首充缓存、弹窗入口、礼物面板与支付文件。 + +- [x] 18:03:41 已完成 Dart 格式化。 + +- [ ] 18:03:50 开始静态检查本次变更文件,确认首充缓存和支付刷新逻辑无编译错误。 + +- [ ] 18:04:31 静态检查发现支付文件 lint,开始收紧 iOS/Google 支付刷新实现并移除 iOS 未使用空方法。 + +- [x] 18:05:31 已收紧支付刷新实现:异步前缓存 Provider 引用,iOS 支付文件移除未使用空方法并修复 forEach lint。 + +- [x] 18:06:11 静态检查通过:本次涉及的 7 个 Dart 文件 flutter analyze 无问题。 + +- [ ] 18:06:26 检查 iOS 模拟器设备,准备进行运行验证。 + +- [x] 18:06:55 已确认本机可用 iPhone 17 Pro 与 iPhone 17 Pro Max 模拟器,开始用 iPhone 17 Pro 编译运行验证。