又修复了一版

This commit is contained in:
roxy 2026-05-22 19:32:01 +08:00
parent 2999de23d1
commit 0e2f4cf4bf
24 changed files with 1815 additions and 191 deletions

View File

@ -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<GiftPage> with TickerProviderStateMixin {
TabController? _tabController;
List<String> _tabTypes = <String>[];
bool _didApplyInitialTab = false;
bool _showFirstRechargeGiftEntry = false;
/// 访
BusinessLogicStrategy get _strategy => SCGlobalConfig.businessLogicStrategy;
@ -240,7 +238,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
listen: false,
).giftBackpack(forceRefresh: true),
);
unawaited(_loadFirstRechargeGiftEntryState());
Provider.of<SocialChatUserProfileManager>(context, listen: false).balance();
rtcProvider?.roomWheatMap.forEach((k, v) {
if (v.user != null) {
@ -276,35 +273,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
_normalizeCpRecipientSelection(notify: true);
}
Future<void> _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<GiftPage> 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<GiftPage> with TickerProviderStateMixin {
animationCount: request.clickCount,
);
}
_syncLocalCpInviteAfterGift(request);
await _refreshGiftBackpack();
_refreshCpStateAfterGift(request, profileManager);
return;
@ -1612,6 +1583,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
request.quantity,
false,
roomId: request.roomId,
relationType: request.cpRelationType,
)
: await repository.giveGift(
request.acceptUserIds,
@ -1619,6 +1591,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
request.quantity,
false,
roomId: request.roomId,
relationType: request.cpRelationType,
);
_giftFxLog(
'$requestName success trigger=$trigger '
@ -1662,6 +1635,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
);
}
profileManager?.updateBalance(result);
_syncLocalCpInviteAfterGift(request);
_refreshCpStateAfterGift(request, profileManager);
} catch (e) {
final errorMessage =
@ -1705,6 +1679,41 @@ class _GiftPageState extends State<GiftPage> 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<RtmProvider>(
context,
listen: false,
).insertLocalOutgoingCpGiftInvite(
receiverId: receiverId,
receiverProfile: receiverProfile,
relationType: relationType,
gift: request.gift,
giftCount: request.quantity,
),
);
}
Future<List<MicRes>> _giveBackpackGiftToRecipients(
SCChatRoomRepository repository,
_GiftSendRequest request,
@ -1726,6 +1735,7 @@ class _GiftPageState extends State<GiftPage> 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<String>.from(acceptUserIds)..sort();
final type =

View File

@ -498,7 +498,7 @@ class _SCIndexPageState extends State<SCIndexPage>
return;
}
_hasRequestedFirstRechargeDialog = true;
await SCDialogUtils.showFirstRechargeDialog(context);
await SCDialogUtils.showFirstRechargeDialog(context, forceRefresh: true);
}
Future<void> _waitForRegisterRewardSocketIfNeeded() async {

View File

@ -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<PersonDetailPage> {
) {
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<PersonDetailPage> {
],
),
);
return _buildProfileRelationProgressTap(
profile: profile,
relation: relation,
child: card,
);
}
Widget _buildProfileCpCardText(
@ -1093,7 +1102,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
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<PersonDetailPage> {
],
),
);
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<PersonDetailPage> {
}
}
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<void> _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<void> _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<SocialChatUserProfileManager>(
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<PersonDetailPage> {
}
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<String?> values) {
@ -2455,6 +2637,278 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
}
}
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 <dynamic>[])
.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 <String, dynamic>{};
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<String> 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,

View File

