修复cp问题
This commit is contained in:
parent
79e78cd897
commit
2999de23d1
@ -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');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
15
lib/shared/tools/sc_cp_relation_broadcast_assets.dart
Normal file
15
lib/shared/tools/sc_cp_relation_broadcast_assets.dart
Normal 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";
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
|
||||
130
lib/shared/tools/sc_cp_relation_pair_utils.dart
Normal file
130
lib/shared/tools/sc_cp_relation_pair_utils.dart
Normal 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);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
// 停顿2秒(你代码中是3秒,这里保持原样)
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 第二阶段:从中到左
|
||||
setState(() {
|
||||
_firstPhaseCompleted = true;
|
||||
});
|
||||
_controller.reset();
|
||||
await _controller.forward();
|
||||
|
||||
if (mounted && !_isSwipeAnimating) {
|
||||
widget.onAnimationCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理向左滑动
|
||||
void _handleSwipeLeft() {
|
||||
if (_isSwipeAnimating) return;
|
||||
|
||||
setState(() {
|
||||
_isSwipeAnimating = true;
|
||||
});
|
||||
|
||||
// 停止主动画
|
||||
_controller.stop();
|
||||
|
||||
// 启动滑动动画
|
||||
_swipeController.reset();
|
||||
_swipeController.forward();
|
||||
}
|
||||
|
||||
String _coinsVaFormat(num coins) {
|
||||
int value = coins.toInt();
|
||||
if (value > 99999) {
|
||||
return "${(value / 1000).toStringAsFixed(0)}k";
|
||||
}
|
||||
return "$value";
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_swipeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _buildGameAnimation();
|
||||
}
|
||||
|
||||
Widget _buildGameAnimation() {
|
||||
// 如果正在执行滑动动画,使用滑动动画
|
||||
if (_isSwipeAnimating) {
|
||||
return SlideTransition(
|
||||
position: _swipeAnimation,
|
||||
child: _buildContent(),
|
||||
);
|
||||
}
|
||||
|
||||
// 否则使用原来的动画
|
||||
return SlideTransition(
|
||||
position: _firstPhaseCompleted ? _secondAnimation : _firstAnimation,
|
||||
child: _buildContent(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
debouncer.debounce(
|
||||
duration: Duration(milliseconds: 350),
|
||||
onDebounce: () {
|
||||
if (widget.message.roomId != null &&
|
||||
widget.message.roomId!.isNotEmpty) {
|
||||
SCRoomUtils.goRoom(
|
||||
widget.message.roomId!,
|
||||
navigatorKey.currentState!.context,
|
||||
fromFloting: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
// 获取当前的文本方向
|
||||
final textDirection = Directionality.of(context);
|
||||
|
||||
// 定义一个根据方向转换速度符号的辅助函数
|
||||
double effectiveVelocity(double velocity) {
|
||||
// 在RTL模式下,反转速度的正负号
|
||||
return textDirection == TextDirection.rtl
|
||||
? -velocity
|
||||
: velocity;
|
||||
}
|
||||
|
||||
double velocity = effectiveVelocity(details.primaryVelocity ?? 0);
|
||||
if (velocity < 0) {
|
||||
///向左滑动,把当前视图向左平移出去
|
||||
_handleSwipeLeft();
|
||||
}
|
||||
},
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Container(
|
||||
alignment: Alignment.topCenter,
|
||||
height: 120.w,
|
||||
width: 325.w,
|
||||
margin: EdgeInsets.only(top: 10.w),
|
||||
child: Stack(
|
||||
alignment:
|
||||
level == 5
|
||||
? AlignmentDirectional.topCenter
|
||||
: AlignmentDirectional.center,
|
||||
children: [
|
||||
if (widget.message.userAvatarUrl?.isNotEmpty ?? false)
|
||||
_buildHead(),
|
||||
Image.asset("sc_images/index/sc_icon_gamebroad_lv${level}_bg.webp"),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min, // 宽度由内容决定
|
||||
children: [
|
||||
SizedBox(width: level < 2 ? 82.w : 95.w),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height:
|
||||
level < 2
|
||||
? 22.w
|
||||
: (level == 5 ? 31.w : 25.w),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 55.w,
|
||||
maxHeight: 18.w,
|
||||
),
|
||||
child:
|
||||
(widget.message.userName?.length ??
|
||||
0) >
|
||||
8
|
||||
? Marquee(
|
||||
text:
|
||||
widget.message.userName ??
|
||||
"",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
decoration:
|
||||
TextDecoration.none,
|
||||
),
|
||||
scrollAxis: Axis.horizontal,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
blankSpace: 20.0,
|
||||
velocity: 40.0,
|
||||
pauseAfterRound: Duration(
|
||||
seconds: 1,
|
||||
),
|
||||
accelerationDuration: Duration(
|
||||
seconds: 1,
|
||||
),
|
||||
accelerationCurve:
|
||||
Curves.easeOut,
|
||||
decelerationDuration: Duration(
|
||||
milliseconds: 500,
|
||||
),
|
||||
decelerationCurve:
|
||||
Curves.easeOut,
|
||||
)
|
||||
: Text(
|
||||
widget.message.userName ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
decoration:
|
||||
TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
text(
|
||||
"ID:${widget.message.userId} ",
|
||||
textColor: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
Transform.translate(
|
||||
offset: Offset(
|
||||
0,
|
||||
level < 2 ? -5 : (level == 5 ? -5 : 0),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
text(
|
||||
SCAppLocalizations.of(context)!.get,
|
||||
textColor: Colors.orange,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 16.sp,
|
||||
fontStyle: FontStyle.italic
|
||||
),
|
||||
buildNumForGame(
|
||||
_coinsVaFormat(
|
||||
widget.message.coins ?? 0,
|
||||
),
|
||||
size:
|
||||
(widget.message.coins ?? 0) > 99999
|
||||
? 26.w
|
||||
: 20.w,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: level < 2 ? 10.w : 21.w),
|
||||
child: netImage(
|
||||
url: widget.message.giftUrl ?? "",
|
||||
width: 28.w,
|
||||
borderRadius: BorderRadius.all(Radius.circular(3.0)),
|
||||
),
|
||||
),
|
||||
SizedBox(width: level < 2 ? 63.w : 65.w),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHead() {
|
||||
if (level == 1) {
|
||||
return PositionedDirectional(
|
||||
start: 43.w,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 12.w),
|
||||
child: head(url: widget.message.userAvatarUrl ?? "", width: 40.w),
|
||||
),
|
||||
);
|
||||
} else if (level == 2) {
|
||||
return PositionedDirectional(
|
||||
start: 10.w,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 25.w),
|
||||
child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w),
|
||||
),
|
||||
);
|
||||
} else if (level == 3) {
|
||||
return PositionedDirectional(
|
||||
start: 10.w,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 25.w),
|
||||
child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w),
|
||||
),
|
||||
);
|
||||
} else if (level == 4) {
|
||||
return PositionedDirectional(
|
||||
start: 10.w,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 25.w),
|
||||
child: head(url: widget.message.userAvatarUrl ?? "", width: 70.w),
|
||||
),
|
||||
);
|
||||
} else if (level == 5) {
|
||||
return PositionedDirectional(
|
||||
start: 22.w,
|
||||
top: 20.w,
|
||||
child: Container(
|
||||
child: head(url: widget.message.userAvatarUrl ?? "", width: 48.w),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
228
lib/ui_kit/widgets/room/seat/room_mic_relation_effect.dart
Normal file
228
lib/ui_kit/widgets/room/seat/room_mic_relation_effect.dart
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#import "FlutterPagPlugin.h"
|
||||
#import "TGFlutterPagRender.h"
|
||||
#import "TGFlutterPagDownloadManager.h"
|
||||
#import "TGFlutterPagPlatformView.h"
|
||||
|
||||
/**
|
||||
FlutterPagPlugin,处理flutter MethodChannel约定的方法
|
||||
@ -33,6 +34,15 @@
|
||||
/// 用于通信的channel
|
||||
@property (nonatomic, strong)FlutterMethodChannel* channel;
|
||||
|
||||
- (NSDictionary<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];
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
#import <Flutter/Flutter.h>
|
||||
|
||||
@interface TGFlutterPagPlatformViewFactory : NSObject <FlutterPlatformViewFactory>
|
||||
- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar;
|
||||
@end
|
||||
@ -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
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/
|
||||
|
||||
BIN
sc_images/room/anim/game_win.svga
Normal file
BIN
sc_images/room/anim/game_win.svga
Normal file
Binary file not shown.
BIN
sc_images/room/cp_broadcast/CP_broadcast.pag
Normal file
BIN
sc_images/room/cp_broadcast/CP_broadcast.pag
Normal file
Binary file not shown.
BIN
sc_images/room/cp_broadcast/brother_broadcast.pag
Normal file
BIN
sc_images/room/cp_broadcast/brother_broadcast.pag
Normal file
Binary file not shown.
BIN
sc_images/room/cp_broadcast/sisters_broadcast.pag
Normal file
BIN
sc_images/room/cp_broadcast/sisters_broadcast.pag
Normal file
Binary file not shown.
BIN
sc_images/room/mic/CP_mic_1.pag
Normal file
BIN
sc_images/room/mic/CP_mic_1.pag
Normal file
Binary file not shown.
BIN
sc_images/room/mic/CP_mic_2.svga
Normal file
BIN
sc_images/room/mic/CP_mic_2.svga
Normal file
Binary file not shown.
BIN
sc_images/room/mic/CP_mic_3.svga
Normal file
BIN
sc_images/room/mic/CP_mic_3.svga
Normal file
Binary file not shown.
BIN
sc_images/room/mic/CP_mic_4.pag
Normal file
BIN
sc_images/room/mic/CP_mic_4.pag
Normal file
Binary file not shown.
BIN
sc_images/room/mic/CP_mic_5.pag
Normal file
BIN
sc_images/room/mic/CP_mic_5.pag
Normal file
Binary file not shown.
BIN
sc_images/room/mic/brother_mic.pag
Normal file
BIN
sc_images/room/mic/brother_mic.pag
Normal file
Binary file not shown.
BIN
sc_images/room/mic/sisters_mic.pag
Normal file
BIN
sc_images/room/mic/sisters_mic.pag
Normal file
Binary file not shown.
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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',
|
||||
() {
|
||||
|
||||
65
test/sc_cp_relation_pair_utils_test.dart
Normal file
65
test/sc_cp_relation_pair_utils_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
64
需求进度.md
64
需求进度.md
@ -1,5 +1,6 @@
|
||||
# 首充弹窗需求进度(2026-05-21)
|
||||
|
||||
- [ ] 2026-05-22 新增问题:后台首充已配置,但前端未显示首充弹窗,房间礼物面板也未显示首充悬浮板;本轮需用 iPhone 17 Pro 模拟器实际排查并修复。
|
||||
- [x] 2026-05-22 用户反馈:礼物弹窗在 iPhone 17 Pro Max 仍报 `A Stack requires bounded constraints from its parent`;本轮已实际运行 iPhone 17 Pro Max 复现,并依据运行日志修到不再报错。
|
||||
- [x] 2026-05-22 已确认 iPhone 17 Pro Max 模拟器在线,设备 ID:`8D2D87EB-4769-43B7-8DC2-580D46A76B6C`。
|
||||
- [x] 2026-05-22 已在 iPhone 17 Pro Max 启动主 App;控制台先出现 SmartDialog overlay 运行期异常:`RenderAnimatedOpacity ... SmartDialogWidget ... size: MISSING`,说明问题发生在弹窗 overlay 内容尺寸约束层。
|
||||
@ -916,3 +917,66 @@
|
||||
- 如果需要正式 iOS 分发包,为 `scripts/build_release.py` 补入 `--ios-codesign` 所需签名参数并导出 `.ipa`。
|
||||
- 继续评估 Agora 扩展库是否都需要,优先核查 `lip sync`、`spatial audio`、`clear vision`、`segmentation`、`face capture` 等扩展 so 能否裁剪。
|
||||
- 决定是否处理剩余 12 张特殊 `webp`:改格式、换压缩工具,或维持现状。
|
||||
- [ ] 2026-05-22 16:08:31 已确认 iPhone 17 Pro 模拟器在线,并开始检查首充弹窗/礼物悬浮板显示条件。
|
||||
- [ ] 2026-05-22 16:08:53 已读取首充接口模型、弹窗加载逻辑、礼物面板悬浮板条件和首页首充入口,准备定位显示断点。
|
||||
- [ ] 2026-05-22 16:09:12 已确认礼物面板悬浮板仍依赖登录资料 firstRecharge,本地还需接入首充奖励 home 接口状态。
|
||||
- [ ] 2026-05-22 16:12:00 已确认首页入场弹层队列目前只串注册奖励、运营弹窗、每日签到,没有首充弹窗主动展示步骤。
|
||||
- [ ] 2026-05-22 16:12:44 准备用 iPhone 17 Pro 启动当前工程现状,复现首充弹窗和礼物面板悬浮板不显示问题。
|
||||
- [ ] 2026-05-22 16:15:24 iPhone 17 Pro 当前正在编译启动 lib/main.dart,等待进入应用后对比现状。
|
||||
- [ ] 2026-05-22 16:16:56 已截取 iPhone 17 Pro 修复前首页截图,用于确认当前没有首充弹窗。
|
||||
- [ ] 2026-05-22 16:17:15 iPhone 17 Pro 修复前截图显示每日签到弹窗正常展示,但没有首充弹窗,运行日志也未出现首充 home 接口请求。
|
||||
- [ ] 2026-05-22 16:17:34 开始读取首充奖励 Flutter 接口文档,核对 configured/enabled/activityStatus/rewarded 等字段含义。
|
||||
- [ ] 2026-05-22 16:18:13 准备修改首页入场弹层队列和礼物面板首充悬浮板判断,统一由 first-recharge-reward/home 接口驱动。
|
||||
- [ ] 2026-05-22 16:19:01 已完成首充入口代码修改:首页入场队列新增首充远程弹窗,礼物面板悬浮板改为读取首充 home 接口状态。
|
||||
- [ ] 2026-05-22 16:19:18 开始格式化本轮修改的 gift_page.dart 和 index_page.dart。
|
||||
- [ ] 2026-05-22 16:19:28 开始对首充相关修改文件执行 flutter analyze。
|
||||
- [ ] 2026-05-22 16:20:32 flutter analyze 发现 index_page.dart 缺少 AccountStorage 导入,准备补齐后复查。
|
||||
- [ ] 2026-05-22 16:21:15 已补齐 AccountStorage 导入,重新格式化并执行 flutter analyze。
|
||||
- [ ] 2026-05-22 16:22:10 flutter analyze 通过,首充入口修改当前无静态错误。
|
||||
- [ ] 2026-05-22 16:22:37 准备对 iPhone 17 Pro 当前 flutter run 会话执行 hot restart 验证首页首充弹窗。
|
||||
- [ ] 2026-05-22 16:23:24 已截取 iPhone 17 Pro 首页热重启后截图,检查首充弹窗是否展示。
|
||||
- [ ] 2026-05-22 16:24:03 iPhone 17 Pro 热重启后已看到首充弹窗,首页入场首充接口展示链路验证通过;继续验证房间礼物面板悬浮板。
|
||||
- [ ] 2026-05-22 16:26:26 已在 iPhone 17 Pro 进入房间并打开礼物面板,非 CP 礼物 tab 上方已显示首充悬浮板并完成截图。
|
||||
- [ ] 2026-05-22 16:27:08 已切到 CP tab,确认顶部为 CP 专属悬浮板,不显示首充悬浮板,并保存截图。
|
||||
- [ ] 2026-05-22 16:30:42 已点击礼物面板首充悬浮板,确认会关闭礼物面板并打开首充弹窗,已保存截图。
|
||||
- [ ] 2026-05-22 16:31:26 验证礼物悬浮板点击可打开首充弹窗;同时发现进房时有既有 Provider build 期 notify 异常,开始确认是否需一并修复。
|
||||
- [ ] 2026-05-22 16:33:39 准备修复 voice_room_page 在 build 阶段同步通知 RtcProvider 导致的 setState/markNeedsBuild 异常。
|
||||
- [ ] 2026-05-22 16:34:37 已将房间页 Provider 通知延后到首帧后,开始格式化并重新执行 flutter analyze。
|
||||
- [ ] 2026-05-22 16:36:03 flutter analyze 再次通过,包含首充入口和房间页 Provider 通知修复。
|
||||
- [ ] 2026-05-22 16:36:23 准备再次 hot restart iPhone 17 Pro,用干净日志复验首页弹窗、进房、礼物悬浮板和 CP 悬浮板。
|
||||
- [ ] 2026-05-22 16:38:24 最终复验:iPhone 17 Pro hot restart 后首页首充弹窗再次正常显示并截图。
|
||||
- [ ] 2026-05-22 16:40:39 新增反馈:首充奖励 item 不能固定占六个,空数据不显示;启动需补接口决定的进场弹窗;弹窗顺序调整为注册奖励 > 每日签到 > 进场弹窗 > 首充弹窗。
|
||||
- [ ] 2026-05-22 16:41:07 开始读取 APP 进场弹窗接口文档、首页启动弹窗队列、进场弹窗 coordinator 和响应模型。
|
||||
- [ ] 2026-05-22 16:41:43 已定位进场弹窗路径和启动顺序问题,继续检查首充弹窗 rewards grid 是否固定占位。
|
||||
- [ ] 2026-05-22 16:44:01 准备修改进场接口路径、首页启动弹窗顺序,以及首充奖励 item 的空数据过滤/动态数量渲染。
|
||||
- [ ] 2026-05-22 16:46:18 已修改首充 rewards 过滤/动态渲染、首页弹窗顺序和进场弹窗接口路径。
|
||||
- [ ] 2026-05-22 16:46:39 开始格式化并分析首充弹窗、首页启动队列、进场接口仓库和相关页面。
|
||||
- [ ] 2026-05-22 16:48:20 flutter analyze 通过,首充动态 item、进场接口路径和弹窗顺序修改无静态错误。
|
||||
- [ ] 2026-05-22 16:48:51 准备为进场弹窗 coordinator 开启 debug 日志,便于模拟器验证接口队列和本地 limit 判断。
|
||||
- [ ] 2026-05-22 16:49:39 已开启 EntryPopup debug 日志,开始格式化并重新分析相关文件。
|
||||
- [ ] 2026-05-22 16:50:21 flutter analyze 发现 entry popup coordinator 存在空 catch info,准备改为 debug 日志。
|
||||
- [ ] 2026-05-22 16:50:51 已补齐 entry popup 异常 debug 日志,重新执行格式化和分析。
|
||||
- [ ] 2026-05-22 16:51:45 flutter analyze 最终通过,进场弹窗 debug 日志改动无静态问题。
|
||||
- [ ] 2026-05-22 16:52:50 已复查代码:启动顺序为注册奖励 > 每日签到 > 进场弹窗 > 首充弹窗,首充奖励只生成有效 item,进场接口路径已对齐文档。
|
||||
- [ ] 2026-05-22 16:53:25 准备用 iPhone 17 Pro 重新启动验证:检查弹窗顺序、进场弹窗接口和首充动态 item 显示。
|
||||
- [ ] 2026-05-22 16:54:59 iPhone 17 Pro 正在编译启动 lib/main.dart,等待进入首页验证弹窗顺序。
|
||||
- [ ] 2026-05-22 16:55:57 已截取 iPhone 17 Pro 启动后第一阶段弹窗截图。
|
||||
- [ ] 2026-05-22 16:56:28 iPhone 17 Pro 启动第一阶段确认展示每日签到弹窗,符合注册奖励之后、进场弹窗之前的顺序要求。
|
||||
- [ ] 2026-05-22 17:01:20 iPhone 17 Pro 日志显示 /app/popups/entry 当前环境返回 404,准备增加 /go/app/popups/entry 兼容降级。
|
||||
- [ ] 2026-05-22 17:03:08 已为进场弹窗接口增加 404 降级到 /go/app/popups/entry,开始格式化并分析。
|
||||
- [ ] 2026-05-22 17:04:34 flutter analyze 通过,进场弹窗接口降级兼容无静态问题。
|
||||
- [ ] 2026-05-22 17:05:08 准备 hot restart iPhone 17 Pro,复验进场接口兼容降级和弹窗队列。
|
||||
- [ ] 2026-05-22 17:07:03 hot restart 后已重新截取启动队列第一阶段截图。
|
||||
- [ ] 2026-05-22 17:13:01 EntryPopup 日志确认接口已返回 queue=[invite, game],但当前 invite 图片为 cdn.example.com 示例地址,预加载失败所以按规则跳过展示;准备修正金币 item 过滤。
|
||||
- [ ] 2026-05-22 17:14:16 已放宽金币奖励有效判断,金币金额在 content/displayTitle 时也保留;开始格式化和分析。
|
||||
- [ ] 2026-05-22 17:15:21 flutter analyze 通过,金币奖励 content/displayTitle 保留逻辑无静态错误。
|
||||
- [ ] 2026-05-22 17:15:52 准备再次 hot restart 验证首充弹窗:金币奖励应保留,空 item 不显示。
|
||||
- [ ] 2026-05-22 17:18:02 已等待启动队列推进并截取首充弹窗动态 item 截图。
|
||||
- [ ] 2026-05-22 17:19:23 hot restart 后 9 秒仍停留在每日签到,确认启动队列会等待前一个弹窗结束再继续。
|
||||
- [ ] 2026-05-22 17:20:46 已截取最终首充弹窗截图:金币奖励保留,两个空占位 item 已不显示。
|
||||
- [ ] 2026-05-22 17:22:05 准备优化进场弹窗候选选择:队列中某个弹窗图片预加载失败时,继续尝试下一个可展示候选。
|
||||
- [ ] 2026-05-22 17:23:10 已让进场弹窗图片失败时继续尝试下一个候选,开始格式化并分析。
|
||||
- [ ] 2026-05-22 17:24:18 flutter analyze 通过,进场弹窗候选兜底逻辑无静态问题。
|
||||
- [ ] 2026-05-22 17:26:58 准备热加载进场弹窗候选兜底逻辑,并通过前后台切换触发一次 entry popup 验证日志。
|
||||
- [ ] 2026-05-22 17:31:21 当前 iPhone 17 Pro flutter run 会话已停止,避免后台继续占用;候选兜底逻辑已通过 flutter analyze,未再做前后台日志复验。
|
||||
- [x] 2026-05-22 17:32:46 本轮完成:首充奖励按有效接口 item 动态渲染;启动弹窗顺序调整为注册奖励 > 每日签到 > 进场弹窗 > 首充弹窗;进场弹窗接口已接入并支持路径降级与候选图片兜底。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user