修复cp问题

This commit is contained in:
roxy 2026-05-22 17:35:49 +08:00
parent 79e78cd897
commit 2999de23d1
49 changed files with 3587 additions and 1110 deletions

View File

@ -1868,14 +1868,7 @@ class _MessageItem extends StatelessWidget {
SCSystemInvitMessageRes? _cpInviteFromMessageData(dynamic data) {
try {
final content = data is Map ? data["content"] : null;
if (content is String && content.trim().isNotEmpty) {
final decoded = jsonDecode(content);
return SCSystemInvitMessageRes.fromJson(decoded);
}
if (content is Map) {
return SCSystemInvitMessageRes.fromJson(content);
}
return SCSystemInvitMessageRes.fromMessageData(data);
} catch (error) {
_cpMessageLog('parse invite failed error=$error');
}

View File

@ -1843,14 +1843,7 @@ class _MessageItem extends StatelessWidget {
SCSystemInvitMessageRes? _cpInviteFromMessageData(dynamic data) {
try {
final content = data is Map ? data["content"] : null;
if (content is String && content.trim().isNotEmpty) {
final decoded = jsonDecode(content);
return SCSystemInvitMessageRes.fromJson(decoded);
}
if (content is Map) {
return SCSystemInvitMessageRes.fromJson(content);
}
return SCSystemInvitMessageRes.fromMessageData(data);
} catch (_) {}
return null;
}

View File

@ -18,10 +18,9 @@ import 'package:yumi/app/config/business_logic_strategy.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/api.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_system_invit_message_res.dart';
import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart';
import 'package:yumi/main.dart';
import 'package:provider/provider.dart';
@ -84,6 +83,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
TabController? _tabController;
List<String> _tabTypes = <String>[];
bool _didApplyInitialTab = false;
bool _showFirstRechargeGiftEntry = false;
/// 访
BusinessLogicStrategy get _strategy => SCGlobalConfig.businessLogicStrategy;
@ -240,6 +240,7 @@ 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) {
@ -275,6 +276,35 @@ 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) {
@ -287,9 +317,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
bool _isCpGift(SocialChatGiftRes? gift) => scIsCpGift(gift);
String _cpRelationTypeForGift(SocialChatGiftRes gift) =>
scCpRelationTypeForGift(gift);
bool get _isCpRecipientSingleMode =>
_isCurrentCpGiftTab || _isCpGift(checkedGift);
@ -703,10 +730,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
final currentTabType = giftTabs[tabController.index].type;
final isCpTab = currentTabType == "CP";
final showFirstRechargeEntry =
!SCGlobalConfig.isReview &&
!isCpTab &&
(AccountStorage().getCurrentUser()?.userProfile?.firstRecharge ??
false);
!SCGlobalConfig.isReview && !isCpTab && _showFirstRechargeGiftEntry;
_ensureCurrentTabSelection(ref, giftTabs[tabController.index].type);
final isCpRecipientSingleMode = isCpTab || _isCpGift(checkedGift);
return SafeArea(
@ -1577,7 +1601,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
}
await _refreshGiftBackpack();
_refreshCpStateAfterGift(request, profileManager);
_sendCpInviteMessageAfterGift(request);
return;
}
@ -1640,7 +1663,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
}
profileManager?.updateBalance(result);
_refreshCpStateAfterGift(request, profileManager);
_sendCpInviteMessageAfterGift(request);
} catch (e) {
final errorMessage =
request.isBackpackGiftRequest && _isBackpackQuantityError(e)
@ -1683,90 +1705,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
unawaited(profileManager?.refreshLoadedCpProfiles());
}
void _sendCpInviteMessageAfterGift(_GiftSendRequest request) {
if (!_isCpGift(request.gift)) {
return;
}
if (request.acceptUserIds.length != 1 || request.acceptUsers.length != 1) {
_giftFxLog(
'skip relation invite C2C reason=invalid_accept_count '
'acceptUserIds=${request.acceptUserIds.join(",")}',
);
return;
}
unawaited(_sendCpInviteMessageAfterGiftAsync(request));
}
Future<void> _sendCpInviteMessageAfterGiftAsync(
_GiftSendRequest request,
) async {
final relationType = _cpRelationTypeForGift(request.gift);
final receiverProfile = request.acceptUsers.first.user;
final receiverId =
(receiverProfile?.id ?? request.acceptUserIds.first).trim();
final currentProfile = AccountStorage().getCurrentUser()?.userProfile;
if (receiverId.isEmpty || currentProfile == null) {
_giftFxLog(
'skip relation invite C2C reason=missing_user '
'receiverId=$receiverId relationType=$relationType '
'hasCurrent=${currentProfile != null}',
);
return;
}
final providerContext = navigatorKey.currentState?.context ?? context;
final rtmProvider = Provider.of<RtmProvider>(
providerContext,
listen: false,
);
try {
final applyId = await SCAccountRepository().cpRelationshipApplyId(
receiverId,
relationType: relationType,
);
if ((applyId ?? "").trim().isEmpty) {
_giftFxLog(
'skip relation invite C2C reason=no_apply_id '
'receiverId=$receiverId relationType=$relationType',
);
return;
}
final expireAtSeconds =
DateTime.now().millisecondsSinceEpoch ~/ 1000 + 86400;
final giftCover =
(request.gift.giftPhoto ?? "").trim().isNotEmpty
? request.gift.giftPhoto
: request.gift.giftSourceUrl;
final invite = SCSystemInvitMessageRes(
account: currentProfile.account ?? currentProfile.id,
actualAccount: currentProfile.id,
userAvatar: currentProfile.userAvatar ?? "",
userHeaddress: currentProfile.getHeaddress()?.sourceUrl,
userHeaddressCover: currentProfile.getHeaddress()?.cover,
userNickname: currentProfile.userNickname ?? "",
relationType: relationType,
dismissEndTime: expireAtSeconds,
giftCover: giftCover,
giftCount: request.quantity,
);
_giftFxLog(
'send relation invite C2C applyId=$applyId '
'receiverId=$receiverId relationType=$relationType '
'giftId=${request.gift.id}',
);
await rtmProvider.sendCpInviteBuildMessage(
applyId: applyId!,
invite: invite,
receiverId: receiverId,
receiverProfile: receiverProfile,
);
} catch (error) {
_giftFxLog(
'send relation invite C2C failed '
'receiverId=$receiverId relationType=$relationType error=$error',
);
}
}
Future<List<MicRes>> _giveBackpackGiftToRecipients(
SCChatRoomRepository repository,
_GiftSendRequest request,

View File

@ -15,7 +15,9 @@ import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/tools/sc_dialog_utils.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
@ -50,6 +52,7 @@ class _SCIndexPageState extends State<SCIndexPage>
Locale? _lastLocale;
bool _hasShownEntryDialogs = false;
bool _hasShownRegisterRewardDialog = false;
bool _hasRequestedFirstRechargeDialog = false;
bool _isShowingDailySignInDialog = false;
bool _showRegisterRewardAfterDailySignIn = false;
bool _isOpeningFirstRegisterRoomGame = false;
@ -438,14 +441,27 @@ class _SCIndexPageState extends State<SCIndexPage>
return;
}
await _showDailySignInDialogIfNeeded();
if (!mounted) {
return;
}
await _showEntryPopupIfNeeded();
if (!mounted) {
return;
}
await _showFirstRechargeDialogIfNeeded();
if (!mounted) {
return;
}
await _openFirstRegisterRoomGameIfNeeded();
}
Future<void> _showDailySignInDialogIfNeeded() async {
final dialogData = await _loadDailySignInDialogData();
if (!mounted || dialogData == null) {
await _openFirstRegisterRoomGameIfNeeded();
return;
}
_isShowingDailySignInDialog = true;
@ -461,7 +477,6 @@ class _SCIndexPageState extends State<SCIndexPage>
if (!mounted) {
return;
}
await _openFirstRegisterRoomGameIfNeeded();
}
Future<void> _showEntryPopupIfNeeded() async {
@ -474,6 +489,18 @@ class _SCIndexPageState extends State<SCIndexPage>
);
}
Future<void> _showFirstRechargeDialogIfNeeded() async {
if (_hasRequestedFirstRechargeDialog ||
SCGlobalConfig.isReview ||
AccountStorage().getToken().isEmpty ||
!mounted ||
!(ModalRoute.of(context)?.isCurrent ?? false)) {
return;
}
_hasRequestedFirstRechargeDialog = true;
await SCDialogUtils.showFirstRechargeDialog(context);
}
Future<void> _waitForRegisterRewardSocketIfNeeded() async {
if (!DataPersistence.getAwaitRegisterRewardSocket() ||
DataPersistence.getPendingRegisterRewardDialog()) {

View File

@ -79,6 +79,8 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
int _shownRoomStartupFailureToken = 0;
RtcProvider? _rtcProvider;
bool _roomProviderRebuildScheduled = false;
bool _roomRouteVisibleScheduled = false;
bool _roomVisualEffectsEnsureScheduled = false;
ModalRoute<dynamic>? _routeObserverRoute;
SCRoomTopLayerGuardLease? _coveredRouteGuardLease;
bool _roomRouteVisible = false;
@ -87,14 +89,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
void initState() {
super.initState();
_tabController = TabController(length: _chatTabCount, vsync: this);
Provider.of<RtcProvider>(
context,
listen: false,
).setVoiceRoomRouteVisible(true);
SCRoomTopLayerGuard().activeListenable.addListener(
_handleRoomTopLayerGuardChanged,
);
_enableRoomVisualEffects();
_tabController.addListener(_handleTabChange);
_subscription = eventBus.on<SCGiveRoomLuckPageDisposeEvent>().listen((
event,
@ -131,12 +128,12 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_routeObserverRoute = route;
modalRouteObserver.subscribe(this, route);
if (route.isCurrent) {
_handleRoomRouteVisible();
_scheduleRoomRouteVisible();
} else {
_handleRoomRouteCovered();
}
}
_ensureRoomVisualEffectsEnabled();
_scheduleEnsureRoomVisualEffectsEnabled();
}
@override
@ -184,6 +181,21 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_setRoomRouteVisible(true);
}
void _scheduleRoomRouteVisible() {
if (_roomRouteVisibleScheduled) {
return;
}
_roomRouteVisibleScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_roomRouteVisibleScheduled = false;
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) {
return;
}
_handleRoomRouteVisible();
_ensureRoomVisualEffectsEnabled();
});
}
void _handleRoomRouteCovered() {
_setRoomRouteVisible(false);
_acquireCoveredRouteGuard();
@ -260,6 +272,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_enableRoomVisualEffects();
}
void _scheduleEnsureRoomVisualEffectsEnabled() {
if (_roomVisualEffectsEnsureScheduled) {
return;
}
_roomVisualEffectsEnsureScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_roomVisualEffectsEnsureScheduled = false;
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) {
return;
}
_ensureRoomVisualEffectsEnabled();
});
}
void _clearTransientRoomVisualEffects({required String reason}) {
if (!mounted) {
return;

View File

@ -19,6 +19,7 @@ import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/shared/tools/sc_lk_event_bus.dart';
import 'package:yumi/shared/tools/sc_pick_utils.dart';
@ -1476,7 +1477,9 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
}
String _profileRelationDays(CPRes relation) {
return _firstNonBlankProfileValue([relation.days, "0"]);
return scCpRelationDisplayDays(
_firstNonBlankProfileValue([relation.days, "0"]),
);
}
String _profileRelationLevelDisplayText(String rawText, CPRes relation) {

View File

@ -308,6 +308,18 @@ class _PersistedVoiceRoomCleanupRequest {
final bool clearMarkerOnly;
}
class _RoomMicRelationProfileCache {
const _RoomMicRelationProfileCache({
required this.profile,
required this.loadedAt,
});
final SocialChatUserProfile profile;
final DateTime loadedAt;
bool isExpired(DateTime now, Duration ttl) => now.difference(loadedAt) > ttl;
}
class RealTimeCommunicationManager extends ChangeNotifier {
static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog';
static const String _roomRocketPagLaunchDialogTag =
@ -327,6 +339,12 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Duration(milliseconds: 1800),
Duration(milliseconds: 3200),
];
static const List<String> _roomMicRelationTypes = [
'CP',
'BROTHER',
'SISTERS',
];
static const Duration _roomMicRelationCacheTtl = Duration(minutes: 5);
static const int _roomPasswordNotTrueErrorCode = 9005;
static const Duration _micListPollingInterval = Duration(seconds: 2);
@ -415,6 +433,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Map<num, MicRes> roomWheatMap = {};
final Map<num, int> _seatEmojiEventVersions = {};
int _emojiPlaybackEventVersion = 0;
final Map<String, _RoomMicRelationProfileCache> _roomMicRelationCache =
<String, _RoomMicRelationProfileCache>{};
final Set<String> _roomMicRelationLoadingUserIds = <String>{};
final Set<String> _roomMusicPublishingUserIds = <String>{};
final Map<String, Timer> _roomMusicPublishingClearTimers = <String, Timer>{};
@ -1428,13 +1449,266 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}) {
final changed = !_sameMicMaps(roomWheatMap, nextMap);
roomWheatMap = nextMap;
final relationCacheChanged = _mergeCachedRoomMicRelationProfiles();
_scheduleRoomMicRelationHydration();
_syncSelfMicRuntimeState();
final roomMusicChanged = _pruneRoomMusicPublishingUsersToMicSeats();
if (changed || notifyIfUnchanged || roomMusicChanged) {
if (changed ||
notifyIfUnchanged ||
roomMusicChanged ||
relationCacheChanged) {
notifyListeners();
}
}
bool _mergeCachedRoomMicRelationProfiles() {
final now = DateTime.now();
_roomMicRelationCache.removeWhere(
(_, cache) => cache.isExpired(now, _roomMicRelationCacheTtl),
);
var changed = false;
final mergedMap = roomWheatMap.map((index, mic) {
final user = mic.user;
final userId = _roomMicRelationUserId(user);
if (user == null || userId.isEmpty) {
return MapEntry(index, mic);
}
final cache = _roomMicRelationCache[userId];
if (cache == null || cache.isExpired(now, _roomMicRelationCacheTtl)) {
return MapEntry(index, mic);
}
final mergedUser = _mergeRoomMicRelationProfile(user, cache.profile);
if (identical(mergedUser, user)) {
return MapEntry(index, mic);
}
changed = true;
return MapEntry(index, mic.copyWith(user: mergedUser));
});
if (changed) {
roomWheatMap = mergedMap;
}
return changed;
}
void _scheduleRoomMicRelationHydration() {
final roomId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (roomId.isEmpty || roomWheatMap.isEmpty) {
return;
}
final now = DateTime.now();
final queuedUserIds = <String>{};
for (final mic in roomWheatMap.values) {
final user = mic.user;
final userId = _roomMicRelationUserId(user);
if (user == null || userId.isEmpty || !queuedUserIds.add(userId)) {
continue;
}
final cache = _roomMicRelationCache[userId];
if (cache == null && _hasCompleteRoomMicRelationLists(user)) {
continue;
}
if (cache != null && !cache.isExpired(now, _roomMicRelationCacheTtl)) {
continue;
}
if (!_roomMicRelationLoadingUserIds.add(userId)) {
continue;
}
unawaited(_hydrateRoomMicRelationProfile(roomId, userId, user));
}
}
Future<void> _hydrateRoomMicRelationProfile(
String roomId,
String userId,
SocialChatUserProfile profile,
) async {
try {
final hydratedProfile = await _loadRoomMicRelationProfile(
profile,
userId,
);
_roomMicRelationCache[userId] = _RoomMicRelationProfileCache(
profile: hydratedProfile,
loadedAt: DateTime.now(),
);
_applyHydratedRoomMicRelationProfile(roomId, userId, hydratedProfile);
} finally {
_roomMicRelationLoadingUserIds.remove(userId);
}
}
Future<SocialChatUserProfile> _loadRoomMicRelationProfile(
SocialChatUserProfile profile,
String userId,
) async {
final cpPairs = <CPRes>[];
final closeFriendPairs = <CPRes>[];
await Future.wait(
_roomMicRelationTypes.map((relationType) async {
final cabin = await _safeLoadRoomMicRelationCabin(userId, relationType);
final pair = cabin?.cpPairUserProfileCO;
if (!_hasRoomMicRelationPair(pair)) {
return;
}
final normalizedType = scNormalizeCpRelationType(
pair?.relationType ?? relationType,
);
final enrichedPair = pair!.copyWith(
relationType: normalizedType,
cpValue: pair.cpValue ?? cabin?.cpVal?.toDouble(),
levelText:
pair.levelText ?? _roomMicRelationLevelTextFromCabin(cabin),
);
if (normalizedType == "CP") {
cpPairs.add(enrichedPair);
} else {
closeFriendPairs.add(enrichedPair);
}
}),
);
return profile.copyWith(
cpList: cpPairs,
closeFriendList: closeFriendPairs,
isCpRelation: cpPairs.isNotEmpty,
);
}
Future<SCCpCabinRes?> _safeLoadRoomMicRelationCabin(
String userId,
String relationType,
) async {
try {
return await SCAccountRepository().cpCabin(
userId,
relationType: relationType,
);
} catch (error) {
if (kDebugMode) {
debugPrint(
"[CP][MicRelation] cabin failed userId=$userId "
"relationType=$relationType error=$error",
);
}
return null;
}
}
String? _roomMicRelationLevelTextFromCabin(SCCpCabinRes? cabin) {
final config = cabin?.currentCpCabinConfigCO;
if (config is! Map) {
return null;
}
final level = _firstRoomMicRelationConfigText([
config["level"],
config["cpLevel"],
config["cabinLevel"],
config["lv"],
]);
if (level.isEmpty) {
return null;
}
return "Lv.$level";
}
String _firstRoomMicRelationConfigText(Iterable<dynamic> values) {
for (final value in values) {
final text = value?.toString().trim() ?? "";
if (text.isNotEmpty) {
return text;
}
}
return "";
}
void _applyHydratedRoomMicRelationProfile(
String roomId,
String userId,
SocialChatUserProfile hydratedProfile,
) {
final currentRoomId =
(currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (currentRoomId != roomId) {
return;
}
var changed = false;
final mergedMap = roomWheatMap.map((index, mic) {
final user = mic.user;
if (user == null || _roomMicRelationUserId(user) != userId) {
return MapEntry(index, mic);
}
final mergedUser = _mergeRoomMicRelationProfile(user, hydratedProfile);
if (identical(mergedUser, user)) {
return MapEntry(index, mic);
}
changed = true;
return MapEntry(index, mic.copyWith(user: mergedUser));
});
if (!changed) {
return;
}
roomWheatMap = mergedMap;
notifyListeners();
}
SocialChatUserProfile _mergeRoomMicRelationProfile(
SocialChatUserProfile user,
SocialChatUserProfile relationProfile,
) {
final nextCpList = relationProfile.cpList ?? const <CPRes>[];
final nextCloseFriendList =
relationProfile.closeFriendList ?? const <CPRes>[];
if (_sameRoomMicRelationLists(user.cpList, nextCpList) &&
_sameRoomMicRelationLists(user.closeFriendList, nextCloseFriendList)) {
return user;
}
return user.copyWith(
cpList: nextCpList,
closeFriendList: nextCloseFriendList,
isCpRelation: nextCpList.isNotEmpty,
);
}
bool _hasCompleteRoomMicRelationLists(SocialChatUserProfile profile) {
return profile.cpList != null && profile.closeFriendList != null;
}
bool _hasRoomMicRelationPair(CPRes? pair) {
return (pair?.cpUserId?.trim().isNotEmpty ?? false) ||
(pair?.cpAccount?.trim().isNotEmpty ?? false) ||
(pair?.meUserId?.trim().isNotEmpty ?? false) ||
(pair?.meAccount?.trim().isNotEmpty ?? false);
}
bool _sameRoomMicRelationLists(List<CPRes>? left, List<CPRes>? right) {
final leftList = left ?? const <CPRes>[];
final rightList = right ?? const <CPRes>[];
if (leftList.length != rightList.length) {
return false;
}
for (var index = 0; index < leftList.length; index += 1) {
if (_roomMicRelationPairKey(leftList[index]) !=
_roomMicRelationPairKey(rightList[index])) {
return false;
}
}
return true;
}
String _roomMicRelationPairKey(CPRes pair) {
return [
pair.meUserId?.trim() ?? "",
pair.meAccount?.trim() ?? "",
pair.cpUserId?.trim() ?? "",
pair.cpAccount?.trim() ?? "",
pair.relationType?.trim() ?? "",
pair.status?.trim() ?? "",
].join("|");
}
String _roomMicRelationUserId(SocialChatUserProfile? user) {
return user?.id?.trim() ?? "";
}
bool _pruneRoomMusicPublishingUsersToMicSeats() {
if (_roomMusicPublishingUserIds.isEmpty) {
return false;
@ -1519,6 +1793,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
await _roomRtcEngineAdapter?.leaveRoom();
} catch (error) {}
if (clearMicSeats) {
_roomMicRelationCache.clear();
_roomMicRelationLoadingUserIds.clear();
roomWheatMap.clear();
notifyListeners();
}

View File

@ -14,6 +14,7 @@ import 'package:yumi/shared/tools/sc_message_utils.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/tools/sc_cp_relation_broadcast_assets.dart';
import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
@ -293,6 +294,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
Timer? _roomRocketRewardPopupRetryTimer;
Timer? _roomRocketRoomReadyRewardRetryTimer;
final Set<String> _shownCpInviteMessageKeys = <String>{};
final Set<String> _forwardedCpInviteC2CMessageKeys = <String>{};
int get currentLuckGiftBurstPlaybackToken => _luckGiftPushPlaybackToken;
String? roomRedPacketBroadcastGroupId;
String? roomRedPacketBroadcastRegionCode;
@ -886,6 +888,13 @@ class RealTimeMessagingManager extends ChangeNotifier {
fallback: currentProfile.id,
);
final relationType = scNormalizeCpRelationType(invite.relationType);
_forwardCpInviteC2CMessageIfNeeded(
sourceMessage: message,
data: data,
applyId: applyId,
invite: invite,
isSender: isSender,
);
if (isSender) {
final receiver = _cpInviteReceiverFromMessageData(data);
_cpInviteLog(
@ -968,14 +977,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
SCSystemInvitMessageRes? _cpInviteFromMessageData(Map<String, dynamic> data) {
try {
final content = data["content"];
if (content is String && content.trim().isNotEmpty) {
final decoded = jsonDecode(content);
return SCSystemInvitMessageRes.fromJson(decoded);
}
if (content is Map) {
return SCSystemInvitMessageRes.fromJson(content);
}
return SCSystemInvitMessageRes.fromMessageData(data);
} catch (error) {
_cpInviteLog('parse CP_BUILD content failed error=$error data=$data');
}
@ -1351,6 +1353,110 @@ class RealTimeMessagingManager extends ChangeNotifier {
);
}
void _forwardCpInviteC2CMessageIfNeeded({
required V2TimMessage? sourceMessage,
required Map<String, dynamic> data,
required String applyId,
required SCSystemInvitMessageRes invite,
required bool isSender,
}) {
if (!isSender || sourceMessage?.isSelf == true) {
return;
}
final normalizedApplyId = applyId.trim();
if (normalizedApplyId.isEmpty ||
!_forwardedCpInviteC2CMessageKeys.add(normalizedApplyId)) {
return;
}
final receiverId = _cpInviteReceiverIdFromMessageData(data);
if (receiverId.isEmpty) {
_forwardedCpInviteC2CMessageKeys.remove(normalizedApplyId);
_cpInviteLog(
'skip forwarding CP_BUILD C2C reason=no_receiver '
'applyId=$normalizedApplyId',
);
return;
}
_cpInviteLog(
'forward backend CP_BUILD to C2C '
'receiver=$receiverId applyId=$normalizedApplyId',
);
unawaited(
sendCpInviteBuildMessage(
applyId: normalizedApplyId,
invite: invite,
receiverId: receiverId,
receiverProfile: _cpInviteReceiverProfileFromMessageData(data),
),
);
}
String _cpInviteReceiverIdFromMessageData(Map<String, dynamic> data) {
final content = _cpInviteContentMap(data);
return _firstCpInviteValue([
data["acceptActualAccount"]?.toString(),
data["acceptUserId"]?.toString(),
data["receiverActualAccount"]?.toString(),
data["receiverId"]?.toString(),
data["receiverUserId"]?.toString(),
data["targetUserId"]?.toString(),
data["toUserId"]?.toString(),
content["acceptActualAccount"]?.toString(),
content["acceptUserId"]?.toString(),
content["receiverActualAccount"]?.toString(),
content["receiverId"]?.toString(),
content["receiverUserId"]?.toString(),
content["targetUserId"]?.toString(),
content["toUserId"]?.toString(),
]) ??
"";
}
SocialChatUserProfile? _cpInviteReceiverProfileFromMessageData(
Map<String, dynamic> data,
) {
final content = _cpInviteContentMap(data);
final id = _cpInviteReceiverIdFromMessageData(data);
final account = _firstCpInviteValue([
data["acceptAccount"]?.toString(),
data["receiverAccount"]?.toString(),
content["acceptAccount"]?.toString(),
content["receiverAccount"]?.toString(),
]);
final nickname = _firstCpInviteValue([
data["acceptNickname"]?.toString(),
data["acceptUserNickname"]?.toString(),
data["receiverNickname"]?.toString(),
data["receiverUserNickname"]?.toString(),
content["acceptNickname"]?.toString(),
content["acceptUserNickname"]?.toString(),
content["receiverNickname"]?.toString(),
content["receiverUserNickname"]?.toString(),
]);
final avatar = _firstCpInviteValue([
data["acceptUserAvatar"]?.toString(),
data["acceptAvatar"]?.toString(),
data["receiverUserAvatar"]?.toString(),
data["receiverAvatar"]?.toString(),
content["acceptUserAvatar"]?.toString(),
content["acceptAvatar"]?.toString(),
content["receiverUserAvatar"]?.toString(),
content["receiverAvatar"]?.toString(),
]);
if (id.isEmpty &&
(account ?? "").isEmpty &&
(nickname ?? "").isEmpty &&
(avatar ?? "").isEmpty) {
return null;
}
return SocialChatUserProfile(
id: id.isNotEmpty ? id : null,
account: account,
userNickname: nickname,
userAvatar: avatar,
);
}
Future<void> sendCpInviteBuildMessage({
required String applyId,
required SCSystemInvitMessageRes invite,
@ -2270,6 +2376,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomAllMsgList.removeAt(roomAllMsgList.length - 1);
}
msgAllListener?.call(msg);
_enqueueCpRelationBroadcastFloatingIfNeeded(msg);
_showCpRelationFormedDialogIfNeeded(msg);
if (msg.type == SCRoomMsgType.text) {
@ -2327,6 +2434,71 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
}
void _enqueueCpRelationBroadcastFloatingIfNeeded(Msg msg) {
if (msg.type != SCRoomMsgType.cpSystemNotice) {
return;
}
final action = msg.noticeAction?.trim() ?? "";
if (action != "RELATION_ESTABLISHED" && action != "RELATION_JOINED_ROOM") {
return;
}
if (!_isLiveCpRelationBroadcastMessage(msg) ||
!_shouldShowCpRelationBroadcastFloating(msg.groupId ?? "")) {
return;
}
final relationType = scNormalizeCpRelationType(msg.role);
final isEntry = action == "RELATION_JOINED_ROOM";
final firstProfile = isEntry ? msg.user : (msg.toUser ?? msg.user);
final secondProfile = isEntry ? msg.toUser : (msg.user ?? msg.toUser);
OverlayManager().addMessage(
SCFloatingMessage(
type: scCpRelationBroadcastFloatingType,
priority: 970,
roomId: msg.groupId ?? "",
userId: _roomMsgUserId(firstProfile),
toUserId: _roomMsgUserId(secondProfile),
userAvatarUrl: firstProfile?.userAvatar ?? "",
toUserAvatarUrl: secondProfile?.userAvatar ?? "",
userName: _roomMsgUserDisplayName(firstProfile),
toUserName: _roomMsgUserDisplayName(secondProfile),
giftUrl: scCpRelationBroadcastAsset(relationType),
giftId: action,
giftTab: relationType,
displayText: msg.msg ?? "",
durationSeconds: 5,
),
);
}
bool _isLiveCpRelationBroadcastMessage(Msg msg) {
final timestamp = msg.time ?? 0;
if (timestamp <= 0) {
return true;
}
return DateTime.now().millisecondsSinceEpoch - timestamp < 60000;
}
bool _shouldShowCpRelationBroadcastFloating(String groupId) {
final normalizedGroupId = groupId.trim();
final currentContext = context;
if (normalizedGroupId.isEmpty ||
currentContext == null ||
!currentContext.mounted ||
!_isCurrentVoiceRoomGroup(normalizedGroupId)) {
return false;
}
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
currentContext,
listen: false,
);
return rtcProvider.shouldShowRoomVisualEffects &&
rtcProvider.isVoiceRoomRouteVisible;
}
String _roomMsgUserId(SocialChatUserProfile? profile) {
return _firstCpInviteValue([profile?.id, profile?.account]) ?? "";
}
void _showCpRelationFormedDialogIfNeeded(Msg msg) {
if (msg.type != SCRoomMsgType.cpSystemNotice ||
msg.noticeAction != "RELATION_ESTABLISHED") {
@ -2792,6 +2964,93 @@ class RealTimeMessagingManager extends ChangeNotifier {
OverlayManager().addMessage(_buildLuckyGiftFloatingMessage(broadCastRes));
}
void _handleGameWinBroadcast(
dynamic rawData, {
required bool isRegionBroadcast,
}) {
if (SCGlobalConfig.isReview) {
return;
}
final wrapper = _broadcastPayloadMap(rawData);
final payload = _broadcastPayloadMap(wrapper['data'] ?? rawData);
if (payload.isEmpty) {
return;
}
final winCoins =
_payloadNum(payload['currencyDiff']) ??
_payloadNum(payload['winAmount']) ??
_payloadNum(payload['awardAmount']) ??
0;
if (winCoins <= 0) {
return;
}
final roomId = _payloadRoomId(payload);
final userId = _firstNonBlank([
_payloadText(payload['account']),
_payloadText(payload['actualAccount']),
_payloadText(payload['userId']),
_payloadText(payload['sendUserId']),
_payloadText(payload['senderUserId']),
]);
final userName = _firstNonBlank([
_payloadText(payload['userNickname']),
_payloadText(payload['nickname']),
_payloadText(payload['userName']),
userId,
]);
final gameId = _firstNonBlank([
_payloadText(payload['currentGameId']),
_payloadText(payload['gameId']),
_payloadText(payload['providerGameId']),
_payloadText(payload['vendorGameId']),
_payloadText(payload['currentProviderGameId']),
_payloadText(payload['currentVendorGameId']),
]);
final gameName = _firstNonBlank([
_payloadText(payload['currentGameName']),
_payloadText(payload['gameName']),
_payloadText(payload['name']),
'game',
]);
final gameIconUrl = _firstNonBlank([
_payloadAssetText(payload['gameUrl']),
_payloadAssetText(payload['currentGameCover']),
_payloadAssetText(payload['gameCover']),
_payloadAssetText(payload['cover']),
_payloadAssetText(payload['iconUrl']),
_payloadAssetText(payload['icon']),
]);
final roundId = _firstNonBlank([
_payloadText(payload['gameRoundId']),
_payloadText(payload['game_round_id']),
_payloadText(payload['roundId']),
_payloadText(payload['roundNo']),
]);
OverlayManager().addMessage(
SCFloatingMessage(
type: 2,
userId: userId,
userAvatarUrl: _payloadAssetText(payload['userAvatar']),
userName: userName,
toUserName: gameName,
giftUrl: gameIconUrl,
giftId: roundId,
gameId: gameId,
roomId: roomId,
coins: winCoins,
multiple:
_payloadNum(payload['multiple']) ??
_payloadNum(payload['winMultiple']),
displayText: _payloadText(payload['msg']),
broadcastScope:
isRegionBroadcast ? SCFloatingMessage.broadcastScopeRegion : '',
priority: 1000,
),
);
}
void _handleRoomLuckyGiftMessage(SCBroadCastLuckGiftPush broadCastRes) {
final rewardData = broadCastRes.data;
if (rewardData == null) {
@ -4653,25 +4912,10 @@ class RealTimeMessagingManager extends ChangeNotifier {
notifyListeners();
}
} else if (type == "GAME_BAISHUN_WIN") {
if (SCGlobalConfig.isReview) {
///
return;
}
var fdata = data["data"];
var winCoins = fdata["currencyDiff"];
if (winCoins > 14999) {
///5000
SCFloatingMessage msg = SCFloatingMessage(
type: 2,
userId: fdata["account"],
userAvatarUrl: fdata["userAvatar"],
userName: fdata["userNickname"],
giftUrl: fdata["gameUrl"],
roomId: fdata["roomId"],
coins: fdata["currencyDiff"],
);
OverlayManager().addMessage(msg);
}
_handleGameWinBroadcast(
data,
isRegionBroadcast: isRegionBroadcastGroup,
);
} else if (type == "GAME_LUCKY_GIFT") {
final broadCastRes = SCBroadCastLuckGiftPush.fromJson(data);
_giftFxLog(
@ -4890,6 +5134,10 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
return;
}
if (data["type"] == "GAME_BAISHUN_WIN") {
_handleGameWinBroadcast(data, isRegionBroadcast: false);
return;
}
Msg msg = Msg.fromJson(data);
if (msg.type == SCRoomMsgType.sendGift ||

View File

@ -1,3 +1,5 @@
import 'dart:convert';
// account : ""
// countryCode : ""
// countryName : ""
@ -62,7 +64,7 @@ class SCSystemInvitMessageRes {
_actualAccount = json['actualAccount'];
_applyType = json['applyType'];
_content = json['content'];
_relationType = json['relationType'];
_relationType = _firstNonBlank(json, _relationTypeKeys);
_dismissEndTime = _asInt(json['dismissEndTime']);
_giftCover = json['giftCover'];
_giftCount = _asInt(json['giftCount']);
@ -126,8 +128,43 @@ class SCSystemInvitMessageRes {
map['giftCount'] = _giftCount;
return map;
}
static SCSystemInvitMessageRes? fromMessageData(dynamic data) {
final root = _stringKeyMap(data);
if (root.isEmpty) {
return null;
}
final content = _contentMap(root['content']);
if (content.isEmpty &&
root.containsKey('content') &&
!_hasInviteIdentity(root)) {
return null;
}
final source = <String, dynamic>{...(content.isNotEmpty ? content : root)};
final relationType =
_firstNonBlank(source, _relationTypeKeys) ??
_firstNonBlank(root, _relationTypeKeys) ??
_relationTypeFromGiftPayload(source) ??
_relationTypeFromGiftPayload(root);
if (relationType != null && relationType.isNotEmpty) {
source['relationType'] = relationType;
}
return SCSystemInvitMessageRes.fromJson(source);
}
}
const List<String> _relationTypeKeys = [
'relationType',
'relation_type',
'cpRelationType',
'cp_relation_type',
'relationshipType',
'relationship_type',
'relation',
'cpType',
'cp_type',
];
String? _firstNonBlank(dynamic json, List<String> keys) {
if (json is! Map) {
return null;
@ -140,3 +177,106 @@ String? _firstNonBlank(dynamic json, List<String> keys) {
}
return null;
}
bool _hasInviteIdentity(Map<String, dynamic> source) {
return _firstNonBlank(source, const [
'account',
'actualAccount',
'userAvatar',
'userNickname',
'nickname',
]) !=
null;
}
Map<String, dynamic> _stringKeyMap(dynamic value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map((key, item) => MapEntry(key.toString(), item));
}
return const <String, dynamic>{};
}
Map<String, dynamic> _contentMap(dynamic content) {
if (content is String && content.trim().isNotEmpty) {
try {
final decoded = jsonDecode(content);
return _stringKeyMap(decoded);
} catch (_) {
return const <String, dynamic>{};
}
}
return _stringKeyMap(content);
}
String? _relationTypeFromGiftPayload(Map<String, dynamic> source) {
for (final key in const ['gift', 'giftInfo', 'giftConfig', 'giftVO']) {
final gift = _stringKeyMap(source[key]);
if (gift.isEmpty) {
continue;
}
final explicit = _firstNonBlank(gift, _relationTypeKeys);
if (explicit != null && explicit.isNotEmpty) {
return explicit;
}
final inferred = _inferRelationTypeFromGift(gift);
if (inferred != null) {
return inferred;
}
}
return null;
}
String? _inferRelationTypeFromGift(Map<String, dynamic> gift) {
for (final key in const [
'giftCode',
'giftName',
'name',
'special',
'standardId',
]) {
final inferred = _inferRelationTypeFromText(gift[key]?.toString());
if (inferred != null) {
return inferred;
}
}
return null;
}
String? _inferRelationTypeFromText(String? value) {
final upper = value?.trim().toUpperCase();
if (upper == null || upper.isEmpty) {
return null;
}
if (_hasRelationToken(upper, const ['SIS', 'SISTER', 'SISTERS'])) {
return 'SISTERS';
}
if (_hasRelationToken(upper, const [
'BRO',
'BROS',
'BROTHER',
'BROTHERS',
'SWORN_BRO',
'SWORN_BROS',
'SWORN_BROTHER',
'SWORN_BROTHERS',
])) {
return 'BROTHER';
}
if (_hasRelationToken(upper, const ['CP', 'COUPLE'])) {
return 'CP';
}
return null;
}
bool _hasRelationToken(String upperText, List<String> tokens) {
for (final token in tokens) {
final escaped = RegExp.escape(token);
if (RegExp('(^|[^A-Z0-9])$escaped([^A-Z0-9]|\$)').hasMatch(upperText)) {
return true;
}
}
return false;
}

View File

@ -12,6 +12,8 @@ class SCFloatingMessage {
String? fallbackUrl; //
String? giftId; // id
String? giftTab; //
String? gameId; // id
String? displayText; //
int? type;
int? rocketLevel;
num? coins;
@ -35,6 +37,8 @@ class SCFloatingMessage {
this.fallbackUrl = '',
this.giftId = '',
this.giftTab = '',
this.gameId = '',
this.displayText = '',
this.number = 0,
this.coins = 0,
this.durationSeconds,
@ -57,6 +61,8 @@ class SCFloatingMessage {
fallbackUrl = json['fallbackUrl'];
giftId = json['giftId'];
giftTab = json['giftTab'];
gameId = json['gameId']?.toString();
displayText = json['displayText']?.toString();
coins = json['coins'];
number = json['number'];
durationSeconds = json['durationSeconds'];
@ -82,6 +88,8 @@ class SCFloatingMessage {
map['fallbackUrl'] = fallbackUrl;
map['giftId'] = giftId;
map['giftTab'] = giftTab;
map['gameId'] = gameId;
map['displayText'] = displayText;
map['coins'] = coins;
map['number'] = number;
map['durationSeconds'] = durationSeconds;

View File

@ -17,6 +17,7 @@ import 'package:yumi/ui_kit/widgets/room/floating/floating_luck_gift_screen_widg
import 'package:yumi/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart';
import 'package:yumi/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart';
import 'package:yumi/ui_kit/widgets/room/floating/floating_vip_entry_screen_widget.dart';
import 'package:yumi/ui_kit/widgets/room/floating/floating_cp_relation_screen_widget.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
@ -30,8 +31,9 @@ class OverlayManager {
static const Duration _rocketFloatingFallbackDedupWindow = Duration(
seconds: 30,
);
static const Duration _gameWinFloatingDedupWindow = Duration(seconds: 10);
static const int _maxStackedFloatingVisibleCount = 2;
static const List<int> _stackedFloatingOrder = [5, 1, 4, 3];
static const List<int> _stackedFloatingOrder = [6, 5, 1, 4, 3];
static const double _stackedFloatingBaseTop = 70;
static const double _stackedFloatingGap = 8;
@ -190,10 +192,45 @@ class OverlayManager {
}
return 'room_red_packet|$packetId';
}
if (message.type == 2) {
final roundId = message.giftId?.trim() ?? '';
final userId = message.userId?.trim() ?? '';
if (roundId.isNotEmpty) {
return 'game_win|$roundId|$userId';
}
final roomId = message.roomId?.trim() ?? '';
final gameId = message.gameId?.trim() ?? '';
final coins = message.coins ?? 0;
if (roomId.isEmpty && userId.isEmpty && gameId.isEmpty && coins == 0) {
return null;
}
return 'game_win_fallback|$roomId|$userId|$gameId|$coins';
}
if (message.type == 6) {
final roomId = message.roomId?.trim() ?? '';
final action = message.giftId?.trim() ?? '';
final relationType = message.giftTab?.trim() ?? '';
final userId = message.userId?.trim() ?? '';
final toUserId = message.toUserId?.trim() ?? '';
if (roomId.isEmpty &&
action.isEmpty &&
relationType.isEmpty &&
userId.isEmpty &&
toUserId.isEmpty) {
return null;
}
return 'cp_relation|$roomId|$action|$relationType|$userId|$toUserId';
}
return null;
}
Duration _floatingDedupWindowFor(SCFloatingMessage message) {
if (message.type == 2) {
return _gameWinFloatingDedupWindow;
}
if (message.type == 6) {
return _gameWinFloatingDedupWindow;
}
if (message.type == 3 && (message.giftId?.trim() ?? '').isEmpty) {
return _rocketFloatingFallbackDedupWindow;
}
@ -245,13 +282,16 @@ class OverlayManager {
unawaited(
warmImageResource(
message.giftUrl ?? "",
logicalWidth: message.type == 5 ? 350 : 96,
logicalHeight: message.type == 5 ? 84 : 96,
logicalWidth: message.type == 5 || message.type == 6 ? 350 : 96,
logicalHeight: message.type == 5 || message.type == 6 ? 84 : 96,
),
);
if (message.type == 0) {
unawaited(_warmLuckyGiftBannerSvga());
}
if (message.type == 2) {
unawaited(_warmGameWinBannerSvga());
}
}
Future<void> _warmLuckyGiftBannerSvga() async {
@ -264,6 +304,16 @@ class OverlayManager {
}
}
Future<void> _warmGameWinBannerSvga() async {
try {
await SCSvgaAssetWidget.preloadAsset(
FloatingGameScreenWidget.backgroundSvgaAssetPath,
);
} catch (error) {
// Preload failure is non-fatal; the banner will retry during rendering.
}
}
void _safeScheduleNext() {
if (_isSuppressed) return;
if (_isProcessing || _messageQueue.isEmpty) return;
@ -470,6 +520,12 @@ class OverlayManager {
message: message,
onAnimationCompleted: onComplete,
);
case 6:
//CP /
return FloatingCpRelationScreenWidget(
message: message,
onAnimationCompleted: onComplete,
);
default:
onComplete();
return Container();
@ -487,6 +543,8 @@ class OverlayManager {
return 4;
case 5:
return 5;
case 6:
return 6;
default:
return null;
}
@ -528,6 +586,8 @@ class OverlayManager {
return 66;
case 5:
return 84;
case 6:
return FloatingCpRelationScreenWidget.bannerHeight;
default:
return 68;
}
@ -679,6 +739,9 @@ class OverlayManager {
if (normalizedRoomId.isNotEmpty) {
_removeMessagesByType(3, roomId: normalizedRoomId);
}
if (normalizedRoomId.isNotEmpty) {
_removeMessagesByType(2, roomId: normalizedRoomId);
}
if (normalizedRoomId.isNotEmpty) {
_removeMessagesByType(4, roomId: normalizedRoomId);
}
@ -734,9 +797,14 @@ class OverlayManager {
return message.type == 3 && message.isRegionBroadcast;
}
bool _isRegionGameWinMessage(SCFloatingMessage message) {
return message.type == 2 && message.isRegionBroadcast;
}
bool _isCrossRoomRegionFloatingMessage(SCFloatingMessage message) {
return _isRegionRedPacketMessage(message) ||
_isRegionRoomRocketMessage(message);
_isRegionRoomRocketMessage(message) ||
_isRegionGameWinMessage(message);
}
bool _shouldDeferRegionRedPacketMessage(
@ -856,15 +924,25 @@ class OverlayManager {
currentRoomId == messageRoomId;
if (isCurrentVisibleRoom) {
// / RTM IM
return message.type == 4 || message.type == 3;
return message.type == 4 || message.type == 3 || message.type == 2;
}
final isCurrentRoomMessage =
currentRoomId.isNotEmpty &&
messageRoomId.isNotEmpty &&
currentRoomId == messageRoomId;
if ((message.type == 4 || message.type == 3) && isCurrentRoomMessage) {
if ((message.type == 4 || message.type == 3 || message.type == 2) &&
isCurrentRoomMessage) {
return false;
}
if (message.type == 2) {
if (SCGlobalConfig.isFloatingBroadcastRoomOnly) {
return false;
}
if (rtcProvider.shouldShowRoomVisualEffects) {
return true;
}
return SCGlobalConfig.isFloatingBroadcastAll && _homeRootTabsVisible;
}
if ((message.type == 4 || message.type == 3) &&
rtcProvider.shouldShowRoomVisualEffects) {
return true;
@ -920,6 +998,7 @@ class OverlayManager {
if (activeMessage != null &&
(activeMessage.type == 0 ||
activeMessage.type == 1 ||
activeMessage.type == 2 ||
activeMessage.type == 3 ||
activeMessage.type == 5 ||
(includeRedPacket && activeMessage.type == 4)) &&
@ -959,6 +1038,7 @@ class OverlayManager {
final isRoomFloatingType =
type == 0 ||
type == 1 ||
type == 2 ||
type == 3 ||
type == 5 ||
(includeRedPacket && type == 4);

View File

@ -18,7 +18,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_top_four_with_reward_re
import 'package:yumi/shared/business_logic/models/res/version_manage_lates_review_res.dart';
import 'package:yumi/shared/business_logic/repositories/config_repository.dart';
import 'package:yumi/shared/data_sources/sources/remote/net/api.dart'
show BaseNetworkClient;
show BaseNetworkClient, DioException;
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
import 'package:yumi/shared/tools/sc_deviceId_utils.dart';
@ -58,23 +58,37 @@ class SCConfigRepositoryImp implements SocialChatConfigRepository {
return result;
}
///go/app/popups/entry
///app/popups/entry
@override
Future<SCEntryPopupRes> entryPopup() async {
final deviceId = await SCDeviceIdUtils.getDeviceId();
final result = await http.post<SCEntryPopupRes>(
"/go/app/popups/entry",
data: {
"scene": "APP_ENTER",
"platform": Platform.isIOS ? "ios" : "android",
"appVersion": SCGlobalConfig.version,
"deviceId": deviceId,
"language": SCGlobalConfig.lang,
},
final data = {
"scene": "APP_ENTER",
"platform": Platform.isIOS ? "ios" : "android",
"appVersion": SCGlobalConfig.version,
"deviceId": deviceId,
"language": SCGlobalConfig.lang,
};
try {
return await _entryPopupByPath("/app/popups/entry", data);
} on DioException catch (error) {
if (error.response?.statusCode != 404) {
rethrow;
}
return _entryPopupByPath("/go/app/popups/entry", data);
}
}
Future<SCEntryPopupRes> _entryPopupByPath(
String path,
Map<String, dynamic> data,
) {
return http.post<SCEntryPopupRes>(
path,
data: data,
extra: const {BaseNetworkClient.silentErrorToastKey: true},
fromJson: (json) => SCEntryPopupRes.fromJson(json),
);
return result;
}
///sys/config/enum/config

View File

@ -0,0 +1,15 @@
import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart';
const int scCpRelationBroadcastFloatingType = 6;
String scCpRelationBroadcastAsset(String? relationType) {
switch (scNormalizeCpRelationType(relationType)) {
case "BROTHER":
return "sc_images/room/cp_broadcast/brother_broadcast.pag";
case "SISTERS":
return "sc_images/room/cp_broadcast/sisters_broadcast.pag";
case "CP":
default:
return "sc_images/room/cp_broadcast/CP_broadcast.pag";
}
}

View File

@ -41,6 +41,17 @@ String scCpRelationPossessiveLabel(String? relationType) {
}
}
String scCpRelationEntryLabel(String? relationType) {
switch (scNormalizeCpRelationType(relationType)) {
case "BROTHER":
return "brother";
case "SISTERS":
return "sister";
default:
return "cp";
}
}
String scCpRelationDialogTitle(String? relationType) {
switch (scNormalizeCpRelationType(relationType)) {
case "BROTHER":
@ -52,6 +63,19 @@ String scCpRelationDialogTitle(String? relationType) {
}
}
String scCpRelationDisplayDays(String? days) {
final text = days?.trim() ?? "";
final value = num.tryParse(text.replaceAll(",", ""));
if (value == null) {
return text.isEmpty ? "1" : text;
}
final displayValue = value + 1;
if (displayValue % 1 == 0) {
return displayValue.toInt().toString();
}
return displayValue.toString();
}
String scBuildCpRelationEstablishedNotice({
required String userName1,
required String userName2,
@ -66,7 +90,14 @@ String scBuildCpRelationJoinNotice({
required String joinedName,
required String? relationType,
}) {
return "[System Notification] $ownerName's "
"${scCpRelationPossessiveLabel(relationType)}, $joinedName, "
"has joined the party.";
return "[System Notification] ${scBuildCpRelationEntryBroadcastText(ownerName: ownerName, joinedName: joinedName, relationType: relationType)}.";
}
String scBuildCpRelationEntryBroadcastText({
required String ownerName,
required String joinedName,
required String? relationType,
}) {
return "$ownerName's ${scCpRelationEntryLabel(relationType)} "
"$joinedName enter the room";
}

View File

@ -0,0 +1,130 @@
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart';
class SCCpRelationPairInfo {
const SCCpRelationPairInfo({required this.relationType, this.cpLevel});
final String relationType;
final int? cpLevel;
}
String? scCpRelationTypeBetweenProfiles(
SocialChatUserProfile? left,
SocialChatUserProfile? right,
) {
return scCpRelationInfoBetweenProfiles(left, right)?.relationType;
}
SCCpRelationPairInfo? scCpRelationInfoBetweenProfiles(
SocialChatUserProfile? left,
SocialChatUserProfile? right,
) {
if (!_hasRelationIdentity(left) || !_hasRelationIdentity(right)) {
return null;
}
SCCpRelationPairInfo? fallbackInfo;
for (final relation in _relationsForProfile(left)) {
final relationInfo = _relationInfoIfContainsBoth(relation, left!, right!);
if (relationInfo != null) {
if (relationInfo.relationType != "CP" || relationInfo.cpLevel != null) {
return relationInfo;
}
fallbackInfo ??= relationInfo;
}
}
for (final relation in _relationsForProfile(right)) {
final relationInfo = _relationInfoIfContainsBoth(relation, left!, right!);
if (relationInfo != null) {
if (relationInfo.relationType != "CP" || relationInfo.cpLevel != null) {
return relationInfo;
}
fallbackInfo ??= relationInfo;
}
}
return fallbackInfo;
}
int scCpProfileRelationVersion(SocialChatUserProfile? profile) {
if (profile == null) {
return 0;
}
return Object.hashAll(
_relationsForProfile(profile).map(
(relation) => Object.hash(
relation.meUserId ?? "",
relation.meAccount ?? "",
relation.cpUserId ?? "",
relation.cpAccount ?? "",
relation.relationType ?? "",
relation.status ?? "",
relation.levelText ?? "",
relation.cpValue ?? 0,
),
),
);
}
Iterable<CPRes> _relationsForProfile(SocialChatUserProfile? profile) sync* {
yield* profile?.cpList ?? const <CPRes>[];
yield* profile?.closeFriendList ?? const <CPRes>[];
}
SCCpRelationPairInfo? _relationInfoIfContainsBoth(
CPRes relation,
SocialChatUserProfile left,
SocialChatUserProfile right,
) {
final leftKeys = _profileKeys(left);
final rightKeys = _profileKeys(right);
if (leftKeys.isEmpty || rightKeys.isEmpty) {
return null;
}
final relationKeys = _relationKeys(relation);
if (!relationKeys.any(leftKeys.contains) ||
!relationKeys.any(rightKeys.contains)) {
return null;
}
final relationType = scNormalizeCpRelationType(relation.relationType);
return SCCpRelationPairInfo(
relationType: relationType,
cpLevel: relationType == "CP" ? _cpLevelFromRelation(relation) : null,
);
}
bool _hasRelationIdentity(SocialChatUserProfile? profile) =>
_profileKeys(profile).isNotEmpty;
Set<String> _profileKeys(SocialChatUserProfile? profile) {
return <String>{
_normalizedRelationKey(profile?.id),
_normalizedRelationKey(profile?.account),
}..remove("");
}
Set<String> _relationKeys(CPRes relation) {
return <String>{
_normalizedRelationKey(relation.meUserId),
_normalizedRelationKey(relation.meAccount),
_normalizedRelationKey(relation.cpUserId),
_normalizedRelationKey(relation.cpAccount),
}..remove("");
}
String _normalizedRelationKey(String? value) => value?.trim() ?? "";
int? _cpLevelFromRelation(CPRes relation) {
final levelText = relation.levelText?.trim() ?? "";
if (levelText.isEmpty) {
return null;
}
final levelMatch = RegExp(
r'(?:lv\.?|level|等级|级)\s*([1-5])|([1-5])\s*(?:级|level)',
caseSensitive: false,
).firstMatch(levelText);
final rawLevel = levelMatch?.group(1) ?? levelMatch?.group(2);
final level = int.tryParse(rawLevel ?? "");
if (level == null) {
return null;
}
return level.clamp(1, 5);
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
@ -68,43 +69,53 @@ class SCEntryPopupCoordinator {
'response requestId=${response.requestId} '
'queue=${response.queue} popups=${response.popups.keys.toList()}',
);
final popup = _pickDisplayablePopup(response);
if (popup == null) {
final candidates = _displayablePopups(response);
if (candidates.isEmpty) {
_log('skip reason=no-displayable-popup');
return;
}
final imageUrl = normalizeImageResourceUrl(popup.image);
final imageReady = await warmImageResource(
imageUrl,
logicalWidth: 326,
timeout: const Duration(seconds: 8),
);
if (!imageReady || !context.mounted) {
_log(
'skip reason=image-not-ready key=${popup.key} '
'image=${_clip(imageUrl)} mounted=${context.mounted}',
for (final popup in candidates) {
final imageUrl = normalizeImageResourceUrl(popup.image);
final imageReady = await warmImageResource(
imageUrl,
logicalWidth: 326,
timeout: const Duration(seconds: 8),
);
if (!context.mounted) {
_log('skip reason=context-unmounted-after-image key=${popup.key}');
return;
}
if (!imageReady) {
_log(
'skip candidate key=${popup.key} reason=image-not-ready '
'image=${_clip(imageUrl)}',
);
continue;
}
_log('show key=${popup.key} popupId=${popup.popupId}');
await SCEntryPopupDialog.show(
context,
popup: popup,
imageUrl: imageUrl,
onDisplayed: () {
unawaited(_markShown(popup));
},
onTap: () {
unawaited(_handlePopupTap(context, popup, onSelectRootTab));
},
);
return;
}
_log('show key=${popup.key} popupId=${popup.popupId}');
await SCEntryPopupDialog.show(
context,
popup: popup,
imageUrl: imageUrl,
onDisplayed: () {
unawaited(_markShown(popup));
},
onTap: () {
unawaited(_handlePopupTap(context, popup, onSelectRootTab));
},
);
_log('skip reason=no-image-ready-candidates');
} catch (error) {
_log('request failed error=$error');
} finally {
_isRunning = false;
}
}
static SCEntryPopupItem? _pickDisplayablePopup(SCEntryPopupRes response) {
static List<SCEntryPopupItem> _displayablePopups(SCEntryPopupRes response) {
final candidates = <SCEntryPopupItem>[];
final queuedKeys = <String>{};
for (final popupKey in response.queue) {
queuedKeys.add(popupKey);
@ -119,7 +130,7 @@ class SCEntryPopupCoordinator {
continue;
}
_log('candidate key=${popup.key} source=queue');
return popup;
candidates.add(popup);
}
final fallbackPopups =
@ -134,9 +145,9 @@ class SCEntryPopupCoordinator {
continue;
}
_log('candidate key=${popup.key} source=priority-fallback');
return popup;
candidates.add(popup);
}
return null;
return candidates;
}
static String? _cannotShowReason(SCEntryPopupItem popup) {
@ -447,7 +458,9 @@ class SCEntryPopupCoordinator {
final room = await SCChatRoomRepository().specific(roomId);
resolvedPreviewData = RoomEntryPreviewData.fromMyRoom(room);
}
} catch (error) {}
} catch (error) {
_log('open room preview failed roomId=$roomId error=$error');
}
if (!context.mounted) {
return false;
}
@ -521,7 +534,11 @@ class SCEntryPopupCoordinator {
return '';
}
static void _log(String message) {}
static void _log(String message) {
if (kDebugMode) {
debugPrint('[EntryPopup] $message');
}
}
static String _clip(String value, {int maxLength = 180}) {
if (value.length <= maxLength) {

View File

@ -156,36 +156,59 @@ class FirstRechargeDialogData {
SCFirstRechargeRewardHomeRes home,
) {
final levels = home.availableLevelConfigs.take(2).toList();
final packages =
levels.map((level) {
return FirstRechargePackage(
priceText: _formatPriceText(level),
discountText: level.discountText,
totalValueText: level.totalValueText,
rechargeAmountCents: level.rechargeAmountCents,
productPackage: level.productPackage,
rewards:
level.rewardItems.map((item) {
return FirstRechargeReward(
title: item.displayTitle,
style:
item.isCoinReward
? FirstRechargeRewardStyle.coin
: FirstRechargeRewardStyle.prop,
badgeText: item.displayBadgeText,
cover: item.cover,
);
}).toList(),
);
}).toList();
final packages = <FirstRechargePackage>[];
var selectedPackageIndex = 0;
for (final level in levels) {
final rewards =
level.rewardItems.where(_isDisplayableRewardItem).map((item) {
return FirstRechargeReward(
title: _rewardTitle(item),
style:
item.isCoinReward
? FirstRechargeRewardStyle.coin
: FirstRechargeRewardStyle.prop,
badgeText: item.displayBadgeText,
cover: item.cover,
);
}).toList();
if (rewards.isEmpty) {
continue;
}
if (level.current) {
selectedPackageIndex = packages.length;
}
packages.add(
FirstRechargePackage(
priceText: _formatPriceText(level),
discountText: level.discountText,
totalValueText: level.totalValueText,
rechargeAmountCents: level.rechargeAmountCents,
productPackage: level.productPackage,
rewards: rewards,
),
);
}
final currentIndex = levels.indexWhere((level) => level.current);
return FirstRechargeDialogData(
packages: packages,
initialPackageIndex: currentIndex >= 0 ? currentIndex : 0,
initialPackageIndex: selectedPackageIndex,
);
}
static bool _isDisplayableRewardItem(SCFirstRechargeRewardItem item) {
if (item.isCoinReward) {
return item.quantity > 0 || item.displayTitle.trim().isNotEmpty;
}
return item.name.trim().isNotEmpty || item.cover.trim().isNotEmpty;
}
static String _rewardTitle(SCFirstRechargeRewardItem item) {
if (item.isCoinReward) {
return item.displayTitle;
}
return item.name.trim();
}
static String _formatPriceText(SCFirstRechargeLevelConfig level) {
final amount = level.rechargeAmount.trim();
if (amount.isNotEmpty) {
@ -476,14 +499,9 @@ class _FirstRechargeDialogState extends State<FirstRechargeDialog> {
Offset(227, 316),
];
return List<Widget>.generate(positions.length, (index) {
final reward =
index < rewards.length
? rewards[index]
: const FirstRechargeReward(
title: '',
style: FirstRechargeRewardStyle.prop,
);
final visibleRewards = rewards.take(positions.length).toList();
return List<Widget>.generate(visibleRewards.length, (index) {
final reward = visibleRewards[index];
final position = positions[index];
return Positioned(
left: position.dx * scale,

View File

@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/shared/tools/sc_cp_relation_broadcast_assets.dart';
import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart';
class FloatingCpRelationScreenWidget extends StatefulWidget {
const FloatingCpRelationScreenWidget({
super.key,
required this.message,
required this.onAnimationCompleted,
});
static const double bannerWidth = 350;
static const double bannerHeight = 84;
final SCFloatingMessage message;
final VoidCallback onAnimationCompleted;
@override
State<FloatingCpRelationScreenWidget> createState() =>
_FloatingCpRelationScreenWidgetState();
}
class _FloatingCpRelationScreenWidgetState
extends State<FloatingCpRelationScreenWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<Offset> _offsetAnimation;
bool _completed = false;
@override
void initState() {
super.initState();
final seconds = widget.message.durationSeconds?.toInt() ?? 5;
_controller = AnimationController(
duration: Duration(seconds: seconds <= 0 ? 5 : seconds),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(1, 0),
end: const Offset(-1, 0),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_complete();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _complete() {
if (_completed) {
return;
}
_completed = true;
widget.onAnimationCompleted();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _offsetAnimation,
child: SizedBox(
width: FloatingCpRelationScreenWidget.bannerWidth.w,
height: FloatingCpRelationScreenWidget.bannerHeight.w,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: _buildPagBackground()),
Positioned.fill(child: _buildText()),
],
),
),
);
}
Widget _buildPagBackground() {
final asset =
(widget.message.giftUrl?.trim().isNotEmpty ?? false)
? widget.message.giftUrl!.trim()
: scCpRelationBroadcastAsset(widget.message.giftTab);
return RoomRocketPagPreview(
resource: asset,
width: FloatingCpRelationScreenWidget.bannerWidth.w,
height: FloatingCpRelationScreenWidget.bannerHeight.w,
fit: BoxFit.cover,
clipBehavior: Clip.none,
defaultBuilder: (_) => _buildFallbackBackground(),
);
}
Widget _buildFallbackBackground() {
final colors = _relationColors(widget.message.giftTab);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(42.w),
gradient: LinearGradient(colors: colors),
),
);
}
Widget _buildText() {
return Padding(
padding: EdgeInsetsDirectional.only(start: 82.w, end: 54.w),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: ClipRect(
child: Text(
_displayText(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: TextStyle(
fontSize: _textFontSize().sp,
height: 1.18,
color: Colors.white,
fontWeight: FontWeight.w800,
decoration: TextDecoration.none,
shadows: [
Shadow(
color: const Color(0xFF5B0F59).withValues(alpha: 0.86),
blurRadius: 4.w,
offset: Offset(0, 1.w),
),
Shadow(
color: Colors.black.withValues(alpha: 0.34),
blurRadius: 8.w,
offset: Offset(0, 1.5.w),
),
],
),
),
),
),
);
}
String _displayText() {
if (widget.message.giftId == "RELATION_JOINED_ROOM") {
return scBuildCpRelationEntryBroadcastText(
ownerName: _shortName(widget.message.toUserName),
joinedName: _shortName(widget.message.userName),
relationType: widget.message.giftTab,
);
}
final explicitText = widget.message.displayText?.trim() ?? "";
if (explicitText.isNotEmpty) {
return _stripSystemNotificationPrefix(explicitText);
}
return _stripSystemNotificationPrefix(
scBuildCpRelationEstablishedNotice(
userName1: _shortName(widget.message.userName),
userName2: _shortName(widget.message.toUserName),
relationType: widget.message.giftTab,
),
);
}
String _shortName(String? value) {
final text = value?.trim() ?? "";
if (text.length <= 14) {
return text;
}
return "${text.substring(0, 13)}...";
}
double _textFontSize() {
final length = _displayText().characters.length;
if (length > 58) {
return 9.5;
}
if (length > 42) {
return 10.5;
}
return 11.5;
}
String _stripSystemNotificationPrefix(String text) {
return text
.replaceFirst(
RegExp(
r'^\s*[\[\【]\s*sys(?:tem|term)\s+notification\s*[\]\】]\s*',
caseSensitive: false,
),
'',
)
.trim();
}
List<Color> _relationColors(String? relationType) {
switch (scNormalizeCpRelationType(relationType)) {
case "BROTHER":
return const [Color(0xFF0B5DA8), Color(0xFF63B8FF)];
case "SISTERS":
return const [Color(0xFF8E2BA8), Color(0xFFFF8BF3)];
case "CP":
default:
return const [Color(0xFFFF59B7), Color(0xFFFF9BD8)];
}
}
}

View File

@ -1,412 +1,484 @@
import 'package:flutter/material.dart';
import 'package:flutter_debouncer/flutter_debouncer.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/main.dart';
import 'package:marquee/marquee.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
class FloatingGameScreenWidget extends StatefulWidget {
final SCFloatingMessage message;
final VoidCallback onAnimationCompleted; //
const FloatingGameScreenWidget({
Key? key,
required this.message,
required this.onAnimationCompleted,
}) : super(key: key);
@override
_FloatingGameScreenWidgetState createState() =>
_FloatingGameScreenWidgetState();
}
class _FloatingGameScreenWidgetState extends State<FloatingGameScreenWidget>
with TickerProviderStateMixin {
Debouncer debouncer = Debouncer();
late AnimationController _controller;
late Animation<Offset> _firstAnimation; //
late Animation<Offset> _secondAnimation; //
late AnimationController _swipeController; //
late Animation<Offset> _swipeAnimation; //
bool _firstPhaseCompleted = false;
bool _isSwipeAnimating = false; //
///
int level = 1;
@override
void initState() {
super.initState();
if ((widget.message.coins ?? 0) > 14999 &&
(widget.message.coins ?? 0) < 50000) {
level = 1;
} else if ((widget.message.coins ?? 0) > 49999 &&
(widget.message.coins ?? 0) < 100000) {
level = 2;
} else if ((widget.message.coins ?? 0) > 99999 &&
(widget.message.coins ?? 0) < 300000) {
level = 3;
} else if ((widget.message.coins ?? 0) > 299999 &&
(widget.message.coins ?? 0) < 500000) {
level = 4;
} else if ((widget.message.coins ?? 0) > 499999) {
level = 5;
}
//
_controller = AnimationController(
duration: const Duration(milliseconds: 1500), // 1.5
vsync: this,
);
//
_swipeController = AnimationController(
duration: const Duration(milliseconds: 550), // 500ms
vsync: this,
);
//
_swipeController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onAnimationCompleted();
}
});
//
_firstAnimation = Tween<Offset>(
begin: const Offset(1.5, 0.0), //
end: Offset.zero, //
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));
//
_secondAnimation = Tween<Offset>(
begin: Offset.zero, //
end: const Offset(-1.5, 0.0), //
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));
//
_swipeAnimation = Tween<Offset>(
begin: Offset.zero, //
end: const Offset(-1.5, 0.0), //
).animate(CurvedAnimation(
parent: _swipeController,
curve: Curves.easeIn,
));
//
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_startAnimationSequence();
}
});
}
void _startAnimationSequence() async {
//
_controller.reset();
await _controller.forward();
// 23
await Future.delayed(const Duration(seconds: 3));
if (!mounted) return;
//
setState(() {
_firstPhaseCompleted = true;
});
_controller.reset();
await _controller.forward();
if (mounted && !_isSwipeAnimating) {
widget.onAnimationCompleted();
}
}
//
void _handleSwipeLeft() {
if (_isSwipeAnimating) return;
setState(() {
_isSwipeAnimating = true;
});
//
_controller.stop();
//
_swipeController.reset();
_swipeController.forward();
}
String _coinsVaFormat(num coins) {
int value = coins.toInt();
if (value > 99999) {
return "${(value / 1000).toStringAsFixed(0)}k";
}
return "$value";
}
@override
void dispose() {
_controller.dispose();
_swipeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _buildGameAnimation();
}
Widget _buildGameAnimation() {
// 使
if (_isSwipeAnimating) {
return SlideTransition(
position: _swipeAnimation,
child: _buildContent(),
);
}
// 使
return SlideTransition(
position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation,
child: _buildContent(),
);
}
Widget _buildContent() {
return GestureDetector(
onTap: () {
debouncer.debounce(
duration: Duration(milliseconds: 350),
onDebounce: () {
if (widget.message.roomId != null &&
widget.message.roomId!.isNotEmpty) {
SCRoomUtils.goRoom(
widget.message.roomId!,
navigatorKey.currentState!.context,
fromFloting: true,
);
}
},
);
},
onHorizontalDragEnd: (details) {
//
final textDirection = Directionality.of(context);
//
double effectiveVelocity(double velocity) {
// RTL模式下
return textDirection == TextDirection.rtl
? -velocity
: velocity;
}
double velocity = effectiveVelocity(details.primaryVelocity ?? 0);
if (velocity < 0) {
///
_handleSwipeLeft();
}
},
child: Align(
alignment: Alignment.topCenter,
child: Directionality(
textDirection: TextDirection.ltr,
child: Container(
alignment: Alignment.topCenter,
height: 120.w,
width: 325.w,
margin: EdgeInsets.only(top: 10.w),
child: Stack(
alignment:
level == 5
? AlignmentDirectional.topCenter
: AlignmentDirectional.center,
children: [
if (widget.message.userAvatarUrl?.isNotEmpty ?? false)
_buildHead(),
Image.asset("sc_images/index/sc_icon_gamebroad_lv${level}_bg.webp"),
Row(
mainAxisSize: MainAxisSize.min, //
children: [
SizedBox(width: level < 2 ? 82.w : 95.w),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height:
level < 2
? 22.w
: (level == 5 ? 31.w : 25.w),
),
Row(
children: [
Container(
constraints: BoxConstraints(
maxWidth: 55.w,
maxHeight: 18.w,
),
child:
(widget.message.userName?.length ??
0) >
8
? Marquee(
text:
widget.message.userName ??
"",
style: TextStyle(
fontSize: 12.sp,
color: Colors.orange,
fontWeight: FontWeight.bold,
decoration:
TextDecoration.none,
),
scrollAxis: Axis.horizontal,
crossAxisAlignment:
CrossAxisAlignment.start,
blankSpace: 20.0,
velocity: 40.0,
pauseAfterRound: Duration(
seconds: 1,
),
accelerationDuration: Duration(
seconds: 1,
),
accelerationCurve:
Curves.easeOut,
decelerationDuration: Duration(
milliseconds: 500,
),
decelerationCurve:
Curves.easeOut,
)
: Text(
widget.message.userName ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
color: Colors.orange,
fontWeight: FontWeight.bold,
decoration:
TextDecoration.none,
),
),
),
text(
"ID:${widget.message.userId} ",
textColor: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12.sp,
),
],
),
Transform.translate(
offset: Offset(
0,
level < 2 ? -5 : (level == 5 ? -5 : 0),
),
child: Row(
children: [
text(
SCAppLocalizations.of(context)!.get,
textColor: Colors.orange,
fontWeight: FontWeight.w900,
fontSize: 16.sp,
fontStyle: FontStyle.italic
),
buildNumForGame(
_coinsVaFormat(
widget.message.coins ?? 0,
),
size:
(widget.message.coins ?? 0) > 99999
? 26.w
: 20.w,
),
],
),
),
],
),
],
),
),
Container(
margin: EdgeInsets.only(top: level < 2 ? 10.w : 21.w),
child: netImage(
url: widget.message.giftUrl ?? "",
width: 28.w,
borderRadius: BorderRadius.all(Radius.circular(3.0)),
),
),
SizedBox(width: level < 2 ? 63.w : 65.w),
],
),
],
),
),
),
),
);
}
Widget _buildHead() {
if (level == 1) {
return PositionedDirectional(
start: 43.w,
child: Container(
margin: EdgeInsets.only(top: 12.w),
child: head(url: widget.message.userAvatarUrl ?? "", width: 40.w),
),
);
} else if (level == 2) {
return PositionedDirectional(
start: 10.w,
child: Container(
margin: EdgeInsets.only(top: 25.w),
child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w),
),
);
} else if (level == 3) {
return PositionedDirectional(
start: 10.w,
child: Container(
margin: EdgeInsets.only(top: 25.w),
child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w),
),
);
} else if (level == 4) {
return PositionedDirectional(
start: 10.w,
child: Container(
margin: EdgeInsets.only(top: 25.w),
child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w),
),
);
} else if (level == 5) {
return PositionedDirectional(
start: 22.w,
top: 20.w,
child: Container(
child: head(url: widget.message.userAvatarUrl ?? "", width: 48.w),
),
);
}
return Container();
}
}
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_debouncer/flutter_debouncer.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svga/flutter_svga.dart';
import 'package:provider/provider.dart';
import 'package:yumi/main.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class FloatingGameScreenWidget extends StatefulWidget {
static const String backgroundSvgaAssetPath =
"sc_images/room/anim/game_win.svga";
const FloatingGameScreenWidget({
super.key,
required this.message,
required this.onAnimationCompleted,
});
final SCFloatingMessage message;
final VoidCallback onAnimationCompleted;
@override
State<FloatingGameScreenWidget> createState() =>
_FloatingGameScreenWidgetState();
}
class _FloatingGameScreenWidgetState extends State<FloatingGameScreenWidget>
with TickerProviderStateMixin {
static const String _avatarDynamicKey = "avatar";
static const String _gameIconDynamicKey = "gameicon";
static const String _textLine1DynamicKey = "text1";
static const String _textLine2DynamicKey = "text2";
final Debouncer _debouncer = Debouncer();
late final AnimationController _controller;
late final Animation<Offset> _offsetAnimation;
late final AnimationController _swipeController;
late final Animation<Offset> _swipeAnimation;
bool _isSwipeAnimating = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 5),
vsync: this,
);
_swipeController = AnimationController(
duration: const Duration(milliseconds: 550),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: const Offset(-1.0, 0.0),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_swipeAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(-1.5, 0.0),
).animate(CurvedAnimation(parent: _swipeController, curve: Curves.easeIn));
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed && !_isSwipeAnimating) {
widget.onAnimationCompleted();
}
});
_swipeController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onAnimationCompleted();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
_swipeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
onHorizontalDragEnd: _handleHorizontalDragEnd,
child: SlideTransition(
position: _isSwipeAnimating ? _swipeAnimation : _offsetAnimation,
child: SizedBox(
width: 350.w,
height: 128.w,
child: SCSvgaAssetWidget(
assetPath: FloatingGameScreenWidget.backgroundSvgaAssetPath,
width: 350.w,
height: 128.w,
fit: BoxFit.fill,
dynamicIdentity: [
widget.message.userAvatarUrl ?? "",
widget.message.userName ?? "",
widget.message.userId ?? "",
widget.message.giftUrl ?? "",
widget.message.gameId ?? "",
widget.message.displayText ?? "",
widget.message.coins ?? 0,
widget.message.multiple ?? 0,
].join("|"),
movieConfigurer: _configureGameWinSvga,
fallback: _buildFallbackBanner(),
),
),
),
);
}
void _handleTap() {
_debouncer.debounce(
duration: const Duration(milliseconds: 350),
onDebounce: () {
final currentContext = navigatorKey.currentState?.context;
if (currentContext == null || !currentContext.mounted) {
return;
}
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
currentContext,
listen: false,
);
final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
final messageRoomId = (widget.message.roomId ?? "").trim();
final isSameVisibleRoom =
rtcProvider.shouldShowRoomVisualEffects &&
rtcProvider.isVoiceRoomRouteVisible &&
currentRoomId.isNotEmpty &&
(messageRoomId.isEmpty || currentRoomId == messageRoomId);
if (isSameVisibleRoom) {
showRoomGameListSheet(
currentContext,
initialGameId: _resolvedGameId(),
);
return;
}
if (messageRoomId.isNotEmpty) {
SCRoomUtils.goRoom(messageRoomId, currentContext, fromFloting: true);
}
},
);
}
void _handleHorizontalDragEnd(DragEndDetails details) {
final textDirection = Directionality.of(context);
final velocity =
textDirection == TextDirection.rtl
? -(details.primaryVelocity ?? 0)
: (details.primaryVelocity ?? 0);
if (velocity < 0) {
_handleSwipeLeft();
}
}
void _handleSwipeLeft() {
if (_isSwipeAnimating) {
return;
}
setState(() {
_isSwipeAnimating = true;
});
_controller.stop();
_swipeController.reset();
_swipeController.forward();
}
Future<void> _configureGameWinSvga(MovieEntity movieEntity) async {
final avatarUrl = (widget.message.userAvatarUrl ?? "").trim();
if (avatarUrl.isNotEmpty) {
try {
final avatarImage = await _loadUiImage(
avatarUrl,
logicalWidth: 96,
logicalHeight: 96,
);
if (avatarImage != null) {
movieEntity.dynamicItem.setImage(avatarImage, _avatarDynamicKey);
}
} catch (_) {}
}
final gameIconUrl = (widget.message.giftUrl ?? "").trim();
if (gameIconUrl.isNotEmpty) {
try {
final gameIcon = await _loadUiImage(
gameIconUrl,
logicalWidth: 96,
logicalHeight: 96,
);
if (gameIcon != null) {
movieEntity.dynamicItem.setImage(gameIcon, _gameIconDynamicKey);
}
} catch (_) {}
}
final lines = _resolveTextLines();
try {
movieEntity.dynamicItem.setImage(
await _buildTextLineImage(lines.$1, highlighted: false),
_textLine1DynamicKey,
);
movieEntity.dynamicItem.setImage(
await _buildTextLineImage(lines.$2, highlighted: true),
_textLine2DynamicKey,
);
} catch (_) {}
}
(String, String) _resolveTextLines() {
final rawText = (widget.message.displayText ?? "").trim();
if (rawText.isNotEmpty) {
final normalized = rawText.replaceAll(RegExp(r"\s+"), " ");
final winIndex = normalized.indexOf(" win ");
if (winIndex > 0 && winIndex < normalized.length - 5) {
return (
normalized.substring(0, winIndex).trim(),
normalized.substring(winIndex + 1).trim(),
);
}
return _splitText(normalized);
}
final userName = _firstNonBlank([
widget.message.userName,
widget.message.userId,
]);
final gameName = _firstNonBlank([widget.message.toUserName, "game"]);
final multiple = _formatMultiple(widget.message.multiple);
final coins = _formatCoins(widget.message.coins);
final line1 =
userName.isEmpty
? "play $gameName get x $multiple"
: "$userName play $gameName get x $multiple";
return (line1, "win $coins coins");
}
(String, String) _splitText(String text) {
const maxLineLength = 32;
if (text.length <= maxLineLength) {
return (text, "");
}
final midpoint = (text.length / 2).round();
var splitIndex = text.lastIndexOf(" ", midpoint);
if (splitIndex < 10) {
splitIndex = text.indexOf(" ", midpoint);
}
if (splitIndex < 10 || splitIndex >= text.length - 1) {
splitIndex = midpoint;
}
return (
text.substring(0, splitIndex).trim(),
text.substring(splitIndex).trim(),
);
}
Future<ui.Image> _buildTextLineImage(
String value, {
required bool highlighted,
}) async {
const double logicalWidth = 318;
const double logicalHeight = 24;
const double pixelRatio = 3;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.scale(pixelRatio, pixelRatio);
canvas.clipRect(const Rect.fromLTWH(0, 0, logicalWidth, logicalHeight));
final painter = TextPainter(
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: "...",
text: TextSpan(
text: value,
style: TextStyle(
fontSize: highlighted ? 18 : 16,
height: 1,
color:
highlighted ? const Color(0xFFFFE95A) : const Color(0xFFFFFFFF),
fontWeight: highlighted ? FontWeight.w900 : FontWeight.w800,
decoration: TextDecoration.none,
),
),
)..layout(maxWidth: logicalWidth - 8);
painter.paint(canvas, Offset(4, (logicalHeight - painter.height) / 2));
final picture = recorder.endRecording();
return picture.toImage(
(logicalWidth * pixelRatio).round(),
(logicalHeight * pixelRatio).round(),
);
}
Future<ui.Image?> _loadUiImage(
String resource, {
double? logicalWidth,
double? logicalHeight,
}) async {
final target = resource.trim();
if (target.isEmpty) {
return null;
}
final provider = buildCachedImageProvider(
target,
logicalWidth: logicalWidth,
logicalHeight: logicalHeight,
);
final stream = provider.resolve(ImageConfiguration.empty);
final completer = Completer<ui.Image?>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(imageInfo, synchronousCall) {
if (!completer.isCompleted) {
completer.complete(imageInfo.image);
}
stream.removeListener(listener);
},
onError: (Object error, StackTrace? stackTrace) {
if (!completer.isCompleted) {
completer.complete(null);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future.timeout(
const Duration(seconds: 2),
onTimeout: () {
stream.removeListener(listener);
return null;
},
);
}
Widget _buildFallbackBanner() {
return Container(
width: 350.w,
height: 128.w,
padding: EdgeInsets.symmetric(horizontal: 26.w),
decoration: BoxDecoration(
color: const Color(0xDD291436),
borderRadius: BorderRadius.circular(8.w),
border: Border.all(color: const Color(0xFFFFD35A), width: 1.w),
),
child: Row(
children: [
_buildFallbackImage(
widget.message.userAvatarUrl,
size: 46.w,
circular: true,
),
SizedBox(width: 10.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_resolveTextLines().$1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w800,
decoration: TextDecoration.none,
),
),
SizedBox(height: 3.w),
Text(
_resolveTextLines().$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFFFFE95A),
fontSize: 14.sp,
fontWeight: FontWeight.w900,
decoration: TextDecoration.none,
),
),
],
),
),
SizedBox(width: 8.w),
_buildFallbackImage(widget.message.giftUrl, size: 40.w),
],
),
);
}
Widget _buildFallbackImage(
String? resource, {
required double size,
bool circular = false,
}) {
final target = (resource ?? "").trim();
final fallbackAsset =
circular
? "sc_images/general/sc_icon_avar_defalt.png"
: "sc_images/room/sc_icon_botton_game.png";
final child =
target.isEmpty
? Image.asset(
fallbackAsset,
width: size,
height: size,
fit: BoxFit.cover,
)
: Image(
image: buildCachedImageProvider(
target,
logicalWidth: size,
logicalHeight: size,
),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => Image.asset(
fallbackAsset,
width: size,
height: size,
fit: BoxFit.cover,
),
);
if (!circular) {
return ClipRRect(borderRadius: BorderRadius.circular(6.w), child: child);
}
return ClipOval(child: child);
}
String? _resolvedGameId() {
final gameId = (widget.message.gameId ?? "").trim();
return gameId.isEmpty ? null : gameId;
}
String _formatCoins(num? value) {
final coins = value ?? 0;
if (coins >= 100000) {
return "${(coins / 1000).toStringAsFixed(0)}k";
}
if (coins == coins.roundToDouble()) {
return coins.toInt().toString();
}
return coins.toStringAsFixed(2);
}
String _formatMultiple(num? value) {
final multiple = value ?? 0;
if (multiple == multiple.roundToDouble()) {
return multiple.toInt().toString();
}
return multiple.toStringAsFixed(2);
}
String _firstNonBlank(Iterable<String?> values) {
for (final value in values) {
final text = value?.trim() ?? "";
if (text.isNotEmpty) {
return text;
}
}
return "";
}
}