@ -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<String> _shownCpInviteMessageKeys = <String>{};
final Set<String> _forwardedCpInviteC2CMessageKeys = <String>{};
final Set<String> _echoedCpInviteC2CMessageKeys = <String>{};
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<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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 = <String, dynamic>{...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<String, dynamic> 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<String, dynamic> data) {
final content = _cpInviteContentMap(data);
return _firstCpInviteValue([
@ -1512,6 +1674,158 @@ class RealTimeMessagingManager extends ChangeNotifier {
);
}
Future<void> 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 = <String, dynamic>{
"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<void> _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<void> _sendCpBuildCustomMessage({
required String receiverId,
required Map<String, dynamic> data,
@ -1572,6 +1886,228 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
}
void _persistBackendCpInviteToC2CConversationIfNeeded({
required V2TimMessage? sourceMessage,
required Map<String, dynamic> 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<V2TimMessage?> _insertCpBuildC2CMessageToLocalStorage({
required String peerId,
required String senderId,
required Map<String, dynamic> 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<String, dynamic> 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,

View File

@ -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<String> _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<String?> _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<dynamic> values) {
for (final value in values) {
final text = value?.toString().trim() ?? "";
if (text.isNotEmpty && text.toLowerCase() != "null") {
return text;
}
}
return "";
}
Future<SocialChatUserProfile> _withCurrentVipLevel(
SocialChatUserProfile profile,
String userId,

View File

@ -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<String, List<SocialChatGiftRes>> giftByTab = {};
Map<String, List<SocialChatGiftRes>> 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<SCFirstRechargeRewardHomeRes?> 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<void> fetchCountryList() async {
if (_isFetchingCountryList) {

View File

@ -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<SelecteProductConfig> _products = [];
final List<SelecteProductConfig> _products = [];
late BuildContext context;
Map<String, SCProductConfigRes> productMap = {};
@ -125,11 +126,11 @@ class IOSPaymentProcessor extends ChangeNotifier {
// iOS购买
Future<void> 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<void> _verifyIOSPayment(PurchaseDetails purchase) async {
final profileManager = Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
);
final firstRechargeManager = Provider.of<SCAppGeneralManager>(
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<SocialChatUserProfileManager>(
context,
listen: false,
).fetchUserProfileData();
Provider.of<SocialChatUserProfileManager>(
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<void> _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错误处理

View File

@ -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<SCAppGeneralManager>(
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<SCAppGeneralManager>(
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<void> _ensurePurchaseConsumed(PurchaseDetails purchase) async {
try {

View File

@ -1162,6 +1162,10 @@ class CPRes {
_levelText = _asString(
source['levelText'] ??
source['levelName'] ??
source['level'] ??
source['cpLevel'] ??
source['cabinLevel'] ??
source['lv'] ??
source['name'] ??
source['title'],
);

View File

@ -4,6 +4,7 @@ class SCCpRightsConfigRes {
this.cpUserId,
this.relationshipStatus,
this.intimacyValue,
this.dismissConsumeGold,
List<SCCpRightsLevelRes>? 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<SCCpRightsLevelRes> levels;
static String? _asString(dynamic value) {

View File

@ -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 = <String, dynamic>{...(content.isNotEmpty ? content : root)};
final source = <String, dynamic>{
...root,
if (content.isNotEmpty) ...content,
};
final relationType =
_firstNonBlank(source, _relationTypeKeys) ??
_firstNonBlank(root, _relationTypeKeys) ??

View File

@ -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,
});
///

View File

@ -610,6 +610,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
String? roomId,
SCGiveAwayGiftRoomAcceptsCmd? accepts,
String? dynamicContentId,
String? relationType,
}) async {
Map<String, dynamic> 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<String, dynamic> 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 = <String, dynamic>{
"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<Object?>(
"/gift/backpack/give-away",
data: params,

View File

@ -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<String> 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;
}

View File

@ -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 <dynamic>[],
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<dynamic> 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<SCCpRightsLevelRes> 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 <String, dynamic>{};
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<String> 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() ?? '');
}

View File

@ -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<String> _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);
}

View File

@ -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<void> showFirstRechargeDialog(BuildContext context) {
return FirstRechargeDialog.showFromRemote(context);
static Future<void> showFirstRechargeDialog(
BuildContext context, {
bool forceRefresh = false,
}) {
return FirstRechargeDialog.showFromRemote(
context,
forceRefresh: forceRefresh,
);
}
static void showDynamicCommentOptDialog(

View File

@ -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<void> showFromRemote(BuildContext context) async {
static Future<void> showFromRemote(
BuildContext context, {
bool forceRefresh = false,
}) async {
try {
SCLoadingManager.show(context: context);
final home = await SCConfigRepositoryImp().firstRechargeRewardHome();
final home = await Provider.of<SCAppGeneralManager>(
context,
listen: false,
).refreshFirstRechargeRewardHome(forceRefresh: forceRefresh);
if (!context.mounted) {
return;
}
if (!home.shouldShowRewardDialog) {
if (home == null || !home.shouldShowRewardDialog) {
SmartDialog.dismiss(tag: dialogTag);
return;
}

View File

@ -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<ConversationItem> {
!_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<ConversationItem> {
}*/
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<RtmProvider>(
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<ConversationItem> {
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 =
<String?>[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<String, dynamic>? _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<String, dynamic> _cpInviteContentMap(Map<String, dynamic> 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 <String, dynamic>{};
}
List<String> _compactCpInviteValues(Iterable<String?> values) {
return values
.map((value) => value?.trim() ?? "")
.where((value) => value.isNotEmpty)
.toList();
}
String? _firstCpInviteValue(Iterable<String?> values) {
for (final value in values) {
final text = value?.trim() ?? "";
if (text.isNotEmpty) {
return text;
}
}
return null;
}
}

View File

@ -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<RoomUserInfoCard> {
}
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<RoomUserInfoCard> {
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<RoomUserInfoCard> {
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<RoomUserInfoCard> {
Future<void> _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<RoomUserInfoCard> {
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) {

View File

@ -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': {

View File

@ -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');
});
});
}

View File

@ -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);
});

View File

@ -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 编译运行验证。