From 6a957634360d81a3ce0e83511d58b68a928ee7ad Mon Sep 17 00:00:00 2001 From: roxy Date: Thu, 30 Apr 2026 19:54:24 +0800 Subject: [PATCH] 1.2.7 --- ios/Runner.xcodeproj/project.pbxproj | 12 +- lib/modules/chat/message_chat_page.dart | 310 ++++++++++++-- lib/modules/gift/gift_page.dart | 39 +- .../room/manager/room_member_page.dart | 107 ++--- .../room/rank/room_gift_rank_tab_page.dart | 77 ++-- lib/modules/room/voice_room_page.dart | 205 +++++++-- .../room_game/utils/room_game_viewport.dart | 21 +- lib/modules/user/me_page2.dart | 20 + lib/services/audio/rtc_manager.dart | 349 ++++++++++++++- lib/services/audio/rtm_manager.dart | 4 +- lib/services/auth/user_profile_manager.dart | 82 +++- .../models/res/follow_room_res.dart | 152 +++---- .../models/res/join_room_res.dart | 201 +++++---- .../business_logic/models/res/login_res.dart | 264 +++++++++++- .../business_logic/models/res/mic_res.dart | 29 +- .../models/res/room_gift_rank_res.dart | 27 +- .../models/res/room_member_res.dart | 16 +- .../business_logic/models/res/room_res.dart | 236 ++++++----- .../models/res/room_user_card_res.dart | 305 ++++++------- .../models/message/sc_floating_message.dart | 4 + .../local/floating_screen_manager.dart | 18 +- .../repositories/sc_room_repository_imp.dart | 29 +- .../tools/sc_gift_vap_svga_manager.dart | 263 +++++++++--- lib/shared/tools/sc_path_utils.dart | 16 +- .../room/anim/room_entrance_screen.dart | 70 +++ .../anim/room_gift_seat_flight_overlay.dart | 253 +++++++---- .../floating_vip_entry_screen_widget.dart | 401 ++++++++++++++++++ .../widgets/room/room_bottom_widget.dart | 191 +++++---- lib/ui_kit/widgets/room/room_msg_input.dart | 176 ++++++-- lib/ui_kit/widgets/room/room_msg_item.dart | 361 ++++++++++++++-- lib/ui_kit/widgets/sc_nine_patch_image.dart | 209 +++++++++ .../widgets/svga/sc_network_svga_widget.dart | 179 ++++++++ pubspec.yaml | 2 +- test/gift_vap_svga_manager_priority_test.dart | 53 +++ test/sc_path_utils_test.dart | 29 ++ test/user_profile_vip_colored_id_test.dart | 185 ++++++++ 36 files changed, 3958 insertions(+), 937 deletions(-) create mode 100644 lib/ui_kit/widgets/room/floating/floating_vip_entry_screen_widget.dart create mode 100644 lib/ui_kit/widgets/sc_nine_patch_image.dart create mode 100644 lib/ui_kit/widgets/svga/sc_network_svga_widget.dart create mode 100644 test/gift_vap_svga_manager_priority_test.dart create mode 100644 test/sc_path_utils_test.dart create mode 100644 test/user_profile_vip_colored_id_test.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bd9cabe..170e4b3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -502,7 +502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = S9X2AJ2US9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.6; + MARKETING_VERSION = 1.2.7; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -693,7 +693,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.6; + MARKETING_VERSION = 1.2.7; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -722,7 +722,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -731,7 +731,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.6; + MARKETING_VERSION = 1.2.7; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/lib/modules/chat/message_chat_page.dart b/lib/modules/chat/message_chat_page.dart index bf23fe8..2d073c6 100644 --- a/lib/modules/chat/message_chat_page.dart +++ b/lib/modules/chat/message_chat_page.dart @@ -1,5 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; + import 'package:extended_image/extended_image.dart' show ExtendedImage, ExtendedRawImage, ExtendedImageState, LoadState; import 'package:extended_text/extended_text.dart'; @@ -54,6 +56,8 @@ import 'package:yumi/shared/tools/sc_path_utils.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/modules/index/main_route.dart'; +import 'package:yumi/ui_kit/widgets/sc_nine_patch_image.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart'; import '../../shared/business_logic/models/res/sc_user_red_packet_send_res.dart'; @@ -1245,31 +1249,7 @@ class _MessageItem extends StatelessWidget { return Builder( builder: (ct) { return GestureDetector( - child: Container( - decoration: BoxDecoration( - color: Color(0xff18F2B1).withOpacity(0.1), - borderRadius: BorderRadius.only( - topLeft: - message.isSelf! ? Radius.circular(8) : Radius.circular(0), - topRight: Radius.circular(8.w), - bottomLeft: Radius.circular(8.w), - bottomRight: - message.isSelf! ? Radius.circular(0) : Radius.circular(8), - ), - ), - padding: EdgeInsets.all(8.0.w), - child: Container( - constraints: BoxConstraints(maxWidth: width(220)), - child: ExtendedText( - content, - // specialTextSpanBuilder: MySpecialTextSpanBuilder(), - style: TextStyle( - fontSize: sp(14), - color: message.isSelf! ? Colors.white : Colors.white, - ), - ), - ), - ), + child: _buildTextBubble(content), onLongPress: () { _showMsgItemMenu(ct, content); }, @@ -1278,6 +1258,286 @@ class _MessageItem extends StatelessWidget { ); } + Widget _buildTextBubble(String content) { + final chatBubble = _senderProfile()?.getChatBox(); + final chatBubbleUrl = _resolveChatBubbleImageUrl(chatBubble); + if (chatBubbleUrl.isNotEmpty) { + return _buildVipTextBubble(content: content, imageUrl: chatBubbleUrl); + } + return _buildDefaultTextBubble(content); + } + + SocialChatUserProfile? _senderProfile() { + if (message.isSelf ?? false) { + return AccountStorage().getCurrentUser()?.userProfile; + } + return friend; + } + + Widget _buildDefaultTextBubble(String content) { + return Container( + decoration: BoxDecoration( + color: Color(0xff18F2B1).withValues(alpha: 0.1), + borderRadius: BorderRadius.only( + topLeft: message.isSelf! ? Radius.circular(8) : Radius.circular(0), + topRight: Radius.circular(8.w), + bottomLeft: Radius.circular(8.w), + bottomRight: + message.isSelf! ? Radius.circular(0) : Radius.circular(8), + ), + ), + padding: EdgeInsets.all(8.0.w), + child: Container( + constraints: BoxConstraints(maxWidth: width(220)), + child: ExtendedText( + content, + style: TextStyle(fontSize: sp(14), color: Colors.white), + ), + ), + ); + } + + Widget _buildVipTextBubble({ + required String content, + required String imageUrl, + }) { + final textStyle = TextStyle(fontSize: sp(14), color: Colors.white); + final multilineMaxBubbleWidth = math.min( + ScreenUtil().screenWidth * 0.64, + 260.w, + ); + final multilineMaxTextWidth = math.max( + 90.w, + multilineMaxBubbleWidth - 58.w, + ); + final multilineTextPainter = TextPainter( + text: TextSpan(text: content, style: textStyle), + textDirection: Directionality.of(context), + )..layout(maxWidth: multilineMaxTextWidth); + if (multilineTextPainter.computeLineMetrics().length > 1) { + return _buildMultilineVipTextBubble( + content: content, + imageUrl: imageUrl, + textStyle: textStyle, + textSize: multilineTextPainter.size, + maxBubbleWidth: multilineMaxBubbleWidth, + ); + } + + return _buildSingleLineVipTextBubble( + content: content, + imageUrl: imageUrl, + textStyle: textStyle, + ); + } + + Widget _buildMultilineVipTextBubble({ + required String content, + required String imageUrl, + required TextStyle textStyle, + required Size textSize, + required double maxBubbleWidth, + }) { + final bubbleWidth = math + .max(126.w, textSize.width + 58.w) + .clamp(126.w, maxBubbleWidth); + final textMaxWidth = math.max(90.w, bubbleWidth - 58.w); + final bubbleHeight = math.max(58.w, textSize.height + 26.w); + + return SizedBox( + width: bubbleWidth, + height: bubbleHeight, + child: Stack( + fit: StackFit.expand, + children: [ + _buildVipBubbleBackground( + url: imageUrl, + width: bubbleWidth, + height: bubbleHeight, + fallback: _buildVipBubbleFallback(), + ), + Padding( + padding: EdgeInsetsDirectional.fromSTEB(29.w, 11.w, 29.w, 11.w), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: SizedBox( + width: textMaxWidth, + child: ExtendedText(content, style: textStyle), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSingleLineVipTextBubble({ + required String content, + required String imageUrl, + required TextStyle textStyle, + }) { + final horizontalPadding = 18.w; + final verticalPadding = 8.w; + final minSkinWidth = 132.w; + final minSkinHeight = 44.w; + final maxTextWidth = width(220); + final maxBubbleWidth = maxTextWidth + horizontalPadding * 2; + final textSize = _measureTextSize( + text: content, + style: textStyle, + maxWidth: maxTextWidth, + ); + final bubbleWidth = math.min( + maxBubbleWidth, + math.max(horizontalPadding * 2, textSize.width + horizontalPadding * 2), + ); + final textMaxWidth = math.max(1.0, bubbleWidth - horizontalPadding * 2); + final bubbleHeight = math.max( + verticalPadding * 2, + textSize.height + verticalPadding * 2, + ); + final skinAlignment = + message.isSelf! + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart; + + return SizedBox( + width: bubbleWidth, + height: bubbleHeight, + child: Stack( + clipBehavior: Clip.none, + fit: StackFit.expand, + children: [ + Positioned.fill(child: _buildVipBubbleFallback()), + Positioned.fill( + child: _buildVipBubbleSkin( + url: imageUrl, + width: math.max(bubbleWidth, minSkinWidth), + height: math.max(bubbleHeight, minSkinHeight), + alignment: skinAlignment, + ), + ), + Padding( + padding: EdgeInsetsDirectional.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: SizedBox( + width: textMaxWidth, + child: ExtendedText(content, style: textStyle), + ), + ), + ), + ], + ), + ); + } + + Widget _buildVipBubbleSkin({ + required String url, + required double width, + required double height, + required AlignmentGeometry alignment, + }) { + return OverflowBox( + alignment: alignment, + minWidth: width, + maxWidth: width, + minHeight: height, + maxHeight: height, + child: + SCNetworkSvgaWidget.isSvga(url) + ? SCNetworkSvgaWidget( + resource: url, + width: width, + height: height, + fit: BoxFit.fill, + fallback: SizedBox(width: width, height: height), + ) + : SCNinePatchImage( + url: url, + width: width, + height: height, + centerSliceRatio: const EdgeInsets.fromLTRB( + 0.34, + 0.34, + 0.34, + 0.34, + ), + fallback: SizedBox(width: width, height: height), + ), + ); + } + + Widget _buildVipBubbleBackground({ + required String url, + required double width, + required double height, + required Widget fallback, + }) { + if (SCNetworkSvgaWidget.isSvga(url)) { + return SCNetworkSvgaWidget( + resource: url, + width: width, + height: height, + fit: BoxFit.fill, + fallback: fallback, + ); + } + return SCNinePatchImage( + url: url, + width: width, + height: height, + centerSliceRatio: const EdgeInsets.fromLTRB(0.34, 0.34, 0.34, 0.34), + fallback: fallback, + ); + } + + Size _measureTextSize({ + required String text, + required TextStyle style, + required double maxWidth, + }) { + final textPainter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: Directionality.of(context), + )..layout(maxWidth: maxWidth); + return textPainter.size; + } + + Widget _buildVipBubbleFallback() { + return DecoratedBox( + decoration: BoxDecoration( + color: Color(0xff18F2B1).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8.w), + ), + ); + } + + String _resolveChatBubbleImageUrl(PropsResources? resource) { + return _firstNonBlank([ + resource?.imSendCoverUrl, + resource?.imOpenedUrl, + resource?.imNotOpenedUrl, + resource?.roomSendCoverUrl, + resource?.roomOpenedUrl, + resource?.roomNotOpenedUrl, + resource?.cover, + ]); + } + + String _firstNonBlank(Iterable values) { + for (final value in values) { + final text = value?.trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return ""; + } + void _showMsgItemMenu(BuildContext ct, String content) { SmartDialog.showAttach( tag: "showMsgItemMenu", diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index 3b9d27c..97e7460 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -136,6 +136,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { required int quantity, int animationCount = 1, }) { + final targetUserIds = _resolveAcceptUserIds(acceptUsers); final rtmProvider = Provider.of( navigatorKey.currentState!.context, listen: false, @@ -173,6 +174,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { gift: gift, user: currentUser, toUser: targetUser, + targetUserIds: targetUserIds, number: quantity, customAnimationCount: animationCount, ), @@ -220,19 +222,13 @@ class _GiftPageState extends State with TickerProviderStateMixin { Provider.of(context, listen: false).balance(); rtcProvider?.roomWheatMap.forEach((k, v) { if (v.user != null) { - if (v.user?.id == AccountStorage().getCurrentUser()?.userProfile?.id) { - listMai.add(HeadSelect(true, v)); - } else { - listMai.add(HeadSelect(false, v)); - } - } - isAll = true; - for (var mai in listMai) { - if (!mai.isSelect) { - isAll = false; - } + listMai.add(HeadSelect(widget.toUser == null, v)); } }); + isAll = + widget.toUser == null && + listMai.isNotEmpty && + listMai.every((mai) => mai.isSelect); } @override @@ -1397,6 +1393,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { int animationCount = 1, }) { ///发送一条IM消息 + final targetUserIds = _resolveAcceptUserIds(acceptUsers); for (var u in acceptUsers) { final special = gift.special ?? ""; final giftSourceUrl = gift.giftSourceUrl ?? ""; @@ -1428,6 +1425,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { gift: gift, user: AccountStorage().getCurrentUser()?.userProfile, toUser: u.user, + targetUserIds: targetUserIds, number: quantity, customAnimationCount: animationCount, type: SCRoomMsgType.gift, @@ -1603,11 +1601,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { required int quantity, int animationCount = 1, }) async { - final targetUserIds = - acceptUsers - .map((u) => u.user?.id ?? "") - .where((id) => id.isNotEmpty) - .toList(); + final targetUserIds = _resolveAcceptUserIds(acceptUsers); final firstTargetUser = acceptUsers.isNotEmpty ? acceptUsers.first.user : null; _giftFxLog( @@ -1631,6 +1625,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { gift: gift, user: AccountStorage().getCurrentUser()?.userProfile, toUser: firstTargetUser, + targetUserIds: targetUserIds, number: quantity, customAnimationCount: animationCount, type: SCRoomMsgType.luckGiftAnimOther, @@ -1644,6 +1639,18 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'targetUserIds=${targetUserIds.join(",")}', ); } + + List _resolveAcceptUserIds(List acceptUsers) { + final targetUserIds = []; + for (final acceptUser in acceptUsers) { + final userId = (acceptUser.user?.id ?? "").trim(); + if (userId.isEmpty || targetUserIds.contains(userId)) { + continue; + } + targetUserIds.add(userId); + } + return targetUserIds; + } } class CheckNumber extends StatelessWidget { diff --git a/lib/modules/room/manager/room_member_page.dart b/lib/modules/room/manager/room_member_page.dart index 7f99ba8..e5537c3 100644 --- a/lib/modules/room/manager/room_member_page.dart +++ b/lib/modules/room/manager/room_member_page.dart @@ -9,9 +9,10 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:provider/provider.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; -import 'package:yumi/app/constants/sc_screen.dart'; -import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; -import 'package:yumi/shared/business_logic/models/res/room_member_res.dart'; +import 'package:yumi/app/constants/sc_screen.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_member_res.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; @@ -93,9 +94,10 @@ class _RoomMemberPageState list.isNotEmpty ? buildTag(list.first.roles) : Container(), SizedBox(height: 3.w), Column( - children: List.generate(list.length, (cIndex) { - SocialChatRoomMemberRes userInfo = list[cIndex]; - return GestureDetector( + children: List.generate(list.length, (cIndex) { + SocialChatRoomMemberRes userInfo = list[cIndex]; + final userProfile = _mergeCurrentUserVip(userInfo.userProfile); + return GestureDetector( child: Container( margin: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.w), @@ -106,10 +108,9 @@ class _RoomMemberPageState child: Row( children: [ head( - url: userInfo.userProfile?.userAvatar ?? "", - width: 52.w, - headdress: - userInfo.userProfile?.getHeaddress()?.sourceUrl, + url: userProfile?.userAvatar ?? "", + width: 52.w, + headdress: userProfile?.getHeaddress()?.sourceUrl, ), SizedBox(width: 3.w), Column( @@ -129,57 +130,47 @@ class _RoomMemberPageState SizedBox(width: 3.w), socialchatNickNameText( maxWidth: 160.w, - userInfo.userProfile?.userNickname ?? "", + userProfile?.userNickname ?? "", fontSize: 14.sp, textColor: Colors.black, type: - userInfo.userProfile?.getVIP()?.name ?? - "", - needScroll: - (userInfo - .userProfile - ?.userNickname - ?.characters - .length ?? - 0) > + userProfile?.getVIP()?.name ?? "", + needScroll: + (userProfile + ?.userNickname + ?.characters + .length ?? + 0) > 14, ), SizedBox(width: 3.w), - userInfo.userProfile?.getVIP() != null - ? netImage( - url: - userInfo.userProfile - ?.getVIP() - ?.cover ?? - "", + userProfile?.getVIP() != null + ? netImage( + url: userProfile?.getVIP()?.cover ?? "", width: 25.w, height: 25.w, ) : Container(), SizedBox(width: 5.w), - userInfo.userProfile?.wearBadge?.isNotEmpty ?? - false + userProfile?.wearBadge?.isNotEmpty ?? + false ? SizedBox( height: 25.w, child: ListView.separated( scrollDirection: Axis.horizontal, shrinkWrap: true, itemCount: - userInfo - .userProfile - ?.wearBadge - ?.length ?? - 0, + userProfile?.wearBadge?.length ?? + 0, itemBuilder: (context, index) { return netImage( width: 25.w, height: 25.w, url: - userInfo - .userProfile - ?.wearBadge?[index] - .selectUrl ?? - "", + userProfile + ?.wearBadge?[index] + .selectUrl ?? + "", ); }, separatorBuilder: ( @@ -197,18 +188,17 @@ class _RoomMemberPageState ), SizedBox(height: 3.w), SCSpecialIdBadge( - idText: userInfo.userProfile?.getID() ?? "", - showAnimated: - userInfo.userProfile?.hasSpecialId() ?? false, + idText: userProfile?.getID() ?? "", + showAnimated: userProfile?.hasSpecialId() ?? false, assetPath: SCSpecialIdAssets.userId, animationWidth: 62.w, animationHeight: 24.w, showTextBesideAnimated: true, animatedTextSpacing: 0, showAnimatedGradientText: - userInfo.userProfile - ?.shouldShowColoredSpecialIdText() ?? - false, + userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, animationTextStyle: TextStyle( color: Colors.black, fontSize: 12.sp, @@ -362,10 +352,29 @@ class _RoomMemberPageState if (onErr != null) { onErr(); } - } - } - - Widget buildTag(String? roles) { + } + } + + SocialChatUserProfile? _mergeCurrentUserVip( + SocialChatUserProfile? profile, + ) { + if (profile == null || profile.vipLevelForColoredId > 0) { + return profile; + } + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + if (currentProfile == null || + currentProfile.vipLevelForColoredId <= 0 || + currentProfile.id != profile.id) { + return profile; + } + return profile.copyWith( + vipLevel: + currentProfile.vipLevel ?? + currentProfile.vipLevelForColoredId.toString(), + ); + } + + Widget buildTag(String? roles) { if (roles == SCRoomRolesType.HOMEOWNER.name) { return Row( children: [ diff --git a/lib/modules/room/rank/room_gift_rank_tab_page.dart b/lib/modules/room/rank/room_gift_rank_tab_page.dart index 2c03940..b52901f 100644 --- a/lib/modules/room/rank/room_gift_rank_tab_page.dart +++ b/lib/modules/room/rank/room_gift_rank_tab_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; import 'package:yumi/main.dart'; import 'package:yumi/app_localizations.dart'; @@ -47,9 +49,10 @@ class _RoomGiftRankTabPageState return buildList(context); } - @override - Widget buildItemOne(RoomGiftRankRes userInfo, int index) { - return GestureDetector( + @override + Widget buildItemOne(RoomGiftRankRes userInfo, int index) { + final userProfile = _mergeCurrentUserVip(userInfo.userProfile); + return GestureDetector( child: Container( margin: EdgeInsets.symmetric(vertical: 3.w), padding: EdgeInsets.symmetric(horizontal: 16.w), @@ -90,14 +93,14 @@ class _RoomGiftRankTabPageState SizedBox(width: 10.w), GestureDetector( child: head( - url: userInfo.userProfile?.userAvatar ?? "", + url: userProfile?.userAvatar ?? "", width: 55.w, // headdress: userInfo.userProfile?.getHeaddress()?.sourceUrl, ), onTap: () { showBottomInCenterDialog( navigatorKey.currentState!.context, - RoomUserInfoCard(userId: userInfo.userProfile?.id), + RoomUserInfoCard(userId: userProfile?.id), ); SmartDialog.dismiss(tag: "showRoomGiftRankPage"); }, @@ -110,16 +113,12 @@ class _RoomGiftRankTabPageState children: [ socialchatNickNameText( maxWidth: 170.w, - userInfo.userProfile?.userNickname ?? "", - fontSize: 14.sp, - type: userInfo.userProfile?.getVIP()?.name ?? "", - needScroll: - (userInfo - .userProfile - ?.userNickname - ?.characters - .length ?? - 0) > + userProfile?.userNickname ?? "", + fontSize: 14.sp, + type: userProfile?.getVIP()?.name ?? "", + needScroll: + (userProfile?.userNickname?.characters.length ?? + 0) > 14, ), ], @@ -131,18 +130,17 @@ class _RoomGiftRankTabPageState textDirection: TextDirection.ltr, children: [ SCSpecialIdBadge( - idText: userInfo.userProfile?.getID() ?? "", - showAnimated: - userInfo.userProfile?.hasSpecialId() ?? false, + idText: userProfile?.getID() ?? "", + showAnimated: userProfile?.hasSpecialId() ?? false, assetPath: SCSpecialIdAssets.userId, animationWidth: 62.w, animationHeight: 24.w, showTextBesideAnimated: true, animatedTextSpacing: 0, showAnimatedGradientText: - userInfo.userProfile - ?.shouldShowColoredSpecialIdText() ?? - false, + userProfile + ?.shouldShowColoredSpecialIdText() ?? + false, animationTextStyle: TextStyle( color: Colors.white, fontSize: 12.sp, @@ -165,7 +163,7 @@ class _RoomGiftRankTabPageState ), onTap: () { Clipboard.setData( - ClipboardData(text: userInfo.userProfile?.getID() ?? ""), + ClipboardData(text: userProfile?.getID() ?? ""), ); SCTts.show( SCAppLocalizations.of(context)!.copiedToClipboard, @@ -191,11 +189,30 @@ class _RoomGiftRankTabPageState ), ), onTap: () {}, - ); - } - - @override - empty() { + ); + } + + SocialChatUserProfile? _mergeCurrentUserVip( + SocialChatUserProfile? profile, + ) { + if (profile == null || profile.vipLevelForColoredId > 0) { + return profile; + } + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + if (currentProfile == null || + currentProfile.vipLevelForColoredId <= 0 || + currentProfile.id != profile.id) { + return profile; + } + return profile.copyWith( + vipLevel: + currentProfile.vipLevel ?? + currentProfile.vipLevelForColoredId.toString(), + ); + } + + @override + empty() { return mainEmpty( image: Image.asset( 'sc_images/general/sc_icon_loading.png', diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 246efe2..c6936b5 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -1,5 +1,7 @@ -import 'dart:math' as math; import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; +import 'package:yumi/app/constants/sc_room_msg_type.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -56,6 +58,9 @@ class _VoiceRoomPageState extends State static const Duration _giftAnimationQueueDrainWindow = _luckyGiftQueueDrainWindow; static const int _maxTrackedGiftAnimations = _maxLuckyGiftTrackedAnimations; + static const Duration _giftFlightBatchDedupeWindow = Duration( + milliseconds: 650, + ); late TabController _tabController; final List _pages = [AllChatPage(), ChatPage(), GiftChatPage()]; @@ -64,6 +69,7 @@ class _VoiceRoomPageState extends State RoomGiftSeatFlightController(); final Map _luckyGiftComboSessions = {}; + final Map _giftFlightBatchDedupeTimers = {}; int _shownRoomStartupFailureToken = 0; RtcProvider? _rtcProvider; bool _roomProviderRebuildScheduled = false; @@ -84,6 +90,7 @@ class _VoiceRoomPageState extends State listen: false, ).toggleGiftAnimationVisibility(false); _clearLuckyGiftComboSessions(); + _clearGiftFlightBatchDedupeTimers(); _giftSeatFlightController.clear(); } }); @@ -169,6 +176,7 @@ class _VoiceRoomPageState extends State RoomEntranceHelper.clearQueue(); _clearLuckyGiftComboSessions(); + _clearGiftFlightBatchDedupeTimers(); _giftSeatFlightController.clear(); OverlayManager().removeRoom(); SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend'); @@ -314,7 +322,10 @@ class _VoiceRoomPageState extends State child: Stack( children: [ Column( - children: [_buildChatView(), RoomBottomWidget()], + children: [ + _buildChatView(), + const RoomBottomWidget(showGiftComboButton: false), + ], ), LGiftAnimalPage(), Transform.translate( @@ -336,6 +347,7 @@ class _VoiceRoomPageState extends State // _buildPlayViews(), ///幸运礼物中奖动画 LuckGiftNomorAnimWidget(), + const RoomGiftComboFloatingLayer(), ], ), ), @@ -491,16 +503,27 @@ class _VoiceRoomPageState extends State ).enqueueGiftAnimation(giftModel); final giftPhoto = (msg.gift?.giftPhoto ?? "").trim(); - final targetUserId = _resolveGiftTargetUserId(msg); + final targetUserIds = _resolveGiftTargetUserIds(msg); + final targetGroupKey = _buildGiftTargetGroupKey(targetUserIds); if (_supportsComboMilestoneEffects(msg)) { - _handleComboMilestoneVisuals(msg, targetUserId); + _handleComboMilestoneVisuals(msg, targetGroupKey); } final isLuckyGift = _isLuckyGiftMessage(msg); if (isLuckyGift) { - _handleLuckyGiftComboVisuals(msg, giftPhoto, targetUserId); + _handleLuckyGiftComboVisuals( + msg, + giftPhoto, + targetGroupKey, + targetUserIds, + ); return; } - _handleStandardGiftComboVisuals(msg, giftPhoto, targetUserId); + _handleStandardGiftComboVisuals( + msg, + giftPhoto, + targetGroupKey, + targetUserIds, + ); } void _luckyGiftRewardTickerListener(Msg msg) { @@ -612,17 +635,24 @@ class _VoiceRoomPageState extends State void _handleStandardGiftComboVisuals( Msg msg, String giftPhoto, - String? targetUserId, + String? targetGroupKey, + List targetUserIds, ) { if ((msg.number ?? 0) <= 0) { return; } if (!_shouldPlaySeatFlightGiftAnimation(msg) || - targetUserId == null || + targetGroupKey == null || + targetUserIds.isEmpty || giftPhoto.isEmpty) { return; } - final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetUserId); + final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds); + if (targetUserIds.length > 1 && + !_markGiftFlightBatchForPlayback(flightBatchKey)) { + return; + } + final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey); final session = _luckyGiftComboSessions.putIfAbsent( sessionKey, () => _LuckyGiftComboSession(), @@ -633,8 +663,9 @@ class _VoiceRoomPageState extends State _enqueueTrackedSeatFlightAnimations( sessionKey: sessionKey, giftPhoto: giftPhoto, - targetUserId: targetUserId, + targetUserIds: targetUserIds, animationCount: _resolveTrackedAnimationCount(msg), + batchKey: flightBatchKey, ); _scheduleGiftAnimationSessionEnd(sessionKey); @@ -643,12 +674,16 @@ class _VoiceRoomPageState extends State void _handleLuckyGiftComboVisuals( Msg msg, String giftPhoto, - String? targetUserId, + String? targetGroupKey, + List targetUserIds, ) { if ((msg.number ?? 0) <= 0) { return; } - final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetUserId); + if (targetGroupKey == null || targetUserIds.isEmpty) { + return; + } + final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey); final session = _luckyGiftComboSessions[sessionKey]; if (session == null) { return; @@ -656,14 +691,19 @@ class _VoiceRoomPageState extends State session.endTimer?.cancel(); session.clearQueueTimer?.cancel(); - if (_shouldPlaySeatFlightGiftAnimation(msg) && - targetUserId != null && - giftPhoto.isNotEmpty) { + if (_shouldPlaySeatFlightGiftAnimation(msg) && giftPhoto.isNotEmpty) { + final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds); + if (targetUserIds.length > 1 && + !_markGiftFlightBatchForPlayback(flightBatchKey)) { + _scheduleGiftAnimationSessionEnd(sessionKey); + return; + } _enqueueTrackedSeatFlightAnimations( sessionKey: sessionKey, giftPhoto: giftPhoto, - targetUserId: targetUserId, + targetUserIds: targetUserIds, animationCount: _resolveTrackedAnimationCount(msg), + batchKey: flightBatchKey, ); } _scheduleGiftAnimationSessionEnd(sessionKey); @@ -689,28 +729,42 @@ class _VoiceRoomPageState extends State void _enqueueTrackedSeatFlightAnimations({ required String sessionKey, required String giftPhoto, - required String targetUserId, + required List targetUserIds, required int animationCount, + required String batchKey, }) { unawaited( warmImageResource(giftPhoto, logicalWidth: 96.w, logicalHeight: 96.w), ); + final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds); + if (normalizedTargetUserIds.isEmpty) { + return; + } final normalizedAnimationCount = math.max(animationCount, 1); final cappedAnimationCount = math.min( normalizedAnimationCount, _maxTrackedGiftAnimations, ); + final maxTrackedRequests = math.max( + _maxTrackedGiftAnimations * normalizedTargetUserIds.length, + _maxTrackedGiftAnimations, + ); for (var index = 0; index < cappedAnimationCount; index += 1) { - _giftSeatFlightController.enqueueLimited( - RoomGiftSeatFlightRequest( - imagePath: giftPhoto, - targetUserId: targetUserId, - beginSize: 96.w, - endSize: 28.w, - queueTag: sessionKey, - ), - maxTrackedRequests: _maxTrackedGiftAnimations, - ); + final roundBatchTag = + normalizedTargetUserIds.length > 1 ? '$batchKey|round:$index' : null; + for (final targetUserId in normalizedTargetUserIds) { + _giftSeatFlightController.enqueueLimited( + RoomGiftSeatFlightRequest( + imagePath: giftPhoto, + targetUserId: targetUserId, + beginSize: 96.w, + endSize: 28.w, + queueTag: sessionKey, + batchTag: roundBatchTag, + ), + maxTrackedRequests: maxTrackedRequests, + ); + } } } @@ -742,6 +796,96 @@ class _VoiceRoomPageState extends State }); } + List _resolveGiftTargetUserIds(Msg msg) { + final targetUserIds = []; + void addTargetUserId(String? userId) { + final normalizedUserId = (userId ?? "").trim(); + if (normalizedUserId.isEmpty || + targetUserIds.contains(normalizedUserId)) { + return; + } + targetUserIds.add(normalizedUserId); + } + + for (final userId in msg.targetUserIds ?? const []) { + addTargetUserId(userId); + } + + if (msg.type == SCRoomMsgType.luckGiftAnimOther) { + final rawTargetUserIds = (msg.msg ?? "").trim(); + if (rawTargetUserIds.startsWith("[")) { + try { + final decoded = jsonDecode(rawTargetUserIds); + if (decoded is List) { + for (final userId in decoded) { + addTargetUserId(userId?.toString()); + } + } + } catch (_) {} + } + } + + addTargetUserId(_resolveGiftTargetUserId(msg)); + return targetUserIds; + } + + List _normalizeGiftTargetUserIds(List targetUserIds) { + final normalizedTargetUserIds = []; + for (final targetUserId in targetUserIds) { + final normalizedTargetUserId = targetUserId.trim(); + if (normalizedTargetUserId.isEmpty || + normalizedTargetUserIds.contains(normalizedTargetUserId)) { + continue; + } + normalizedTargetUserIds.add(normalizedTargetUserId); + } + return normalizedTargetUserIds; + } + + String? _buildGiftTargetGroupKey(List targetUserIds) { + final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds); + if (normalizedTargetUserIds.isEmpty) { + return null; + } + normalizedTargetUserIds.sort(); + return normalizedTargetUserIds.join(","); + } + + String _buildGiftFlightBatchKey(Msg msg, List targetUserIds) { + final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds); + normalizedTargetUserIds.sort(); + final timeBucket = + ((msg.time ?? DateTime.now().millisecondsSinceEpoch) / 650).floor(); + return [ + msg.type ?? "", + msg.gift?.id ?? "", + msg.user?.id ?? "", + msg.number?.toString() ?? "", + msg.customAnimationCount?.toString() ?? "", + normalizedTargetUserIds.join(","), + timeBucket.toString(), + ].join("|"); + } + + bool _markGiftFlightBatchForPlayback(String batchKey) { + final normalizedBatchKey = batchKey.trim(); + if (normalizedBatchKey.isEmpty) { + return true; + } + if (_giftFlightBatchDedupeTimers.containsKey(normalizedBatchKey)) { + return false; + } + + late final Timer timer; + timer = Timer(_giftFlightBatchDedupeWindow, () { + if (identical(_giftFlightBatchDedupeTimers[normalizedBatchKey], timer)) { + _giftFlightBatchDedupeTimers.remove(normalizedBatchKey); + } + }); + _giftFlightBatchDedupeTimers[normalizedBatchKey] = timer; + return true; + } + String _buildLuckyGiftComboSessionKey(Msg msg, String? targetUserId) { final senderId = (msg.user?.id ?? '').trim(); final giftId = (msg.gift?.id ?? '').trim(); @@ -755,6 +899,13 @@ class _VoiceRoomPageState extends State _luckyGiftComboSessions.clear(); } + void _clearGiftFlightBatchDedupeTimers() { + for (final timer in _giftFlightBatchDedupeTimers.values) { + timer.cancel(); + } + _giftFlightBatchDedupeTimers.clear(); + } + bool _shouldPlaySeatFlightGiftAnimation(Msg msg) { final gift = msg.gift; if (gift == null) { diff --git a/lib/modules/room_game/utils/room_game_viewport.dart b/lib/modules/room_game/utils/room_game_viewport.dart index acb563a..9902491 100644 --- a/lib/modules/room_game/utils/room_game_viewport.dart +++ b/lib/modules/room_game/utils/room_game_viewport.dart @@ -67,10 +67,6 @@ double resolveRoomGameCompatibilityBottomHeight(BuildContext context) { if (mediaQuery == null) { return 0; } - final isClassicScreen = mediaQuery.padding.bottom <= 0.5; - if (!isClassicScreen) { - return 0; - } return (mediaQuery.size.width * 0.19).clamp(56.0, 88.0).toDouble(); } @@ -97,12 +93,7 @@ class RoomGameCompatibilityBottomFrame extends StatelessWidget { class _RoomGameBottomFramePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final radius = Radius.circular(size.height * 0.18); - final frameRect = RRect.fromRectAndCorners( - Offset.zero & size, - topLeft: radius, - topRight: radius, - ); + final frameRect = Offset.zero & size; final backgroundPaint = Paint() @@ -110,11 +101,11 @@ class _RoomGameBottomFramePainter extends CustomPainter { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF0E392E), Color(0xFF082A23)], - ).createShader(Offset.zero & size); - canvas.drawRRect(frameRect, backgroundPaint); + ).createShader(frameRect); + canvas.drawRect(frameRect, backgroundPaint); canvas.save(); - canvas.clipRRect(frameRect); + canvas.clipRect(frameRect); final patternPaint = Paint() ..color = const Color(0xFF2C5B4A).withValues(alpha: 0.20) @@ -156,8 +147,8 @@ class _RoomGameBottomFramePainter extends CustomPainter { ..style = PaintingStyle.stroke ..strokeWidth = 1; - canvas.drawRRect(frameRect.deflate(1.5), borderPaint); - canvas.drawRRect(frameRect.deflate(5), innerBorderPaint); + canvas.drawRect(frameRect.deflate(1.5), borderPaint); + canvas.drawRect(frameRect.deflate(5), innerBorderPaint); final highlightPaint = Paint() diff --git a/lib/modules/user/me_page2.dart b/lib/modules/user/me_page2.dart index 47b605a..fc6ba75 100644 --- a/lib/modules/user/me_page2.dart +++ b/lib/modules/user/me_page2.dart @@ -86,6 +86,7 @@ class _MePage2State extends State { 'badge=${_vipEntryBadgeLogValue(status.badge)}', ); if (!mounted) return; + _syncCurrentProfileVipStatus(status); setState(() { _vipStatus = status; _isVipStatusLoading = false; @@ -113,6 +114,25 @@ class _MePage2State extends State { return repository.vipStatus(); } + void _syncCurrentProfileVipStatus(SCVipStatusRes status) { + final profileManager = Provider.of( + context, + listen: false, + ); + final profile = profileManager.currentUserProfile; + if (profile == null) return; + + final nextVipLevel = + status.active == false || status.levelInt <= 0 + ? '0' + : status.levelInt.toString(); + if (profile.vipLevel == nextVipLevel) return; + + profileManager.syncCurrentUserProfile( + profile.copyWith(vipLevel: nextVipLevel), + ); + } + void _refreshAfterVipReturn(SocialChatUserProfileManager profileManager) { debugPrint('[VIP][MeEntry] refresh user data after vip page return'); profileManager.fetchUserProfileData(loadGuardCount: false); diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 7221dba..51eec2f 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -10,6 +10,7 @@ import 'package:yumi/shared/tools/sc_permission_utils.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart'; import 'package:yumi/services/room/rc_room_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; @@ -43,6 +44,7 @@ import '../../shared/business_logic/models/res/sc_is_follow_room_res.dart'; import '../../shared/business_logic/models/res/sc_room_red_packet_list_res.dart'; import '../../shared/business_logic/models/res/sc_room_rocket_status_res.dart'; import '../../shared/business_logic/models/res/sc_room_theme_list_res.dart'; +import '../../shared/business_logic/models/res/sc_vip_res.dart'; import '../../shared/tools/sc_room_profile_cache.dart'; import '../../ui_kit/components/sc_float_ichart.dart'; @@ -247,6 +249,7 @@ class RoomEntryPreviewData { ), roomSetting: roomSetting, userProfile: ownerProfile, + userSVipLevel: ownerProfile?.vipLevel, ), ); } @@ -684,6 +687,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { previous.userNickname == next.userNickname && previous.userSex == next.userSex && previous.roles == next.roles && + previous.vipLevel == next.vipLevel && previous.heartbeatVal == next.heartbeatVal; } @@ -748,7 +752,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { : previousMic?.number, ) : roomWheat; - final normalizedUserId = (mergedMic.user?.id ?? "").trim(); + final hydratedMic = _mergeKnownVipIntoMic(mergedMic); + final normalizedUserId = (hydratedMic.user?.id ?? "").trim(); if (normalizedUserId.isNotEmpty) { final existingIndex = userSeatMap[normalizedUserId]; if (existingIndex != null) { @@ -765,7 +770,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } userSeatMap[normalizedUserId] = micIndex; } - nextMap[micIndex] = mergedMic; + nextMap[micIndex] = hydratedMic; } return _stabilizeSelfMicSnapshot(nextMap, previousMap: previousMap); } @@ -2093,7 +2098,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { unawaited(_cleanupStaleEnteredRoom(enteredRoom)); return; } - currenRoom = enteredRoom; + currenRoom = _mergeVoiceRoomVipHints(enteredRoom, currenRoom); + _syncCurrentUserVipHintFromRoom(currenRoom); + unawaited(_refreshCurrentUserVipHintForRoom(entryRequestSerial)); _currentRoomIsEntryPreview = false; _previewRoomSeatCount = null; notifyListeners(); @@ -2112,6 +2119,322 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } + JoinRoomRes _mergeVoiceRoomVipHints( + JoinRoomRes enteredRoom, + JoinRoomRes? previewRoom, + ) { + final roomProfile = enteredRoom.roomProfile; + if (roomProfile == null) { + return enteredRoom; + } + + final enteredOwner = roomProfile.userProfile; + if (_profileHasPositiveVipLevelHint(enteredOwner)) { + return enteredRoom; + } + + final roomId = _firstNonBlankText([roomProfile.roomProfile?.id]); + final ownerId = _firstNonBlankText([ + roomProfile.roomProfile?.userId, + enteredOwner?.id, + ]); + final previewRoomProfile = previewRoom?.roomProfile; + final previewOwner = previewRoomProfile?.userProfile; + final previewRoomId = _firstNonBlankText([ + previewRoomProfile?.roomProfile?.id, + ]); + final previewOwnerId = _firstNonBlankText([ + previewRoomProfile?.roomProfile?.userId, + previewOwner?.id, + ]); + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + + SocialChatUserProfile? resolvedOwner = enteredOwner; + String? vipLevel; + + final canUsePreviewOwner = + previewOwner != null && + _sameNonBlankId(roomId, previewRoomId) && + (ownerId == null || _sameNonBlankId(ownerId, previewOwnerId)); + if (canUsePreviewOwner) { + vipLevel = _profileVipLevelHint(previewOwner); + resolvedOwner ??= previewOwner; + } + + if (vipLevel == null && _sameNonBlankId(ownerId, currentProfile?.id)) { + vipLevel = + _profileVipLevelHint(currentProfile) ?? + _roomVipLevelHintFromEntrants(enteredRoom.entrants); + resolvedOwner ??= currentProfile; + } + + if (vipLevel == null || resolvedOwner == null) { + return enteredRoom; + } + + return enteredRoom.copyWith( + roomProfile: roomProfile.copyWith( + userProfile: resolvedOwner.copyWith(vipLevel: vipLevel), + userSVipLevel: vipLevel, + ), + ); + } + + void _syncCurrentUserVipHintFromRoom(JoinRoomRes? room) { + final currentUser = AccountStorage().getCurrentUser(); + final currentProfile = currentUser?.userProfile; + if (currentUser == null || + currentProfile == null || + _profileHasPositiveVipLevelHint(currentProfile)) { + return; + } + + final ownerProfile = room?.roomProfile?.userProfile; + final ownerId = _firstNonBlankText([ + room?.roomProfile?.roomProfile?.userId, + ownerProfile?.id, + ]); + String? vipLevel; + if (_sameNonBlankId(ownerId, currentProfile.id)) { + vipLevel = _profileVipLevelHint(ownerProfile); + } + vipLevel ??= _roomVipLevelHintFromEntrants(room?.entrants); + if (vipLevel == null) { + return; + } + + AccountStorage().setCurrentUser( + currentUser.copyWith( + userProfile: currentProfile.copyWith(vipLevel: vipLevel), + ), + ); + } + + Future _refreshCurrentUserVipHintForRoom(int entryRequestSerial) async { + final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; + if (roomId.isEmpty) { + return; + } + + final currentUser = AccountStorage().getCurrentUser(); + final currentProfile = currentUser?.userProfile; + if (currentUser == null || currentProfile == null) { + return; + } + + final existingVipLevel = _positiveProfileVipLevelHint(currentProfile); + final vipLevel = + existingVipLevel ?? await _loadCurrentVipLevelForColoredId(); + if (vipLevel == null || vipLevel.isEmpty) { + return; + } + + final updatedProfile = currentProfile.copyWith(vipLevel: vipLevel); + if (!_profileHasPositiveVipLevelHint(currentProfile)) { + AccountStorage().setCurrentUser( + currentUser.copyWith(userProfile: updatedProfile), + ); + } + + if (!_isCurrentRoomEntrySession(entryRequestSerial, roomId)) { + return; + } + + var changed = false; + final mergedRoom = _mergeKnownVipIntoCurrentRoom( + currenRoom, + updatedProfile, + ); + if (!identical(mergedRoom, currenRoom)) { + currenRoom = mergedRoom; + changed = true; + } + + final mergedOnlineUsers = + onlineUsers + .map((user) => _mergeKnownVipIntoProfile(user) ?? user) + .toList(); + if (!_sameOnlineUsers(onlineUsers, mergedOnlineUsers)) { + onlineUsers = mergedOnlineUsers; + _refreshManagerUsers(onlineUsers); + changed = true; + } + + final mergedMicMap = roomWheatMap.map( + (index, mic) => MapEntry(index, _mergeKnownVipIntoMic(mic)), + ); + if (!_sameMicMaps(roomWheatMap, mergedMicMap)) { + roomWheatMap = mergedMicMap; + changed = true; + } + + if (changed) { + notifyListeners(); + } + } + + Future _loadCurrentVipLevelForColoredId() async { + final repository = SCVipRepositoryImp(); + try { + final home = await repository.vipHome(); + final homeVipLevel = _vipLevelFromStatus(home.state); + if (homeVipLevel != null) { + return homeVipLevel; + } + } catch (_) {} + + try { + return _vipLevelFromStatus(await repository.vipStatus()); + } catch (_) { + return null; + } + } + + String? _vipLevelFromStatus(SCVipStatusRes? status) { + if (status == null) { + return null; + } + if (status.active == false) { + return '0'; + } + final level = status.levelInt; + return level > 0 ? level.toString() : '0'; + } + + JoinRoomRes? _mergeKnownVipIntoCurrentRoom( + JoinRoomRes? room, + SocialChatUserProfile currentProfile, + ) { + final roomProfile = room?.roomProfile; + final ownerProfile = roomProfile?.userProfile; + if (room == null || roomProfile == null || ownerProfile == null) { + return room; + } + + final ownerId = _firstNonBlankText([ + roomProfile.roomProfile?.userId, + ownerProfile.id, + ]); + if (!_sameNonBlankId(ownerId, currentProfile.id) || + _profileHasPositiveVipLevelHint(ownerProfile)) { + return room; + } + + final vipLevel = _positiveProfileVipLevelHint(currentProfile); + if (vipLevel == null) { + return room; + } + + return room.copyWith( + roomProfile: roomProfile.copyWith( + userProfile: ownerProfile.copyWith(vipLevel: vipLevel), + userSVipLevel: vipLevel, + ), + ); + } + + MicRes _mergeKnownVipIntoMic(MicRes mic) { + final mergedUser = _mergeKnownVipIntoProfile(mic.user); + if (identical(mergedUser, mic.user)) { + return mic; + } + return mic.copyWith(user: mergedUser); + } + + SocialChatUserProfile? _mergeKnownVipIntoProfile( + SocialChatUserProfile? profile, + ) { + if (profile == null || _profileHasPositiveVipLevelHint(profile)) { + return profile; + } + + final currentProfile = AccountStorage().getCurrentUser()?.userProfile; + final currentVipLevel = _positiveProfileVipLevelHint(currentProfile); + if (_sameNonBlankId(profile.id, currentProfile?.id) && + currentVipLevel != null) { + return profile.copyWith(vipLevel: currentVipLevel); + } + + final ownerProfile = currenRoom?.roomProfile?.userProfile; + final ownerVipLevel = _positiveProfileVipLevelHint(ownerProfile); + final ownerId = _firstNonBlankText([ + currenRoom?.roomProfile?.roomProfile?.userId, + ownerProfile?.id, + ]); + if (_sameNonBlankId(profile.id, ownerId) && ownerVipLevel != null) { + return profile.copyWith(vipLevel: ownerVipLevel); + } + + return profile; + } + + bool _profileHasPositiveVipLevelHint(SocialChatUserProfile? profile) { + return _positiveProfileVipLevelHint(profile) != null; + } + + String? _profileVipLevelHint(SocialChatUserProfile? profile) { + return _firstNonBlankText([profile?.vipLevel]); + } + + String? _positiveProfileVipLevelHint(SocialChatUserProfile? profile) { + final vipLevel = _profileVipLevelHint(profile); + if (profile == null || profile.vipLevelForColoredId <= 0) { + return null; + } + return vipLevel ?? profile.vipLevelForColoredId.toString(); + } + + String? _roomVipLevelHintFromEntrants(Entrants? entrants) { + final abilities = entrants?.nobleVipAbility ?? const []; + var hasAbility = false; + for (final ability in abilities) { + final text = ability.trim(); + final normalized = text.toLowerCase(); + if (text.isEmpty || normalized == '0' || normalized == 'false') { + continue; + } + hasAbility = true; + if (_looksLikeVipLevelText(text)) { + return text; + } + } + return hasAbility ? '1' : null; + } + + bool _looksLikeVipLevelText(String text) { + final plainLevel = int.tryParse(text); + if ((plainLevel ?? 0) > 0) { + return true; + } + if (RegExp( + r'(?:S?VIP|NOBLE[_\s-]*VIP)[^\d]*[1-9]\d*', + caseSensitive: false, + ).hasMatch(text)) { + return true; + } + return RegExp(r'[1-9]\d*$').hasMatch(text); + } + + String? _firstNonBlankText(Iterable values) { + for (final value in values) { + final text = value?.toString().trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return null; + } + + bool _sameNonBlankId(dynamic left, dynamic right) { + final leftText = left?.toString().trim(); + final rightText = right?.toString().trim(); + return leftText != null && + leftText.isNotEmpty && + rightText != null && + rightText.isNotEmpty && + leftText == rightText; + } + bool _isActiveRoomEntryRequest(int entryRequestSerial) { return entryRequestSerial == _roomEntryRequestSerial && _roomStartupStatus == RoomStartupStatus.loading; @@ -2355,7 +2678,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { !_isCurrentRoomEntrySession(entryRequestSerial, roomId)) { return; } - SCGiftVapSvgaManager().play(sourceUrl, priority: 100, type: 1); + SCGiftVapSvgaManager().play( + sourceUrl, + priority: SCGiftVapSvgaManager.entryEffectPriority, + type: SCGiftVapSvgaManager.entryEffectType, + ); }); } } @@ -2405,8 +2732,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (roomId != currenRoom?.roomProfile?.roomProfile?.id) { return; } - final changed = !_sameOnlineUsers(onlineUsers, fetchedUsers); - onlineUsers = fetchedUsers; + final mergedUsers = + fetchedUsers.map((user) => _mergeKnownVipIntoProfile(user)!).toList(); + final changed = !_sameOnlineUsers(onlineUsers, mergedUsers); + onlineUsers = mergedUsers; _refreshManagerUsers(onlineUsers); if (changed || notifyIfUnchanged) { notifyListeners(); @@ -3399,15 +3728,19 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (groupId != currenRoom?.roomProfile?.roomProfile?.roomAccount) { return; } + final mergedUser = _mergeKnownVipIntoProfile(user) ?? user; bool isExtOnlineList = false; for (var us in onlineUsers) { - if (us.id == user.id) { + if (us.id == mergedUser.id) { isExtOnlineList = true; break; } } if (!isExtOnlineList) { - Provider.of(context!, listen: false).onlineUsers.add(user); + Provider.of( + context!, + listen: false, + ).onlineUsers.add(mergedUser); _refreshManagerUsers(onlineUsers); notifyListeners(); } diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 901e3c1..10ae4eb 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -1517,8 +1517,8 @@ class RealTimeMessagingManager extends ChangeNotifier { shouldShowRoomVisualEffects) { SCGiftVapSvgaManager().play( msg.user?.getMountains()?.sourceUrl ?? "", - priority: 100, - type: 1, + priority: SCGiftVapSvgaManager.entryEffectPriority, + type: SCGiftVapSvgaManager.entryEffectType, ); } } diff --git a/lib/services/auth/user_profile_manager.dart b/lib/services/auth/user_profile_manager.dart index a293e5c..cb3f313 100644 --- a/lib/services/auth/user_profile_manager.dart +++ b/lib/services/auth/user_profile_manager.dart @@ -4,11 +4,13 @@ import 'package:yumi/ui_kit/components/sc_tts.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.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/data_sources/sources/repositories/sc_vip_repository_imp.dart'; import 'package:yumi/shared/business_logic/models/req/sc_user_profile_cmd.dart'; import 'package:yumi/shared/business_logic/models/res/country_res.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/business_logic/models/res/room_user_card_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_identity_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart'; class SocialChatUserProfileManager extends ChangeNotifier { SCUserProfileCmd? editUser; @@ -86,6 +88,7 @@ class SocialChatUserProfileManager extends ChangeNotifier { return; } var userInfo = await SCAccountRepository().loadUserInfo(userId); + userInfo = await _withCurrentVipLevel(userInfo, userId); syncCurrentUserProfile(userInfo); } @@ -101,7 +104,8 @@ class SocialChatUserProfileManager extends ChangeNotifier { void getUserInfoById(String userId) async { userProfile = null; - userProfile = await SCAccountRepository().loadUserInfo(userId); + final loadedProfile = await SCAccountRepository().loadUserInfo(userId); + userProfile = await _withCurrentVipLevel(loadedProfile, userId); notifyListeners(); } @@ -114,9 +118,85 @@ class SocialChatUserProfileManager extends ChangeNotifier { void roomUserCard(String roomId, String userId) async { userCardInfo = null; userCardInfo = await SCChatRoomRepository().roomUserCard(roomId, userId); + userCardInfo = await _withCurrentVipLevelForCard(userCardInfo, userId); notifyListeners(); } + Future _withCurrentVipLevelForCard( + RoomUserCardRes? card, + String userId, + ) async { + final profile = card?.userProfile; + if (card == null || profile == null) { + return card; + } + final mergedProfile = await _withCurrentVipLevel(profile, userId); + if (mergedProfile == profile) { + return card; + } + return card.copyWith(userProfile: mergedProfile); + } + + Future _withCurrentVipLevel( + SocialChatUserProfile profile, + String userId, + ) async { + if (!_isCurrentUserId(userId)) { + return profile; + } + + final profileVipLevel = profile.vipLevel?.trim(); + if (profileVipLevel != null && profileVipLevel.isNotEmpty) { + return profile; + } + + final currentVipLevel = currentUserProfile?.vipLevel?.trim(); + final resolvedVipLevel = + currentVipLevel != null && currentVipLevel.isNotEmpty + ? currentVipLevel + : await _loadCurrentVipLevel(); + if (resolvedVipLevel == null || resolvedVipLevel.isEmpty) { + return profile; + } + + return profile.copyWith(vipLevel: resolvedVipLevel); + } + + bool _isCurrentUserId(String userId) { + final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id; + return currentUserId != null && + currentUserId.isNotEmpty && + currentUserId == userId; + } + + Future _loadCurrentVipLevel() async { + final repository = SCVipRepositoryImp(); + try { + final home = await repository.vipHome(); + final homeVipLevel = _vipLevelFromStatus(home.state); + if (homeVipLevel != null) { + return homeVipLevel; + } + } catch (_) {} + + try { + return _vipLevelFromStatus(await repository.vipStatus()); + } catch (_) { + return null; + } + } + + String? _vipLevelFromStatus(SCVipStatusRes? status) { + if (status == null) { + return null; + } + if (status.active == false) { + return '0'; + } + final level = status.levelInt; + return level > 0 ? level.toString() : '0'; + } + void followUser(String userId) async { var result = await SCAccountRepository().followUser(userId); if (result) { diff --git a/lib/shared/business_logic/models/res/follow_room_res.dart b/lib/shared/business_logic/models/res/follow_room_res.dart index de2321b..a5bd4b8 100644 --- a/lib/shared/business_logic/models/res/follow_room_res.dart +++ b/lib/shared/business_logic/models/res/follow_room_res.dart @@ -53,8 +53,8 @@ class FollowRoomRes { /// userProfile : {"account":"","accountStatus":"","age":0,"bornDay":0,"bornMonth":0,"bornYear":0,"countryCode":"","countryId":0,"countryName":"","createTime":0,"del":false,"freezingTime":0,"id":"s","originSys":"","ownSpecialId":{"account":"","expiredTime":0},"sameRegion":false,"sysOriginChild":"","useProps":[{"expireTime":0,"propsResources":{"amount":0.0,"code":"","cover":"","expand":"","id":0,"name":"","sourceUrl":"","type":""},"userId":0}],"userAvatar":"","userNickname":"","userSex":0,"wearBadge":[{"animationUrl":"","badgeKey":"","badgeLevel":0,"badgeName":"","expireTime":0,"id":0,"milestone":0,"notSelectUrl":"","selectUrl":"","type":"","userId":0}]} /// userSVipLevel : "" -class RoomProfile { - RoomProfile({ +class RoomProfile { + RoomProfile({ String? countryCode, String? countryName, String? event, @@ -69,12 +69,12 @@ class RoomProfile { String? roomGameIcon, String? roomName, String? sysOrigin, - String? userId, - SocialChatUserProfile? userProfile, - String? userSVipLevel, - RoomMemberCounter? roomCounter, - ExtValues? extValues, - }) { + String? userId, + SocialChatUserProfile? userProfile, + String? userSVipLevel, + RoomMemberCounter? roomCounter, + ExtValues? extValues, + }) { _countryCode = countryCode; _countryName = countryName; _event = event; @@ -89,12 +89,16 @@ class RoomProfile { _roomGameIcon = roomGameIcon; _roomName = roomName; _sysOrigin = sysOrigin; - _userId = userId; - _userProfile = userProfile; - _userSVipLevel = userSVipLevel; - _roomCounter = roomCounter; - _extValues = extValues; - } + _userId = userId; + _userSVipLevel = userSVipLevel; + final normalizedVipLevel = userSVipLevel?.trim(); + _userProfile = + normalizedVipLevel == null || normalizedVipLevel.isEmpty + ? userProfile + : userProfile?.copyWith(vipLevel: normalizedVipLevel); + _roomCounter = roomCounter; + _extValues = extValues; + } RoomProfile.fromJson(dynamic json) { _countryCode = json['countryCode']; @@ -115,18 +119,22 @@ class RoomProfile { _roomName = json['roomName']; _sysOrigin = json['sysOrigin']; _userId = json['userId']; - _userProfile = - json['userProfile'] != null - ? SocialChatUserProfile.fromJson(json['userProfile']) - : null; - _userSVipLevel = json['userSVipLevel']; - final counterJson = json['roomCounter'] ?? json['counter']; - _roomCounter = - counterJson != null ? RoomMemberCounter.fromJson(counterJson) : null; - _extValues = - json['extValues'] != null - ? ExtValues.fromJson(json['extValues']) - : null; + _userProfile = + json['userProfile'] != null + ? SocialChatUserProfile.fromJson(json['userProfile']) + : null; + _userSVipLevel = socialChatVipLevelHintFromJson(json); + final normalizedVipLevel = _userSVipLevel?.trim(); + if (normalizedVipLevel != null && normalizedVipLevel.isNotEmpty) { + _userProfile = _userProfile?.copyWith(vipLevel: normalizedVipLevel); + } + final counterJson = json['roomCounter'] ?? json['counter']; + _roomCounter = + counterJson != null ? RoomMemberCounter.fromJson(counterJson) : null; + _extValues = + json['extValues'] != null + ? ExtValues.fromJson(json['extValues']) + : null; } String? _countryCode; String? _countryName; @@ -142,11 +150,11 @@ class RoomProfile { String? _roomGameIcon; String? _roomName; String? _sysOrigin; - String? _userId; - SocialChatUserProfile? _userProfile; - String? _userSVipLevel; - RoomMemberCounter? _roomCounter; - ExtValues? _extValues; + String? _userId; + SocialChatUserProfile? _userProfile; + String? _userSVipLevel; + RoomMemberCounter? _roomCounter; + ExtValues? _extValues; RoomProfile copyWith({ String? countryCode, String? countryName, @@ -162,12 +170,12 @@ class RoomProfile { String? roomGameIcon, String? roomName, String? sysOrigin, - String? userId, - SocialChatUserProfile? userProfile, - String? userSVipLevel, - RoomMemberCounter? roomCounter, - ExtValues? extValues, - }) => RoomProfile( + String? userId, + SocialChatUserProfile? userProfile, + String? userSVipLevel, + RoomMemberCounter? roomCounter, + ExtValues? extValues, + }) => RoomProfile( countryCode: countryCode ?? _countryCode, countryName: countryName ?? _countryName, event: event ?? _event, @@ -182,12 +190,12 @@ class RoomProfile { roomGameIcon: roomGameIcon ?? _roomGameIcon, roomName: roomName ?? _roomName, sysOrigin: sysOrigin ?? _sysOrigin, - userId: userId ?? _userId, - userProfile: userProfile ?? _userProfile, - userSVipLevel: userSVipLevel ?? _userSVipLevel, - roomCounter: roomCounter ?? _roomCounter, - extValues: extValues ?? _extValues, - ); + userId: userId ?? _userId, + userProfile: userProfile ?? _userProfile, + userSVipLevel: userSVipLevel ?? _userSVipLevel, + roomCounter: roomCounter ?? _roomCounter, + extValues: extValues ?? _extValues, + ); String? get countryCode => _countryCode; String? get countryName => _countryName; String? get event => _event; @@ -203,25 +211,25 @@ class RoomProfile { String? get roomName => _roomName; String? get sysOrigin => _sysOrigin; String? get userId => _userId; - SocialChatUserProfile? get userProfile => _userProfile; - String? get userSVipLevel => _userSVipLevel; - RoomMemberCounter? get roomCounter => _roomCounter; - ExtValues? get extValues => _extValues; - - String get displayMemberCount { - final memberCount = roomCounter?.memberCount; - if (memberCount != null) { - if (memberCount == memberCount.roundToDouble()) { - return memberCount.toInt().toString(); - } - return memberCount.toString(); - } - final memberQuantity = extValues?.memberQuantity; - if ((memberQuantity ?? "").trim().isNotEmpty) { - return memberQuantity!; - } - return "0"; - } + SocialChatUserProfile? get userProfile => _userProfile; + String? get userSVipLevel => _userSVipLevel; + RoomMemberCounter? get roomCounter => _roomCounter; + ExtValues? get extValues => _extValues; + + String get displayMemberCount { + final memberCount = roomCounter?.memberCount; + if (memberCount != null) { + if (memberCount == memberCount.roundToDouble()) { + return memberCount.toInt().toString(); + } + return memberCount.toString(); + } + final memberQuantity = extValues?.memberQuantity; + if ((memberQuantity ?? "").trim().isNotEmpty) { + return memberQuantity!; + } + return "0"; + } Map toJson() { final map = {}; @@ -240,16 +248,16 @@ class RoomProfile { map['roomName'] = _roomName; map['sysOrigin'] = _sysOrigin; map['userId'] = _userId; - if (_userProfile != null) { - map['userProfile'] = _userProfile?.toJson(); - } - map['userSVipLevel'] = _userSVipLevel; - if (_roomCounter != null) { - map['roomCounter'] = _roomCounter?.toJson(); - } - if (_extValues != null) { - map['extValues'] = _extValues?.toJson(); - } + if (_userProfile != null) { + map['userProfile'] = _userProfile?.toJson(); + } + map['userSVipLevel'] = _userSVipLevel; + if (_roomCounter != null) { + map['roomCounter'] = _roomCounter?.toJson(); + } + if (_extValues != null) { + map['extValues'] = _extValues?.toJson(); + } return map; } } diff --git a/lib/shared/business_logic/models/res/join_room_res.dart b/lib/shared/business_logic/models/res/join_room_res.dart index f38d9c6..575353a 100644 --- a/lib/shared/business_logic/models/res/join_room_res.dart +++ b/lib/shared/business_logic/models/res/join_room_res.dart @@ -17,17 +17,31 @@ class JoinRoomRes { _roomProps = roomProps; } - JoinRoomRes.fromJson(dynamic json) { - _entrants = - json['entrants'] != null ? Entrants.fromJson(json['entrants']) : null; - _roomProfile = - json['roomProfile'] != null - ? RoomProfile.fromJson(json['roomProfile']) - : null; - _roomProps = - json['roomProps'] != null - ? RoomProps.fromJson(json['roomProps']) - : null; + JoinRoomRes.fromJson(dynamic json) { + _entrants = + json['entrants'] != null ? Entrants.fromJson(json['entrants']) : null; + _roomProfile = + json['roomProfile'] != null + ? RoomProfile.fromJson(json['roomProfile']) + : null; + final roomVipLevel = _firstNonBlankRoomVipLevel([ + json['userSVipLevel'], + json['supperVip'], + json['superVip'], + json['vipLevel'], + json['svipLevel'], + json['sVipLevel'], + json['nobleVipLevel'], + json['vipLevelCode'], + json['levelCode'], + ]); + if (roomVipLevel != null) { + _roomProfile = _roomProfile?.copyWith(userSVipLevel: roomVipLevel); + } + _roomProps = + json['roomProps'] != null + ? RoomProps.fromJson(json['roomProps']) + : null; } Entrants? _entrants; @@ -62,13 +76,23 @@ class JoinRoomRes { map['roomProps'] = _roomProps?.toJson(); } return map; - } -} - -/// layoutCode : "" -/// roomTheme : {"expireTime":0,"id":0,"themeBack":"","themeStatus":"","useTheme":false} - -class RoomProps { + } +} + +String? _firstNonBlankRoomVipLevel(Iterable values) { + for (final value in values) { + final text = value?.toString().trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return null; +} + +/// layoutCode : "" +/// roomTheme : {"expireTime":0,"id":0,"themeBack":"","themeStatus":"","useTheme":false} + +class RoomProps { RoomProps({String? layoutCode, RoomTheme? roomTheme}) { _layoutCode = layoutCode; _roomTheme = roomTheme; @@ -181,26 +205,33 @@ class RoomTheme { /// regionCode : "" /// roomCounter : {"adminCount":0,"memberCount":0} /// roomProfile : {"activeTime":0,"countryCode":"","countryName":"","createTime":0,"del":false,"event":"","id":"0","langCode":"","nationalFlag":"","roomAccount":"","roomCover":"","roomDesc":"","roomName":"","sysOrigin":"","updateTime":0,"userId":"0"} -/// roomSetting : {"adminLockSeat":false,"allowMusic":false,"joinGolds":0,"lockLocation":"","maxAdmin":0,"maxMember":0,"mikeSize":0,"openKtvMode":false,"password":"","roomId":0,"roomSpecialMikeType":"","showHeartbeat":false,"takeMicRole":"","touristMike":false,"touristMsg":false,"userId":"0"} -/// userProfile : {"account":"","accountStatus":"","age":0,"bornDay":0,"bornMonth":0,"bornYear":0,"countryCode":"","countryId":0,"countryName":"","createTime":0,"del":false,"freezingTime":0,"id":"0","originSys":"","ownSpecialId":{"account":"","expiredTime":0},"sameRegion":false,"sysOriginChild":"","useProps":[{"expireTime":0,"propsResources":{"amount":0.0,"code":"","cover":"","expand":"","id":0,"name":"","sourceUrl":"","type":""},"userId":0}],"userAvatar":"","userNickname":"","userSex":0,"wearBadge":[{"animationUrl":"","badgeKey":"","badgeLevel":0,"badgeName":"","expireTime":0,"id":0,"milestone":0,"notSelectUrl":"","selectUrl":"","type":"","userId":0}]} - -class RoomProfile { - RoomProfile({ - String? regionCode, - RoomCounter? roomCounter, - RoomProfile2? roomProfile, - RoomSetting? roomSetting, - SocialChatUserProfile? userProfile, - }) { - _regionCode = regionCode; - _roomCounter = roomCounter; - _roomProfile = roomProfile; - _roomSetting = roomSetting; - _userProfile = userProfile; - } - - RoomProfile.fromJson(dynamic json) { - _regionCode = json['regionCode']; +/// roomSetting : {"adminLockSeat":false,"allowMusic":false,"joinGolds":0,"lockLocation":"","maxAdmin":0,"maxMember":0,"mikeSize":0,"openKtvMode":false,"password":"","roomId":0,"roomSpecialMikeType":"","showHeartbeat":false,"takeMicRole":"","touristMike":false,"touristMsg":false,"userId":"0"} +/// userProfile : {"account":"","accountStatus":"","age":0,"bornDay":0,"bornMonth":0,"bornYear":0,"countryCode":"","countryId":0,"countryName":"","createTime":0,"del":false,"freezingTime":0,"id":"0","originSys":"","ownSpecialId":{"account":"","expiredTime":0},"sameRegion":false,"sysOriginChild":"","useProps":[{"expireTime":0,"propsResources":{"amount":0.0,"code":"","cover":"","expand":"","id":0,"name":"","sourceUrl":"","type":""},"userId":0}],"userAvatar":"","userNickname":"","userSex":0,"wearBadge":[{"animationUrl":"","badgeKey":"","badgeLevel":0,"badgeName":"","expireTime":0,"id":0,"milestone":0,"notSelectUrl":"","selectUrl":"","type":"","userId":0}]} +/// userSVipLevel : "" + +class RoomProfile { + RoomProfile({ + String? regionCode, + RoomCounter? roomCounter, + RoomProfile2? roomProfile, + RoomSetting? roomSetting, + SocialChatUserProfile? userProfile, + String? userSVipLevel, + }) { + _regionCode = regionCode; + _roomCounter = roomCounter; + _roomProfile = roomProfile; + _roomSetting = roomSetting; + _userSVipLevel = userSVipLevel; + final normalizedVipLevel = userSVipLevel?.trim(); + _userProfile = + normalizedVipLevel == null || normalizedVipLevel.isEmpty + ? userProfile + : userProfile?.copyWith(vipLevel: normalizedVipLevel); + } + + RoomProfile.fromJson(dynamic json) { + _regionCode = json['regionCode']; _roomCounter = json['roomCounter'] != null ? RoomCounter.fromJson(json['roomCounter']) @@ -213,31 +244,52 @@ class RoomProfile { json['roomSetting'] != null ? RoomSetting.fromJson(json['roomSetting']) : null; - _userProfile = - json['userProfile'] != null - ? SocialChatUserProfile.fromJson(json['userProfile']) - : null; - } - - String? _regionCode; - RoomCounter? _roomCounter; - RoomProfile2? _roomProfile; - RoomSetting? _roomSetting; - SocialChatUserProfile? _userProfile; - - RoomProfile copyWith({ - String? regionCode, - RoomCounter? roomCounter, - RoomProfile2? roomProfile, - RoomSetting? roomSetting, - SocialChatUserProfile? userProfile, - }) => RoomProfile( - regionCode: regionCode ?? _regionCode, - roomCounter: roomCounter ?? _roomCounter, - roomProfile: roomProfile ?? _roomProfile, - roomSetting: roomSetting ?? _roomSetting, - userProfile: userProfile ?? _userProfile, - ); + _userProfile = + json['userProfile'] != null + ? SocialChatUserProfile.fromJson(json['userProfile']) + : null; + _userSVipLevel = _firstNonBlankRoomVipLevel([ + json['userSVipLevel'], + json['supperVip'], + json['superVip'], + json['vipLevel'], + json['svipLevel'], + json['sVipLevel'], + json['nobleVipLevel'], + json['vipLevelCode'], + json['levelCode'], + json['roomProfile'] is Map ? json['roomProfile']['userSVipLevel'] : null, + json['roomProfile'] is Map ? json['roomProfile']['supperVip'] : null, + json['roomProfile'] is Map ? json['roomProfile']['vipLevel'] : null, + ]); + final normalizedVipLevel = _userSVipLevel?.trim(); + if (normalizedVipLevel != null && normalizedVipLevel.isNotEmpty) { + _userProfile = _userProfile?.copyWith(vipLevel: normalizedVipLevel); + } + } + + String? _regionCode; + RoomCounter? _roomCounter; + RoomProfile2? _roomProfile; + RoomSetting? _roomSetting; + SocialChatUserProfile? _userProfile; + String? _userSVipLevel; + + RoomProfile copyWith({ + String? regionCode, + RoomCounter? roomCounter, + RoomProfile2? roomProfile, + RoomSetting? roomSetting, + SocialChatUserProfile? userProfile, + String? userSVipLevel, + }) => RoomProfile( + regionCode: regionCode ?? _regionCode, + roomCounter: roomCounter ?? _roomCounter, + roomProfile: roomProfile ?? _roomProfile, + roomSetting: roomSetting ?? _roomSetting, + userProfile: userProfile ?? _userProfile, + userSVipLevel: userSVipLevel ?? _userSVipLevel, + ); String? get regionCode => _regionCode; @@ -247,11 +299,13 @@ class RoomProfile { RoomSetting? get roomSetting => _roomSetting; - SocialChatUserProfile? get userProfile => _userProfile; - - Map toJson() { - final map = {}; - map['regionCode'] = _regionCode; + SocialChatUserProfile? get userProfile => _userProfile; + + String? get userSVipLevel => _userSVipLevel; + + Map toJson() { + final map = {}; + map['regionCode'] = _regionCode; if (_roomCounter != null) { map['roomCounter'] = _roomCounter?.toJson(); } @@ -261,11 +315,12 @@ class RoomProfile { if (_roomSetting != null) { map['roomSetting'] = _roomSetting?.toJson(); } - if (_userProfile != null) { - map['userProfile'] = _userProfile?.toJson(); - } - return map; - } + if (_userProfile != null) { + map['userProfile'] = _userProfile?.toJson(); + } + map['userSVipLevel'] = _userSVipLevel; + return map; + } void setRoomSetting(RoomSetting roomSetting) { _roomSetting = roomSetting; diff --git a/lib/shared/business_logic/models/res/login_res.dart b/lib/shared/business_logic/models/res/login_res.dart index b7d2f3e..3718284 100644 --- a/lib/shared/business_logic/models/res/login_res.dart +++ b/lib/shared/business_logic/models/res/login_res.dart @@ -121,6 +121,7 @@ class SocialChatUserProfile { num? heartbeatVal, String? originSys, OwnSpecialId? ownSpecialId, + String? vipLevel, bool? sameRegion, int? firstRechargeEndTime, String? sysOriginChild, @@ -160,6 +161,7 @@ class SocialChatUserProfile { _wealthLevel = wealthLevel; _heartbeatVal = heartbeatVal; _ownSpecialId = ownSpecialId; + _vipLevel = vipLevel; _sameRegion = sameRegion; _isCpRelation = isCpRelation; _firstRechargeEndTime = firstRechargeEndTime; @@ -178,10 +180,14 @@ class SocialChatUserProfile { _backgroundPhotos = backgroundPhotos; _personalPhotos = personalPhotos; _cpList = cpList; - } - - SocialChatUserProfile.fromJson(dynamic json) { - _account = json['account']; + } + + SocialChatUserProfile.fromJson(dynamic json) { + final sourceJson = json; + if (sourceJson is Map && sourceJson['userProfile'] is Map) { + json = sourceJson['userProfile']; + } + _account = json['account']; _accountStatus = json['accountStatus']; _age = json['age']; _bornDay = json['bornDay']; @@ -204,6 +210,10 @@ class SocialChatUserProfile { json['ownSpecialId'] != null ? OwnSpecialId.fromJson(json['ownSpecialId']) : null; + _vipLevel = _firstNonBlankString([ + ..._vipLevelHintValues(json), + ..._vipLevelHintValues(sourceJson), + ]); _sameRegion = json['sameRegion']; _isCpRelation = json['isCpRelation']; _firstRechargeEndTime = json['firstRechargeEndTime']; @@ -274,6 +284,7 @@ class SocialChatUserProfile { num? _wealthLevel; num? _heartbeatVal; OwnSpecialId? _ownSpecialId; + String? _vipLevel; bool? _sameRegion; bool? _isCpRelation; int? _firstRechargeEndTime; @@ -314,6 +325,7 @@ class SocialChatUserProfile { num? wealthLevel, num? heartbeatVal, OwnSpecialId? ownSpecialId, + String? vipLevel, bool? sameRegion, bool? isCpRelation, int? firstRechargeEndTime, @@ -353,6 +365,7 @@ class SocialChatUserProfile { wealthLevel: wealthLevel ?? _wealthLevel, heartbeatVal: heartbeatVal ?? _heartbeatVal, ownSpecialId: ownSpecialId ?? _ownSpecialId, + vipLevel: vipLevel ?? _vipLevel, sameRegion: sameRegion ?? _sameRegion, isCpRelation: isCpRelation ?? _isCpRelation, firstRechargeEndTime: firstRechargeEndTime ?? _firstRechargeEndTime, @@ -413,6 +426,8 @@ class SocialChatUserProfile { OwnSpecialId? get ownSpecialId => _ownSpecialId; + String? get vipLevel => _vipLevel; + bool? get sameRegion => _sameRegion; bool? get isCpRelation => _isCpRelation; @@ -494,6 +509,26 @@ class SocialChatUserProfile { return pr; } + PropsResources? getFloatPicture() { + PropsResources? pr; + _useProps?.forEach((value) { + if (value.propsResources?.type == SCPropsType.FLOAT_PICTURE.name) { + pr = value.propsResources; + } + }); + return pr; + } + + PropsResources? getVipBadge() { + PropsResources? pr; + _useProps?.forEach((value) { + if (value.propsResources?.type == SCPropsType.BADGE.name) { + pr = value.propsResources; + } + }); + return pr; + } + PropsResources? getMountains() { PropsResources? pr; _useProps?.forEach((value) { @@ -547,6 +582,9 @@ class SocialChatUserProfile { if (_ownSpecialId != null) { map['ownSpecialId'] = _ownSpecialId?.toJson(); } + if (_vipLevel != null) { + map['vipLevel'] = _vipLevel; + } map['sameRegion'] = _sameRegion; map['isCpRelation'] = _isCpRelation; map['firstRecharge'] = _firstRecharge; @@ -591,22 +629,212 @@ class SocialChatUserProfile { return false; } - /// ID 数字渐变字的 VIP 条件预留。 - /// 当前先复用 useProps 里的 NOBLE_VIP,道具接口稳定后可切到独立字段。 - bool hasVipPrivilegeForColoredId() { - return _useProps?.any( - (value) => value.propsResources?.type == SCPropsType.NOBLE_VIP.name, - ) ?? - false; - } + int get vipLevelForColoredId { + final hasExplicitVipLevel = _vipLevel?.trim().isNotEmpty ?? false; + final explicitLevel = _parseVipLevelForColoredId(_vipLevel) ?? 0; + if (hasExplicitVipLevel && explicitLevel <= 0) { + return 0; + } + if (explicitLevel > 0) { + return explicitLevel; + } - bool shouldShowColoredSpecialIdText() { - return hasVipPrivilegeForColoredId(); - } + final vipResource = getVIP(); + final resourceLevel = + _parseVipLevelForColoredId( + vipResource?.name, + allowPlainNumber: false, + ) ?? + _parseVipLevelForColoredId( + vipResource?.code, + allowPlainNumber: false, + allowTrailingNumber: false, + ); + if ((resourceLevel ?? 0) > 0) { + return resourceLevel!; + } + + return vipResource == null ? 0 : 1; + } + + int get vipVisualResourceLevelForColoredId { + for (final resource in [ + getVipBadge(), + getHeaddress(), + getChatBox(), + getFloatPicture(), + getDataCard(), + getMountains(), + ]) { + final level = _vipLevelFromVisualResource(resource); + if ((level ?? 0) > 0) { + return level!; + } + } + return 0; + } + + /// ID 数字渐变字的 VIP 条件。 + /// 新 VIP 接口优先读等级字段;旧数据继续兼容 useProps 里的 NOBLE_VIP。 + bool hasVipPrivilegeForColoredId() { + return vipLevelForColoredId > 0; + } + + bool hasVipVisualResourceForColoredId() { + return vipVisualResourceLevelForColoredId > 0; + } + + bool shouldShowColoredSpecialIdText() { + return hasVipPrivilegeForColoredId() || hasVipVisualResourceForColoredId(); + } void setHeartbeatVal(num? heartbeatVal) { _heartbeatVal = (_heartbeatVal ?? 0) + (heartbeatVal ?? 0); } +} + +String? socialChatVipLevelHintFromJson(dynamic json) { + return _firstNonBlankString(_vipLevelHintValues(json)); +} + +SocialChatUserProfile? socialChatProfileWithVipLevelHint( + SocialChatUserProfile? profile, + dynamic json, +) { + final vipLevel = socialChatVipLevelHintFromJson(json); + if (profile == null || vipLevel == null) { + return profile; + } + return profile.copyWith(vipLevel: vipLevel); +} + +SocialChatUserProfile? socialChatUserProfileFromJsonWithVipHint( + dynamic json, { + String userProfileKey = 'userProfile', +}) { + if (json == null) { + return null; + } + if (json is Map && json[userProfileKey] != null) { + return socialChatProfileWithVipLevelHint( + SocialChatUserProfile.fromJson(json[userProfileKey]), + json, + ); + } + return SocialChatUserProfile.fromJson(json); +} + +Iterable _vipLevelHintValues(dynamic json) sync* { + if (json is! Map) { + return; + } + yield json['vipLevel']; + yield json['svipLevel']; + yield json['sVipLevel']; + yield json['userSVipLevel']; + yield json['supperVip']; + yield json['superVip']; + yield json['nobleVipLevel']; + yield json['vipLevelCode']; + yield json['levelCode']; + yield _nestedVipLevel(json['vipStatus']); + yield _nestedVipLevel(json['vipState']); + yield _nestedVipLevel(json['vip']); + final nestedProfile = json['userProfile']; + if (nestedProfile is Map) { + yield* _vipLevelHintValues(nestedProfile); + } +} + +String? _firstNonBlankString(Iterable values) { + for (final value in values) { + final text = value?.toString().trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return null; +} + +dynamic _nestedVipLevel(dynamic value) { + if (value is Map) { + final active = value['active']; + if (active == false || + active?.toString().trim().toLowerCase() == 'false') { + return '0'; + } + return value['level'] ?? + value['vipLevel'] ?? + value['userSVipLevel'] ?? + value['supperVip'] ?? + value['levelCode']; + } + return null; +} + +int? _vipLevelFromVisualResource(PropsResources? resource) { + if (resource == null) { + return null; + } + return _parseVipLevelForColoredId( + resource.name, + allowPlainNumber: false, + ) ?? + _parseVipLevelForColoredId( + resource.code, + allowPlainNumber: false, + ) ?? + _parseVipLevelForColoredId( + resource.cover, + allowPlainNumber: false, + ) ?? + _parseVipLevelForColoredId( + resource.sourceUrl, + allowPlainNumber: false, + ) ?? + _parseVipLevelForColoredId( + resource.roomOpenedUrl, + allowPlainNumber: false, + ) ?? + _parseVipLevelForColoredId( + resource.roomSendCoverUrl, + allowPlainNumber: false, + ); +} + +int? _parseVipLevelForColoredId( + dynamic value, { + bool allowPlainNumber = true, + bool allowTrailingNumber = true, +}) { + final text = value?.toString().trim(); + if (text == null || text.isEmpty) { + return null; + } + + if (allowPlainNumber) { + final plainLevel = int.tryParse(text); + if ((plainLevel ?? 0) > 0) { + return plainLevel; + } + } + + final vipLevelMatch = RegExp( + r'(?:S?VIP|NOBLE[_\s-]*VIP)[^\d]*([1-9]\d*)', + caseSensitive: false, + ).firstMatch(text); + if (vipLevelMatch != null) { + return int.tryParse(vipLevelMatch.group(1) ?? ''); + } + + if (allowTrailingNumber) { + final trailingLevelMatch = RegExp(r'([1-9]\d*)$').firstMatch(text); + if (trailingLevelMatch != null) { + return int.tryParse(trailingLevelMatch.group(1) ?? ''); + } + } + + return null; } /// animationUrl : "" @@ -992,11 +1220,11 @@ class PropsResources { PropsResources.fromJson(dynamic json) { _amount = json['amount']; _code = json['code']; - _cover = json['cover']; + _cover = json['cover'] ?? json['coverUrl']; _createTime = json['createTime']; _del = json['del']; _expand = json['expand']; - _id = json['id']; + _id = json['id'] ?? json['resourceId']; _imNotOpenedUrl = json['imNotOpenedUrl']; _imOpenedUrl = json['imOpenedUrl']; _imOpenedUrlTwo = json['imOpenedUrlTwo']; @@ -1005,7 +1233,7 @@ class PropsResources { _roomNotOpenedUrl = json['roomNotOpenedUrl']; _roomOpenedUrl = json['roomOpenedUrl']; _roomSendCoverUrl = json['roomSendCoverUrl']; - _sourceUrl = json['sourceUrl']; + _sourceUrl = json['sourceUrl'] ?? json['resourceUrl'] ?? json['url']; _sysOrigin = json['sysOrigin']; _type = json['type']; _updateTime = json['updateTime']; diff --git a/lib/shared/business_logic/models/res/mic_res.dart b/lib/shared/business_logic/models/res/mic_res.dart index 7b68ab9..a6e8988 100644 --- a/lib/shared/business_logic/models/res/mic_res.dart +++ b/lib/shared/business_logic/models/res/mic_res.dart @@ -34,11 +34,14 @@ class MicRes { _roomId = json['roomId']; _micIndex = json['micIndex']; _micLock = json['micLock']; - _micMute = json['micMute']; - _user = - json['user'] != null - ? SocialChatUserProfile.fromJson(json['user']) - : null; + _micMute = json['micMute']; + _user = + json['user'] != null + ? socialChatProfileWithVipLevelHint( + SocialChatUserProfile.fromJson(json['user']), + json, + ) + : null; _roomToken = json['roomToken']; _type = json['type']; _number = json['number']; @@ -84,13 +87,15 @@ class MicRes { bool? get micLock => _micLock; - bool? get micMute => _micMute; - - SocialChatUserProfile? get user => _user; - - String? get roomToken => _roomToken; - - String? get type => _type; + bool? get micMute => _micMute; + + SocialChatUserProfile? get user => _user; + + String? get roomToken => _roomToken; + + int? get volume => _volume; + + String? get type => _type; String? get emojiPath => _emojiPath; diff --git a/lib/shared/business_logic/models/res/room_gift_rank_res.dart b/lib/shared/business_logic/models/res/room_gift_rank_res.dart index 5ff076c..d7559f2 100644 --- a/lib/shared/business_logic/models/res/room_gift_rank_res.dart +++ b/lib/shared/business_logic/models/res/room_gift_rank_res.dart @@ -4,21 +4,24 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart'; /// totalFormat : "" /// total : 0 -class RoomGiftRankRes { - RoomGiftRankRes({ - SocialChatUserProfile? userProfile, - String? totalFormat, - String? total,}){ - _userProfile = userProfile; +class RoomGiftRankRes { + RoomGiftRankRes({ + SocialChatUserProfile? userProfile, + String? totalFormat, + String? total,}){ + _userProfile = userProfile; _totalFormat = totalFormat; _total = total; } - - RoomGiftRankRes.fromJson(dynamic json) { - _userProfile = json['userProfile'] != null ? SocialChatUserProfile.fromJson(json['userProfile']) : null; - _totalFormat = json['totalFormat']; - _total = json['total']; - } + + RoomGiftRankRes.fromJson(dynamic json) { + _userProfile = + json['userProfile'] != null + ? socialChatUserProfileFromJsonWithVipHint(json) + : null; + _totalFormat = json['totalFormat']; + _total = json['total']; + } SocialChatUserProfile? _userProfile; String? _totalFormat; String? _total; diff --git a/lib/shared/business_logic/models/res/room_member_res.dart b/lib/shared/business_logic/models/res/room_member_res.dart index 0412a3a..40ceafd 100644 --- a/lib/shared/business_logic/models/res/room_member_res.dart +++ b/lib/shared/business_logic/models/res/room_member_res.dart @@ -11,14 +11,14 @@ class SocialChatRoomMemberRes { _roles = roles; } - SocialChatRoomMemberRes.fromJson(dynamic json) { - _id = json['id']; - _userProfile = - json['userProfile'] != null - ? SocialChatUserProfile.fromJson(json['userProfile']) - : null; - _roles = json['roles']; - } + SocialChatRoomMemberRes.fromJson(dynamic json) { + _id = json['id']; + _userProfile = + json['userProfile'] != null + ? socialChatUserProfileFromJsonWithVipHint(json) + : null; + _roles = json['roles']; + } String? _id; SocialChatUserProfile? _userProfile; diff --git a/lib/shared/business_logic/models/res/room_res.dart b/lib/shared/business_logic/models/res/room_res.dart index cae3e23..fd5715c 100644 --- a/lib/shared/business_logic/models/res/room_res.dart +++ b/lib/shared/business_logic/models/res/room_res.dart @@ -18,8 +18,8 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart'; /// userProfile : {"account":"","accountStatus":"","age":0,"bornDay":0,"bornMonth":0,"bornYear":0,"countryCode":"","countryId":0,"countryName":"","createTime":0,"del":false,"freezingTime":0,"id":0,"originSys":"","ownSpecialId":{"account":"","expiredTime":0},"sameRegion":false,"sysOriginChild":"","useProps":[{"expireTime":0,"propsResources":{"amount":0.0,"code":"","cover":"","expand":"","id":0,"name":"","sourceUrl":"","type":""},"userId":0}],"userAvatar":"","userNickname":"","userSex":0,"wearBadge":[{"animationUrl":"","badgeKey":"","badgeLevel":0,"badgeName":"","expireTime":0,"id":0,"milestone":0,"notSelectUrl":"","selectUrl":"","type":"","userId":0}]} /// userSVipLevel : "" -class SocialChatRoomRes { - SocialChatRoomRes({ +class SocialChatRoomRes { + SocialChatRoomRes({ String? countryCode, String? countryName, String? event, @@ -34,12 +34,12 @@ class SocialChatRoomRes { String? roomGameIcon, String? roomName, String? sysOrigin, - String? userId, - SocialChatUserProfile? userProfile, - String? userSVipLevel, - RoomMemberCounter? roomCounter, - ExtValues? extValues, - }) { + String? userId, + SocialChatUserProfile? userProfile, + String? userSVipLevel, + RoomMemberCounter? roomCounter, + ExtValues? extValues, + }) { _countryCode = countryCode; _countryName = countryName; _event = event; @@ -54,12 +54,16 @@ class SocialChatRoomRes { _roomGameIcon = roomGameIcon; _roomName = roomName; _sysOrigin = sysOrigin; - _userId = userId; - _userProfile = userProfile; - _userSVipLevel = userSVipLevel; - _roomCounter = roomCounter; - _extValues = extValues; - } + _userId = userId; + _userSVipLevel = userSVipLevel; + final normalizedVipLevel = userSVipLevel?.trim(); + _userProfile = + normalizedVipLevel == null || normalizedVipLevel.isEmpty + ? userProfile + : userProfile?.copyWith(vipLevel: normalizedVipLevel); + _roomCounter = roomCounter; + _extValues = extValues; + } SocialChatRoomRes.fromJson(dynamic json) { _countryCode = json['countryCode']; @@ -82,18 +86,22 @@ class SocialChatRoomRes { _roomName = json['roomName']; _sysOrigin = json['sysOrigin']; _userId = json['userId']; - _userProfile = - json['userProfile'] != null - ? SocialChatUserProfile.fromJson(json['userProfile']) - : null; - _userSVipLevel = json['userSVipLevel']; - final counterJson = json['roomCounter'] ?? json['counter']; - _roomCounter = - counterJson != null ? RoomMemberCounter.fromJson(counterJson) : null; - _extValues = - json['extValues'] != null - ? ExtValues.fromJson(json['extValues']) - : null; + _userProfile = + json['userProfile'] != null + ? SocialChatUserProfile.fromJson(json['userProfile']) + : null; + _userSVipLevel = socialChatVipLevelHintFromJson(json); + final normalizedVipLevel = _userSVipLevel?.trim(); + if (normalizedVipLevel != null && normalizedVipLevel.isNotEmpty) { + _userProfile = _userProfile?.copyWith(vipLevel: normalizedVipLevel); + } + final counterJson = json['roomCounter'] ?? json['counter']; + _roomCounter = + counterJson != null ? RoomMemberCounter.fromJson(counterJson) : null; + _extValues = + json['extValues'] != null + ? ExtValues.fromJson(json['extValues']) + : null; } String? _countryCode; @@ -110,11 +118,11 @@ class SocialChatRoomRes { String? _roomGameIcon; String? _roomName; String? _sysOrigin; - String? _userId; - SocialChatUserProfile? _userProfile; - String? _userSVipLevel; - RoomMemberCounter? _roomCounter; - ExtValues? _extValues; + String? _userId; + SocialChatUserProfile? _userProfile; + String? _userSVipLevel; + RoomMemberCounter? _roomCounter; + ExtValues? _extValues; SocialChatRoomRes copyWith({ String? countryCode, @@ -131,12 +139,12 @@ class SocialChatRoomRes { String? roomGameIcon, String? roomName, String? sysOrigin, - String? userId, - SocialChatUserProfile? userProfile, - String? userSVipLevel, - RoomMemberCounter? roomCounter, - ExtValues? extValues, - }) => SocialChatRoomRes( + String? userId, + SocialChatUserProfile? userProfile, + String? userSVipLevel, + RoomMemberCounter? roomCounter, + ExtValues? extValues, + }) => SocialChatRoomRes( countryCode: countryCode ?? _countryCode, countryName: countryName ?? _countryName, event: event ?? _event, @@ -151,12 +159,12 @@ class SocialChatRoomRes { roomGameIcon: roomGameIcon ?? _roomGameIcon, roomName: roomName ?? _roomName, sysOrigin: sysOrigin ?? _sysOrigin, - userId: userId ?? _userId, - userProfile: userProfile ?? _userProfile, - userSVipLevel: userSVipLevel ?? _userSVipLevel, - roomCounter: roomCounter ?? _roomCounter, - extValues: extValues ?? _extValues, - ); + userId: userId ?? _userId, + userProfile: userProfile ?? _userProfile, + userSVipLevel: userSVipLevel ?? _userSVipLevel, + roomCounter: roomCounter ?? _roomCounter, + extValues: extValues ?? _extValues, + ); String? get countryCode => _countryCode; @@ -188,25 +196,25 @@ class SocialChatRoomRes { String? get userId => _userId; - SocialChatUserProfile? get userProfile => _userProfile; - - String? get userSVipLevel => _userSVipLevel; - - RoomMemberCounter? get roomCounter => _roomCounter; - - ExtValues? get extValues => _extValues; - - String get displayMemberCount { - final memberCount = roomCounter?.memberCount; - if (memberCount != null) { - return _formatRoomMemberCount(memberCount); - } - final memberQuantity = extValues?.memberQuantity; - if ((memberQuantity ?? "").trim().isNotEmpty) { - return memberQuantity!; - } - return "0"; - } + SocialChatUserProfile? get userProfile => _userProfile; + + String? get userSVipLevel => _userSVipLevel; + + RoomMemberCounter? get roomCounter => _roomCounter; + + ExtValues? get extValues => _extValues; + + String get displayMemberCount { + final memberCount = roomCounter?.memberCount; + if (memberCount != null) { + return _formatRoomMemberCount(memberCount); + } + final memberQuantity = extValues?.memberQuantity; + if ((memberQuantity ?? "").trim().isNotEmpty) { + return memberQuantity!; + } + return "0"; + } Map toJson() { final map = {}; @@ -225,58 +233,58 @@ class SocialChatRoomRes { map['roomName'] = _roomName; map['sysOrigin'] = _sysOrigin; map['userId'] = _userId; - if (_userProfile != null) { - map['userProfile'] = _userProfile?.toJson(); - } - map['userSVipLevel'] = _userSVipLevel; - if (_roomCounter != null) { - map['roomCounter'] = _roomCounter?.toJson(); - } - if (_extValues != null) { - map['extValues'] = _extValues?.toJson(); - } - return map; - } -} - -String _formatRoomMemberCount(num value) { - if (value == value.roundToDouble()) { - return value.toInt().toString(); - } - return value.toString(); -} - -class RoomMemberCounter { - RoomMemberCounter({num? adminCount, num? memberCount}) { - _adminCount = adminCount; - _memberCount = memberCount; - } - - RoomMemberCounter.fromJson(dynamic json) { - _adminCount = json['adminCount']; - _memberCount = json['memberCount']; - } - - num? _adminCount; - num? _memberCount; - - RoomMemberCounter copyWith({num? adminCount, num? memberCount}) => - RoomMemberCounter( - adminCount: adminCount ?? _adminCount, - memberCount: memberCount ?? _memberCount, - ); - - num? get adminCount => _adminCount; - - num? get memberCount => _memberCount; - - Map toJson() { - final map = {}; - map['adminCount'] = _adminCount; - map['memberCount'] = _memberCount; - return map; - } -} + if (_userProfile != null) { + map['userProfile'] = _userProfile?.toJson(); + } + map['userSVipLevel'] = _userSVipLevel; + if (_roomCounter != null) { + map['roomCounter'] = _roomCounter?.toJson(); + } + if (_extValues != null) { + map['extValues'] = _extValues?.toJson(); + } + return map; + } +} + +String _formatRoomMemberCount(num value) { + if (value == value.roundToDouble()) { + return value.toInt().toString(); + } + return value.toString(); +} + +class RoomMemberCounter { + RoomMemberCounter({num? adminCount, num? memberCount}) { + _adminCount = adminCount; + _memberCount = memberCount; + } + + RoomMemberCounter.fromJson(dynamic json) { + _adminCount = json['adminCount']; + _memberCount = json['memberCount']; + } + + num? _adminCount; + num? _memberCount; + + RoomMemberCounter copyWith({num? adminCount, num? memberCount}) => + RoomMemberCounter( + adminCount: adminCount ?? _adminCount, + memberCount: memberCount ?? _memberCount, + ); + + num? get adminCount => _adminCount; + + num? get memberCount => _memberCount; + + Map toJson() { + final map = {}; + map['adminCount'] = _adminCount; + map['memberCount'] = _memberCount; + return map; + } +} /// account : "" /// accountStatus : "" diff --git a/lib/shared/business_logic/models/res/room_user_card_res.dart b/lib/shared/business_logic/models/res/room_user_card_res.dart index 3fe7811..a9108f1 100644 --- a/lib/shared/business_logic/models/res/room_user_card_res.dart +++ b/lib/shared/business_logic/models/res/room_user_card_res.dart @@ -12,19 +12,19 @@ import 'package:yumi/shared/business_logic/models/res/login_res.dart'; /// userLevel : {"charmLevel":0,"extValues":{"":{}},"wealthLevel":0} /// userProfile : {"account":"","accountStatus":"","age":0,"bornDay":0,"bornMonth":0,"bornYear":0,"countryCode":"","countryId":0,"countryName":"","createTime":0,"del":false,"freezingTime":0,"id":"0","originSys":"","ownSpecialId":{"account":"","expiredTime":0},"sameRegion":false,"sysOriginChild":"","useProps":[{"expireTime":0,"propsResources":{"amount":0.0,"code":"","cover":"","expand":"","id":0,"name":"","sourceUrl":"","type":""},"userId":0}],"userAvatar":"","userNickname":"","userSex":0,"wearBadge":[{"animationUrl":"","badgeKey":"","badgeLevel":0,"badgeName":"","expireTime":0,"id":0,"milestone":0,"notSelectUrl":"","selectUrl":"","type":"","userId":0}]} -class RoomUserCardRes { - RoomUserCardRes({ - bool? agent, - bool? anchor, - bool? follow, - bool? friend, - String? ktvIntegral, - String? roomRole, - String? supperVip, - List? useBadge, - List? useProps, - UserLevel? userLevel, - SocialChatUserProfile? userProfile,}){ +class RoomUserCardRes { + RoomUserCardRes({ + bool? agent, + bool? anchor, + bool? follow, + bool? friend, + String? ktvIntegral, + String? roomRole, + String? supperVip, + List? useBadge, + List? useProps, + UserLevel? userLevel, + SocialChatUserProfile? userProfile,}){ _agent = agent; _anchor = anchor; _follow = follow; @@ -35,8 +35,12 @@ class RoomUserCardRes { _useBadge = useBadge; _useProps = useProps; _userLevel = userLevel; - _userProfile = userProfile; -} + final normalizedVipLevel = supperVip?.trim(); + _userProfile = + normalizedVipLevel == null || normalizedVipLevel.isEmpty + ? userProfile + : userProfile?.copyWith(vipLevel: normalizedVipLevel); +} RoomUserCardRes.fromJson(dynamic json) { _agent = json['agent']; @@ -45,7 +49,7 @@ class RoomUserCardRes { _friend = json['friend']; _ktvIntegral = json['ktvIntegral']; _roomRole = json['roomRole']; - _supperVip = json['supperVip']; + _supperVip = socialChatVipLevelHintFromJson(json); if (json['useBadge'] != null) { _useBadge = []; json['useBadge'].forEach((v) { @@ -58,8 +62,15 @@ class RoomUserCardRes { _useProps?.add(UseProps.fromJson(v)); }); } - _userLevel = json['userLevel'] != null ? UserLevel.fromJson(json['userLevel']) : null; - _userProfile = json['userProfile'] != null ? SocialChatUserProfile.fromJson(json['userProfile']) : null; + _userLevel = json['userLevel'] != null ? UserLevel.fromJson(json['userLevel']) : null; + _userProfile = + json['userProfile'] != null + ? socialChatUserProfileFromJsonWithVipHint(json) + : null; + final normalizedVipLevel = _supperVip?.trim(); + if (normalizedVipLevel != null && normalizedVipLevel.isNotEmpty) { + _userProfile = _userProfile?.copyWith(vipLevel: normalizedVipLevel); + } } bool? _agent; bool? _anchor; @@ -72,29 +83,29 @@ class RoomUserCardRes { List? _useProps; UserLevel? _userLevel; SocialChatUserProfile? _userProfile; -RoomUserCardRes copyWith({ bool? agent, - bool? anchor, - bool? follow, - bool? friend, - String? ktvIntegral, - String? roomRole, - String? supperVip, - List? useBadge, - List? useProps, - UserLevel? userLevel, - SocialChatUserProfile? userProfile, -}) => RoomUserCardRes( agent: agent ?? _agent, - anchor: anchor ?? _anchor, - follow: follow ?? _follow, - friend: friend ?? _friend, - ktvIntegral: ktvIntegral ?? _ktvIntegral, - roomRole: roomRole ?? _roomRole, - supperVip: supperVip ?? _supperVip, - useBadge: useBadge ?? _useBadge, - useProps: useProps ?? _useProps, - userLevel: userLevel ?? _userLevel, - userProfile: userProfile ?? _userProfile, -); +RoomUserCardRes copyWith({ bool? agent, + bool? anchor, + bool? follow, + bool? friend, + String? ktvIntegral, + String? roomRole, + String? supperVip, + List? useBadge, + List? useProps, + UserLevel? userLevel, + SocialChatUserProfile? userProfile, +}) => RoomUserCardRes( agent: agent ?? _agent, + anchor: anchor ?? _anchor, + follow: follow ?? _follow, + friend: friend ?? _friend, + ktvIntegral: ktvIntegral ?? _ktvIntegral, + roomRole: roomRole ?? _roomRole, + supperVip: supperVip ?? _supperVip, + useBadge: useBadge ?? _useBadge, + useProps: useProps ?? _useProps, + userLevel: userLevel ?? _userLevel, + userProfile: userProfile ?? _userProfile, +); bool? get agent => _agent; bool? get anchor => _anchor; bool? get follow => _follow; @@ -129,9 +140,9 @@ RoomUserCardRes copyWith({ bool? agent, map['userProfile'] = _userProfile?.toJson(); } return map; - } - -} + } + +} /// account : "" /// accountStatus : "" @@ -168,19 +179,19 @@ RoomUserCardRes copyWith({ bool? agent, /// type : "" /// userId : 0 -class WearBadge { - WearBadge({ - String? animationUrl, - String? badgeKey, - num? badgeLevel, - String? badgeName, - num? expireTime, - num? id, - num? milestone, - String? notSelectUrl, - String? selectUrl, - String? type, - num? userId,}){ +class WearBadge { + WearBadge({ + String? animationUrl, + String? badgeKey, + num? badgeLevel, + String? badgeName, + num? expireTime, + num? id, + num? milestone, + String? notSelectUrl, + String? selectUrl, + String? type, + num? userId,}){ _animationUrl = animationUrl; _badgeKey = badgeKey; _badgeLevel = badgeLevel; @@ -189,10 +200,10 @@ class WearBadge { _id = id; _milestone = milestone; _notSelectUrl = notSelectUrl; - _selectUrl = selectUrl; - _type = type; - _userId = userId; -} + _selectUrl = selectUrl; + _type = type; + _userId = userId; +} WearBadge.fromJson(dynamic json) { _animationUrl = json['animationUrl']; @@ -218,29 +229,29 @@ class WearBadge { String? _selectUrl; String? _type; num? _userId; -WearBadge copyWith({ String? animationUrl, - String? badgeKey, - num? badgeLevel, - String? badgeName, - num? expireTime, - num? id, - num? milestone, - String? notSelectUrl, - String? selectUrl, - String? type, - num? userId, -}) => WearBadge( animationUrl: animationUrl ?? _animationUrl, - badgeKey: badgeKey ?? _badgeKey, - badgeLevel: badgeLevel ?? _badgeLevel, - badgeName: badgeName ?? _badgeName, - expireTime: expireTime ?? _expireTime, - id: id ?? _id, - milestone: milestone ?? _milestone, - notSelectUrl: notSelectUrl ?? _notSelectUrl, - selectUrl: selectUrl ?? _selectUrl, - type: type ?? _type, - userId: userId ?? _userId, -); +WearBadge copyWith({ String? animationUrl, + String? badgeKey, + num? badgeLevel, + String? badgeName, + num? expireTime, + num? id, + num? milestone, + String? notSelectUrl, + String? selectUrl, + String? type, + num? userId, +}) => WearBadge( animationUrl: animationUrl ?? _animationUrl, + badgeKey: badgeKey ?? _badgeKey, + badgeLevel: badgeLevel ?? _badgeLevel, + badgeName: badgeName ?? _badgeName, + expireTime: expireTime ?? _expireTime, + id: id ?? _id, + milestone: milestone ?? _milestone, + notSelectUrl: notSelectUrl ?? _notSelectUrl, + selectUrl: selectUrl ?? _selectUrl, + type: type ?? _type, + userId: userId ?? _userId, +); String? get animationUrl => _animationUrl; String? get badgeKey => _badgeKey; num? get badgeLevel => _badgeLevel; @@ -267,17 +278,17 @@ WearBadge copyWith({ String? animationUrl, map['type'] = _type; map['userId'] = _userId; return map; - } - -} - -class UserLevel { - UserLevel({ - num? charmLevel, - num? wealthLevel,}){ - _charmLevel = charmLevel; - _wealthLevel = wealthLevel; -} + } + +} + +class UserLevel { + UserLevel({ + num? charmLevel, + num? wealthLevel,}){ + _charmLevel = charmLevel; + _wealthLevel = wealthLevel; +} UserLevel.fromJson(dynamic json) { _charmLevel = json['charmLevel']; @@ -285,11 +296,11 @@ class UserLevel { } num? _charmLevel; num? _wealthLevel; -UserLevel copyWith({ num? charmLevel, - num? wealthLevel, -}) => UserLevel( charmLevel: charmLevel ?? _charmLevel, - wealthLevel: wealthLevel ?? _wealthLevel, -); +UserLevel copyWith({ num? charmLevel, + num? wealthLevel, +}) => UserLevel( charmLevel: charmLevel ?? _charmLevel, + wealthLevel: wealthLevel ?? _wealthLevel, +); num? get charmLevel => _charmLevel; num? get wealthLevel => _wealthLevel; @@ -298,23 +309,23 @@ UserLevel copyWith({ num? charmLevel, map['charmLevel'] = _charmLevel; map['wealthLevel'] = _wealthLevel; return map; - } - -} - -class UseBadge { - UseBadge({ - String? animationUrl, - String? badgeKey, - num? badgeLevel, - String? badgeName, - num? expireTime, - String? id, - String? milestone, - String? notSelectUrl, - String? selectUrl, - String? type, - String? userId,}){ + } + +} + +class UseBadge { + UseBadge({ + String? animationUrl, + String? badgeKey, + num? badgeLevel, + String? badgeName, + num? expireTime, + String? id, + String? milestone, + String? notSelectUrl, + String? selectUrl, + String? type, + String? userId,}){ _animationUrl = animationUrl; _badgeKey = badgeKey; _badgeLevel = badgeLevel; @@ -323,10 +334,10 @@ class UseBadge { _id = id; _milestone = milestone; _notSelectUrl = notSelectUrl; - _selectUrl = selectUrl; - _type = type; - _userId = userId; -} + _selectUrl = selectUrl; + _type = type; + _userId = userId; +} UseBadge.fromJson(dynamic json) { _animationUrl = json['animationUrl']; @@ -352,29 +363,29 @@ class UseBadge { String? _selectUrl; String? _type; String? _userId; -UseBadge copyWith({ String? animationUrl, - String? badgeKey, - num? badgeLevel, - String? badgeName, - num? expireTime, - String? id, - String? milestone, - String? notSelectUrl, - String? selectUrl, - String? type, - String? userId, -}) => UseBadge( animationUrl: animationUrl ?? _animationUrl, - badgeKey: badgeKey ?? _badgeKey, - badgeLevel: badgeLevel ?? _badgeLevel, - badgeName: badgeName ?? _badgeName, - expireTime: expireTime ?? _expireTime, - id: id ?? _id, - milestone: milestone ?? _milestone, - notSelectUrl: notSelectUrl ?? _notSelectUrl, - selectUrl: selectUrl ?? _selectUrl, - type: type ?? _type, - userId: userId ?? _userId, -); +UseBadge copyWith({ String? animationUrl, + String? badgeKey, + num? badgeLevel, + String? badgeName, + num? expireTime, + String? id, + String? milestone, + String? notSelectUrl, + String? selectUrl, + String? type, + String? userId, +}) => UseBadge( animationUrl: animationUrl ?? _animationUrl, + badgeKey: badgeKey ?? _badgeKey, + badgeLevel: badgeLevel ?? _badgeLevel, + badgeName: badgeName ?? _badgeName, + expireTime: expireTime ?? _expireTime, + id: id ?? _id, + milestone: milestone ?? _milestone, + notSelectUrl: notSelectUrl ?? _notSelectUrl, + selectUrl: selectUrl ?? _selectUrl, + type: type ?? _type, + userId: userId ?? _userId, +); String? get animationUrl => _animationUrl; String? get badgeKey => _badgeKey; num? get badgeLevel => _badgeLevel; @@ -401,6 +412,6 @@ UseBadge copyWith({ String? animationUrl, map['type'] = _type; map['userId'] = _userId; return map; - } - -} \ No newline at end of file + } + +} diff --git a/lib/shared/data_sources/models/message/sc_floating_message.dart b/lib/shared/data_sources/models/message/sc_floating_message.dart index 9c1e63f..df627ec 100644 --- a/lib/shared/data_sources/models/message/sc_floating_message.dart +++ b/lib/shared/data_sources/models/message/sc_floating_message.dart @@ -7,6 +7,7 @@ class SCFloatingMessage { String? userName; // 用户昵称 String? toUserName; // 用户昵称 String? giftUrl; // 礼物图标 + String? fallbackUrl; // 兜底资源 String? giftId; // 礼物id int? type; int? rocketLevel; @@ -26,6 +27,7 @@ class SCFloatingMessage { this.userName = '', this.toUserName = '', this.giftUrl = '', + this.fallbackUrl = '', this.giftId = '', this.number = 0, this.coins = 0, @@ -44,6 +46,7 @@ class SCFloatingMessage { userName = json['userName']; toUserName = json['toUserName']; giftUrl = json['giftUrl']; + fallbackUrl = json['fallbackUrl']; giftId = json['giftId']; coins = json['coins']; number = json['number']; @@ -63,6 +66,7 @@ class SCFloatingMessage { map['userName'] = userName; map['toUserName'] = toUserName; map['giftUrl'] = giftUrl; + map['fallbackUrl'] = fallbackUrl; map['giftId'] = giftId; map['coins'] = coins; map['number'] = number; diff --git a/lib/shared/data_sources/sources/local/floating_screen_manager.dart b/lib/shared/data_sources/sources/local/floating_screen_manager.dart index 3acca6c..1da87e2 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -14,6 +14,7 @@ import 'package:yumi/ui_kit/widgets/room/floating/floating_gift_screen_widget.da import 'package:yumi/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart'; 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/shared/data_sources/models/message/sc_floating_message.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart'; @@ -76,8 +77,8 @@ class OverlayManager { unawaited( warmImageResource( message.giftUrl ?? "", - logicalWidth: 96, - logicalHeight: 96, + logicalWidth: message.type == 5 ? 350 : 96, + logicalHeight: message.type == 5 ? 84 : 96, ), ); if (message.type == 0) { @@ -209,6 +210,12 @@ class OverlayManager { message: message, onAnimationCompleted: onComplete, ); + case 5: + //VIP进房飘窗 + return FloatingVipEntryScreenWidget( + message: message, + onAnimationCompleted: onComplete, + ); default: onComplete(); return Container(); @@ -233,6 +240,7 @@ class OverlayManager { _removeActiveRoomMessage(); _removeMessagesByType(1); _removeMessagesByType(0); + _removeMessagesByType(5); } // 辅助方法:移除特定类型的消息 @@ -266,7 +274,7 @@ class OverlayManager { if (message == null) { return false; } - if (message.type != 0 && message.type != 1) { + if (message.type != 0 && message.type != 1 && message.type != 5) { return true; } @@ -289,7 +297,9 @@ class OverlayManager { void _removeActiveRoomMessage() { final activeMessage = _currentMessage; if (activeMessage == null || - (activeMessage.type != 0 && activeMessage.type != 1)) { + (activeMessage.type != 0 && + activeMessage.type != 1 && + activeMessage.type != 5)) { return; } _currentOverlayEntry?.remove(); diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 63142f0..55b1dab 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -196,8 +196,8 @@ class SCChatRoomRepository implements SocialChatRoomRepository { num mickIndex, { String? eventType, String? inviterId, - }) async { - Map params = {}; + }) async { + Map params = {}; params["roomId"] = roomId; params["mickIndex"] = mickIndex; if (eventType != null) { @@ -262,12 +262,12 @@ class SCChatRoomRepository implements SocialChatRoomRepository { silentErrorToast ? const {BaseNetworkClient.silentErrorToastKey: true} : null, - fromJson: - (json) => - (json as List) - .map((e) => SocialChatUserProfile.fromJson(e)) - .toList(), - ); + fromJson: + (json) => + (json as List) + .map((e) => socialChatUserProfileFromJsonWithVipHint(e)!) + .toList(), + ); return result; } @@ -316,8 +316,8 @@ class SCChatRoomRepository implements SocialChatRoomRepository { String? roomId, SCGiveAwayGiftRoomAcceptsCmd? accepts, String? dynamicContentId, - }) async { - Map params = {}; + }) async { + Map params = {}; if (roomId != null) { params["roomId"] = roomId; } @@ -512,9 +512,10 @@ class SCChatRoomRepository implements SocialChatRoomRepository { bool? adminLockSeat, bool? showHeartbeat, bool? openKtvMode, - String? roomSpecialMikeType, - }) async { - Map params = {}; + String? roomSpecialMikeType, + }) async { + final rtmProvider = Provider.of(context, listen: false); + Map params = {}; params["roomId"] = roomId; if (touristMike != null) { params["touristMike"] = touristMike; @@ -552,7 +553,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { fromJson: (json) => json as bool, ); - Provider.of(context, listen: false).dispatchMessage( + rtmProvider.dispatchMessage( Msg( groupId: roomAcount, msg: roomId, diff --git a/lib/shared/tools/sc_gift_vap_svga_manager.dart b/lib/shared/tools/sc_gift_vap_svga_manager.dart index eda1c7d..1be8426 100644 --- a/lib/shared/tools/sc_gift_vap_svga_manager.dart +++ b/lib/shared/tools/sc_gift_vap_svga_manager.dart @@ -14,7 +14,15 @@ import 'package:tancent_vap/widgets/vap_view.dart'; import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; class SCGiftVapSvgaManager { - static const int _maxPendingTaskCount = 18; + static const int giftEffectType = 0; + static const int entryEffectType = 1; + static const int giftEffectPriority = 100; + static const int entryEffectPriority = 1000; + static const int _maxPendingGiftTaskCount = 24; + static const int _maxPendingEntryTaskCount = 5; + static const int _maxConsecutiveEntryTasksBeforeGift = 2; + static const Duration _taskWatchdogTimeout = Duration(seconds: 20); + static const Duration _entryTaskTtl = Duration(seconds: 6); Map videoItemCache = {}; static SCGiftVapSvgaManager? _inst; static const int _maxPreloadConcurrency = 1; @@ -26,7 +34,10 @@ class SCGiftVapSvgaManager { factory SCGiftVapSvgaManager() => _inst ??= SCGiftVapSvgaManager._internal(); - final SCPriorityQueue _tq = SCPriorityQueue( + final SCPriorityQueue _giftQueue = SCPriorityQueue( + (a, b) => a.compareTo(b), // 调用 SCVapTask 的 compareTo 方法 + ); + final SCPriorityQueue _entryQueue = SCPriorityQueue( (a, b) => a.compareTo(b), // 调用 SCVapTask 的 compareTo 方法 ); VapController? _rgc; @@ -34,12 +45,14 @@ class SCGiftVapSvgaManager { bool _play = false; bool _dis = false; SCVapTask? _currentTask; + Timer? _currentTaskWatchdogTimer; final Queue _preloadQueue = Queue(); final Set _queuedPreloadPaths = {}; final Map> _svgaLoadTasks = {}; final Map> _playablePathTasks = {}; final Map _playablePathCache = {}; int _activePreloadCount = 0; + int _consecutiveEntryTaskCount = 0; bool _pause = false; @@ -63,6 +76,26 @@ class SCGiftVapSvgaManager { return SCPathUtils.getFileExtension(path).toLowerCase() == ".svga"; } + static int resolveEffectPriority({ + int priority = 0, + int type = giftEffectType, + }) { + if (type == entryEffectType) { + return priority > entryEffectPriority ? priority : entryEffectPriority; + } + if (priority != 0) { + return priority; + } + return giftEffectPriority; + } + + bool _isEntryTask(SCVapTask task) => task.type == entryEffectType; + + int get _pendingTaskCount => _giftQueue.length + _entryQueue.length; + + String get _queueSummary => + 'entry=${_entryQueue.length} gift=${_giftQueue.length}'; + bool _isControllerReady(SCVapTask task) { if (_needsSvgaController(task.path)) { return _rsc != null; @@ -253,6 +286,96 @@ class SCGiftVapSvgaManager { } } + void _enqueueTask(SCVapTask task) { + if (_isEntryTask(task)) { + _dropExpiredEntryTasks(); + _entryQueue.add(task); + _trimEntryQueue(); + return; + } + _giftQueue.add(task); + _trimGiftQueue(); + } + + void _trimEntryQueue() { + while (_entryQueue.length > _maxPendingEntryTaskCount) { + final removedTask = _removeEntryOverflowTask(); + _completeDroppedQueuedTask(removedTask, 'trim entry queue'); + } + } + + SCVapTask _removeEntryOverflowTask() { + final tasks = _entryQueue.unorderedElements.cast(); + var candidate = tasks.first; + for (final task in tasks.skip(1)) { + if (task.priority < candidate.priority || + (task.priority == candidate.priority && + task.createdAt.isBefore(candidate.createdAt))) { + candidate = task; + } + } + _entryQueue.remove(candidate); + return candidate; + } + + void _trimGiftQueue() { + while (_giftQueue.length > _maxPendingGiftTaskCount) { + final removedTask = _giftQueue.removeLast(); + _completeDroppedQueuedTask(removedTask, 'trim gift queue'); + } + } + + void _dropExpiredEntryTasks() { + if (_entryQueue.isEmpty) { + return; + } + final now = DateTime.now(); + final expiredTasks = + _entryQueue.unorderedElements + .cast() + .where((task) => now.difference(task.createdAt) > _entryTaskTtl) + .toList(); + for (final task in expiredTasks) { + _entryQueue.remove(task); + _completeDroppedQueuedTask( + task, + 'drop expired entry task ageMs=${now.difference(task.createdAt).inMilliseconds}', + ); + } + } + + void _completeDroppedQueuedTask(SCVapTask task, String reason) { + SCRoomEffectScheduler().completeHighCostTask(debugLabel: task.path); + _log( + '$reason path=${task.path} priority=${task.priority} ' + 'type=${task.type} queue=$_queueSummary', + ); + } + + SCVapTask? _peekNextQueuedTask() { + _dropExpiredEntryTasks(); + if (_entryQueue.isNotEmpty) { + final shouldYieldToGift = + _giftQueue.isNotEmpty && + _consecutiveEntryTaskCount >= _maxConsecutiveEntryTasksBeforeGift; + if (!shouldYieldToGift) { + return _entryQueue.first; + } + return _giftQueue.first; + } + return _giftQueue.first; + } + + void _removeQueuedTask(SCVapTask task) { + if (_isEntryTask(task)) { + _entryQueue.remove(task); + _consecutiveEntryTaskCount++; + return; + } + _giftQueue.remove(task); + _consecutiveEntryTaskCount = 0; + } + void _scheduleNextTask({Duration delay = Duration.zero}) { if (_dis) { return; @@ -268,8 +391,14 @@ class SCGiftVapSvgaManager { if (_dis) { return; } + final finishedTask = _currentTask; + if (!_play && finishedTask == null) { + _scheduleNextTask(delay: delay); + return; + } + _cancelCurrentTaskWatchdog(); SCRoomEffectScheduler().completeHighCostTask( - debugLabel: _currentTask?.path, + debugLabel: finishedTask?.path, ); _play = false; _currentTask = null; @@ -288,7 +417,7 @@ class SCGiftVapSvgaManager { _rgc = vapController; _log( 'bindVapCtrl hasVapCtrl=${_rgc != null} ' - 'hasSvgaCtrl=${_rsc != null} queue=${_tq.length} mute=$_mute', + 'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute', ); _rgc?.setAnimListener( onVideoStart: () { @@ -314,18 +443,13 @@ class SCGiftVapSvgaManager { _rsc = svgaController; _log( 'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} ' - 'hasVapCtrl=${_rgc != null} queue=${_tq.length}', + 'hasVapCtrl=${_rgc != null} queue=$_queueSummary', ); _rsc?.addStatusListener((AnimationStatus status) { if (status.isCompleted) { _log('svga completed path=${_currentTask?.path}'); _rsc?.reset(); - SCRoomEffectScheduler().completeHighCostTask( - debugLabel: _currentTask?.path, - ); - _play = false; - _currentTask = null; - _pn(); + _finishCurrentTask(); } }); _scheduleNextTask(); @@ -337,18 +461,22 @@ class SCGiftVapSvgaManager { String path, { int priority = 0, Map? customResources, - int type = 0, + int type = giftEffectType, }) { if (path.isEmpty) { _log('play ignored because path is empty'); return; } + final resolvedPriority = resolveEffectPriority( + priority: priority, + type: type, + ); _log( 'play request path=$path ext=${SCPathUtils.getFileExtension(path)} ' - 'priority=$priority type=$type ' + 'priority=$priority resolvedPriority=$resolvedPriority type=$type ' 'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} ' 'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} ' - 'disposed=$_dis playing=$_play queueBefore=${_tq.length} ' + 'disposed=$_dis playing=$_play queueBefore=$_queueSummary ' 'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null}', ); if (SCGlobalConfig.allowsHighCostAnimations) { @@ -358,31 +486,15 @@ class SCGiftVapSvgaManager { } final task = SCVapTask( path: path, - priority: priority, + type: type, + priority: resolvedPriority, customResources: customResources, ); - if (_tq.length >= _maxPendingTaskCount && priority <= 0) { - _log( - 'drop play request because queue is full path=$path ' - 'priority=$priority queue=${_tq.length}', - ); - return; - } - _tq.add(task); SCRoomEffectScheduler().registerHighCostTaskQueued(debugLabel: path); + _enqueueTask(task); unawaited(preload(path)); - while (_tq.length > _maxPendingTaskCount) { - final removedTask = _tq.removeLast(); - SCRoomEffectScheduler().completeHighCostTask( - debugLabel: removedTask.path, - ); - _log( - 'trim queued task path=${removedTask.path} ' - 'priority=${removedTask.priority} queue=${_tq.length}', - ); - } - _log('task enqueued path=$path queueAfter=${_tq.length}'); + _log('task enqueued path=$path queueAfter=$_queueSummary'); if (!_play) { _pn(); } @@ -398,32 +510,34 @@ class SCGiftVapSvgaManager { // 播放下一个任务 Future _pn() async { if (_pause) { - _log('skip _pn because paused queue=${_tq.length}'); + _log('skip _pn because paused queue=$_queueSummary'); return; } - if (_dis || _tq.isEmpty || _play) return; + if (_dis || _pendingTaskCount == 0 || _play) return; - final task = _tq.first; + final task = _peekNextQueuedTask(); if (task == null || !_isControllerReady(task)) { _log( 'controller not ready for path=${task?.path} ' 'needSvga=${task != null ? _needsSvgaController(task.path) : "unknown"} ' 'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null} ' - 'queue=${_tq.length}', + 'queue=$_queueSummary', ); return; } - _tq.removeFirst(); + _removeQueuedTask(task); _play = true; _currentTask = task; + _armCurrentTaskWatchdog(task); try { final pathType = SCPathUtils.getPathType(task.path); _log( 'start task path=${task.path} ' 'pathType=$pathType ' - 'queueRemaining=${_tq.length} ' - 'needSvga=${_needsSvgaController(task.path)}', + 'queueRemaining=$_queueSummary ' + 'needSvga=${_needsSvgaController(task.path)} ' + 'type=${task.type} consecutiveEntry=$_consecutiveEntryTaskCount', ); if (pathType == PathType.asset) { await _pa(task); @@ -447,6 +561,9 @@ class SCGiftVapSvgaManager { if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") { _log('play asset svga path=${task.path}'); final entity = await _loadSvgaEntity(task.path); + if (!_isCurrentTask(task)) { + return; + } _log('use prepared asset svga path=${task.path}'); _rsc?.videoItem = entity; _rsc?.reset(); @@ -470,6 +587,9 @@ class SCGiftVapSvgaManager { } if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") { final entity = await _loadSvgaEntity(task.path); + if (!_isCurrentTask(task)) { + return; + } _log('play local svga file path=${task.path}'); _rsc?.videoItem = entity; _rsc?.reset(); @@ -483,11 +603,17 @@ class SCGiftVapSvgaManager { } _log('play local vap/mp4 file path=${task.path}'); await _rgc?.setMute(_mute); + if (!_isCurrentTask(task)) { + return; + } if (task.customResources != null) { task.customResources?.forEach((k, v) async { await _rgc?.setVapTagContent(k, v); }); } + if (!_isCurrentTask(task)) { + return; + } await _rgc!.playFile(task.path); } @@ -504,6 +630,9 @@ class SCGiftVapSvgaManager { _finishCurrentTask(); return; } + if (!_isCurrentTask(task)) { + return; + } _log('use prepared network svga path=${task.path}'); _rsc?.videoItem = entity; _rsc?.reset(); @@ -512,11 +641,15 @@ class SCGiftVapSvgaManager { } else { _log('download network vap/mp4 path=${task.path}'); final playablePath = await _ensurePlayableFilePath(task.path); + if (!_isCurrentTask(task)) { + return; + } if (!_dis) { _log('use prepared network vap/mp4 local path=$playablePath'); await _pf( SCVapTask( path: playablePath, + type: task.type, priority: task.priority, customResources: task.customResources, ), @@ -533,25 +666,52 @@ class SCGiftVapSvgaManager { } } + bool _isCurrentTask(SCVapTask task) { + return !_dis && _play && identical(_currentTask, task); + } + + void _armCurrentTaskWatchdog(SCVapTask task) { + _cancelCurrentTaskWatchdog(); + _currentTaskWatchdogTimer = Timer(_taskWatchdogTimeout, () { + if (!_isCurrentTask(task)) { + return; + } + _log('task watchdog timeout path=${task.path}'); + _rgc?.stop(); + _rsc?.stop(); + _rsc?.reset(); + _rsc?.videoItem = null; + _finishCurrentTask(delay: const Duration(milliseconds: 50)); + }); + } + + void _cancelCurrentTaskWatchdog() { + _currentTaskWatchdogTimer?.cancel(); + _currentTaskWatchdogTimer = null; + } + //暂停动画播放 void pauseAnim() { _pause = true; - _log('pauseAnim queue=${_tq.length}'); + _log('pauseAnim queue=$_queueSummary'); } //恢复动画播放 void resumeAnim() { _pause = false; - _log('resumeAnim queue=${_tq.length}'); + _log('resumeAnim queue=$_queueSummary'); _pn(); } void stopPlayback() { - _log('stopPlayback queue=${_tq.length} currentPath=${_currentTask?.path}'); + _log('stopPlayback queue=$_queueSummary currentPath=${_currentTask?.path}'); _play = false; _currentTask = null; _pause = false; - _tq.clear(); + _consecutiveEntryTaskCount = 0; + _cancelCurrentTaskWatchdog(); + _giftQueue.clear(); + _entryQueue.clear(); _preloadQueue.clear(); _queuedPreloadPaths.clear(); _activePreloadCount = 0; @@ -564,7 +724,7 @@ class SCGiftVapSvgaManager { // 释放资源 void dispose() { - _log('dispose queue=${_tq.length} currentPath=${_currentTask?.path}'); + _log('dispose queue=$_queueSummary currentPath=${_currentTask?.path}'); _dis = true; stopPlayback(); _svgaLoadTasks.clear(); @@ -580,7 +740,7 @@ class SCGiftVapSvgaManager { // 清除所有任务 void clearTasks() { _log( - 'clearTasks queueBefore=${_tq.length} currentPath=${_currentTask?.path}', + 'clearTasks queueBefore=$_queueSummary currentPath=${_currentTask?.path}', ); stopPlayback(); } @@ -594,6 +754,7 @@ class SCVapTask implements Comparable { final int type; final int priority; final Map? customResources; + final DateTime createdAt; final int _seq; static int _nextSeq = 0; @@ -603,7 +764,9 @@ class SCVapTask implements Comparable { this.priority = 0, this.customResources, this.type = 0, - }) : _seq = _nextSeq++; + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(), + _seq = _nextSeq++; @override int compareTo(SCVapTask other) { @@ -618,7 +781,7 @@ class SCVapTask implements Comparable { @override String toString() { - return 'SCVapTask{path: $path, priority: $priority, seq: $_seq}'; + return 'SCVapTask{path: $path, type: $type, priority: $priority, seq: $_seq}'; } } @@ -649,6 +812,8 @@ class SCPriorityQueue { void clear() => _els.clear(); + bool remove(E element) => _els.remove(element); + E removeLast() { if (isEmpty) throw StateError("No elements"); return _els.removeLast(); diff --git a/lib/shared/tools/sc_path_utils.dart b/lib/shared/tools/sc_path_utils.dart index f2f4736..1d5b8e3 100644 --- a/lib/shared/tools/sc_path_utils.dart +++ b/lib/shared/tools/sc_path_utils.dart @@ -90,7 +90,21 @@ class SCPathUtils { /// 获取路径的文件扩展名 static String getFileExtension(String filePath) { - return path.extension(filePath).toLowerCase(); + final value = filePath.trim(); + if (value.isEmpty) { + return ''; + } + + final uri = Uri.tryParse(value); + if (uri != null && (uri.scheme.isNotEmpty || uri.host.isNotEmpty)) { + final uriPathExtension = path.extension(uri.path).toLowerCase(); + if (uriPathExtension.isNotEmpty) { + return uriPathExtension; + } + } + + final normalizedValue = value.split('?').first.split('#').first; + return path.extension(normalizedValue).toLowerCase(); } /// 从网络地址中提取文件名 diff --git a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart index 50317da..aae2df1 100644 --- a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart +++ b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart @@ -9,6 +9,9 @@ import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:provider/provider.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; +import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; @@ -40,6 +43,7 @@ class _RoomAnimationQueueScreenState extends State { if (!_effectsEnabled) { return; } + _enqueueVipEntryFloatingMessage(msg); unawaited( warmImageResource( msg.user?.userAvatar ?? "", @@ -59,6 +63,72 @@ class _RoomAnimationQueueScreenState extends State { }); } + void _enqueueVipEntryFloatingMessage(Msg msg) { + final floatPicture = msg.user?.getFloatPicture(); + final floatPictureUrl = _resolveVipFloatPictureUrl(floatPicture); + final floatPictureFallbackUrl = _resolveVipFloatPictureFallbackUrl( + floatPicture, + ); + if (floatPictureUrl.isEmpty) { + return; + } + final roomId = + Provider.of( + context, + listen: false, + ).currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? + ""; + if (roomId.isEmpty) { + return; + } + debugPrint( + '[VIP][RoomFloat] enqueue entry float ' + 'userId=${msg.user?.id} roomId=$roomId resourceId=${floatPicture?.id} ' + 'url=$floatPictureUrl', + ); + OverlayManager().addMessage( + SCFloatingMessage( + type: 5, + userId: msg.user?.id, + roomId: roomId, + userAvatarUrl: msg.user?.userAvatar, + userName: msg.user?.userNickname, + giftUrl: floatPictureUrl, + fallbackUrl: floatPictureFallbackUrl, + priority: 900, + ), + ); + } + + String _resolveVipFloatPictureUrl(PropsResources? resource) { + return _firstNonBlank([ + resource?.sourceUrl, + resource?.roomSendCoverUrl, + resource?.roomOpenedUrl, + resource?.cover, + resource?.roomNotOpenedUrl, + ]); + } + + String _resolveVipFloatPictureFallbackUrl(PropsResources? resource) { + return _firstNonBlank([ + resource?.roomSendCoverUrl, + resource?.roomOpenedUrl, + resource?.cover, + resource?.roomNotOpenedUrl, + ]); + } + + String _firstNonBlank(Iterable values) { + for (final value in values) { + final text = value?.trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return ""; + } + void _addToQueue(Msg msg) { if (!_effectsEnabled || !mounted) { return; diff --git a/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart index 42bfcd6..0c9705e 100644 --- a/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart +++ b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -16,6 +17,7 @@ class RoomGiftSeatFlightRequest { this.beginSize = 96, this.endSize = 34, this.queueTag, + this.batchTag, }); final String imagePath; @@ -25,6 +27,7 @@ class RoomGiftSeatFlightRequest { final double beginSize; final double endSize; final String? queueTag; + final String? batchTag; } class RoomGiftSeatFlightController { @@ -121,6 +124,7 @@ class RoomGiftSeatFlightController { } final queueTag = request.queueTag?.trim(); + final batchTag = request.batchTag?.trim(); return RoomGiftSeatFlightRequest( imagePath: imagePath, targetUserId: targetUserId, @@ -129,6 +133,7 @@ class RoomGiftSeatFlightController { beginSize: request.beginSize, endSize: request.endSize, queueTag: queueTag == null || queueTag.isEmpty ? null : queueTag, + batchTag: batchTag == null || batchTag.isEmpty ? null : batchTag, ); } @@ -216,12 +221,12 @@ class _RoomGiftSeatFlightOverlayState extends State final GlobalKey _overlayKey = GlobalKey(); late final AnimationController _controller; + Timer? _activeFlightWatchdogTimer; RoomGiftSeatFlightRequest? _centerRequest; ImageProvider? _centerImageProvider; - RoomGiftSeatFlightRequest? _activeRequest; - ImageProvider? _activeImageProvider; - Offset? _activeTargetOffset; + List<_ActiveRoomGiftSeatFlight> _activeFlights = + const <_ActiveRoomGiftSeatFlight>[]; bool _isPlaying = false; int _sessionToken = 0; @@ -248,6 +253,7 @@ class _RoomGiftSeatFlightOverlayState extends State @override void dispose() { widget.controller._detach(this); + _cancelActiveFlightWatchdog(); _controller.dispose(); super.dispose(); } @@ -283,14 +289,13 @@ class _RoomGiftSeatFlightOverlayState extends State void _clear() { _sessionToken += 1; _queue.clear(); + _cancelActiveFlightWatchdog(); _controller.stop(); _controller.reset(); if (!mounted) { _centerRequest = null; _centerImageProvider = null; - _activeRequest = null; - _activeImageProvider = null; - _activeTargetOffset = null; + _activeFlights = const <_ActiveRoomGiftSeatFlight>[]; _imageProviderCache.clear(); _isPlaying = false; return; @@ -298,17 +303,17 @@ class _RoomGiftSeatFlightOverlayState extends State setState(() { _centerRequest = null; _centerImageProvider = null; - _activeRequest = null; - _activeImageProvider = null; - _activeTargetOffset = null; + _activeFlights = const <_ActiveRoomGiftSeatFlight>[]; _imageProviderCache.clear(); _isPlaying = false; }); } bool _hasTrackedRequests(String queueTag) { - if ((_activeRequest?.queueTag ?? '').trim() == queueTag) { - return true; + for (final activeFlight in _activeFlights) { + if ((activeFlight.request.queueTag ?? '').trim() == queueTag) { + return true; + } } for (final queuedRequest in _queue) { if ((queuedRequest.request.queueTag ?? '').trim() == queueTag) { @@ -334,8 +339,10 @@ class _RoomGiftSeatFlightOverlayState extends State int _countTrackedRequests(String queueTag) { var count = 0; - if ((_activeRequest?.queueTag ?? '').trim() == queueTag) { - count += 1; + for (final activeFlight in _activeFlights) { + if ((activeFlight.request.queueTag ?? '').trim() == queueTag) { + count += 1; + } } for (final queuedRequest in _queue) { if ((queuedRequest.request.queueTag ?? '').trim() == queueTag) { @@ -361,6 +368,33 @@ class _RoomGiftSeatFlightOverlayState extends State } } + List<_QueuedRoomGiftSeatFlightRequest> _removeNextQueuedBatch() { + if (_queue.isEmpty) { + return const <_QueuedRoomGiftSeatFlightRequest>[]; + } + + final firstRequest = _queue.removeFirst(); + final batchTag = (firstRequest.request.batchTag ?? '').trim(); + if (batchTag.isEmpty) { + return <_QueuedRoomGiftSeatFlightRequest>[firstRequest]; + } + + final batch = <_QueuedRoomGiftSeatFlightRequest>[firstRequest]; + while (_queue.isNotEmpty && + (_queue.first.request.batchTag ?? '').trim() == batchTag) { + batch.add(_queue.removeFirst()); + } + return batch; + } + + void _requeueBatchAtFront( + List<_QueuedRoomGiftSeatFlightRequest> queuedBatch, + ) { + for (final queuedRequest in queuedBatch.reversed) { + _queue.addFirst(queuedRequest); + } + } + void _scheduleNextAnimation() { if (_isPlaying || _queue.isEmpty || !mounted) { return; @@ -381,78 +415,131 @@ class _RoomGiftSeatFlightOverlayState extends State } final activeSessionToken = _sessionToken; - final queuedRequest = _queue.removeFirst(); - final targetOffset = _resolveTargetOffset( - queuedRequest.request.targetUserId, + final queuedBatch = _removeNextQueuedBatch(); + final activeFlights = <_ActiveRoomGiftSeatFlight>[]; + final unresolvedBatch = <_QueuedRoomGiftSeatFlightRequest>[]; + + for (final queuedRequest in queuedBatch) { + final targetOffset = _resolveTargetOffset( + queuedRequest.request.targetUserId, + ); + if (targetOffset == null) { + unresolvedBatch.add(queuedRequest); + continue; + } + + final imageProvider = _resolveImageProvider( + queuedRequest.request.imagePath, + request: queuedRequest.request, + ); + activeFlights.add( + _ActiveRoomGiftSeatFlight( + request: queuedRequest.request, + imageProvider: imageProvider, + targetOffset: targetOffset, + ), + ); + } + + final shouldRetryBatch = unresolvedBatch.any( + (queuedRequest) => queuedRequest.retryCount < 6, ); - if (targetOffset == null) { - if (queuedRequest.retryCount < 6) { + if (shouldRetryBatch) { + final retryBatch = + queuedBatch + .map( + (queuedRequest) => queuedRequest.copyWith( + retryCount: queuedRequest.retryCount + 1, + ), + ) + .toList(); + if (retryBatch.isNotEmpty) { Future.delayed(const Duration(milliseconds: 120), () { if (!mounted || activeSessionToken != _sessionToken) { return; } - _queue.addFirst( - queuedRequest.copyWith(retryCount: queuedRequest.retryCount + 1), - ); + _requeueBatchAtFront(retryBatch); _scheduleNextAnimation(); }); - } else { - _syncIdleCenterVisual(); - _scheduleNextAnimation(); } return; } - final imageProvider = _resolveImageProvider( - queuedRequest.request.imagePath, - ); + if (activeFlights.isEmpty) { + _syncIdleCenterVisual(); + _scheduleNextAnimation(); + return; + } + _isPlaying = true; - _controller.duration = queuedRequest.request.flightDuration; + final centerFlight = activeFlights.first; + _controller.duration = centerFlight.request.flightDuration; setState(() { - _centerRequest = queuedRequest.request; - _centerImageProvider = imageProvider; - _activeRequest = queuedRequest.request; - _activeTargetOffset = targetOffset; - _activeImageProvider = imageProvider; + _centerRequest = centerFlight.request; + _centerImageProvider = centerFlight.imageProvider; + _activeFlights = activeFlights; }); try { - await precacheImage( - imageProvider, - context, - ).timeout(const Duration(milliseconds: 250)); + await Future.wait( + activeFlights.map( + (flight) => precacheImage( + flight.imageProvider, + context, + ).timeout(const Duration(milliseconds: 250)), + ), + ); } catch (_) {} if (!mounted || activeSessionToken != _sessionToken || - _activeRequest != queuedRequest.request) { + !identical(_activeFlights, activeFlights)) { return; } _controller.reset(); + _armActiveFlightWatchdog(centerFlight.request.flightDuration); _controller.forward(); } void _finishCurrentAnimation() { + _cancelActiveFlightWatchdog(); if (!mounted) { - _activeRequest = null; - _activeImageProvider = null; - _activeTargetOffset = null; + _activeFlights = const <_ActiveRoomGiftSeatFlight>[]; _isPlaying = false; _syncIdleCenterVisual(); return; } setState(() { - _activeRequest = null; - _activeImageProvider = null; - _activeTargetOffset = null; + _activeFlights = const <_ActiveRoomGiftSeatFlight>[]; _isPlaying = false; }); _syncIdleCenterVisual(); _scheduleNextAnimation(); } + void _armActiveFlightWatchdog(Duration flightDuration) { + _cancelActiveFlightWatchdog(); + final timeout = flightDuration + const Duration(milliseconds: 900); + _activeFlightWatchdogTimer = Timer(timeout, () { + _activeFlightWatchdogTimer = null; + if (!_isPlaying || _activeFlights.isEmpty) { + return; + } + debugPrint( + '[GiftSeatFlight] watchdog timeout active=${_activeFlights.length}', + ); + _controller.stop(); + _finishCurrentAnimation(); + }); + } + + void _cancelActiveFlightWatchdog() { + _activeFlightWatchdogTimer?.cancel(); + _activeFlightWatchdogTimer = null; + } + void _ensureCenterVisual(RoomGiftSeatFlightRequest request) { if (_centerRequest != null && _centerImageProvider != null) { return; @@ -473,7 +560,7 @@ class _RoomGiftSeatFlightOverlayState extends State } void _syncIdleCenterVisual() { - if (_isPlaying || _activeRequest != null) { + if (_isPlaying || _activeFlights.isNotEmpty) { return; } @@ -532,7 +619,10 @@ class _RoomGiftSeatFlightOverlayState extends State String imagePath, { RoomGiftSeatFlightRequest? request, }) { - final currentRequest = request ?? _activeRequest ?? _centerRequest; + final currentRequest = + request ?? + (_activeFlights.isNotEmpty ? _activeFlights.first.request : null) ?? + _centerRequest; final logicalSize = currentRequest == null ? null @@ -561,16 +651,17 @@ class _RoomGiftSeatFlightOverlayState extends State key: _overlayKey, child: (_centerRequest == null || _centerImageProvider == null) && - (_activeRequest == null || - _activeImageProvider == null || - _activeTargetOffset == null) + _activeFlights.isEmpty ? const SizedBox.shrink() : AnimatedBuilder( animation: _controller, builder: (context, child) { final center = overlaySize.center(Offset.zero); final currentCenterRequest = - _centerRequest ?? _activeRequest; + _centerRequest ?? + (_activeFlights.isNotEmpty + ? _activeFlights.first.request + : null); final children = []; if (currentCenterRequest != null && @@ -585,40 +676,38 @@ class _RoomGiftSeatFlightOverlayState extends State ); } - final activeRequest = _activeRequest; - final activeImageProvider = _activeImageProvider; - final activeTargetOffset = _activeTargetOffset; - if (activeRequest != null && - activeImageProvider != null && - activeTargetOffset != null) { + if (_activeFlights.isNotEmpty) { final travelProgress = Curves.easeInOutCubic.transform( _controller.value.clamp(0.0, 1.0), ); - final nodeCenter = - Offset.lerp( - center, - activeTargetOffset, - travelProgress, - )!; - final nodeSize = - lerpDouble( - activeRequest.beginSize, - activeRequest.endSize, - travelProgress, - )!; final fadeOut = 1 - Curves.easeOut.transform( ((travelProgress - 0.82) / 0.18).clamp(0.0, 1.0), ); - children.add( - _buildGiftNode( - imageProvider: activeImageProvider, - center: nodeCenter, - size: nodeSize, - opacity: fadeOut, - ), - ); + for (final activeFlight in _activeFlights) { + final activeRequest = activeFlight.request; + final nodeCenter = + Offset.lerp( + center, + activeFlight.targetOffset, + travelProgress, + )!; + final nodeSize = + lerpDouble( + activeRequest.beginSize, + activeRequest.endSize, + travelProgress, + )!; + children.add( + _buildGiftNode( + imageProvider: activeFlight.imageProvider, + center: nodeCenter, + size: nodeSize, + opacity: fadeOut, + ), + ); + } } return RepaintBoundary( @@ -677,6 +766,18 @@ class _RoomGiftSeatFlightOverlayState extends State } } +class _ActiveRoomGiftSeatFlight { + const _ActiveRoomGiftSeatFlight({ + required this.request, + required this.imageProvider, + required this.targetOffset, + }); + + final RoomGiftSeatFlightRequest request; + final ImageProvider imageProvider; + final Offset targetOffset; +} + class _QueuedRoomGiftSeatFlightRequest { const _QueuedRoomGiftSeatFlightRequest({ required this.request, diff --git a/lib/ui_kit/widgets/room/floating/floating_vip_entry_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_vip_entry_screen_widget.dart new file mode 100644 index 0000000..1e29416 --- /dev/null +++ b/lib/ui_kit/widgets/room/floating/floating_vip_entry_screen_widget.dart @@ -0,0 +1,401 @@ +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:yumi/app_localizations.dart'; +import 'package:yumi/main.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/components/sc_compontent.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart'; + +class FloatingVipEntryScreenWidget extends StatefulWidget { + const FloatingVipEntryScreenWidget({ + super.key, + required this.message, + required this.onAnimationCompleted, + }); + + final SCFloatingMessage message; + final VoidCallback onAnimationCompleted; + + @override + State createState() => + _FloatingVipEntryScreenWidgetState(); +} + +class _FloatingVipEntryScreenWidgetState + extends State + with TickerProviderStateMixin { + static const double _bannerWidth = 350; + static const double _bannerHeight = 84; + static const String _avatarDynamicKey = "avatar"; + static const String _nicknameDynamicKey = "nickname"; + + late final AnimationController _controller; + late final Animation _offsetAnimation; + late final AnimationController _swipeController; + late final Animation _swipeAnimation; + final Debouncer _debouncer = Debouncer(); + + bool _isSwipeAnimating = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 5), + vsync: this, + ); + _swipeController = AnimationController( + duration: const Duration(milliseconds: 550), + vsync: this, + ); + _offsetAnimation = Tween( + begin: const Offset(1, 0), + end: const Offset(-1, 0), + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _swipeAnimation = Tween( + begin: Offset.zero, + end: const Offset(-1.5, 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: _goRoom, + onHorizontalDragEnd: _handleHorizontalDragEnd, + child: SlideTransition( + position: _isSwipeAnimating ? _swipeAnimation : _offsetAnimation, + child: SizedBox( + width: _bannerWidth.w, + height: _bannerHeight.w, + child: _buildBanner(), + ), + ), + ); + } + + Widget _buildBanner() { + final url = widget.message.giftUrl?.trim(); + if (url != null && url.isNotEmpty) { + if (SCNetworkSvgaWidget.isSvga(url)) { + return SCNetworkSvgaWidget( + resource: url, + width: _bannerWidth.w, + height: _bannerHeight.w, + fit: BoxFit.cover, + dynamicIdentity: [ + url, + widget.message.userAvatarUrl ?? "", + widget.message.userName ?? "", + ].join("|"), + movieConfigurer: _configureVipEntrySvga, + fallback: _buildFallbackBanner(widget.message.fallbackUrl), + ); + } + return _buildFallbackBanner(url); + } + return _buildFallbackBanner(widget.message.fallbackUrl); + } + + Widget _buildFallbackBanner(String? imageUrl) { + return Stack( + fit: StackFit.expand, + children: [ + _buildImageBackground(imageUrl), + Padding( + padding: EdgeInsetsDirectional.only(start: 72.w, end: 20.w), + child: Row( + textDirection: TextDirection.ltr, + children: [ + head( + url: widget.message.userAvatarUrl ?? "", + width: 50.w, + showDefault: true, + ), + SizedBox(width: 10.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.message.userName ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + height: 1.1, + ), + ), + SizedBox(height: 3.w), + Text( + SCAppLocalizations.of(context)!.enterTheRoom, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.92), + fontSize: 12.sp, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + height: 1.1, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildImageBackground(String? url) { + final imageUrl = url?.trim(); + if (imageUrl == null || imageUrl.isEmpty) { + return _buildFallbackBackground(); + } + return ClipRRect( + borderRadius: BorderRadius.circular(_bannerHeight.w / 2), + child: netImage( + url: imageUrl, + width: _bannerWidth.w, + height: _bannerHeight.w, + fit: BoxFit.cover, + noDefaultImg: true, + errorWidget: _buildFallbackBackground(), + ), + ); + } + + Widget _buildFallbackBackground() { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_bannerHeight.w / 2), + gradient: const LinearGradient( + colors: [Color(0xFF6573FF), Color(0xFF8D5CFF)], + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 10.w, + offset: Offset(0, 4.w), + ), + ], + ), + ); + } + + Future _configureVipEntrySvga(MovieEntity movieEntity) async { + _logMissingDynamicKeys(movieEntity); + + final avatarUrl = (widget.message.userAvatarUrl ?? "").trim(); + if (avatarUrl.isNotEmpty) { + try { + final avatarImage = await _loadCircularUiImage( + avatarUrl, + logicalSize: 96, + ); + if (avatarImage != null) { + movieEntity.dynamicItem.setImage(avatarImage, _avatarDynamicKey); + } + } catch (error) { + debugPrint("[VIP][RoomFloat][SVGA] avatar sync failed: $error"); + } + } + + final nickname = (widget.message.userName ?? "").trim(); + if (nickname.isNotEmpty) { + try { + movieEntity.dynamicItem.setImage( + await _buildTransparentImage(), + _nicknameDynamicKey, + ); + movieEntity.dynamicItem.setText( + TextPainter( + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: "…", + text: TextSpan( + text: nickname, + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w800, + height: 1, + decoration: TextDecoration.none, + shadows: [ + Shadow( + color: Color(0x99000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + ), + )..layout(maxWidth: 220), + _nicknameDynamicKey, + ); + } catch (error) { + debugPrint("[VIP][RoomFloat][SVGA] nickname sync failed: $error"); + } + } + } + + void _logMissingDynamicKeys(MovieEntity movieEntity) { + final keys = + movieEntity.sprites + .map((sprite) => sprite.imageKey) + .where((key) => key.isNotEmpty) + .toSet(); + final missing = [ + if (!keys.contains(_avatarDynamicKey)) _avatarDynamicKey, + if (!keys.contains(_nicknameDynamicKey)) _nicknameDynamicKey, + ]; + if (missing.isEmpty) { + return; + } + debugPrint( + "[VIP][RoomFloat][SVGA] dynamic keys missing=${missing.join(",")} " + "available=${keys.take(30).join(",")}", + ); + } + + Future _loadCircularUiImage( + String resource, { + required int logicalSize, + }) async { + final image = await _loadUiImage( + resource, + logicalWidth: logicalSize.toDouble(), + logicalHeight: logicalSize.toDouble(), + ); + if (image == null) { + return null; + } + + final size = logicalSize.toDouble(); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final rect = Rect.fromLTWH(0, 0, size, size); + canvas.clipPath(Path()..addOval(rect)); + paintImage( + canvas: canvas, + rect: rect, + image: image, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ); + return recorder.endRecording().toImage(logicalSize, logicalSize); + } + + Future _loadUiImage( + String resource, { + double? logicalWidth, + double? logicalHeight, + }) async { + final target = resource.trim(); + if (target.isEmpty) { + return null; + } + final provider = buildCachedImageProvider( + target, + logicalWidth: logicalWidth, + logicalHeight: logicalHeight, + ); + final stream = provider.resolve(ImageConfiguration.empty); + final completer = Completer(); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (imageInfo, synchronousCall) { + if (!completer.isCompleted) { + completer.complete(imageInfo.image); + } + stream.removeListener(listener); + }, + onError: (Object error, StackTrace? stackTrace) { + if (!completer.isCompleted) { + completer.complete(null); + } + stream.removeListener(listener); + }, + ); + stream.addListener(listener); + return completer.future.timeout( + const Duration(seconds: 2), + onTimeout: () { + stream.removeListener(listener); + return null; + }, + ); + } + + Future _buildTransparentImage() { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawColor(Colors.transparent, BlendMode.clear); + return recorder.endRecording().toImage(1, 1); + } + + void _goRoom() { + _debouncer.debounce( + duration: const Duration(milliseconds: 350), + onDebounce: () { + final roomId = widget.message.roomId ?? ""; + if (roomId.isEmpty) return; + SCRoomUtils.goRoom( + roomId, + navigatorKey.currentState!.context, + 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 || _isSwipeAnimating) return; + setState(() => _isSwipeAnimating = true); + _controller.stop(); + _swipeController.reset(); + _swipeController.forward(); + } +} diff --git a/lib/ui_kit/widgets/room/room_bottom_widget.dart b/lib/ui_kit/widgets/room/room_bottom_widget.dart index f83c18d..8e2dad6 100644 --- a/lib/ui_kit/widgets/room/room_bottom_widget.dart +++ b/lib/ui_kit/widgets/room/room_bottom_widget.dart @@ -22,25 +22,53 @@ import '../../../services/audio/rtm_manager.dart'; import '../../components/sc_debounce_widget.dart'; class RoomBottomWidget extends StatefulWidget { - const RoomBottomWidget({super.key}); + const RoomBottomWidget({super.key, this.showGiftComboButton = true}); + + final bool showGiftComboButton; @override State createState() => _RoomBottomWidgetState(); } +class RoomGiftComboFloatingLayer extends StatelessWidget { + const RoomGiftComboFloatingLayer({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (context, provider) => _shouldShowRoomBottomMic(provider), + builder: (context, showMic, child) { + return LayoutBuilder( + builder: (context, constraints) { + final inputWidth = _resolveRoomBottomInputWidth( + maxWidth: constraints.maxWidth, + showMic: showMic, + ); + final giftCenterX = _resolveRoomGiftCenterX( + maxWidth: constraints.maxWidth, + inputWidth: inputWidth, + showMic: showMic, + ); + + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: giftCenterX - (_floatingButtonWidth.w / 2), + bottom: _floatingButtonBottomOffset.w, + child: const RoomGiftComboFloatingButton(), + ), + ], + ); + }, + ); + }, + ); + } +} + class _RoomBottomWidgetState extends State { int roomMenuStime1 = 0; - static const double _bottomBarHeight = 72; - static const double _floatingButtonHostHeight = 122; - static const double _floatingButtonWidth = - RoomGiftComboFloatingButton.hostSize; - static const double _floatingButtonBottomOffset = 54; - static const double _giftActionWidth = 52; - static const double _circleActionWidth = 46; - static const double _emojiActionWidth = 38; - static const double _chatEmojiGap = 8; - static const double _compactGap = 14; - static const double _horizontalPadding = 16; @override void dispose() { @@ -55,17 +83,17 @@ class _RoomBottomWidgetState extends State { child: Selector( selector: (context, provider) => _RoomBottomSnapshot( - showMic: _shouldShowMic(provider), + showMic: _shouldShowRoomBottomMic(provider), isMic: provider.isMic, ), builder: (context, bottomSnapshot, child) { return LayoutBuilder( builder: (context, constraints) { - final inputWidth = _resolveInputWidth( + final inputWidth = _resolveRoomBottomInputWidth( maxWidth: constraints.maxWidth, showMic: bottomSnapshot.showMic, ); - final giftCenterX = _resolveGiftCenterX( + final giftCenterX = _resolveRoomGiftCenterX( maxWidth: constraints.maxWidth, inputWidth: inputWidth, showMic: bottomSnapshot.showMic, @@ -74,11 +102,12 @@ class _RoomBottomWidgetState extends State { return Stack( clipBehavior: Clip.none, children: [ - Positioned( - left: giftCenterX - (_floatingButtonWidth.w / 2), - bottom: _floatingButtonBottomOffset.w, - child: const RoomGiftComboFloatingButton(), - ), + if (widget.showGiftComboButton) + Positioned( + left: giftCenterX - (_floatingButtonWidth.w / 2), + bottom: _floatingButtonBottomOffset.w, + child: const RoomGiftComboFloatingButton(), + ), Positioned( left: 0, right: 0, @@ -199,58 +228,6 @@ class _RoomBottomWidgetState extends State { ); } - double _resolveInputWidth({required double maxWidth, required bool showMic}) { - final contentWidth = maxWidth - (_horizontalPadding.w * 2); - final actionWidth = - _emojiActionWidth.w + - _chatEmojiGap.w + - _giftActionWidth.w + - _circleActionWidth.w * (showMic ? 3 : 2) + - (showMic ? 16.w : _compactGap.w * 2); - final availableWidth = contentWidth - actionWidth; - return availableWidth.clamp(82.w, 112.w).toDouble(); - } - - double _resolveGiftCenterX({ - required double maxWidth, - required double inputWidth, - required bool showMic, - }) { - final contentWidth = maxWidth - (_horizontalPadding.w * 2); - if (showMic) { - final occupiedWidth = - inputWidth + - _chatEmojiGap.w + - _emojiActionWidth.w + - _giftActionWidth.w + - _circleActionWidth.w * 3; - final gapCount = 4; - final gap = ((contentWidth - occupiedWidth) / gapCount).clamp( - 0.0, - double.infinity, - ); - return _horizontalPadding.w + - inputWidth + - _chatEmojiGap.w + - _emojiActionWidth.w + - gap + - (_giftActionWidth.w / 2); - } - - final fixedWidth = - inputWidth + - _chatEmojiGap.w + - _emojiActionWidth.w + - _giftActionWidth.w + - _circleActionWidth.w * 2 + - _compactGap.w * 2; - final spacerWidth = (contentWidth - fixedWidth).clamp(0.0, double.infinity); - return _horizontalPadding.w + - inputWidth + - spacerWidth + - (_giftActionWidth.w / 2); - } - void _showGiftPanel() { SmartDialog.show( tag: "showGiftControl", @@ -354,10 +331,76 @@ class _RoomBottomWidgetState extends State { ), ); } +} - bool _shouldShowMic(RtcProvider provider) { - return provider.isOnMai(); +const double _bottomBarHeight = 72; +const double _floatingButtonHostHeight = 122; +const double _floatingButtonWidth = RoomGiftComboFloatingButton.hostSize; +const double _floatingButtonBottomOffset = 54; +const double _giftActionWidth = 52; +const double _circleActionWidth = 46; +const double _emojiActionWidth = 38; +const double _chatEmojiGap = 8; +const double _compactGap = 14; +const double _horizontalPadding = 16; + +double _resolveRoomBottomInputWidth({ + required double maxWidth, + required bool showMic, +}) { + final contentWidth = maxWidth - (_horizontalPadding.w * 2); + final actionWidth = + _emojiActionWidth.w + + _chatEmojiGap.w + + _giftActionWidth.w + + _circleActionWidth.w * (showMic ? 3 : 2) + + (showMic ? 16.w : _compactGap.w * 2); + final availableWidth = contentWidth - actionWidth; + return availableWidth.clamp(82.w, 112.w).toDouble(); +} + +double _resolveRoomGiftCenterX({ + required double maxWidth, + required double inputWidth, + required bool showMic, +}) { + final contentWidth = maxWidth - (_horizontalPadding.w * 2); + if (showMic) { + final occupiedWidth = + inputWidth + + _chatEmojiGap.w + + _emojiActionWidth.w + + _giftActionWidth.w + + _circleActionWidth.w * 3; + final gapCount = 4; + final gap = ((contentWidth - occupiedWidth) / gapCount).clamp( + 0.0, + double.infinity, + ); + return _horizontalPadding.w + + inputWidth + + _chatEmojiGap.w + + _emojiActionWidth.w + + gap + + (_giftActionWidth.w / 2); } + + final fixedWidth = + inputWidth + + _chatEmojiGap.w + + _emojiActionWidth.w + + _giftActionWidth.w + + _circleActionWidth.w * 2 + + _compactGap.w * 2; + final spacerWidth = (contentWidth - fixedWidth).clamp(0.0, double.infinity); + return _horizontalPadding.w + + inputWidth + + spacerWidth + + (_giftActionWidth.w / 2); +} + +bool _shouldShowRoomBottomMic(RtcProvider provider) { + return provider.isOnMai(); } class _RoomBottomSnapshot { diff --git a/lib/ui_kit/widgets/room/room_msg_input.dart b/lib/ui_kit/widgets/room/room_msg_input.dart index 516ad5b..cfc999d 100644 --- a/lib/ui_kit/widgets/room/room_msg_input.dart +++ b/lib/ui_kit/widgets/room/room_msg_input.dart @@ -31,32 +31,45 @@ class RoomMsgInput extends StatefulWidget { } class _RoomMsgInputState extends State { - static const List _roomEmojiAssets = [ - "sc_images/room/emoji/fluent_emoji_01.webp", - "sc_images/room/emoji/fluent_emoji_02.webp", - "sc_images/room/emoji/fluent_emoji_03.webp", - "sc_images/room/emoji/fluent_emoji_04.webp", - "sc_images/room/emoji/fluent_emoji_05.webp", - "sc_images/room/emoji/fluent_emoji_06.webp", - "sc_images/room/emoji/fluent_emoji_07.webp", - "sc_images/room/emoji/fluent_emoji_08.webp", - "sc_images/room/emoji/fluent_emoji_09.webp", - "sc_images/room/emoji/fluent_emoji_10.webp", - "sc_images/room/emoji/monkey_1.gif", - "sc_images/room/emoji/monkey_2.gif", - "sc_images/room/emoji/monkey_3.gif", - "sc_images/room/emoji/monkey_4.gif", - "sc_images/room/emoji/monkey_5.gif", - "sc_images/room/emoji/monkey_6.gif", - "sc_images/room/emoji/monkey_7.gif", - "sc_images/room/emoji/monkey_8.gif", - "sc_images/room/emoji/monkey_9.gif", - "sc_images/room/emoji/monkey_10.gif", + static const List<_RoomEmojiCategory> _roomEmojiCategories = [ + _RoomEmojiCategory( + id: "fluent", + iconAsset: "sc_images/room/emoji/fluent_emoji_01.webp", + assets: [ + "sc_images/room/emoji/fluent_emoji_01.webp", + "sc_images/room/emoji/fluent_emoji_02.webp", + "sc_images/room/emoji/fluent_emoji_03.webp", + "sc_images/room/emoji/fluent_emoji_04.webp", + "sc_images/room/emoji/fluent_emoji_05.webp", + "sc_images/room/emoji/fluent_emoji_06.webp", + "sc_images/room/emoji/fluent_emoji_07.webp", + "sc_images/room/emoji/fluent_emoji_08.webp", + "sc_images/room/emoji/fluent_emoji_09.webp", + "sc_images/room/emoji/fluent_emoji_10.webp", + ], + ), + _RoomEmojiCategory( + id: "monkey", + iconAsset: "sc_images/room/emoji/monkey_1.gif", + assets: [ + "sc_images/room/emoji/monkey_1.gif", + "sc_images/room/emoji/monkey_2.gif", + "sc_images/room/emoji/monkey_3.gif", + "sc_images/room/emoji/monkey_4.gif", + "sc_images/room/emoji/monkey_5.gif", + "sc_images/room/emoji/monkey_6.gif", + "sc_images/room/emoji/monkey_7.gif", + "sc_images/room/emoji/monkey_8.gif", + "sc_images/room/emoji/monkey_9.gif", + "sc_images/room/emoji/monkey_10.gif", + ], + ), ]; bool showSend = false; bool showEmoji = false; bool _emojiAssetsPrecached = false; + int _selectedEmojiCategoryIndex = 0; final FocusNode msgNode = FocusNode(); final TextEditingController controller = TextEditingController(); @@ -234,33 +247,103 @@ class _RoomMsgInputState extends State { } Widget _buildEmojiPanel() { + final selectedCategory = _roomEmojiCategories[_selectedEmojiCategoryIndex]; return Container( - height: 150.w, + height: 220.w, decoration: BoxDecoration( color: _panelBackgroundColor, borderRadius: BorderRadius.circular(height(5)), ), - child: GridView.builder( - padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, - crossAxisSpacing: 8.w, - mainAxisSpacing: 8.w, + child: Column( + children: [ + Expanded( + child: GridView.builder( + key: ValueKey(selectedCategory.id), + padding: EdgeInsets.fromLTRB(18.w, 14.w, 18.w, 10.w), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 18.w, + mainAxisSpacing: 12.w, + ), + itemCount: selectedCategory.assets.length, + itemBuilder: (context, index) { + final emojiAsset = selectedCategory.assets[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + _sendRoomEmoji(emojiAsset); + }, + child: Center( + child: RoomEmojiAssetImage( + key: ValueKey(emojiAsset), + asset: emojiAsset, + width: 42.w, + height: 42.w, + ), + ), + ); + }, + ), + ), + _buildEmojiCategoryBar(), + ], + ), + ); + } + + Widget _buildEmojiCategoryBar() { + return Container( + height: 50.w, + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.w), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.08), + border: Border( + top: BorderSide(color: Colors.white.withValues(alpha: 0.08)), ), - itemCount: _roomEmojiAssets.length, + ), + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _roomEmojiCategories.length, + separatorBuilder: (_, __) => SizedBox(width: 10.w), itemBuilder: (context, index) { - final emojiAsset = _roomEmojiAssets[index]; + final category = _roomEmojiCategories[index]; + final selected = index == _selectedEmojiCategoryIndex; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { - _sendRoomEmoji(emojiAsset); + if (selected) { + return; + } + setState(() { + _selectedEmojiCategoryIndex = index; + }); }, - child: Center( - child: RoomEmojiAssetImage( - key: ValueKey(emojiAsset), - asset: emojiAsset, - width: 30.w, - height: 30.w, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + width: 64.w, + height: 36.w, + decoration: BoxDecoration( + color: + selected + ? Colors.white.withValues(alpha: 0.22) + : Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(18.w), + border: Border.all( + color: + selected + ? Colors.white.withValues(alpha: 0.30) + : Colors.transparent, + width: 1.w, + ), + ), + child: Center( + child: RoomEmojiAssetImage( + key: ValueKey("emoji_category_${category.id}"), + asset: category.iconAsset, + width: 26.w, + height: 26.w, + ), ), ), ); @@ -290,8 +373,11 @@ class _RoomMsgInputState extends State { return; } _emojiAssetsPrecached = true; - for (final asset in _roomEmojiAssets) { - precacheImage(AssetImage(asset), context); + for (final category in _roomEmojiCategories) { + precacheImage(AssetImage(category.iconAsset), context); + for (final asset in category.assets) { + precacheImage(AssetImage(asset), context); + } } } @@ -346,6 +432,18 @@ class _RoomMsgInputState extends State { } } +class _RoomEmojiCategory { + const _RoomEmojiCategory({ + required this.id, + required this.iconAsset, + required this.assets, + }); + + final String id; + final String iconAsset; + final List assets; +} + class PopRoute extends PopupRoute { final Duration _duration = Duration(milliseconds: 350); Widget child; diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index 0154aa3..d39e9e6 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -1,4 +1,6 @@ import 'dart:convert'; +import 'dart:math' as math; + import 'package:extended_text/extended_text.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -18,6 +20,8 @@ import 'package:yumi/shared/business_logic/models/res/mic_res.dart'; import 'package:yumi/shared/business_logic/usecases/sc_case.dart'; import 'package:yumi/modules/index/main_route.dart'; import 'package:yumi/ui_kit/widgets/room/room_emoji_asset_image.dart'; +import 'package:yumi/ui_kit/widgets/sc_nine_patch_image.dart'; +import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart'; import '../../../shared/data_sources/models/enum/sc_activity_reward_props_type.dart'; import '../../../shared/data_sources/models/enum/sc_room_roles_type.dart'; @@ -28,13 +32,12 @@ import '../../components/text/sc_text.dart'; ///消息item class MsgItem extends StatefulWidget { final Function(SocialChatUserProfile? user) onClick; - Msg msg; + final Msg msg; - MsgItem({Key? key, required this.msg, required this.onClick}) - : super(key: key); + const MsgItem({super.key, required this.msg, required this.onClick}); @override - _MsgItemState createState() => _MsgItemState(); + State createState() => _MsgItemState(); } class _MsgItemState extends State { @@ -130,42 +133,11 @@ class _MsgItemState extends State { Flexible( // 使用Flexible替代Expanded child: GestureDetector( - child: Container( - constraints: BoxConstraints( - maxWidth: ScreenUtil().screenWidth * 0.7, // 最大宽度约束 - ), - decoration: BoxDecoration( - color: Colors.black38, - borderRadius: BorderRadius.only( - topRight: Radius.circular(15.w), - bottomLeft: Radius.circular(15.w), - bottomRight: Radius.circular(15.w), - ), - ), - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 4.w, - ), - margin: EdgeInsetsDirectional.only( - start: 54.w, - ).copyWith(bottom: 8.w), - child: ExtendedText( - widget.msg?.msg ?? '', - style: TextStyle( - fontSize: sp(13), - color: Color(_msgColor(widget.msg.type ?? "")), - fontWeight: FontWeight.w500, - ), - softWrap: true, // 自动换行 - specialTextSpanBuilder: AtTextSpanBuilder( - onTapCall: (userId) {}, - ), - ), - ), + child: _buildTextMessageBubble(), onLongPress: () { - if (!(widget.msg?.msg ?? "").startsWith(AtText.flag)) { + if (!(widget.msg.msg ?? "").startsWith(AtText.flag)) { Clipboard.setData( - ClipboardData(text: widget.msg?.msg ?? ''), + ClipboardData(text: widget.msg.msg ?? ''), ); SCTts.show("Copied"); } @@ -194,7 +166,7 @@ class _MsgItemState extends State { borderRadius: BorderRadius.all(Radius.circular(15.w)), ), child: Text( - widget.msg?.msg ?? '', + widget.msg.msg ?? '', style: TextStyle( fontSize: sp(13), color: Color(_msgColor(widget.msg.type ?? "")), @@ -207,6 +179,295 @@ class _MsgItemState extends State { } } + Widget _buildTextMessageBubble() { + final chatBubble = widget.msg.user?.getChatBox(); + final hasChatBubble = _resolveRoomChatBubbleImageUrl(chatBubble).isNotEmpty; + if (hasChatBubble) { + return _buildVipTextMessageBubble(chatBubble); + } + + return Container( + constraints: BoxConstraints(maxWidth: ScreenUtil().screenWidth * 0.7), + decoration: BoxDecoration( + color: Colors.black38, + borderRadius: BorderRadius.only( + topRight: Radius.circular(15.w), + bottomLeft: Radius.circular(15.w), + bottomRight: Radius.circular(15.w), + ), + ), + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.w), + margin: EdgeInsetsDirectional.only(start: 54.w).copyWith(bottom: 8.w), + child: ExtendedText( + widget.msg.msg ?? '', + style: TextStyle( + fontSize: sp(13), + color: Color(_msgColor(widget.msg.type ?? "")), + fontWeight: FontWeight.w500, + ), + softWrap: true, + specialTextSpanBuilder: AtTextSpanBuilder(onTapCall: (userId) {}), + ), + ); + } + + Widget _buildVipTextMessageBubble(PropsResources? resource) { + final messageText = widget.msg.msg ?? ""; + final textStyle = TextStyle( + fontSize: sp(13), + color: Color(_msgColor(widget.msg.type ?? "")), + fontWeight: FontWeight.w500, + ); + final imageUrl = _resolveRoomChatBubbleImageUrl(resource); + final multilineMaxBubbleWidth = math.min( + ScreenUtil().screenWidth * 0.72, + 300.w, + ); + final multilineMaxTextWidth = math.max( + 90.w, + multilineMaxBubbleWidth - 58.w, + ); + final multilineTextPainter = TextPainter( + text: TextSpan(text: messageText, style: textStyle), + textDirection: Directionality.of(context), + )..layout(maxWidth: multilineMaxTextWidth); + if (multilineTextPainter.computeLineMetrics().length > 1) { + return _buildMultilineVipTextMessageBubble( + messageText: messageText, + textStyle: textStyle, + imageUrl: imageUrl, + textSize: multilineTextPainter.size, + maxBubbleWidth: multilineMaxBubbleWidth, + ); + } + + return _buildSingleLineVipTextMessageBubble( + messageText: messageText, + textStyle: textStyle, + imageUrl: imageUrl, + ); + } + + Widget _buildMultilineVipTextMessageBubble({ + required String messageText, + required TextStyle textStyle, + required String imageUrl, + required Size textSize, + required double maxBubbleWidth, + }) { + final bubbleWidth = math + .max(126.w, textSize.width + 58.w) + .clamp(126.w, maxBubbleWidth); + final textMaxWidth = math.max(90.w, bubbleWidth - 58.w); + final bubbleHeight = math.max(58.w, textSize.height + 26.w); + + return Container( + width: bubbleWidth, + height: bubbleHeight, + margin: EdgeInsetsDirectional.only(start: 54.w).copyWith(bottom: 8.w), + child: Stack( + fit: StackFit.expand, + children: [ + _buildVipBubbleBackground( + url: imageUrl, + width: bubbleWidth, + height: bubbleHeight, + fallback: _buildVipBubbleFallbackBackground(), + ), + Padding( + padding: EdgeInsetsDirectional.fromSTEB(29.w, 11.w, 29.w, 11.w), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: SizedBox( + width: textMaxWidth, + child: ExtendedText( + messageText, + style: textStyle, + softWrap: true, + specialTextSpanBuilder: AtTextSpanBuilder( + onTapCall: (userId) {}, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSingleLineVipTextMessageBubble({ + required String messageText, + required TextStyle textStyle, + required String imageUrl, + }) { + final horizontalPadding = 18.w; + final verticalPadding = 4.w; + final minSkinWidth = 132.w; + final minSkinHeight = 42.w; + final maxBubbleWidth = ScreenUtil().screenWidth * 0.7; + final maxTextWidth = math.max(1.0, maxBubbleWidth - horizontalPadding * 2); + final textSize = _measureMessageTextSize( + text: messageText, + style: textStyle, + maxWidth: maxTextWidth, + ); + final bubbleWidth = math.min( + maxBubbleWidth, + math.max(horizontalPadding * 2, textSize.width + horizontalPadding * 2), + ); + final textMaxWidth = math.max(1.0, bubbleWidth - horizontalPadding * 2); + final bubbleHeight = math.max( + verticalPadding * 2, + textSize.height + verticalPadding * 2, + ); + + return Container( + width: bubbleWidth, + height: bubbleHeight, + margin: EdgeInsetsDirectional.only(start: 54.w).copyWith(bottom: 8.w), + child: Stack( + clipBehavior: Clip.none, + fit: StackFit.expand, + children: [ + Positioned.fill(child: _buildVipBubbleFallbackBackground()), + Positioned.fill( + child: _buildVipBubbleSkin( + url: imageUrl, + width: math.max(bubbleWidth, minSkinWidth), + height: math.max(bubbleHeight, minSkinHeight), + alignment: AlignmentDirectional.centerStart, + ), + ), + Padding( + padding: EdgeInsetsDirectional.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: SizedBox( + width: textMaxWidth, + child: ExtendedText( + messageText, + style: textStyle, + softWrap: true, + specialTextSpanBuilder: AtTextSpanBuilder( + onTapCall: (userId) {}, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildVipBubbleSkin({ + required String url, + required double width, + required double height, + required AlignmentGeometry alignment, + }) { + return OverflowBox( + alignment: alignment, + minWidth: width, + maxWidth: width, + minHeight: height, + maxHeight: height, + child: + SCNetworkSvgaWidget.isSvga(url) + ? SCNetworkSvgaWidget( + resource: url, + width: width, + height: height, + fit: BoxFit.fill, + fallback: SizedBox(width: width, height: height), + ) + : SCNinePatchImage( + url: url, + width: width, + height: height, + centerSliceRatio: const EdgeInsets.fromLTRB( + 0.34, + 0.34, + 0.34, + 0.34, + ), + fallback: SizedBox(width: width, height: height), + ), + ); + } + + Widget _buildVipBubbleBackground({ + required String url, + required double width, + required double height, + required Widget fallback, + }) { + if (SCNetworkSvgaWidget.isSvga(url)) { + return SCNetworkSvgaWidget( + resource: url, + width: width, + height: height, + fit: BoxFit.fill, + fallback: fallback, + ); + } + return SCNinePatchImage( + url: url, + width: width, + height: height, + centerSliceRatio: const EdgeInsets.fromLTRB(0.34, 0.34, 0.34, 0.34), + fallback: fallback, + ); + } + + Size _measureMessageTextSize({ + required String text, + required TextStyle style, + required double maxWidth, + }) { + final textPainter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: Directionality.of(context), + )..layout(maxWidth: maxWidth); + return textPainter.size; + } + + Widget _buildVipBubbleFallbackBackground() { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.38), + borderRadius: BorderRadius.only( + topRight: Radius.circular(18.w), + bottomLeft: Radius.circular(18.w), + bottomRight: Radius.circular(18.w), + ), + ), + ); + } + + String _resolveRoomChatBubbleImageUrl(PropsResources? resource) { + return _firstNonBlank([ + resource?.roomSendCoverUrl, + resource?.roomOpenedUrl, + resource?.roomNotOpenedUrl, + resource?.cover, + ]); + } + + String _firstNonBlank(Iterable values) { + for (final value in values) { + final text = value?.trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return ""; + } + ///系统提示 _systemTipsRoomItem(BuildContext context) { return Container( @@ -224,7 +485,7 @@ class _MsgItemState extends State { borderRadius: BorderRadius.all(Radius.circular(15.w)), ), child: Text( - widget.msg?.msg ?? '', + widget.msg.msg ?? '', style: TextStyle( fontSize: sp(13), height: 1.5.w, @@ -658,7 +919,7 @@ class _MsgItemState extends State { ///进入房间 _enterRoomItem(BuildContext context) { List spans = []; - if (widget.msg.role != null && widget.msg.role != 0) { + if (widget.msg.role != null && widget.msg.role != "0") { spans.add( WidgetSpan( alignment: PlaceholderAlignment.middle, @@ -671,7 +932,7 @@ class _MsgItemState extends State { } spans.add( TextSpan( - text: "${widget.msg?.user?.userNickname ?? ''} ", + text: "${widget.msg.user?.userNickname ?? ''} ", style: TextStyle( fontSize: sp(13), color: _roleNameColor(widget.msg.role ?? ""), @@ -718,7 +979,7 @@ class _MsgItemState extends State { child: ListView.separated( scrollDirection: Axis.horizontal, shrinkWrap: true, - itemCount: wearBadge.length ?? 0, + itemCount: wearBadge.length, itemBuilder: (context, index) { return netImage( width: 25.w, @@ -749,7 +1010,6 @@ class _MsgItemState extends State { case SCRoomMsgType.qcfj: case SCRoomMsgType.killXiaMai: return 0xFFFFFFFF; - break; // case type.text: // return _mapTextColor(); // break; @@ -1403,6 +1663,7 @@ class Msg { // 送礼给谁 SocialChatUserProfile? toUser; + List? targetUserIds; int? time = 0; //动画是否已经播放(骰子、石头剪刀布、随机数字) @@ -1422,6 +1683,7 @@ class Msg { this.number, this.customAnimationCount, this.awardAmount, + this.targetUserIds, this.hasPlayedAnimation, this.needUpDataUserInfo, }) { @@ -1437,6 +1699,14 @@ class Msg { number = json['number']; customAnimationCount = json['customAnimationCount']; awardAmount = json['awardAmount']; + final dynamic rawTargetUserIds = json['targetUserIds']; + if (rawTargetUserIds is List) { + targetUserIds = + rawTargetUserIds + .map((item) => item?.toString().trim() ?? "") + .where((id) => id.isNotEmpty) + .toList(); + } hasPlayedAnimation = json['hasPlayedAnimation']; needUpDataUserInfo = json['needUpDataUserInfo']; userWheat = @@ -1466,6 +1736,9 @@ class Msg { map['customAnimationCount'] = customAnimationCount; map['hasPlayedAnimation'] = hasPlayedAnimation; map['needUpDataUserInfo'] = needUpDataUserInfo; + if (targetUserIds != null) { + map['targetUserIds'] = targetUserIds; + } if (userWheat != null) { map['userWheat'] = userWheat?.toJson(); } diff --git a/lib/ui_kit/widgets/sc_nine_patch_image.dart b/lib/ui_kit/widgets/sc_nine_patch_image.dart new file mode 100644 index 0000000..1920f89 --- /dev/null +++ b/lib/ui_kit/widgets/sc_nine_patch_image.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:yumi/shared/tools/sc_network_image_utils.dart'; + +class SCNinePatchImage extends StatefulWidget { + const SCNinePatchImage({ + super.key, + required this.url, + required this.width, + required this.height, + this.centerSliceRatio = const EdgeInsets.fromLTRB(0.34, 0.34, 0.34, 0.34), + this.sourceEdgeInset = const EdgeInsets.all(2), + this.filterQuality = FilterQuality.low, + this.fallback, + }); + + final String url; + final double width; + final double height; + final EdgeInsets centerSliceRatio; + final EdgeInsets sourceEdgeInset; + final FilterQuality filterQuality; + final Widget? fallback; + + @override + State createState() => _SCNinePatchImageState(); +} + +class _SCNinePatchImageState extends State { + ImageStream? _imageStream; + ImageStreamListener? _imageListener; + ui.Image? _image; + bool _hasError = false; + int _resolveSerial = 0; + + @override + void initState() { + super.initState(); + _resolveImage(); + } + + @override + void didUpdateWidget(covariant SCNinePatchImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url || + oldWidget.width != widget.width || + oldWidget.height != widget.height || + oldWidget.sourceEdgeInset != widget.sourceEdgeInset) { + _resolveImage(); + } + } + + @override + void dispose() { + _resolveSerial++; + _removeListener(); + _image?.dispose(); + super.dispose(); + } + + void _resolveImage() { + _removeListener(); + _image?.dispose(); + _image = null; + final serial = ++_resolveSerial; + final url = widget.url.trim(); + if (url.isEmpty) { + setState(() { + _hasError = true; + }); + return; + } + + final provider = buildCachedImageProvider( + url, + logicalWidth: widget.width, + logicalHeight: widget.height, + ); + final stream = provider.resolve(ImageConfiguration.empty); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (imageInfo, synchronousCall) { + unawaited(_cropAndUseImage(imageInfo.image, serial)); + }, + onError: (error, stackTrace) { + if (!mounted || serial != _resolveSerial) { + return; + } + debugPrint("[SCNinePatchImage] load failed url=$url error=$error"); + setState(() { + _image = null; + _hasError = true; + }); + }, + ); + _imageStream = stream; + _imageListener = listener; + stream.addListener(listener); + } + + Future _cropAndUseImage(ui.Image sourceImage, int serial) async { + try { + final croppedImage = await _cropImage(sourceImage); + if (!mounted || serial != _resolveSerial) { + croppedImage.dispose(); + return; + } + setState(() { + _image?.dispose(); + _image = croppedImage; + _hasError = false; + }); + } catch (error) { + debugPrint("[SCNinePatchImage] crop failed error=$error"); + if (!mounted || serial != _resolveSerial) { + return; + } + setState(() { + _image = null; + _hasError = true; + }); + } + } + + Future _cropImage(ui.Image sourceImage) async { + final imageWidth = sourceImage.width.toDouble(); + final imageHeight = sourceImage.height.toDouble(); + final left = widget.sourceEdgeInset.left.clamp(0.0, imageWidth / 4); + final top = widget.sourceEdgeInset.top.clamp(0.0, imageHeight / 4); + final right = widget.sourceEdgeInset.right.clamp(0.0, imageWidth / 4); + final bottom = widget.sourceEdgeInset.bottom.clamp(0.0, imageHeight / 4); + final sourceRect = Rect.fromLTRB( + left, + top, + imageWidth - right, + imageHeight - bottom, + ); + final croppedWidth = sourceRect.width.round().clamp(1, sourceImage.width); + final croppedHeight = sourceRect.height.round().clamp( + 1, + sourceImage.height, + ); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawImageRect( + sourceImage, + sourceRect, + Rect.fromLTWH(0, 0, croppedWidth.toDouble(), croppedHeight.toDouble()), + Paint()..filterQuality = FilterQuality.low, + ); + final picture = recorder.endRecording(); + final croppedImage = await picture.toImage(croppedWidth, croppedHeight); + picture.dispose(); + return croppedImage; + } + + void _removeListener() { + final stream = _imageStream; + final listener = _imageListener; + if (stream != null && listener != null) { + stream.removeListener(listener); + } + _imageStream = null; + _imageListener = null; + } + + @override + Widget build(BuildContext context) { + final image = _image; + if (_hasError || image == null) { + return widget.fallback ?? + SizedBox(width: widget.width, height: widget.height); + } + + return RawImage( + image: image, + width: widget.width, + height: widget.height, + fit: BoxFit.fill, + centerSlice: _buildCenterSlice(image), + filterQuality: widget.filterQuality, + ); + } + + Rect _buildCenterSlice(ui.Image image) { + final imageWidth = image.width.toDouble(); + final imageHeight = image.height.toDouble(); + final left = (imageWidth * widget.centerSliceRatio.left).clamp( + 1.0, + imageWidth - 2.0, + ); + final top = (imageHeight * widget.centerSliceRatio.top).clamp( + 1.0, + imageHeight - 2.0, + ); + final right = (imageWidth * widget.centerSliceRatio.right).clamp( + 1.0, + imageWidth - left - 1.0, + ); + final bottom = (imageHeight * widget.centerSliceRatio.bottom).clamp( + 1.0, + imageHeight - top - 1.0, + ); + return Rect.fromLTRB(left, top, imageWidth - right, imageHeight - bottom); + } +} diff --git a/lib/ui_kit/widgets/svga/sc_network_svga_widget.dart b/lib/ui_kit/widgets/svga/sc_network_svga_widget.dart new file mode 100644 index 0000000..bc164eb --- /dev/null +++ b/lib/ui_kit/widgets/svga/sc_network_svga_widget.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svga/flutter_svga.dart'; +import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; + +typedef SCNetworkSvgaMovieConfigurer = + FutureOr Function(MovieEntity movieEntity); + +class SCNetworkSvgaWidget extends StatefulWidget { + const SCNetworkSvgaWidget({ + super.key, + required this.resource, + this.width, + this.height, + this.active = true, + this.loop = false, + this.fit = BoxFit.contain, + this.filterQuality = FilterQuality.low, + this.allowDrawingOverflow = false, + this.fallback, + this.dynamicIdentity, + this.movieConfigurer, + }); + + final String resource; + final double? width; + final double? height; + final bool active; + final bool loop; + final BoxFit fit; + final FilterQuality filterQuality; + final bool allowDrawingOverflow; + final Widget? fallback; + final Object? dynamicIdentity; + final SCNetworkSvgaMovieConfigurer? movieConfigurer; + + static bool isSvga(String? resource) { + final lower = resource?.trim().toLowerCase() ?? ""; + return lower.endsWith(".svga") || lower.contains(".svga?"); + } + + @override + State createState() => _SCNetworkSvgaWidgetState(); +} + +class _SCNetworkSvgaWidgetState extends State + with SingleTickerProviderStateMixin { + late final SVGAAnimationController _controller; + String? _loadedResource; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _controller = SVGAAnimationController(vsync: this); + _loadResource(); + } + + @override + void didUpdateWidget(covariant SCNetworkSvgaWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.resource != widget.resource || + oldWidget.dynamicIdentity != widget.dynamicIdentity) { + _loadResource(); + return; + } + if (oldWidget.active != widget.active || oldWidget.loop != widget.loop) { + _syncPlayback(restartIfActive: widget.active && !oldWidget.active); + } + } + + Future _loadResource() async { + final resource = widget.resource.trim(); + if (resource.isEmpty || !SCNetworkSvgaWidget.isSvga(resource)) { + setState(() { + _hasError = true; + _loadedResource = null; + _controller.videoItem = null; + }); + return; + } + + setState(() { + _hasError = false; + }); + + try { + final cache = SCGiftVapSvgaManager().videoItemCache; + var movieEntity = cache[resource]; + if (movieEntity == null) { + movieEntity = + resource.startsWith("http") + ? await SVGAParser.shared.decodeFromURL(resource) + : await SVGAParser.shared.decodeFromAssets(resource); + movieEntity.autorelease = false; + cache[resource] = movieEntity; + } + if (widget.movieConfigurer != null) { + movieEntity.dynamicItem.reset(); + await widget.movieConfigurer!(movieEntity); + } + if (!mounted || widget.resource.trim() != resource) { + return; + } + setState(() { + _loadedResource = resource; + _controller.videoItem = movieEntity; + }); + _syncPlayback(restartIfActive: true); + } catch (error) { + debugPrint("[SCNetworkSVGA] load failed resource=$resource error=$error"); + if (!mounted || widget.resource.trim() != resource) { + return; + } + setState(() { + _hasError = true; + _loadedResource = null; + _controller.videoItem = null; + }); + } + } + + void _syncPlayback({bool restartIfActive = false}) { + if (!widget.active || _controller.videoItem == null) { + _controller.stop(); + return; + } + if (widget.loop) { + if (restartIfActive || !_controller.isAnimating) { + _controller.reset(); + _controller.repeat(); + } + return; + } + if (restartIfActive || !_controller.isAnimating) { + _controller.reset(); + _controller.repeat(count: 1); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_hasError || + _controller.videoItem == null || + _loadedResource != widget.resource.trim()) { + return _buildFallback(); + } + + return SizedBox( + width: widget.width, + height: widget.height, + child: IgnorePointer( + child: SVGAImage( + _controller, + fit: widget.fit, + clearsAfterStop: false, + filterQuality: widget.filterQuality, + allowDrawingOverflow: widget.allowDrawingOverflow, + preferredSize: + widget.width != null && widget.height != null + ? Size(widget.width!, widget.height!) + : null, + ), + ), + ); + } + + Widget _buildFallback() { + return widget.fallback ?? + SizedBox(width: widget.width, height: widget.height); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a2d6d96..5ee95fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.6+8 +version: 1.2.7+9 environment: diff --git a/test/gift_vap_svga_manager_priority_test.dart b/test/gift_vap_svga_manager_priority_test.dart new file mode 100644 index 0000000..688363b --- /dev/null +++ b/test/gift_vap_svga_manager_priority_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; + +void main() { + group('SCGiftVapSvgaManager priorities', () { + test('entry effects keep the highest default priority', () { + final giftPriority = SCGiftVapSvgaManager.resolveEffectPriority(); + final entryPriority = SCGiftVapSvgaManager.resolveEffectPriority( + type: SCGiftVapSvgaManager.entryEffectType, + ); + + expect(entryPriority, greaterThan(giftPriority)); + expect(entryPriority, SCGiftVapSvgaManager.entryEffectPriority); + }); + + test('explicit gift priority is kept below default entry priority', () { + expect(SCGiftVapSvgaManager.resolveEffectPriority(priority: 200), 200); + expect( + SCGiftVapSvgaManager.resolveEffectPriority( + priority: 200, + type: SCGiftVapSvgaManager.entryEffectType, + ), + SCGiftVapSvgaManager.entryEffectPriority, + ); + }); + + test( + 'priority queue still orders entry before gift in the same channel', + () { + final queue = SCPriorityQueue((a, b) => a.compareTo(b)); + + queue.add( + SCVapTask( + path: 'entry.svga', + type: SCGiftVapSvgaManager.entryEffectType, + priority: SCGiftVapSvgaManager.resolveEffectPriority( + type: SCGiftVapSvgaManager.entryEffectType, + ), + ), + ); + queue.add( + SCVapTask( + path: 'gift.svga', + priority: SCGiftVapSvgaManager.resolveEffectPriority(), + ), + ); + + expect(queue.removeFirst().path, 'entry.svga'); + expect(queue.removeFirst().path, 'gift.svga'); + }, + ); + }); +} diff --git a/test/sc_path_utils_test.dart b/test/sc_path_utils_test.dart new file mode 100644 index 0000000..8b6e4ae --- /dev/null +++ b/test/sc_path_utils_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/tools/sc_path_utils.dart'; + +void main() { + group('SCPathUtils.getFileExtension', () { + test('normalizes query and fragment from svga urls', () { + expect( + SCPathUtils.getFileExtension( + 'https://example.test/gifts/effect.svga?Expires=1&Signature=abc', + ), + '.svga', + ); + expect( + SCPathUtils.getFileExtension( + 'https://example.test/gifts/effect.SVGA#preview', + ), + '.svga', + ); + }); + + test('keeps local and asset paths unchanged', () { + expect( + SCPathUtils.getFileExtension('sc_images/room/effect.svga'), + '.svga', + ); + expect(SCPathUtils.getFileExtension('/tmp/effect.mp4'), '.mp4'); + }); + }); +} diff --git a/test/user_profile_vip_colored_id_test.dart b/test/user_profile_vip_colored_id_test.dart new file mode 100644 index 0000000..667557f --- /dev/null +++ b/test/user_profile_vip_colored_id_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/business_logic/models/res/join_room_res.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/shared/business_logic/models/res/mic_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_gift_rank_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_member_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_res.dart'; +import 'package:yumi/shared/business_logic/models/res/room_user_card_res.dart'; + +void main() { + group('VIP colored ID', () { + test('uses explicit VIP level from user profile', () { + final profile = SocialChatUserProfile.fromJson({ + 'account': '10001', + 'vipLevel': 3, + }); + + expect(profile.vipLevelForColoredId, 3); + expect(profile.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('VIP level zero disables legacy prop fallback', () { + final profile = SocialChatUserProfile.fromJson({ + 'account': '10001', + 'vipLevel': 0, + 'useProps': [ + { + 'propsResources': {'type': 'NOBLE_VIP', 'code': '10026'}, + }, + ], + }); + + expect(profile.vipLevelForColoredId, 0); + expect(profile.shouldShowColoredSpecialIdText(), isFalse); + }); + + test('keeps legacy NOBLE_VIP prop support', () { + final profile = SocialChatUserProfile.fromJson({ + 'account': '10001', + 'useProps': [ + { + 'propsResources': {'type': 'NOBLE_VIP', 'code': '10026'}, + }, + ], + }); + + expect(profile.vipLevelForColoredId, 1); + expect(profile.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('uses VIP visual resources for colored ID on user cards', () { + final profile = SocialChatUserProfile.fromJson({ + 'account': '10001', + 'useProps': [ + { + 'propsResources': { + 'type': 'AVATAR_FRAME', + 'name': 'VIP5 avatar frame', + 'sourceUrl': 'https://example.test/vip_5_frame.svga', + }, + }, + ], + }); + + expect(profile.vipVisualResourceLevelForColoredId, 5); + expect(profile.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('does not color inactive nested VIP status', () { + final profile = SocialChatUserProfile.fromJson({ + 'account': '10001', + 'vipStatus': {'active': false, 'level': 5}, + }); + + expect(profile.vipLevelForColoredId, 0); + expect(profile.shouldShowColoredSpecialIdText(), isFalse); + }); + + test('pipes room user card supperVip into nested profile', () { + final card = RoomUserCardRes.fromJson({ + 'supperVip': 'VIP4', + 'userProfile': {'account': '10001'}, + }); + + expect(card.userProfile?.vipLevelForColoredId, 4); + expect(card.userProfile?.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('pipes room owner userSVipLevel into nested profile', () { + final room = SocialChatRoomRes.fromJson({ + 'userSVipLevel': '5', + 'userProfile': {'account': '10001'}, + }); + + expect(room.userProfile?.vipLevelForColoredId, 5); + expect(room.userProfile?.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('pipes joined voice room userSVipLevel into owner profile', () { + final room = JoinRoomRes.fromJson({ + 'roomProfile': { + 'userSVipLevel': 'VIP5', + 'userProfile': {'account': '10001'}, + }, + }); + + expect(room.roomProfile?.userProfile?.vipLevelForColoredId, 5); + expect( + room.roomProfile?.userProfile?.shouldShowColoredSpecialIdText(), + isTrue, + ); + }); + + test('pipes joined voice room supperVip into owner profile', () { + final room = JoinRoomRes.fromJson({ + 'roomProfile': { + 'supperVip': '4', + 'userProfile': {'account': '10001'}, + }, + }); + + expect(room.roomProfile?.userProfile?.vipLevelForColoredId, 4); + expect( + room.roomProfile?.userProfile?.shouldShowColoredSpecialIdText(), + isTrue, + ); + }); + + test('pipes top-level joined voice room VIP into owner profile', () { + final room = JoinRoomRes.fromJson({ + 'userSVipLevel': 'SVIP6', + 'roomProfile': { + 'userProfile': {'account': '10001'}, + }, + }); + + expect(room.roomProfile?.userProfile?.vipLevelForColoredId, 6); + expect( + room.roomProfile?.userProfile?.shouldShowColoredSpecialIdText(), + isTrue, + ); + }); + + test('reads VIP level from wrapped user profile payloads', () { + final profile = SocialChatUserProfile.fromJson({ + 'userSVipLevel': 'VIP5', + 'userProfile': {'account': '10001'}, + }); + + expect(profile.account, '10001'); + expect(profile.vipLevelForColoredId, 5); + expect(profile.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('pipes room rank VIP into nested profile', () { + final rank = RoomGiftRankRes.fromJson({ + 'userSVipLevel': 'VIP5', + 'userProfile': {'account': '10001'}, + }); + + expect(rank.userProfile?.vipLevelForColoredId, 5); + expect(rank.userProfile?.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('pipes room member VIP into nested profile', () { + final member = SocialChatRoomMemberRes.fromJson({ + 'supperVip': '5', + 'userProfile': {'account': '10001'}, + }); + + expect(member.userProfile?.vipLevelForColoredId, 5); + expect(member.userProfile?.shouldShowColoredSpecialIdText(), isTrue); + }); + + test('pipes microphone VIP into nested user', () { + final mic = MicRes.fromJson({ + 'userSVipLevel': 'VIP5', + 'user': {'account': '10001'}, + }); + + expect(mic.user?.vipLevelForColoredId, 5); + expect(mic.user?.shouldShowColoredSpecialIdText(), isTrue); + }); + }); +}