View File

@ -368,6 +368,14 @@ class _RocketRuleHtmlText extends StatelessWidget {
}
}
String debugRoomRocketRuleHtmlPlainText(String html) {
final spans = _RocketRuleHtmlParser(
baseStyle: const TextStyle(),
scale: 1,
).parse(html);
return TextSpan(children: spans).toPlainText();
}
class _RocketRuleHtmlParser {
_RocketRuleHtmlParser({required this.baseStyle, required this.scale});
@ -446,9 +454,12 @@ class _RocketRuleHtmlParser {
case 'li':
_appendBlockBreak();
final list = _listStack.isEmpty ? null : _listStack.last;
final prefix =
list == null || !list.ordered ? '- ' : '${list.next()}. ';
_appendRaw(prefix, _currentStyle);
if (list == null) {
_appendRaw('- ', _currentStyle);
} else {
final prefix = list.ordered ? '${list.next()}. ' : '- ';
list.beginItem(prefix);
}
break;
case 'p':
case 'div':
@ -477,7 +488,10 @@ class _RocketRuleHtmlParser {
_appendBlockBreak();
return;
}
if (_isBlockTag(tagName) || tagName == 'li') {
if (tagName == 'li') {
_endCurrentListItem();
_appendBlockBreak();
} else if (_isBlockTag(tagName)) {
_appendBlockBreak();
}
for (var index = _styleStack.length - 1; index >= 0; index -= 1) {
@ -588,7 +602,18 @@ class _RocketRuleHtmlParser {
if (normalized.isEmpty) {
return;
}
_appendRaw(normalized, _currentStyle);
if (normalized.trim().isEmpty) {
if (_segments.isEmpty ||
_segments.last.text.endsWith('\n') ||
_pendingListFrame != null) {
return;
}
_appendRaw(' ', _currentStyle);
return;
}
final style = _currentStyle;
final textAfterMarker = _flushPendingListMarker(normalized, style);
_appendRaw(textAfterMarker, style);
}
void _appendRaw(String text, TextStyle style) {
@ -612,6 +637,9 @@ class _RocketRuleHtmlParser {
}
void _appendBlockBreak() {
if (_pendingListFrame != null) {
return;
}
if (_segments.isEmpty) {
return;
}
@ -622,6 +650,55 @@ class _RocketRuleHtmlParser {
_appendRaw('\n', _currentStyle);
}
String _flushPendingListMarker(String text, TextStyle style) {
final list = _pendingListFrame;
final marker = list?.pendingMarker;
if (list == null || marker == null) {
return text;
}
final leading = RegExp(r'^\s*').firstMatch(text)?.group(0) ?? '';
final visibleText = text.substring(leading.length);
if (visibleText.isEmpty) {
return text;
}
list.suppressGeneratedMarkers ??= _hasManualListMarker(visibleText);
list.pendingMarker = null;
if (list.suppressGeneratedMarkers! ||
_startsWithGeneratedMarker(visibleText, marker)) {
return text;
}
_appendRaw(marker, style);
return visibleText;
}
_HtmlListFrame? get _pendingListFrame {
for (var index = _listStack.length - 1; index >= 0; index -= 1) {
if (_listStack[index].pendingMarker != null) {
return _listStack[index];
}
}
return null;
}
void _endCurrentListItem() {
for (var index = _listStack.length - 1; index >= 0; index -= 1) {
if (_listStack[index].isInItem) {
_listStack[index].endItem();
return;
}
}
}
bool _hasManualListMarker(String text) {
return RegExp(r'^(?:\d+|[a-zA-Z])[.)](?:\s|$)').hasMatch(text) ||
RegExp(r'^(?:[-*•‣◦])(?:\s|$)').hasMatch(text);
}
bool _startsWithGeneratedMarker(String text, String marker) {
return text.startsWith(marker) ||
text.startsWith(marker.trimRight()) && text.length == marker.length - 1;
}
void _trimOuterWhitespace() {
while (_segments.isNotEmpty) {
final trimmed = _segments.first.text.replaceFirst(RegExp(r'^\s+'), '');
@ -791,6 +868,19 @@ class _HtmlListFrame {
final bool ordered;
int _index = 1;
String? pendingMarker;
bool? suppressGeneratedMarkers;
bool isInItem = false;
void beginItem(String marker) {
isInItem = true;
pendingMarker = marker;
}
void endItem() {
isInItem = false;
pendingMarker = null;
}
int next() {
final value = _index;

View File

@ -7,12 +7,13 @@ import 'package:flutter/services.dart';
import 'package:pag/pag.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/ui_kit/components/custom_cached_image.dart';
import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart';
const Duration _pagMemoryTtl = Duration(minutes: 5);
const Size _roomRocketFallbackPagRawSize = Size(750, 1624);
const Rect _roomRocketTopAvatarRawRect = Rect.fromLTWH(291, 164, 168, 168);
const Duration _roomRocketTopAvatarResolveGracePeriod = Duration(
milliseconds: 700,
);
const String _roomRocketTopAvatarLayerName = 'avatar_tip.png';
class RoomRocketPagEffectOverlay extends StatefulWidget {
const RoomRocketPagEffectOverlay({
@ -54,7 +55,40 @@ class RoomRocketPagEffectOverlay extends StatefulWidget {
class _RoomRocketPagEffectOverlayState
extends State<RoomRocketPagEffectOverlay> {
bool _completed = false;
Size? _pagRawSize;
bool _topAvatarLocked = false;
String? _lockedTopAvatarUrl;
Timer? _topAvatarLockTimer;
ValueListenable<String?>? _observedTopAvatarListenable;
@override
void initState() {
super.initState();
_observeTopAvatarListenable(widget.topAvatarUrlListenable);
_resolveTopAvatarLock(notify: false);
}
@override
void didUpdateWidget(covariant RoomRocketPagEffectOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
final listenableChanged =
oldWidget.topAvatarUrlListenable != widget.topAvatarUrlListenable;
final fallbackAvatarChanged = oldWidget.topAvatarUrl != widget.topAvatarUrl;
final resourceChanged = oldWidget.resource.trim() != widget.resource.trim();
if (listenableChanged) {
_observeTopAvatarListenable(widget.topAvatarUrlListenable);
}
if (resourceChanged || listenableChanged || fallbackAvatarChanged) {
_resetTopAvatarLock();
_resolveTopAvatarLock();
}
}
@override
void dispose() {
_topAvatarLockTimer?.cancel();
_observedTopAvatarListenable?.removeListener(_handleTopAvatarChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
@ -67,28 +101,14 @@ class _RoomRocketPagEffectOverlayState
child: SizedBox(
width: size.width,
height: size.height,
child: Stack(
clipBehavior: Clip.none,
children: [
_buildTopAvatar(size),
Positioned.fill(
child: _RoomRocketPagView(
resource: resource,
width: size.width,
height: size.height,
loop: false,
fit: BoxFit.cover,
displayScale: 1,
clipBehavior: Clip.hardEdge,
memoryTtl: _pagMemoryTtl,
onReady: _updatePagRawSize,
onCompleted: _complete,
defaultBuilder:
(_) => const Center(child: SCRotatingDotsLoading()),
),
),
],
),
child:
_topAvatarLocked
? _buildPagViewWithAvatar(
resource: resource,
size: size,
avatarUrl: _lockedTopAvatarUrl,
)
: const Center(child: SCRotatingDotsLoading()),
),
),
),
@ -103,69 +123,77 @@ class _RoomRocketPagEffectOverlayState
widget.onCompleted?.call();
}
void _updatePagRawSize(Size rawSize) {
if (_pagRawSize == rawSize) {
void _observeTopAvatarListenable(ValueListenable<String?>? listenable) {
if (_observedTopAvatarListenable == listenable) {
return;
}
setState(() => _pagRawSize = rawSize);
_observedTopAvatarListenable?.removeListener(_handleTopAvatarChanged);
_observedTopAvatarListenable = listenable;
listenable?.addListener(_handleTopAvatarChanged);
}
Widget _buildTopAvatar(Size canvasSize) {
final listenable = widget.topAvatarUrlListenable;
if (listenable == null) {
return _RoomRocketPagTopAvatar(
avatarUrl: widget.topAvatarUrl,
canvasSize: canvasSize,
pagRawSize: _pagRawSize,
);
void _handleTopAvatarChanged() {
if (_topAvatarLocked) {
return;
}
final avatarUrl = _currentTopAvatarUrl();
if (avatarUrl != null) {
_lockTopAvatar(avatarUrl);
}
return ValueListenableBuilder<String?>(
valueListenable: listenable,
builder:
(context, avatarUrl, _) => _RoomRocketPagTopAvatar(
avatarUrl: _firstNonBlankPagAvatar([
avatarUrl,
widget.topAvatarUrl,
]),
canvasSize: canvasSize,
pagRawSize: _pagRawSize,
),
);
}
}
class _RoomRocketPagTopAvatar extends StatelessWidget {
const _RoomRocketPagTopAvatar({
required this.avatarUrl,
required this.canvasSize,
required this.pagRawSize,
});
final String? avatarUrl;
final Size canvasSize;
final Size? pagRawSize;
@override
Widget build(BuildContext context) {
final url = avatarUrl?.trim() ?? '';
if (url.isEmpty) {
return const SizedBox.shrink();
void _resolveTopAvatarLock({bool notify = true}) {
final avatarUrl = _currentTopAvatarUrl();
if (avatarUrl != null || widget.topAvatarUrlListenable == null) {
_lockTopAvatar(avatarUrl, notify: notify);
return;
}
final rect = _mapRoomRocketPagRawRectToCoverCanvas(
rawRect: _roomRocketTopAvatarRawRect,
rawSize: pagRawSize ?? _roomRocketFallbackPagRawSize,
canvasSize: canvasSize,
);
return Positioned.fromRect(
rect: rect,
child: ClipOval(
child: CustomCachedImage(
imageUrl: url,
fit: BoxFit.cover,
placeholder: const SizedBox.shrink(),
errorWidget: const SizedBox.shrink(),
),
),
_topAvatarLockTimer?.cancel();
_topAvatarLockTimer = Timer(_roomRocketTopAvatarResolveGracePeriod, () {
_lockTopAvatar(_currentTopAvatarUrl());
});
}
void _lockTopAvatar(String? avatarUrl, {bool notify = true}) {
_topAvatarLockTimer?.cancel();
final normalized = _firstNonBlankPagAvatar([avatarUrl]);
_topAvatarLocked = true;
_lockedTopAvatarUrl = normalized;
if (notify && mounted) {
setState(() {});
}
}
void _resetTopAvatarLock() {
_topAvatarLockTimer?.cancel();
_topAvatarLocked = false;
_lockedTopAvatarUrl = null;
}
String? _currentTopAvatarUrl() {
return _firstNonBlankPagAvatar([
widget.topAvatarUrlListenable?.value,
widget.topAvatarUrl,
]);
}
Widget _buildPagViewWithAvatar({
required String resource,
required Size size,
required String? avatarUrl,
}) {
return _RoomRocketPagView(
resource: resource,
width: size.width,
height: size.height,
loop: false,
fit: BoxFit.cover,
displayScale: 1,
clipBehavior: Clip.hardEdge,
memoryTtl: _pagMemoryTtl,
imageReplacementUrlsByName: _topAvatarReplacementUrls(avatarUrl),
onCompleted: _complete,
defaultBuilder: (_) => const Center(child: SCRotatingDotsLoading()),
);
}
}
@ -236,7 +264,7 @@ class _RoomRocketPagView extends StatefulWidget {
required this.clipBehavior,
required this.memoryTtl,
this.underlayBuilder,
this.onReady,
this.imageReplacementUrlsByName = const <String, String>{},
this.onCompleted,
this.defaultBuilder,
});
@ -250,7 +278,7 @@ class _RoomRocketPagView extends StatefulWidget {
final Clip clipBehavior;
final Duration memoryTtl;
final WidgetBuilder? underlayBuilder;
final ValueChanged<Size>? onReady;
final Map<String, String> imageReplacementUrlsByName;
final VoidCallback? onCompleted;
final WidgetBuilder? defaultBuilder;
@ -264,6 +292,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
final int _debugId = _nextDebugId++;
late GlobalKey<PAGViewState> _pagKey;
Uint8List? _pagBytes;
String? _assetName;
Map<String, Uint8List>? _imageReplacementsByName;
late String _imageReplacementSignature;
bool _initialized = false;
bool _loadFailed = false;
bool _releasedFromMemory = false;
@ -274,6 +305,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
void initState() {
super.initState();
_pagKey = GlobalKey<PAGViewState>();
_imageReplacementSignature = _imageReplacementUrlsSignature(
widget.imageReplacementUrlsByName,
);
_debug('mount');
_prepareResource();
}
@ -281,9 +315,16 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
@override
void didUpdateWidget(covariant _RoomRocketPagView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.resource.trim() != widget.resource.trim()) {
final replacementSignature = _imageReplacementUrlsSignature(
widget.imageReplacementUrlsByName,
);
if (oldWidget.resource.trim() != widget.resource.trim() ||
replacementSignature != _imageReplacementSignature) {
_pagKey = GlobalKey<PAGViewState>();
_pagBytes = null;
_assetName = null;
_imageReplacementsByName = null;
_imageReplacementSignature = replacementSignature;
_initialized = false;
_loadFailed = false;
_releasedFromMemory = false;
@ -327,23 +368,53 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
return _withUnderlay(underlay, defaultChild);
}
final bytes = _pagBytes;
if (bytes == null) {
final assetName = _assetName;
final imageReplacementsReady =
!_hasImageReplacementUrls(widget.imageReplacementUrlsByName) ||
_imageReplacementsByName != null;
if ((bytes == null && assetName == null) || !imageReplacementsReady) {
return _withUnderlay(underlay, defaultChild);
}
final pagView = PAGView.bytes(
bytes,
key: _pagKey,
width: null,
height: null,
autoPlay: false,
repeatCount: repeatCount,
onInit: _handleInit,
onAnimationStart: _handleAnimationStart,
onAnimationEnd: _handleAnimationEnd,
onAnimationCancel: _handleAnimationCancel,
onAnimationRepeat: _handleAnimationRepeat,
defaultBuilder: (_) => const SizedBox(width: 1, height: 1),
);
final imageReplacementsByName =
_imageReplacementsByName ?? const <String, Uint8List>{};
final useNativeAssetView =
assetName != null && Platform.isIOS && widget.loop;
final pagView =
assetName != null
? PAGView.asset(
assetName,
key: _pagKey,
width: useNativeAssetView ? widget.width : null,
height: useNativeAssetView ? widget.height : null,
autoPlay: useNativeAssetView,
usePlatformView: useNativeAssetView,
repeatCount: repeatCount,
onInit: useNativeAssetView ? null : _handleInit,
onAnimationStart:
useNativeAssetView ? null : _handleAnimationStart,
onAnimationEnd: useNativeAssetView ? null : _handleAnimationEnd,
onAnimationCancel:
useNativeAssetView ? null : _handleAnimationCancel,
onAnimationRepeat:
useNativeAssetView ? null : _handleAnimationRepeat,
defaultBuilder: (_) => const SizedBox(width: 1, height: 1),
imageReplacementsByName: imageReplacementsByName,
)
: PAGView.bytes(
bytes,
key: _pagKey,
width: null,
height: null,
autoPlay: false,
repeatCount: repeatCount,
onInit: _handleInit,
onAnimationStart: _handleAnimationStart,
onAnimationEnd: _handleAnimationEnd,
onAnimationCancel: _handleAnimationCancel,
onAnimationRepeat: _handleAnimationRepeat,
defaultBuilder: (_) => const SizedBox(width: 1, height: 1),
imageReplacementsByName: imageReplacementsByName,
);
final visibleUnderlay = _initialized ? null : underlay;
final content = Stack(
fit: StackFit.expand,
@ -351,7 +422,10 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
widget.clipBehavior == Clip.none ? Clip.none : Clip.hardEdge,
children: [
if (visibleUnderlay != null) visibleUnderlay,
_scaledPagView(pagView),
if (useNativeAssetView)
Positioned.fill(child: pagView)
else
_scaledPagView(pagView),
if (!_initialized) Positioned.fill(child: defaultChild),
],
);
@ -379,25 +453,68 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
final path = widget.resource.trim();
_releasedFromMemory = false;
_memoryReleaseTimer?.cancel();
_assetName = null;
_imageReplacementsByName = null;
_debug(
'prepare kind=${_pagSourceKind(path)} '
'isPag=${RoomRocketPagEffectOverlay.isPag(path)}',
);
_restartWatchdog();
unawaited(_loadBytes(path));
final assetName = _preferNativePagAsset(path);
final imageReplacementUrls = _validImageReplacementUrls(
widget.imageReplacementUrlsByName,
);
final imageReplacementSignature = _imageReplacementSignature;
if (assetName != null) {
_debug('use native asset loader asset=$assetName');
_assetName = assetName;
unawaited(
_loadImageReplacements(
path: path,
signature: imageReplacementSignature,
urlsByName: imageReplacementUrls,
),
);
if (widget.loop) {
_startWatchdog?.cancel();
_initialized = true;
}
return;
}
unawaited(
_loadBytes(
path: path,
signature: imageReplacementSignature,
imageReplacementUrlsByName: imageReplacementUrls,
),
);
}
Future<void> _loadBytes(String path) async {
Future<void> _loadBytes({
required String path,
required String signature,
required Map<String, String> imageReplacementUrlsByName,
}) async {
final stopwatch = Stopwatch()..start();
try {
_debug(
'${_pagLoadAction(path)} start '
'host=${_pagHost(path)}',
);
final imageReplacementsFuture = _readImageReplacementsByName(
imageReplacementUrlsByName,
).timeout(
const Duration(seconds: 10),
onTimeout: () {
_debug('image replacements timeout');
return const <String, Uint8List>{};
},
);
final bytes = await _readPagBytes(
path,
).timeout(const Duration(seconds: 15));
if (!mounted || widget.resource.trim() != path) {
final imageReplacements = await imageReplacementsFuture;
if (!_isCurrentResource(path, signature)) {
_debug(
'bytes ignored stale mounted=$mounted '
'elapsedMs=${stopwatch.elapsedMilliseconds}',
@ -415,12 +532,13 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
);
setState(() {
_pagBytes = bytes;
_imageReplacementsByName = imageReplacements;
_releasedFromMemory = false;
});
_scheduleMemoryRelease(path);
_restartWatchdog();
} catch (error) {
if (!mounted || widget.resource.trim() != path) {
if (!_isCurrentResource(path, signature)) {
return;
}
_markLoadFailed(
@ -429,6 +547,31 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
}
}
Future<void> _loadImageReplacements({
required String path,
required String signature,
required Map<String, String> urlsByName,
}) async {
if (urlsByName.isEmpty) {
_imageReplacementsByName = const <String, Uint8List>{};
return;
}
try {
final replacements = await _readImageReplacementsByName(
urlsByName,
).timeout(const Duration(seconds: 10));
if (!_isCurrentResource(path, signature)) {
return;
}
setState(() => _imageReplacementsByName = replacements);
} catch (error) {
_debug('image replacements failed: $error');
if (_isCurrentResource(path, signature)) {
setState(() => _imageReplacementsByName = const <String, Uint8List>{});
}
}
}
void _handleInit() {
_debug('init');
_startWatchdog?.cancel();
@ -452,7 +595,6 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
'viewSize=${widget.width?.toStringAsFixed(1)}x'
'${widget.height?.toStringAsFixed(1)} fit=${widget.fit.name}',
);
widget.onReady?.call(Size(rawWidth, rawHeight));
unawaited(_debugLayers(state, rawWidth, rawHeight));
if (!_initialized) {
setState(() => _initialized = true);
@ -541,6 +683,8 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
setState(() {
_pagKey = GlobalKey<PAGViewState>();
_pagBytes = null;
_assetName = null;
_imageReplacementsByName = null;
_initialized = false;
_releasedFromMemory = true;
});
@ -586,6 +730,35 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
}
}
bool _isCurrentResource(String path, String signature) {
return mounted &&
widget.resource.trim() == path &&
_imageReplacementSignature == signature;
}
Future<Map<String, Uint8List>> _readImageReplacementsByName(
Map<String, String> urlsByName,
) async {
if (urlsByName.isEmpty) {
return const <String, Uint8List>{};
}
final result = <String, Uint8List>{};
for (final entry in urlsByName.entries) {
try {
final bytes = await _readPagBytes(entry.value);
if (bytes.isNotEmpty) {
result[entry.key] = bytes;
}
} catch (error) {
_debug(
'image replacement load failed layer=${entry.key} '
'resource=${_shortPagResource(entry.value)} error=$error',
);
}
}
return result;
}
void _debug(String event) {
if (!kDebugMode) {
return;
@ -675,6 +848,45 @@ String? _firstNonBlankPagAvatar(Iterable<String?> values) {
return null;
}
Map<String, String> _topAvatarReplacementUrls(String? avatarUrl) {
final text = avatarUrl?.trim() ?? '';
if (text.isEmpty) {
return const <String, String>{};
}
return <String, String>{_roomRocketTopAvatarLayerName: text};
}
bool _hasImageReplacementUrls(Map<String, String> urlsByName) {
return _validImageReplacementUrls(urlsByName).isNotEmpty;
}
Map<String, String> _validImageReplacementUrls(Map<String, String> urlsByName) {
if (urlsByName.isEmpty) {
return const <String, String>{};
}
final result = <String, String>{};
urlsByName.forEach((key, value) {
final layerName = key.trim();
final resource = value.trim();
if (layerName.isNotEmpty && resource.isNotEmpty) {
result[layerName] = resource;
}
});
return result;
}
String _imageReplacementUrlsSignature(Map<String, String> urlsByName) {
final entries =
_validImageReplacementUrls(urlsByName).entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
if (entries.isEmpty) {
return '';
}
return entries
.map((entry) => '${entry.key}\u0000${entry.value}')
.join('\u0001');
}
@visibleForTesting
String debugNormalizeRoomRocketPagResource(String resource) {
return _normalizePagResource(resource);
@ -752,6 +964,22 @@ String _pagLoadAction(String value) {
};
}
String? _preferNativePagAsset(String value) {
if (!Platform.isIOS) {
return null;
}
final normalized = _normalizePagResource(value);
if (!RoomRocketPagEffectOverlay.isPag(normalized) ||
_pagNetworkUri(normalized) != null ||
_pagFilePath(normalized) != null) {
return null;
}
if (normalized.startsWith('sc_images/') || normalized.startsWith('assets/')) {
return normalized;
}
return null;
}
String _normalizePagResource(String value) {
final normalized = normalizeImageResourceUrl(value.trim());
return _pagNetworkUri(normalized)?.toString() ?? normalized;

View File

@ -8,6 +8,26 @@ import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
void showRoomGameListSheet(BuildContext context, {String? initialGameId}) {
if (!context.mounted) {
return;
}
final guardLease = SCRoomTopLayerGuard().acquire(
reason: 'room_game_list_sheet',
);
try {
unawaited(
showBottomInBottomDialog(
context,
RoomGameBottomSheet(roomContext: context, initialGameId: initialGameId),
barrierColor: Colors.black54,
).whenComplete(guardLease.release),
);
} catch (_) {
guardLease.release();
}
}
class RoomGameEntryButton extends StatelessWidget {
const RoomGameEntryButton({super.key});
@ -15,20 +35,7 @@ class RoomGameEntryButton extends StatelessWidget {
Widget build(BuildContext context) {
return SCDebounceWidget(
onTap: () {
final guardLease = SCRoomTopLayerGuard().acquire(
reason: 'room_game_list_sheet',
);
try {
unawaited(
showBottomInBottomDialog(
context,
RoomGameBottomSheet(roomContext: context),
barrierColor: Colors.black54,
).whenComplete(guardLease.release),
);
} catch (_) {
guardLease.release();
}
showRoomGameListSheet(context);
},
child: SCSvgaAssetWidget(
assetPath: "sc_images/room/sc_icon_room_game_entry_anim.svga",

View File

@ -669,8 +669,9 @@ class _MsgItemState extends State<MsgItem> {
return Container(
alignment: AlignmentDirectional.topStart,
child: Container(
constraints: BoxConstraints(minHeight: 49.w, maxWidth: 315.w),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 6.w),
width: 315.w,
constraints: BoxConstraints(minHeight: 49.w),
padding: EdgeInsetsDirectional.fromSTEB(28.w, 6.w, 18.w, 6.w),
margin: EdgeInsets.symmetric(horizontal: 10.w).copyWith(bottom: 8.w),
decoration: const BoxDecoration(
image: DecorationImage(
@ -679,18 +680,21 @@ class _MsgItemState extends State<MsgItem> {
centerSlice: _cpSystemNoticeBubbleCenterSlice,
),
),
child: Text(
widget.msg.msg ?? '',
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: sp(12),
height: 1.35,
color: Color(_msgColor(widget.msg.type ?? "")),
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
child: Align(
alignment: Alignment.center,
child: Text(
widget.msg.msg ?? '',
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: sp(12),
height: 1.35,
color: Color(_msgColor(widget.msg.type ?? "")),
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.left,
),
),
);

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_notice_utils.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_props_type.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
@ -751,7 +752,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
currentProfile?.id,
]),
rightUserId: _firstNonBlank([partner.userId]),
days: _firstNonBlank([cp?.days, "0"]),
days: scCpRelationDisplayDays(_firstNonBlank([cp?.days, "0"])),
relationType: _normalizeCpRelationType(cp?.relationType),
dismissCost: cp?.dismissCost,
cpValue: cp?.cpValue ?? 0,
@ -1638,7 +1639,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
child: text(
SCAppLocalizations.of(
context,
)!.timeSpentTogether(item.days ?? ""),
)!.timeSpentTogether(scCpRelationDisplayDays(item.days)),
fontWeight: FontWeight.w600,
fontSize: 12.sp,
textColor: Color(0xFFFF79A1),
@ -1830,7 +1831,9 @@ class _RoomCpProgressData {
currentValue: currentValue,
targetValue: currentValue > 0 ? currentValue : 1,
expAwayText: SCStringUtils.formatCompactNumber(0),
nextLevelText: _levelShortText(_levelIndex(cp.levelText)),
nextLevelText: _levelShortText(
_nextLevelIndex(_levelIndex(cp.levelText)),
),
);
}
@ -1900,7 +1903,9 @@ class _RoomCpProgressData {
nextLevelText:
nextLevel == null
? _levelShortText(
currentLevel?.level ?? _levelIndex(cp.levelText),
_nextLevelIndex(
currentLevel?.level ?? _levelIndex(cp.levelText),
),
)
: _levelShortText(nextLevel.level, fallback: nextLevel.levelName),
);
@ -1941,13 +1946,13 @@ class _RoomCpProgressData {
}
static String _levelShortText(int? level, {String? fallback}) {
if (level != null) {
return "Lv. $level";
}
final fallbackText = fallback?.trim() ?? "";
if (fallbackText.isNotEmpty) {
return fallbackText;
}
if (level != null) {
return "Lv. $level";
}
return "Lv. 1";
}
@ -1962,6 +1967,10 @@ class _RoomCpProgressData {
) ??
1;
}
static int _nextLevelIndex(int level) {
return (level + 1).clamp(1, 5).toInt();
}
}
class _CpProgressLevel {

View File

@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class RoomMicRelationEffect extends StatefulWidget {
const RoomMicRelationEffect({
super.key,
required this.relationType,
this.cpLevel,
this.width = 108,
this.height = 54,
});
static const String assetBase = "sc_images/room/mic";
final String relationType;
final int? cpLevel;
final double width;
final double height;
@override
State<RoomMicRelationEffect> createState() => _RoomMicRelationEffectState();
}
class _RoomMicRelationEffectState extends State<RoomMicRelationEffect> {
static const Duration _cycleDuration = Duration(seconds: 4);
static const Duration _pagMemoryTtl = Duration(hours: 1);
Timer? _cycleTimer;
int _assetIndex = 0;
List<_RoomMicRelationAsset> _assets = const [];
@override
void initState() {
super.initState();
_syncAssets(restart: true);
}
@override
void didUpdateWidget(covariant RoomMicRelationEffect oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.relationType != widget.relationType ||
oldWidget.cpLevel != widget.cpLevel) {
_syncAssets(restart: true);
return;
}
_syncAssets();
}
@override
void dispose() {
_cycleTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_assets.isEmpty) {
return const SizedBox.shrink();
}
final asset = _assets[_assetIndex % _assets.length];
final width = widget.width.w;
final height = widget.height.w;
return SizedBox(
width: width,
height: height,
child: _buildAsset(asset, width: width, height: height),
);
}
Widget _buildAsset(
_RoomMicRelationAsset asset, {
required double width,
required double height,
}) {
switch (asset.kind) {
case _RoomMicRelationAssetKind.svga:
return SCSvgaAssetWidget(
assetPath: asset.path,
width: width,
height: height,
active: true,
loop: true,
fit: BoxFit.contain,
allowDrawingOverflow: true,
);
case _RoomMicRelationAssetKind.pag:
return RoomRocketPagPreview(
resource: asset.path,
width: width,
height: height,
loop: true,
fit: BoxFit.contain,
clipBehavior: Clip.none,
memoryTtl: _pagMemoryTtl,
defaultBuilder: (_) => SizedBox(width: width, height: height),
);
}
}
void _syncAssets({bool restart = false}) {
final nextAssets = _assetsForRelation(
widget.relationType,
cpLevel: widget.cpLevel,
);
final changed = !_sameAssetList(_assets, nextAssets);
if (!changed && !restart) {
return;
}
_cycleTimer?.cancel();
_assets = nextAssets;
_assetIndex = 0;
if (mounted && changed) {
setState(() {});
}
if (_assets.length > 1) {
_cycleTimer = Timer.periodic(_cycleDuration, (_) {
if (!mounted || _assets.length <= 1) {
return;
}
setState(() => _assetIndex = (_assetIndex + 1) % _assets.length);
});
}
}
bool _sameAssetList(
List<_RoomMicRelationAsset> left,
List<_RoomMicRelationAsset> right,
) {
if (left.length != right.length) {
return false;
}
for (var index = 0; index < left.length; index += 1) {
if (left[index] != right[index]) {
return false;
}
}
return true;
}
List<_RoomMicRelationAsset> _assetsForRelation(
String relationType, {
required int? cpLevel,
}) {
switch (scNormalizeCpRelationType(relationType)) {
case "BROTHER":
return const [
_RoomMicRelationAsset.pag(
"${RoomMicRelationEffect.assetBase}/brother_mic.pag",
),
];
case "SISTERS":
return const [
_RoomMicRelationAsset.pag(
"${RoomMicRelationEffect.assetBase}/sisters_mic.pag",
),
];
default:
return _cpAssetsForLevel(cpLevel);
}
}
List<_RoomMicRelationAsset> _cpAssetsForLevel(int? cpLevel) {
final level = (cpLevel ?? 1).clamp(1, 5).toInt();
switch (level) {
case 2:
return const [
_RoomMicRelationAsset.svga(
"${RoomMicRelationEffect.assetBase}/CP_mic_2.svga",
),
];
case 3:
return const [
_RoomMicRelationAsset.svga(
"${RoomMicRelationEffect.assetBase}/CP_mic_3.svga",
),
];
case 4:
return const [
_RoomMicRelationAsset.pag(
"${RoomMicRelationEffect.assetBase}/CP_mic_4.pag",
),
];
case 5:
return const [
_RoomMicRelationAsset.pag(
"${RoomMicRelationEffect.assetBase}/CP_mic_5.pag",
),
];
case 1:
default:
return const [
_RoomMicRelationAsset.pag(
"${RoomMicRelationEffect.assetBase}/CP_mic_1.pag",
),
];
}
}
}
enum _RoomMicRelationAssetKind { svga, pag }
class _RoomMicRelationAsset {
const _RoomMicRelationAsset._({required this.path, required this.kind});
const _RoomMicRelationAsset.svga(String path)
: this._(path: path, kind: _RoomMicRelationAssetKind.svga);
const _RoomMicRelationAsset.pag(String path)
: this._(path: path, kind: _RoomMicRelationAssetKind.pag);
final String path;
final _RoomMicRelationAssetKind kind;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _RoomMicRelationAsset &&
other.path == path &&
other.kind == kind;
@override
int get hashCode => Object.hash(path, kind);
}

View File

@ -1,296 +1,302 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import '../../../../modules/room/seat/sc_seat_item.dart';
class RoomSeatWidget extends StatefulWidget {
const RoomSeatWidget({super.key});
@override
State<RoomSeatWidget> createState() => _RoomSeatWidgetState();
}
class _RoomSeatWidgetState extends State<RoomSeatWidget> {
static const int _defaultVoiceRoomSeatCount = 10;
int _lastSeatCount = 0;
int _normalizeSeatCount(int? seatCount) {
switch (seatCount) {
case 5:
case 10:
case 15:
case 20:
return seatCount ?? 0;
default:
return 0;
}
}
int _resolvedSeatCount(_RoomSeatLayoutSnapshot snapshot) {
final int liveSeatCount = _normalizeSeatCount(snapshot.seatCount);
final int configuredSeatCount = _normalizeSeatCount(
snapshot.configuredSeatCount,
);
final int seatCount =
configuredSeatCount > 0 ? configuredSeatCount : liveSeatCount;
if (!snapshot.isExitingCurrentVoiceRoomSession && seatCount > 0) {
_lastSeatCount = seatCount;
}
if (snapshot.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) {
return _lastSeatCount;
}
if (snapshot.hasCurrentRoom && seatCount == 0) {
return _lastSeatCount > 0 ? _lastSeatCount : _defaultVoiceRoomSeatCount;
}
return seatCount;
}
int _seatCountFromMicIndexes(Iterable<num> micIndexes) {
var maxIndex = -1;
var count = 0;
for (final micIndex in micIndexes) {
count += 1;
final resolvedIndex = micIndex.toInt();
if (resolvedIndex > maxIndex) {
maxIndex = resolvedIndex;
}
}
final indexedSeatCount = _normalizeSeatCount(maxIndex + 1);
if (indexedSeatCount > 0) {
return indexedSeatCount;
}
return _normalizeSeatCount(count);
}
int _seatContentVersion(RtcProvider provider) {
var version = provider.roomWheatMap.length;
final micIndexes =
provider.roomWheatMap.keys.toList()..sort((a, b) => a.compareTo(b));
for (final micIndex in micIndexes) {
final seat = provider.micAtIndexForDisplay(micIndex);
final user = seat?.user;
version = Object.hash(
version,
micIndex,
seat?.micLock ?? false,
seat?.micMute ?? false,
user?.id ?? "",
user?.userAvatar ?? "",
user?.userNickname ?? "",
user?.roles ?? "",
user?.getHeaddress()?.sourceUrl ?? "",
user?.getHeaddress()?.cover ?? "",
user?.getVIP()?.name ?? "",
);
}
return version;
}
@override
Widget build(BuildContext context) {
return Selector<RtcProvider, _RoomSeatLayoutSnapshot>(
selector:
(context, provider) => _RoomSeatLayoutSnapshot(
seatCount: _seatCountFromMicIndexes(provider.roomWheatMap.keys),
seatContentVersion: _seatContentVersion(provider),
configuredSeatCount:
provider.currenRoom?.roomProfile?.roomSetting?.mikeSize
?.toInt() ??
provider.previewRoomSeatCount ??
0,
hasCurrentRoom: provider.currenRoom != null,
isExitingCurrentVoiceRoomSession:
provider.isExitingCurrentVoiceRoomSession,
),
builder: (context, snapshot, child) {
final int seatCount = _resolvedSeatCount(snapshot);
return seatCount == 5
? _buildSeat5()
: (seatCount == 10
? _buildSeat10()
: (seatCount == 15
? _buildSeat15()
: (seatCount == 20
? _buildSeat20()
: Container(height: 180.w))));
},
);
}
_buildSeat5() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 0)),
Expanded(flex: 1, child: SCSeatItem(index: 1)),
Expanded(flex: 1, child: SCSeatItem(index: 2)),
Expanded(flex: 1, child: SCSeatItem(index: 3)),
Expanded(flex: 1, child: SCSeatItem(index: 4)),
],
),
],
),
);
}
_buildSeat10() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 0)),
Expanded(flex: 1, child: SCSeatItem(index: 1)),
Expanded(flex: 1, child: SCSeatItem(index: 2)),
Expanded(flex: 1, child: SCSeatItem(index: 3)),
Expanded(flex: 1, child: SCSeatItem(index: 4)),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(flex: 1, child: SCSeatItem(index: 5)),
Expanded(flex: 1, child: SCSeatItem(index: 6)),
Expanded(flex: 1, child: SCSeatItem(index: 7)),
Expanded(flex: 1, child: SCSeatItem(index: 8)),
Expanded(flex: 1, child: SCSeatItem(index: 9)),
],
),
],
),
);
}
_buildSeat15() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 0)),
Expanded(flex: 1, child: SCSeatItem(index: 1)),
Expanded(flex: 1, child: SCSeatItem(index: 2)),
Expanded(flex: 1, child: SCSeatItem(index: 3)),
Expanded(flex: 1, child: SCSeatItem(index: 4)),
],
),
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 5)),
Expanded(flex: 1, child: SCSeatItem(index: 6)),
Expanded(flex: 1, child: SCSeatItem(index: 7)),
Expanded(flex: 1, child: SCSeatItem(index: 8)),
Expanded(flex: 1, child: SCSeatItem(index: 9)),
],
),
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 10)),
Expanded(flex: 1, child: SCSeatItem(index: 11)),
Expanded(flex: 1, child: SCSeatItem(index: 12)),
Expanded(flex: 1, child: SCSeatItem(index: 13)),
Expanded(flex: 1, child: SCSeatItem(index: 14)),
],
),
],
),
);
}
_buildSeat20() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 0)),
Expanded(flex: 1, child: SCSeatItem(index: 1)),
Expanded(flex: 1, child: SCSeatItem(index: 2)),
Expanded(flex: 1, child: SCSeatItem(index: 3)),
Expanded(flex: 1, child: SCSeatItem(index: 4)),
],
),
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 5)),
Expanded(flex: 1, child: SCSeatItem(index: 6)),
Expanded(flex: 1, child: SCSeatItem(index: 7)),
Expanded(flex: 1, child: SCSeatItem(index: 8)),
Expanded(flex: 1, child: SCSeatItem(index: 9)),
],
),
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 10)),
Expanded(flex: 1, child: SCSeatItem(index: 11)),
Expanded(flex: 1, child: SCSeatItem(index: 12)),
Expanded(flex: 1, child: SCSeatItem(index: 13)),
Expanded(flex: 1, child: SCSeatItem(index: 14)),
],
),
Row(
children: [
Expanded(flex: 1, child: SCSeatItem(index: 15)),
Expanded(flex: 1, child: SCSeatItem(index: 16)),
Expanded(flex: 1, child: SCSeatItem(index: 17)),
Expanded(flex: 1, child: SCSeatItem(index: 18)),
Expanded(flex: 1, child: SCSeatItem(index: 19)),
],
),
],
),
);
}
}
class _RoomSeatLayoutSnapshot {
const _RoomSeatLayoutSnapshot({
required this.seatCount,
required this.seatContentVersion,
required this.configuredSeatCount,
required this.hasCurrentRoom,
required this.isExitingCurrentVoiceRoomSession,
});
final int seatCount;
final int seatContentVersion;
final int configuredSeatCount;
final bool hasCurrentRoom;
final bool isExitingCurrentVoiceRoomSession;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _RoomSeatLayoutSnapshot &&
other.seatCount == seatCount &&
other.seatContentVersion == seatContentVersion &&
other.configuredSeatCount == configuredSeatCount &&
other.hasCurrentRoom == hasCurrentRoom &&
other.isExitingCurrentVoiceRoomSession ==
isExitingCurrentVoiceRoomSession;
}
@override
int get hashCode => Object.hash(
seatCount,
seatContentVersion,
configuredSeatCount,
hasCurrentRoom,
isExitingCurrentVoiceRoomSession,
);
}
import 'package:flutter/cupertino.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/shared/tools/sc_cp_relation_pair_utils.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/ui_kit/widgets/room/seat/room_mic_relation_effect.dart';
import '../../../../modules/room/seat/sc_seat_item.dart';
class RoomSeatWidget extends StatefulWidget {
const RoomSeatWidget({super.key});
@override
State<RoomSeatWidget> createState() => _RoomSeatWidgetState();
}
class _RoomSeatWidgetState extends State<RoomSeatWidget> {
static const int _defaultVoiceRoomSeatCount = 10;
static const int _seatColumnCount = 5;
static const double _relationEffectWidth = 108;
static const double _relationEffectHeight = 54;
static const double _relationEffectTop = -2;
int _lastSeatCount = 0;
int _normalizeSeatCount(int? seatCount) {
switch (seatCount) {
case 5:
case 10:
case 15:
case 20:
return seatCount ?? 0;
default:
return 0;
}
}
int _resolvedSeatCount(_RoomSeatLayoutSnapshot snapshot) {
final int liveSeatCount = _normalizeSeatCount(snapshot.seatCount);
final int configuredSeatCount = _normalizeSeatCount(
snapshot.configuredSeatCount,
);
final int seatCount =
configuredSeatCount > 0 ? configuredSeatCount : liveSeatCount;
if (!snapshot.isExitingCurrentVoiceRoomSession && seatCount > 0) {
_lastSeatCount = seatCount;
}
if (snapshot.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) {
return _lastSeatCount;
}
if (snapshot.hasCurrentRoom && seatCount == 0) {
return _lastSeatCount > 0 ? _lastSeatCount : _defaultVoiceRoomSeatCount;
}
return seatCount;
}
int _seatCountFromMicIndexes(Iterable<num> micIndexes) {
var maxIndex = -1;
var count = 0;
for (final micIndex in micIndexes) {
count += 1;
final resolvedIndex = micIndex.toInt();
if (resolvedIndex > maxIndex) {
maxIndex = resolvedIndex;
}
}
final indexedSeatCount = _normalizeSeatCount(maxIndex + 1);
if (indexedSeatCount > 0) {
return indexedSeatCount;
}
return _normalizeSeatCount(count);
}
int _seatContentVersion(RtcProvider provider) {
var version = provider.roomWheatMap.length;
final micIndexes =
provider.roomWheatMap.keys.toList()..sort((a, b) => a.compareTo(b));
for (final micIndex in micIndexes) {
final seat = provider.micAtIndexForDisplay(micIndex);
final user = seat?.user;
version = Object.hash(
version,
micIndex,
seat?.micLock ?? false,
seat?.micMute ?? false,
user?.id ?? "",
user?.userAvatar ?? "",
user?.userNickname ?? "",
user?.roles ?? "",
user?.getHeaddress()?.sourceUrl ?? "",
user?.getHeaddress()?.cover ?? "",
user?.getVIP()?.name ?? "",
scCpProfileRelationVersion(user),
);
}
return version;
}
@override
Widget build(BuildContext context) {
return Selector<RtcProvider, _RoomSeatLayoutSnapshot>(
selector:
(context, provider) => _RoomSeatLayoutSnapshot(
seatCount: _seatCountFromMicIndexes(provider.roomWheatMap.keys),
seatContentVersion: _seatContentVersion(provider),
showRoomVisualEffects: provider.shouldShowRoomVisualEffects,
configuredSeatCount:
provider.currenRoom?.roomProfile?.roomSetting?.mikeSize
?.toInt() ??
provider.previewRoomSeatCount ??
0,
hasCurrentRoom: provider.currenRoom != null,
isExitingCurrentVoiceRoomSession:
provider.isExitingCurrentVoiceRoomSession,
),
builder: (context, snapshot, child) {
final int seatCount = _resolvedSeatCount(snapshot);
return seatCount == 5
? _buildSeat5()
: (seatCount == 10
? _buildSeat10()
: (seatCount == 15
? _buildSeat15()
: (seatCount == 20
? _buildSeat20()
: Container(height: 180.w))));
},
);
}
_buildSeat5() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSeatRow(const [0, 1, 2, 3, 4]),
],
),
);
}
_buildSeat10() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSeatRow(const [0, 1, 2, 3, 4]),
_buildSeatRow(const [5, 6, 7, 8, 9]),
],
),
);
}
_buildSeat15() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSeatRow(const [0, 1, 2, 3, 4]),
_buildSeatRow(const [5, 6, 7, 8, 9]),
_buildSeatRow(const [10, 11, 12, 13, 14]),
],
),
);
}
_buildSeat20() {
return Container(
margin: EdgeInsets.only(top: 3.w, left: 10.w, right: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSeatRow(const [0, 1, 2, 3, 4]),
_buildSeatRow(const [5, 6, 7, 8, 9]),
_buildSeatRow(const [10, 11, 12, 13, 14]),
_buildSeatRow(const [15, 16, 17, 18, 19]),
],
),
);
}
Widget _buildSeatRow(List<int> indexes) {
return LayoutBuilder(
builder: (context, constraints) {
final rowWidth = constraints.maxWidth;
final columnWidth = rowWidth / _seatColumnCount;
return Stack(
clipBehavior: Clip.none,
children: [
Row(
children: [
for (final index in indexes)
Expanded(flex: 1, child: SCSeatItem(index: index)),
],
),
if (rowWidth.isFinite)
..._buildRelationEffectsForRow(
context,
indexes: indexes,
columnWidth: columnWidth,
),
],
);
},
);
}
List<Widget> _buildRelationEffectsForRow(
BuildContext context, {
required List<int> indexes,
required double columnWidth,
}) {
final provider = context.read<RtcProvider>();
if (!provider.shouldShowRoomVisualEffects) {
return const <Widget>[];
}
final effects = <Widget>[];
for (var slot = 0; slot < indexes.length - 1; slot += 1) {
final relationInfo = _relationInfoBetweenSeats(
provider,
indexes[slot],
indexes[slot + 1],
);
if (relationInfo == null) {
continue;
}
final left = ((slot + 1) * columnWidth - (_relationEffectWidth.w / 2))
.clamp(0.0, double.infinity);
effects.add(
Positioned(
left: left,
top: _relationEffectTop.w,
width: _relationEffectWidth.w,
height: _relationEffectHeight.w,
child: IgnorePointer(
child: RoomMicRelationEffect(
relationType: relationInfo.relationType,
cpLevel: relationInfo.cpLevel,
),
),
),
);
}
return effects;
}
SCCpRelationPairInfo? _relationInfoBetweenSeats(
RtcProvider provider,
int leftIndex,
int rightIndex,
) {
final leftUser = provider.micAtIndexForDisplay(leftIndex)?.user;
final rightUser = provider.micAtIndexForDisplay(rightIndex)?.user;
return scCpRelationInfoBetweenProfiles(leftUser, rightUser);
}
}
class _RoomSeatLayoutSnapshot {
const _RoomSeatLayoutSnapshot({
required this.seatCount,
required this.seatContentVersion,
required this.showRoomVisualEffects,
required this.configuredSeatCount,
required this.hasCurrentRoom,
required this.isExitingCurrentVoiceRoomSession,
});
final int seatCount;
final int seatContentVersion;
final bool showRoomVisualEffects;
final int configuredSeatCount;
final bool hasCurrentRoom;
final bool isExitingCurrentVoiceRoomSession;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _RoomSeatLayoutSnapshot &&
other.seatCount == seatCount &&
other.seatContentVersion == seatContentVersion &&
other.showRoomVisualEffects == showRoomVisualEffects &&
other.configuredSeatCount == configuredSeatCount &&
other.hasCurrentRoom == hasCurrentRoom &&
other.isExitingCurrentVoiceRoomSession ==
isExitingCurrentVoiceRoomSession;
}
@override
int get hashCode => Object.hash(
seatCount,
seatContentVersion,
showRoomVisualEffects,
configuredSeatCount,
hasCurrentRoom,
isExitingCurrentVoiceRoomSession,
);
}

View File

@ -10,13 +10,16 @@ import android.view.Surface;
import androidx.annotation.NonNull;
import org.libpag.PAGFile;
import org.libpag.PAGImage;
import org.libpag.PAGLayer;
import org.libpag.PAGScaleMode;
import org.libpag.PAGSurface;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.plugin.common.MethodCall;
@ -79,6 +82,7 @@ public class FlutterPagPlugin implements FlutterPlugin, MethodCallHandler {
final static String _argumentCacheEnabled = "cacheEnabled";
final static String _argumentCacheSize = "cacheSize";
final static String _argumentMultiThreadEnabled = "multiThreadEnabled";
final static String _argumentImageReplacementsByName = "imageReplacementsByName";
// 回调
final static String _playCallback = "PAGCallback";
@ -224,6 +228,7 @@ public class FlutterPagPlugin implements FlutterPlugin, MethodCallHandler {
result.error("-1100", "load composition is null! ", null);
return;
}
applyImageReplacementsByName(composition, call);
final int repeatCount = call.argument(_argumentRepeatCount);
final double initProgress = call.argument(_argumentInitProgress);
@ -276,6 +281,34 @@ public class FlutterPagPlugin implements FlutterPlugin, MethodCallHandler {
});
}
private void applyImageReplacementsByName(PAGFile composition, MethodCall call) {
Map<String, Object> replacements = call.argument(_argumentImageReplacementsByName);
if (composition == null || replacements == null || replacements.isEmpty()) {
return;
}
for (Map.Entry<String, Object> entry : replacements.entrySet()) {
String layerName = entry.getKey();
Object value = entry.getValue();
if (layerName == null) {
continue;
}
layerName = layerName.trim();
if (layerName.isEmpty() || !(value instanceof byte[])) {
continue;
}
byte[] bytes = (byte[]) value;
if (bytes.length == 0) {
continue;
}
PAGImage image = PAGImage.FromBytes(bytes);
if (image == null) {
continue;
}
image.setScaleMode(PAGScaleMode.Zoom);
composition.replaceImageByName(layerName, image);
}
}
void start(MethodCall call) {
FlutterPagPlayer flutterPagPlayer = getFlutterPagPlayer(call);
if (flutterPagPlayer != null) {

View File

@ -8,6 +8,7 @@
#import "FlutterPagPlugin.h"
#import "TGFlutterPagRender.h"
#import "TGFlutterPagDownloadManager.h"
#import "TGFlutterPagPlatformView.h"
/**
FlutterPagPluginflutter MethodChannel
@ -33,6 +34,15 @@
/// channel
@property (nonatomic, strong)FlutterMethodChannel* channel;
- (NSDictionary<NSString*, NSData*>*)imageReplacementsByNameFromArguments:(id)arguments;
- (void)pagRenderWithPagData:(NSData*)pagData
progress:(double)progress
repeatCount:(int)repeatCount
autoPlay:(BOOL)autoPlay
imageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName
result:(FlutterResult)result;
@end
@implementation FlutterPagPlugin
@ -45,6 +55,8 @@
instance.registrar = registrar;
instance.channel = channel;
[registrar addMethodCallDelegate:instance channel:channel];
[registrar registerViewFactory:[[TGFlutterPagPlatformViewFactory alloc] initWithRegistrar:registrar]
withId:@"flutter_pag_plugin_view"];
}
- (void)getLayersUnderPoint:(id)arguments result:(FlutterResult _Nonnull)result {
@ -136,6 +148,7 @@
if(arguments[@"autoPlay"]){
autoPlay = [[arguments objectForKey:@"autoPlay"] boolValue];
}
NSDictionary<NSString*, NSData*>* imageReplacementsByName = [self imageReplacementsByNameFromArguments:arguments];
NSString* assetName = arguments[@"assetName"];
NSData *pagData = nil;
@ -157,26 +170,24 @@
[self setCacheData:key data:pagData];
}
[self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result];
[self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result];
}
NSString* url = arguments[@"url"];
if ([url isKindOfClass:NSString.class] && url.length > 0) {
NSURLSessionDownloadTask *task;
[task resume];
NSString *key = url;
pagData = [self getCacheData:key];
if (!pagData) {
__weak typeof(self) weak_self = self;
[TGFlutterPagDownloadManager download:url completionHandler:^(NSData * _Nonnull data, NSError * _Nonnull error) {
if (data) {
[weak_self setCacheData:key data:pagData];
[weak_self pagRenderWithPagData:data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result];
[weak_self setCacheData:key data:data];
[weak_self pagRenderWithPagData:data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result];
}else{
result(@-1);
}
}];
}else{
[self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result];
[self pagRenderWithPagData:pagData progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result];
}
}
@ -184,13 +195,47 @@
if(bytesData != nil && [bytesData isKindOfClass:FlutterStandardTypedData.class]){
FlutterStandardTypedData *typedData = bytesData;
if(typedData.type == FlutterStandardDataTypeUInt8 && typedData.data != nil){
[self pagRenderWithPagData:typedData.data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay result:result];
[self pagRenderWithPagData:typedData.data progress:initProgress repeatCount:repeatCount autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName result:result];
}else{
result(@-1);
}
}
}
- (NSDictionary<NSString*, NSData*>*)imageReplacementsByNameFromArguments:(id)arguments {
if (![arguments isKindOfClass:NSDictionary.class]) {
return @{};
}
NSDictionary* params = arguments;
id raw = params[@"imageReplacementsByName"];
if (![raw isKindOfClass:NSDictionary.class]) {
return @{};
}
NSMutableDictionary<NSString*, NSData*>* result = [[NSMutableDictionary alloc] init];
[(NSDictionary*)raw enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL* stop) {
if (![key isKindOfClass:NSString.class]) {
return;
}
NSString* layerName = [(NSString*)key stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (layerName.length == 0) {
return;
}
NSData* data = nil;
if ([value isKindOfClass:FlutterStandardTypedData.class]) {
FlutterStandardTypedData* typedData = value;
if (typedData.type == FlutterStandardDataTypeUInt8) {
data = typedData.data;
}
} else if ([value isKindOfClass:NSData.class]) {
data = value;
}
if (data.length > 0) {
result[layerName] = data;
}
}];
return result;
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
id arguments = call.arguments;
@ -215,21 +260,32 @@
}
}
-(void)pagRenderWithPagData:(NSData *)pagData progress:(double)progress repeatCount:(int)repeatCount autoPlay:(BOOL)autoPlay result:(FlutterResult)result{
-(void)pagRenderWithPagData:(NSData *)pagData progress:(double)progress repeatCount:(int)repeatCount autoPlay:(BOOL)autoPlay imageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName result:(FlutterResult)result{
if (pagData == nil || pagData.length == 0) {
result(@-1);
return;
}
__block int64_t textureId = -1;
__weak typeof(self) weakSelf = self;
TGFlutterPagRender *render = [[TGFlutterPagRender alloc] initWithPagData:pagData progress:progress frameUpdateCallback:^{
TGFlutterPagRender *render = [[TGFlutterPagRender alloc] initWithPagData:pagData progress:progress imageReplacementsByName:imageReplacementsByName frameUpdateCallback:^{
[weakSelf.textures textureFrameAvailable:textureId];
} eventCallback:^(NSString * event) {
[weakSelf.channel invokeMethod:PlayCallback arguments:@{ArgumentTextureId:@(textureId), ArgumentEvent:event}];
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf.channel invokeMethod:PlayCallback arguments:@{ArgumentTextureId:@(textureId), ArgumentEvent:event}];
});
}];
CGSize renderSize = [render size];
if (render == nil || renderSize.width <= 0 || renderSize.height <= 0) {
result(@-1);
return;
}
[render setRepeatCount:repeatCount];
textureId = [self.textures registerTexture:render];
if(_renderMap == nil){
_renderMap = [[NSMutableDictionary alloc] init];
}
[_renderMap setObject:render forKey:@(textureId)];
result(@{@"textureId":@(textureId), @"width":@([render size].width), @"height":@([render size].height)});
result(@{@"textureId":@(textureId), @"width":@(renderSize.width), @"height":@(renderSize.height)});
if(autoPlay){
[render startRender];
}

View File

@ -0,0 +1,5 @@
#import <Flutter/Flutter.h>
@interface TGFlutterPagPlatformViewFactory : NSObject <FlutterPlatformViewFactory>
- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar;
@end

View File

@ -0,0 +1,234 @@
#import "TGFlutterPagPlatformView.h"
#import "TGFlutterPagDownloadManager.h"
#import <libpag/PAGFile.h>
#import <libpag/PAGImage.h>
#import <libpag/PAGScaleMode.h>
#import <libpag/PAGView.h>
#import <UIKit/UIKit.h>
@interface TGFlutterPagPlatformView : NSObject <FlutterPlatformView, PAGViewListener>
@property(nonatomic, strong) PAGView* pagView;
@property(nonatomic, weak) NSObject<FlutterPluginRegistrar>* registrar;
@end
@implementation TGFlutterPagPlatformView
- (instancetype)initWithFrame:(CGRect)frame
viewIdentifier:(int64_t)viewId
arguments:(id)arguments
registrar:(NSObject<FlutterPluginRegistrar>*)registrar {
self = [super init];
if (self) {
_registrar = registrar;
_pagView = [[PAGView alloc] initWithFrame:frame];
_pagView.backgroundColor = UIColor.clearColor;
_pagView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[_pagView setScaleMode:PAGScaleModeLetterBox];
[_pagView addListener:self];
[self configureWithArguments:arguments];
}
return self;
}
- (UIView*)view {
return _pagView;
}
- (void)configureWithArguments:(id)arguments {
if (![arguments isKindOfClass:NSDictionary.class]) {
return;
}
NSDictionary* params = arguments;
int repeatCount = 1;
id repeatValue = params[@"repeatCount"];
if ([repeatValue respondsToSelector:@selector(intValue)]) {
repeatCount = [repeatValue intValue];
}
double initProgress = 0.0;
id progressValue = params[@"initProgress"];
if ([progressValue respondsToSelector:@selector(doubleValue)]) {
initProgress = [progressValue doubleValue];
}
BOOL autoPlay = NO;
id autoPlayValue = params[@"autoPlay"];
if ([autoPlayValue respondsToSelector:@selector(boolValue)]) {
autoPlay = [autoPlayValue boolValue];
}
NSDictionary<NSString*, NSData*>* imageReplacementsByName = [self imageReplacementsByNameFromParams:params];
[_pagView setRepeatCount:repeatCount];
[_pagView setProgress:initProgress];
NSString* assetName = [self nonEmptyString:params[@"assetName"]];
if (assetName.length > 0) {
NSString* package = [self nonEmptyString:params[@"package"]];
NSString* assetPath = [self assetPathForName:assetName package:package];
[self loadPath:assetPath initProgress:initProgress autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName];
return;
}
NSString* url = [self nonEmptyString:params[@"url"]];
if (url.length > 0) {
__weak typeof(self) weakSelf = self;
[TGFlutterPagDownloadManager download:url completionHandler:^(NSData* data, NSError* error) {
if (data == nil || data.length == 0) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf loadData:data initProgress:initProgress autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName];
});
}];
return;
}
id bytesData = params[@"bytesData"];
if ([bytesData isKindOfClass:FlutterStandardTypedData.class]) {
FlutterStandardTypedData* typedData = bytesData;
if (typedData.type == FlutterStandardDataTypeUInt8) {
[self loadData:typedData.data initProgress:initProgress autoPlay:autoPlay imageReplacementsByName:imageReplacementsByName];
}
}
}
- (NSString*)nonEmptyString:(id)value {
if (![value isKindOfClass:NSString.class]) {
return nil;
}
NSString* text = [(NSString*)value stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
return text.length > 0 ? text : nil;
}
- (NSString*)assetPathForName:(NSString*)assetName package:(NSString*)package {
NSString* resourcePath = nil;
if (package.length > 0) {
resourcePath = [_registrar lookupKeyForAsset:assetName fromPackage:package];
} else {
resourcePath = [_registrar lookupKeyForAsset:assetName];
}
return [[NSBundle mainBundle] pathForResource:resourcePath ofType:nil];
}
- (void)loadPath:(NSString*)path initProgress:(double)initProgress autoPlay:(BOOL)autoPlay imageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName {
if (path.length == 0) {
return;
}
if (imageReplacementsByName.count > 0) {
PAGFile* file = [PAGFile Load:path];
[self applyImageReplacementsByName:imageReplacementsByName toFile:file];
[self setFile:file initProgress:initProgress autoPlay:autoPlay];
return;
}
if (![_pagView setPath:path]) {
return;
}
[_pagView setProgress:initProgress];
[_pagView flush];
if (autoPlay) {
[_pagView play];
}
}
- (void)loadData:(NSData*)data initProgress:(double)initProgress autoPlay:(BOOL)autoPlay imageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName {
if (data == nil || data.length == 0) {
return;
}
PAGFile* file = [PAGFile Load:data.bytes size:data.length];
[self applyImageReplacementsByName:imageReplacementsByName toFile:file];
[self setFile:file initProgress:initProgress autoPlay:autoPlay];
}
- (void)setFile:(PAGFile*)file initProgress:(double)initProgress autoPlay:(BOOL)autoPlay {
if (file == nil || file.width <= 0 || file.height <= 0) {
return;
}
[_pagView setComposition:file];
[_pagView setProgress:initProgress];
[_pagView flush];
if (autoPlay) {
[_pagView play];
}
}
- (NSDictionary<NSString*, NSData*>*)imageReplacementsByNameFromParams:(NSDictionary*)params {
id raw = params[@"imageReplacementsByName"];
if (![raw isKindOfClass:NSDictionary.class]) {
return @{};
}
NSMutableDictionary<NSString*, NSData*>* result = [[NSMutableDictionary alloc] init];
[(NSDictionary*)raw enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL* stop) {
if (![key isKindOfClass:NSString.class]) {
return;
}
NSString* layerName = [(NSString*)key stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (layerName.length == 0) {
return;
}
NSData* data = nil;
if ([value isKindOfClass:FlutterStandardTypedData.class]) {
FlutterStandardTypedData* typedData = value;
if (typedData.type == FlutterStandardDataTypeUInt8) {
data = typedData.data;
}
} else if ([value isKindOfClass:NSData.class]) {
data = value;
}
if (data.length > 0) {
result[layerName] = data;
}
}];
return result;
}
- (void)applyImageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName toFile:(PAGFile*)file {
if (file == nil || imageReplacementsByName == nil || imageReplacementsByName.count == 0) {
return;
}
[imageReplacementsByName enumerateKeysAndObjectsUsingBlock:^(NSString* layerName, NSData* data, BOOL* stop) {
if (![layerName isKindOfClass:NSString.class] || layerName.length == 0 ||
![data isKindOfClass:NSData.class] || data.length == 0) {
return;
}
PAGImage* image = [PAGImage FromBytes:data.bytes size:data.length];
if (image == nil) {
return;
}
[image setScaleMode:PAGScaleModeZoom];
[file replaceImageByName:layerName data:image];
}];
}
- (void)dealloc {
[_pagView removeListener:self];
[_pagView stop];
[_pagView setComposition:nil];
}
@end
@implementation TGFlutterPagPlatformViewFactory {
__weak NSObject<FlutterPluginRegistrar>* _registrar;
}
- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
self = [super init];
if (self) {
_registrar = registrar;
}
return self;
}
- (NSObject<FlutterMessageCodec>*)createArgsCodec {
return FlutterStandardMessageCodec.sharedInstance;
}
- (NSObject<FlutterPlatformView>*)createWithFrame:(CGRect)frame
viewIdentifier:(int64_t)viewId
arguments:(id)args {
return [[TGFlutterPagPlatformView alloc] initWithFrame:frame
viewIdentifier:viewId
arguments:args
registrar:_registrar];
}
@end

View File

@ -30,6 +30,7 @@ typedef void(^PAGEventCallback)(NSString *);
- (instancetype)initWithPagData:(NSData*)pagData
progress:(double)initProgress
imageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName
frameUpdateCallback:(FrameUpdateCallback)frameUpdateCallback
eventCallback:(PAGEventCallback)eventCallback;

View File

@ -12,7 +12,10 @@
#import <OpenGLES/ES2/glext.h>
#import <CoreVideo/CoreVideo.h>
#import <UIKit/UIKit.h>
#include <libpag/PAGFile.h>
#include <libpag/PAGImage.h>
#include <libpag/PAGPlayer.h>
#include <libpag/PAGScaleMode.h>
#include <chrono>
#include <mutex>
@ -86,6 +89,7 @@ static int64_t GetCurrentTimeUS() {
- (instancetype)initWithPagData:(NSData*)pagData
progress:(double)initProgress
imageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName
frameUpdateCallback:(FrameUpdateCallback)frameUpdateCallback
eventCallback:(PAGEventCallback)eventCallback
{
@ -95,6 +99,10 @@ static int64_t GetCurrentTimeUS() {
_initProgress = initProgress;
if(pagData){
_pagFile = [PAGFile Load:pagData.bytes size:pagData.length];
if (_pagFile == nil || _pagFile.width <= 0 || _pagFile.height <= 0) {
return self;
}
[self applyImageReplacementsByName:imageReplacementsByName];
_player = [[PAGPlayer alloc] init];
[_player setComposition:_pagFile];
_surface = [PAGSurface MakeFromGPU:CGSizeMake(_pagFile.width, _pagFile.height)];
@ -107,8 +115,30 @@ static int64_t GetCurrentTimeUS() {
return self;
}
- (void)applyImageReplacementsByName:(NSDictionary<NSString*, NSData*>*)imageReplacementsByName
{
if (_pagFile == nil || imageReplacementsByName == nil || imageReplacementsByName.count == 0) {
return;
}
[imageReplacementsByName enumerateKeysAndObjectsUsingBlock:^(NSString* layerName, NSData* data, BOOL* stop) {
if (![layerName isKindOfClass:NSString.class] || layerName.length == 0 ||
![data isKindOfClass:NSData.class] || data.length == 0) {
return;
}
PAGImage* image = [PAGImage FromBytes:data.bytes size:data.length];
if (image == nil) {
return;
}
[image setScaleMode:PAGScaleModeZoom];
[_pagFile replaceImageByName:layerName data:image];
}];
}
- (void)startRender
{
if (_player == nil || _surface == nil) {
return;
}
if (!_displayLink) {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(update)];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
@ -121,6 +151,9 @@ static int64_t GetCurrentTimeUS() {
- (void)stopRender
{
if (_player == nil) {
return;
}
if (_displayLink) {
[_displayLink invalidate];
_displayLink = nil;
@ -136,6 +169,9 @@ static int64_t GetCurrentTimeUS() {
}
- (void)pauseRender{
if (_player == nil) {
return;
}
if (_displayLink) {
[_displayLink invalidate];
_displayLink = nil;
@ -146,12 +182,18 @@ static int64_t GetCurrentTimeUS() {
}
- (void)setProgress:(double)progress{
if (_player == nil) {
return;
}
[_player setProgress:progress];
[_player flush];
_frameUpdateCallback();
}
- (NSArray<NSString *> *)getLayersUnderPoint:(CGPoint)point{
if (_player == nil) {
return @[];
}
NSArray<PAGLayer*>* layers = [_player getLayersUnderPoint:point];
NSMutableArray<NSString *> *layerNames = [[NSMutableArray alloc] init];
for (PAGLayer *layer in layers) {

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class PAGView extends StatefulWidget {
@ -45,6 +46,15 @@ class PAGView extends StatefulWidget {
///
final Widget Function(BuildContext context)? defaultBuilder;
/// Replace editable PAG image layers by layer name before playback starts.
final Map<String, Uint8List> imageReplacementsByName;
/// Uses the native iOS PAGView instead of the texture renderer.
///
/// This is intended for local PAG assets on iOS where the texture renderer can
/// report playback events while presenting transparent frames.
final bool usePlatformView;
static const int REPEAT_COUNT_LOOP = -1; //
static const int REPEAT_COUNT_DEFAULT = 1; //
@ -61,6 +71,8 @@ class PAGView extends StatefulWidget {
this.onAnimationCancel,
this.onAnimationRepeat,
this.defaultBuilder,
this.imageReplacementsByName = const <String, Uint8List>{},
this.usePlatformView = false,
Key? key,
}) : this.bytesData = null,
this.assetName = null,
@ -81,6 +93,8 @@ class PAGView extends StatefulWidget {
this.onAnimationCancel,
this.onAnimationRepeat,
this.defaultBuilder,
this.imageReplacementsByName = const <String, Uint8List>{},
this.usePlatformView = false,
Key? key,
}) : this.bytesData = null,
this.url = null,
@ -100,6 +114,8 @@ class PAGView extends StatefulWidget {
this.onAnimationCancel,
this.onAnimationRepeat,
this.defaultBuilder,
this.imageReplacementsByName = const <String, Uint8List>{},
this.usePlatformView = false,
Key? key,
}) : this.url = null,
this.assetName = null,
@ -148,6 +164,9 @@ class PAGViewState extends State<PAGView> {
static const String _argumentCacheEnabled = "cacheEnabled";
static const String _argumentCacheSize = "cacheSize";
static const String _argumentMultiThreadEnabled = "multiThreadEnabled";
static const String _argumentImageReplacementsByName =
'imageReplacementsByName';
static const String _platformViewType = 'flutter_pag_plugin_view';
//
static const String _playCallback = 'PAGCallback';
@ -161,7 +180,8 @@ class PAGViewState extends State<PAGView> {
static MethodChannel _channel = (const MethodChannel('flutter_pag_plugin')
..setMethodCallHandler((result) {
if (result.method == _playCallback) {
callbackHandlers[result.arguments[_argumentTextureId]]?.call(result.arguments[_argumentEvent]);
callbackHandlers[result.arguments[_argumentTextureId]]
?.call(result.arguments[_argumentEvent]);
}
return Future<dynamic>.value();
@ -172,21 +192,60 @@ class PAGViewState extends State<PAGView> {
@override
void initState() {
super.initState();
newTexture();
if (!_usesPlatformView) {
newTexture();
}
}
bool get _usesPlatformView =>
widget.usePlatformView && defaultTargetPlatform == TargetPlatform.iOS;
Map<String, dynamic> get _creationParams {
final repeatCount = widget.repeatCount <= 0 &&
widget.repeatCount != PAGView.REPEAT_COUNT_LOOP
? PAGView.REPEAT_COUNT_DEFAULT
: widget.repeatCount;
final initProcess = widget.initProgress < 0 ? 0 : widget.initProgress;
return <String, dynamic>{
_argumentAssetName: widget.assetName,
_argumentPackage: widget.package,
_argumentUrl: widget.url,
_argumentBytes: widget.bytesData,
_argumentRepeatCount: repeatCount,
_argumentInitProgress: initProcess,
_argumentAutoPlay: widget.autoPlay,
_argumentImageReplacementsByName:
_validImageReplacements(widget.imageReplacementsByName),
};
}
Map<String, Uint8List> _validImageReplacements(
Map<String, Uint8List> replacements,
) {
if (replacements.isEmpty) {
return const <String, Uint8List>{};
}
final result = <String, Uint8List>{};
replacements.forEach((key, value) {
final layerName = key.trim();
if (layerName.isNotEmpty && value.isNotEmpty) {
result[layerName] = value;
}
});
return result;
}
//
void newTexture() async {
int repeatCount = widget.repeatCount <= 0 && widget.repeatCount != PAGView.REPEAT_COUNT_LOOP ? PAGView.REPEAT_COUNT_DEFAULT : widget.repeatCount;
double initProcess = widget.initProgress < 0 ? 0 : widget.initProgress;
try {
dynamic result =
await _channel.invokeMethod(_nativeInit, {_argumentAssetName: widget.assetName, _argumentPackage: widget.package, _argumentUrl: widget.url, _argumentBytes: widget.bytesData, _argumentRepeatCount: repeatCount, _argumentInitProgress: initProcess, _argumentAutoPlay: widget.autoPlay});
await _channel.invokeMethod(_nativeInit, _creationParams);
if (result is Map) {
_textureId = result[_argumentTextureId];
rawWidth = result[_argumentWidth] ?? 0;
rawHeight = result[_argumentHeight] ?? 0;
} else {
throw StateError('initPag failed: $result');
}
if (mounted) {
setState(() {
@ -216,6 +275,9 @@ class PAGViewState extends State<PAGView> {
///
void start() {
if (_usesPlatformView) {
return;
}
if (!_hasLoadTexture) {
return;
}
@ -224,6 +286,9 @@ class PAGViewState extends State<PAGView> {
///
void stop() {
if (_usesPlatformView) {
return;
}
if (!_hasLoadTexture) {
return;
}
@ -232,6 +297,9 @@ class PAGViewState extends State<PAGView> {
///
void pause() {
if (_usesPlatformView) {
return;
}
if (!_hasLoadTexture) {
return;
}
@ -240,22 +308,43 @@ class PAGViewState extends State<PAGView> {
///
void setProgress(double progress) {
if (_usesPlatformView) {
return;
}
if (!_hasLoadTexture) {
return;
}
_channel.invokeMethod(_nativeSetProgress, {_argumentTextureId: _textureId, _argumentProgress: progress});
_channel.invokeMethod(_nativeSetProgress,
{_argumentTextureId: _textureId, _argumentProgress: progress});
}
///
Future<List<String>> getLayersUnderPoint(double x, double y) async {
if (!_hasLoadTexture) {
if (_usesPlatformView || !_hasLoadTexture) {
return [];
}
return (await _channel.invokeMethod(_nativeGetPointLayer, {_argumentTextureId: _textureId, _argumentPointX: x, _argumentPointY: y}) as List).map((e) => e.toString()).toList();
return (await _channel.invokeMethod(_nativeGetPointLayer, {
_argumentTextureId: _textureId,
_argumentPointX: x,
_argumentPointY: y
}) as List)
.map((e) => e.toString())
.toList();
}
@override
Widget build(BuildContext context) {
if (_usesPlatformView) {
return SizedBox(
width: widget.width ?? defaultSize,
height: widget.height ?? defaultSize,
child: UiKitView(
viewType: _platformViewType,
creationParams: _creationParams,
creationParamsCodec: const StandardMessageCodec(),
),
);
}
if (_hasLoadTexture) {
return SizedBox(
width: widget.width ?? (rawWidth / 2),
@ -274,8 +363,10 @@ class PAGViewState extends State<PAGView> {
@override
void dispose() {
super.dispose();
_channel.invokeMethod(_nativeRelease, {_argumentTextureId: _textureId});
callbackHandlers.remove(_textureId);
if (!_usesPlatformView) {
_channel.invokeMethod(_nativeRelease, {_argumentTextureId: _textureId});
callbackHandlers.remove(_textureId);
}
}
}
@ -285,16 +376,19 @@ typedef PAGCallback = void Function();
class PAG {
// true
static void enableCache(bool enable) {
PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableCache, {PAGViewState._argumentCacheEnabled: enable});
PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableCache,
{PAGViewState._argumentCacheEnabled: enable});
}
// 线true
static void enableMultiThread(bool enable) {
PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableMultiThread, {PAGViewState._argumentMultiThreadEnabled: enable});
PAGViewState._channel.invokeMethod(PAGViewState._nativeEnableMultiThread,
{PAGViewState._argumentMultiThreadEnabled: enable});
}
// 10
static void setCacheSize(int size) {
PAGViewState._channel.invokeMethod(PAGViewState._nativeSetCacheSize, {PAGViewState._argumentCacheSize: size});
PAGViewState._channel.invokeMethod(PAGViewState._nativeSetCacheSize,
{PAGViewState._argumentCacheSize: size});
}
}

View File

@ -175,8 +175,10 @@ flutter:
- sc_images/room/cp_progress/
- sc_images/room/cp_invite/
- sc_images/room/background_examples/
- sc_images/room/cp_broadcast/
- sc_images/room/emoji/
- sc_images/room/entrance/
- sc_images/room/mic/
- sc_images/room/red_packet/
- sc_images/room/rocket/
- sc_images/room/anim/

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:yumi/shared/business_logic/models/res/gift_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_system_invit_message_res.dart';
@ -32,6 +34,12 @@ void main() {
expect(scNormalizeCpRelationType('bro'), 'BROTHER');
expect(scNormalizeCpRelationType('sis'), 'SISTERS');
});
test('relation days display starts from day one', () {
expect(scCpRelationDisplayDays('0'), '1');
expect(scCpRelationDisplayDays('12'), '13');
expect(scCpRelationDisplayDays(''), '1');
});
});
group('scCpRelationTypeForGift', () {
@ -68,4 +76,26 @@ void main() {
expect(invite.toJson()['userHeaddress'], 'frame.svga');
});
});
group('SCSystemInvitMessageRes CP_BUILD relation type', () {
test('uses top-level relation type when content omits it', () {
final invite = SCSystemInvitMessageRes.fromMessageData({
'relationType': 'BROTHER',
'content': jsonEncode({'userNickname': 'Alex'}),
});
expect(invite?.relationType, 'BROTHER');
});
test('infers relation type from gift payload when needed', () {
final invite = SCSystemInvitMessageRes.fromMessageData({
'content': {
'userNickname': 'Sam',
'gift': {'giftCode': 'close_friend_sister_ring'},
},
});
expect(invite?.relationType, 'SISTERS');
});
});
}

View File

@ -4,6 +4,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dar
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_config_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_note_dialog.dart';
void main() {
test('maps localized rocket rule html from go rule response', () {
@ -41,6 +42,42 @@ void main() {
}
});
test('formats rocket rule html lists without detached markers', () {
final text = debugRoomRocketRuleHtmlPlainText(
'<h2>How to Launch a Rocket</h2>'
'<ol>'
'<li><p>Sending gifts in the room will add fuel.</p></li>'
'<li><p>Once the rocket fuel is full, it will launch.</p></li>'
'</ol>',
);
expect(text, contains('How to Launch a Rocket\n1. Sending gifts'));
expect(text, contains('\n2. Once the rocket fuel is full'));
expect(text, isNot(contains('1. \n')));
expect(text, isNot(contains('2. \n')));
});
test('keeps manually numbered rocket rule html from duplicating markers', () {
final text = debugRoomRocketRuleHtmlPlainText(
'<h2>How to Launch a Rocket</h2>'
'<ol>'
'<li><p>1. Sending gifts in the room will add fuel.</p></li>'
'<li><p>2. Once the rocket fuel is full, it will launch.</p></li>'
'<li><p>3. The higher the rocket level, the more rewards.</p></li>'
'<li><p><strong>Leaderboard and Rewards</strong></p></li>'
'</ol>'
'<ol>'
'<li><p>1. The Top 1 user will receive an exclusive reward.</p></li>'
'</ol>',
);
expect(text, contains('How to Launch a Rocket\n1. Sending gifts'));
expect(text, contains('\nLeaderboard and Rewards\n1. The Top 1 user'));
expect(text, isNot(contains('1. \n1.')));
expect(text, isNot(contains('4. \nLeaderboard')));
expect(text, isNot(contains('4. Leaderboard')));
});
test(
'parses level-specific reward previews and rocket kings from levels',
() {

View File

@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/tools/sc_cp_relation_pair_utils.dart';
void main() {
group('scCpRelationTypeBetweenProfiles', () {
test('detects cp relation from either adjacent profile', () {
final left = SocialChatUserProfile(
id: '100',
account: 'left',
cpList: [
CPRes(
meUserId: '100',
meAccount: 'left',
cpUserId: '200',
cpAccount: 'right',
relationType: 'CP',
levelText: 'Lv.3 Sweet',
),
],
);
final right = SocialChatUserProfile(id: '200', account: 'right');
expect(scCpRelationTypeBetweenProfiles(left, right), 'CP');
expect(scCpRelationTypeBetweenProfiles(right, left), 'CP');
expect(scCpRelationInfoBetweenProfiles(left, right)?.cpLevel, 3);
});
test('detects brother and sister close-friend relations', () {
final brotherLeft = SocialChatUserProfile(
id: '100',
closeFriendList: [
CPRes(meUserId: '100', cpUserId: '200', relationType: 'bro'),
],
);
final brotherRight = SocialChatUserProfile(id: '200');
final sisterLeft = SocialChatUserProfile(id: '300');
final sisterRight = SocialChatUserProfile(
id: '400',
closeFriendList: [
CPRes(meUserId: '300', cpUserId: '400', relationType: 'sis'),
],
);
expect(
scCpRelationTypeBetweenProfiles(brotherLeft, brotherRight),
'BROTHER',
);
expect(
scCpRelationTypeBetweenProfiles(sisterLeft, sisterRight),
'SISTERS',
);
});
test('does not match unrelated profiles', () {
final left = SocialChatUserProfile(
id: '100',
cpList: [CPRes(meUserId: '100', cpUserId: '200', relationType: 'CP')],
);
final right = SocialChatUserProfile(id: '300');
expect(scCpRelationTypeBetweenProfiles(left, right), isNull);
});
});
}

View File

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