From 4d86236227ee2faf9123dc41e7958e110031208b Mon Sep 17 00:00:00 2001 From: roxy Date: Sat, 23 May 2026 23:28:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=BC=82=E5=B8=B8=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Podfile.lock | 54 +- ...oom_cp_relation_formed_effect_preview.dart | 113 +++ lib/modules/chat/message_chat_page.dart | 31 +- .../chat/system/message_system_page.dart | 31 +- lib/modules/gift/gift_page.dart | 11 +- .../user/profile/person_detail_page.dart | 76 +- lib/services/audio/rtm_manager.dart | 34 +- lib/services/auth/user_profile_manager.dart | 127 ++- .../local/floating_screen_manager.dart | 3 +- .../data_sources/sources/remote/net/api.dart | 28 +- .../sources/remote/net/network_client.dart | 73 +- .../cp/room_cp_relation_formed_effect.dart | 743 +++++++++++++++--- .../floating/floating_gift_screen_widget.dart | 3 +- .../widgets/room/room_user_info_card.dart | 74 +- test/network_response_data_test.dart | 29 + ...cp_relation_formed_effect_layout_test.dart | 55 ++ 16 files changed, 1289 insertions(+), 196 deletions(-) create mode 100644 lib/debug/room_cp_relation_formed_effect_preview.dart create mode 100644 test/network_response_data_test.dart create mode 100644 test/room_cp_relation_formed_effect_layout_test.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 76d05b1..6ebc174 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -346,15 +346,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: 585674be3c6661708e6cd794ab4f39fb9d8356f9 + app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - audioplayers_darwin: f15e209a3e856d1a7edcf98dc029f484fead2242 - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e - firebase_auth: 5342db41af2ba5ed32a6177d9e326eecbebda912 - firebase_core: 99a37263b3c27536063a7b601d9e2a49400a433c - firebase_crashlytics: 986773c0c7ea4c3f5a4ead141b58522fcd9dfdb1 + firebase_auth: 50af8366c87bb88c80ebeae62eb60189c7246b9b + firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 + firebase_crashlytics: 30dcd6dfd2fe895c0848af46722a4227346c19aa FirebaseAppCheckInterop: 06fe5a3799278ae4667e6c432edd86b1030fa3df FirebaseAuth: a6575e5fbf46b046c58dc211a28a5fbdd8d4c83b FirebaseAuthInterop: 7087d7a4ee4bc4de019b2d0c240974ed5d89e2fd @@ -366,47 +366,47 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 1c6135e8a094cc6368949f5faeeca7ee8948b8aa FirebaseSessions: b9a92c1c51bbb81e78fc3142cda6d925d700f8e7 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e - fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f - google_sign_in_ios: 7411fab6948df90490dc4620ecbcabdc3ca04017 + flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 + fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 + google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 HydraAsync: 8d589bd725b0224f899afafc9a396327405f8063 - image_cropper: a3291c624a953049bc6a02e1f8c8ceb162a24b25 - image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b - in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a + image_cropper: 655b3ba703c9e15e3111e79151624d6154288774 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 libpag: 98742fad4b3ac2a2ee31e383d2483495fb9a5fb4 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - on_audio_query_ios: e5afb0d4b42cfa3e8762d8ec1953540cb4f02100 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - pag: ff30ba5e4be59380a5ddac6b183905729be15a3c - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + on_audio_query_ios: 28a780e2d0d85d92d500ba6e12c6c8167022b2fa + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + pag: f01aa9017ab0e04a83ba4a20d6070a50f0ac9da8 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 - sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82 - tancent_vap: eceaaf095167d102727cf799c75891057834c311 - tencent_cloud_chat_sdk: 546bb906bbe585814e3d2d33afdb91f09c0eac44 - tencent_rtc_sdk: 1ca5201f7bddc691a3a95486325ad6dc792a273d + tancent_vap: fa8ad93814a9f950514a7074c662d2c937084c68 + tencent_cloud_chat_sdk: 55e5fffe20f6b7937a26a674ccccb639563a9790 + tencent_rtc_sdk: f77558b6b436a149d378557c2a837f73c09061bc TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 TXCustomBeautyProcesserPlugin: 099393b941cb40eda12b3a80bf6c0319957b1cfd TXIMSDK_Plus_iOS_XCFramework: 5d1933192fb3b7ef2fe933f1623de4a0486a7fe2 TXLiteAVSDK_Professional: e792e6084a208ecef6196c5f28237ad8078b4d7c - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - video_player_avfoundation: e54d03b3b19f64f2c557354f72ce538387541ba6 - video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1 - wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e - webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 + video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d PODFILE CHECKSUM: a6f49a93e5f85201a2efdadcd7cf184b0e310894 diff --git a/lib/debug/room_cp_relation_formed_effect_preview.dart b/lib/debug/room_cp_relation_formed_effect_preview.dart new file mode 100644 index 0000000..d603418 --- /dev/null +++ b/lib/debug/room_cp_relation_formed_effect_preview.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:yumi/app/constants/sc_screen.dart'; +import 'package:yumi/ui_kit/widgets/room/cp/room_cp_relation_formed_effect.dart'; + +void main() { + runApp(const RoomCpRelationFormedEffectPreviewApp()); +} + +class RoomCpRelationFormedEffectPreviewApp extends StatelessWidget { + const RoomCpRelationFormedEffectPreviewApp({super.key}); + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + designSize: Size(SCScreen.designWidth, SCScreen.designHeight), + splitScreenMode: false, + minTextAdapt: true, + builder: (context, child) { + return MaterialApp( + debugShowCheckedModeBanner: false, + builder: FlutterSmartDialog.init(), + home: const RoomCpRelationFormedEffectPreviewPage(), + ); + }, + ); + } +} + +class RoomCpRelationFormedEffectPreviewPage extends StatefulWidget { + const RoomCpRelationFormedEffectPreviewPage({super.key}); + + @override + State createState() => + _RoomCpRelationFormedEffectPreviewPageState(); +} + +class _RoomCpRelationFormedEffectPreviewPageState + extends State { + Timer? _loopTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _showEffect()); + _loopTimer = Timer.periodic( + const Duration(seconds: 7), + (_) => _showEffect(), + ); + } + + @override + void dispose() { + _loopTimer?.cancel(); + super.dispose(); + } + + void _showEffect() { + RoomCpRelationFormedEffect.show( + leftName: "Avery", + rightName: "Jordan", + leftAvatar: "https://i.pravatar.cc/180?img=32", + rightAvatar: "https://i.pravatar.cc/180?img=47", + relationType: "CP", + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF170D24), + body: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF2B163A), Color(0xFF07040D)], + ), + ), + ), + ), + Positioned( + left: 16, + right: 16, + bottom: 34, + child: Row( + children: [ + FilledButton( + onPressed: _showEffect, + child: const Text("Replay"), + ), + const SizedBox(width: 12), + FilledButton.tonal( + onPressed: + () => SmartDialog.dismiss( + tag: RoomCpRelationFormedEffect.dialogTag, + ), + child: const Text("Dismiss"), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/chat/message_chat_page.dart b/lib/modules/chat/message_chat_page.dart index 909dd07..23b53fe 100644 --- a/lib/modules/chat/message_chat_page.dart +++ b/lib/modules/chat/message_chat_page.dart @@ -1164,6 +1164,8 @@ class _SCMessageChatPageState extends State { } class _MessageItem extends StatelessWidget { + static bool _isOpeningCpInviteDialog = false; + final V2TimMessage message; final V2TimMessage? preMessage; final SCVipResourceRes? selfVipChatBubble; @@ -1473,15 +1475,30 @@ class _MessageItem extends StatelessWidget { SCSystemInvitMessageRes? cpInvite, RoomCpInviteActionState actionState, ) async { - final latestState = await _latestCpInviteStateForMessage(data); - if (!context.mounted) { + if (_isOpeningCpInviteDialog || + SmartDialog.checkExist(tag: RoomCpInviteDialog.dialogTag)) { return; } - final resolvedActionState = - latestState == _CpInviteMessageState.pending - ? actionState - : _cpInviteActionState(latestState); - _showCpInviteDialogFromMessage(data, cpInvite, resolvedActionState); + _isOpeningCpInviteDialog = true; + try { + _showCpInviteDialogFromMessage(data, cpInvite, actionState); + if (actionState != RoomCpInviteActionState.pending) { + return; + } + final latestState = await _latestCpInviteStateForMessage(data); + if (!context.mounted || + latestState == _CpInviteMessageState.pending || + !SmartDialog.checkExist(tag: RoomCpInviteDialog.dialogTag)) { + return; + } + _showCpInviteDialogFromMessage( + data, + cpInvite, + _cpInviteActionState(latestState), + ); + } finally { + _isOpeningCpInviteDialog = false; + } } Widget _buildCpInviteGiftThumb(String giftCover) { diff --git a/lib/modules/chat/system/message_system_page.dart b/lib/modules/chat/system/message_system_page.dart index ef7ee02..8b0ee47 100644 --- a/lib/modules/chat/system/message_system_page.dart +++ b/lib/modules/chat/system/message_system_page.dart @@ -275,6 +275,8 @@ class _MessageSystemPageState extends State { } class _MessageItem extends StatelessWidget { + static bool _isOpeningCpInviteDialog = false; + final V2TimMessage message; final V2TimMessage? preMessage; List currentConversationMessageList = []; @@ -546,15 +548,30 @@ class _MessageItem extends StatelessWidget { SCSystemInvitMessageRes? cpInvite, RoomCpInviteActionState actionState, ) async { - final latestState = await _latestCpInviteStateForMessage(data); - if (!context.mounted) { + if (_isOpeningCpInviteDialog || + SmartDialog.checkExist(tag: RoomCpInviteDialog.dialogTag)) { return; } - final resolvedActionState = - latestState == _CpInviteMessageState.pending - ? actionState - : _cpInviteActionState(latestState); - _showCpInviteDialogFromMessage(data, cpInvite, resolvedActionState); + _isOpeningCpInviteDialog = true; + try { + _showCpInviteDialogFromMessage(data, cpInvite, actionState); + if (actionState != RoomCpInviteActionState.pending) { + return; + } + final latestState = await _latestCpInviteStateForMessage(data); + if (!context.mounted || + latestState == _CpInviteMessageState.pending || + !SmartDialog.checkExist(tag: RoomCpInviteDialog.dialogTag)) { + return; + } + _showCpInviteDialogFromMessage( + data, + cpInvite, + _cpInviteActionState(latestState), + ); + } finally { + _isOpeningCpInviteDialog = false; + } } Widget _buildCpInviteGiftThumb(String giftCover) { diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index 4a73946..36fb537 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -1871,7 +1871,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { toUserAvatarUrl: u.user?.userAvatar ?? "", giftUrl: gift.giftPhoto ?? "", giftId: gift.id, - giftTab: gift.giftTab, + giftTab: _floatingGiftTabFor(gift), roomId: rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id ?? "", coins: coins, number: quantity, @@ -1955,7 +1955,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { toUserName: receiver?.userNickname ?? "", giftUrl: gift.giftPhoto ?? "", giftId: gift.id, - giftTab: gift.giftTab, + giftTab: _floatingGiftTabFor(gift), number: quantity, coins: coins, roomId: roomId, @@ -2001,6 +2001,13 @@ class _GiftPageState extends State with TickerProviderStateMixin { ].join("|"); } + String _floatingGiftTabFor(SocialChatGiftRes gift) { + if (_isCpGift(gift)) { + return scCpRelationTypeForGift(gift); + } + return gift.giftTab ?? ""; + } + bool _supportsComboFeedback(SocialChatGiftRes gift) { final giftTab = (gift.giftTab ?? '').trim(); return giftTab == "LUCK" || diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index eca4385..7127033 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -2046,15 +2046,21 @@ class _PersonDetailPageState extends State with RouteAware { return Consumer( builder: (context, ref, child) { final bool isProfileLoading = ref.userProfile == null; + final bool profileLoadFailed = + ref.userProfileLoadError != null && + !ref.isUserProfileLoading && + ref.userProfile == null; List backgroundPhotos = []; backgroundPhotos = - (isProfileLoading + (isProfileLoading || profileLoadFailed ? [] : (ref.userProfile?.backgroundPhotos ?? [])) .where((t) => t.status == 1) .toList(); final dataCard = - isProfileLoading ? null : _activeDataCard(ref.userProfile); + isProfileLoading || profileLoadFailed + ? null + : _activeDataCard(ref.userProfile); return Directionality( textDirection: window.locale.languageCode == "ar" @@ -2389,25 +2395,32 @@ class _PersonDetailPageState extends State with RouteAware { body: isBlacklist ? Container() - : (isProfileLoading || isBlacklistLoading - ? const SizedBox.shrink() - : SingleChildScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - child: Column( - children: [ - _buildProfileHeader( - ref, - backgroundPhotos, - dataCard, + : (profileLoadFailed + ? _buildProfileLoadFailed(ref) + : (isProfileLoading || isBlacklistLoading + ? const SizedBox.shrink() + : SingleChildScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + _buildProfileHeader( + ref, + backgroundPhotos, + dataCard, + ), + _buildProfileColumnContent( + ref.userProfile, + ), + ], ), - _buildProfileColumnContent(ref.userProfile), - ], - ), - )), + ))), ), // 底部按钮区域 - isBlacklistLoading || isBlacklist || isProfileLoading + isBlacklistLoading || + isBlacklist || + isProfileLoading || + profileLoadFailed ? Container() : Positioned( bottom: 0, @@ -2428,6 +2441,33 @@ class _PersonDetailPageState extends State with RouteAware { ); } + Widget _buildProfileLoadFailed(SocialChatUserProfileManager ref) { + final localizations = SCAppLocalizations.of(context); + return Center( + child: SCDebounceWidget( + onTap: () => ref.getUserInfoById(widget.tageId), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32.w), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.refresh, color: Colors.white70, size: 32.w), + SizedBox(height: 12.w), + text( + localizations?.loadingFailedClickToRetry ?? + 'Loading failed, click to retry', + textAlign: TextAlign.center, + textColor: Colors.white70, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ], + ), + ), + ), + ); + } + void _showPartWaysDialog(SocialChatUserProfileManager ref) { SmartDialog.show( tag: "showPartWaysDialog", diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 3c5ed9a..0722fbb 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -4020,8 +4020,22 @@ class RealTimeMessagingManager extends ChangeNotifier { String _resolveRegionGiftTab(Map payload) { final payloadGiftTab = _payloadText(payload['giftTab']); + final payloadRelationType = _firstNonBlank([ + _payloadText(payload['relationType']), + _payloadText(payload['cpRelationType']), + ]); if (payloadGiftTab.isNotEmpty) { - return payloadGiftTab; + final gift = SocialChatGiftRes( + giftTab: payloadGiftTab, + relationType: payloadRelationType, + ); + return _floatingGiftTabFor(gift); + } + if (payloadRelationType.isNotEmpty) { + final gift = SocialChatGiftRes(relationType: payloadRelationType); + if (scIsCpGift(gift)) { + return _floatingGiftTabFor(gift); + } } final giftId = _payloadText(payload['giftId']); final currentContext = context; @@ -4032,7 +4046,19 @@ class RealTimeMessagingManager extends ChangeNotifier { currentContext, listen: false, ); - return appGeneralManager.getGiftByIdOrStandardId(giftId)?.giftTab ?? ''; + return _floatingGiftTabFor( + appGeneralManager.getGiftByIdOrStandardId(giftId), + ); + } + + String _floatingGiftTabFor(SocialChatGiftRes? gift) { + if (gift == null) { + return ''; + } + if (scIsCpGift(gift)) { + return scCpRelationTypeForGift(gift); + } + return gift.giftTab ?? ''; } bool _isCurrentVoiceRoom(String roomId) { @@ -6338,7 +6364,7 @@ class RealTimeMessagingManager extends ChangeNotifier { toUserAvatarUrl: msg.toUser?.userAvatar ?? "", giftUrl: gift.giftPhoto, giftId: gift.id, - giftTab: gift.giftTab, + giftTab: _floatingGiftTabFor(gift), number: quantity, coins: coins, roomId: roomId, @@ -6400,7 +6426,7 @@ class RealTimeMessagingManager extends ChangeNotifier { toUserAvatarUrl: msg.toUser?.userAvatar ?? "", giftUrl: gift.giftPhoto, giftId: gift.id, - giftTab: gift.giftTab, + giftTab: _floatingGiftTabFor(gift), number: msg.number, coins: coins, roomId: msg.msg, diff --git a/lib/services/auth/user_profile_manager.dart b/lib/services/auth/user_profile_manager.dart index 451a0f0..62c2928 100644 --- a/lib/services/auth/user_profile_manager.dart +++ b/lib/services/auth/user_profile_manager.dart @@ -132,6 +132,8 @@ class SocialChatUserProfileManager extends ChangeNotifier { /// 当前正在查看的个人详情资料,不用于承载登录用户自己的 Me 页数据。 SocialChatUserProfile? userProfile; + Object? userProfileLoadError; + bool isUserProfileLoading = false; int _userInfoRequestSerial = 0; Future getUserInfoById(String userId) { @@ -147,30 +149,122 @@ class SocialChatUserProfileManager extends ChangeNotifier { required bool clearBeforeLoad, }) async { final requestSerial = ++_userInfoRequestSerial; + final normalizedUserId = userId.trim(); if (clearBeforeLoad) { userProfile = null; - notifyListeners(); } - final loadedProfile = await SCAccountRepository().loadUserInfo(userId); - var mergedProfile = await _withCurrentVipLevel(loadedProfile, userId); - mergedProfile = await _withCpCabinProfiles(mergedProfile, userId); - if (requestSerial != _userInfoRequestSerial) { + userProfileLoadError = null; + isUserProfileLoading = true; + notifyListeners(); + + if (normalizedUserId.isEmpty) { + if (requestSerial == _userInfoRequestSerial) { + userProfileLoadError = ArgumentError('userId is empty'); + isUserProfileLoading = false; + notifyListeners(); + } return; } - userProfile = mergedProfile; - notifyListeners(); + + try { + final loadedProfile = await SCAccountRepository().loadUserInfo( + normalizedUserId, + ); + var mergedProfile = await _withCurrentVipLevel( + loadedProfile, + normalizedUserId, + ); + mergedProfile = await _withCpCabinProfiles( + mergedProfile, + normalizedUserId, + ); + if (requestSerial != _userInfoRequestSerial) { + return; + } + userProfile = mergedProfile; + userProfileLoadError = null; + } catch (error, stackTrace) { + if (requestSerial != _userInfoRequestSerial) { + return; + } + userProfile = null; + userProfileLoadError = error; + _debugProfileLoadFailure( + scope: 'detail', + userId: normalizedUserId, + error: error, + stackTrace: stackTrace, + ); + } finally { + if (requestSerial == _userInfoRequestSerial) { + isUserProfileLoading = false; + notifyListeners(); + } + } } ///房间资料卡 RoomUserCardRes? userCardInfo; + Object? userCardLoadError; + bool isUserCardLoading = false; + int _userCardRequestSerial = 0; // List? userCountGuardResList; - void roomUserCard(String roomId, String userId) async { + Future roomUserCard(String roomId, String userId) async { + final requestSerial = ++_userCardRequestSerial; + final normalizedRoomId = roomId.trim(); + final normalizedUserId = userId.trim(); userCardInfo = null; - userCardInfo = await SCChatRoomRepository().roomUserCard(roomId, userId); - userCardInfo = await _withCurrentVipLevelForCard(userCardInfo, userId); + userCardLoadError = null; + isUserCardLoading = true; notifyListeners(); + + if (normalizedUserId.isEmpty) { + if (requestSerial == _userCardRequestSerial) { + userCardLoadError = ArgumentError('userId is empty'); + isUserCardLoading = false; + notifyListeners(); + } + return; + } + + try { + RoomUserCardRes? card = await SCChatRoomRepository().roomUserCard( + normalizedRoomId, + normalizedUserId, + ); + card = await _withCurrentVipLevelForCard(card, normalizedUserId); + if (requestSerial != _userCardRequestSerial) { + return; + } + userCardInfo = card; + userCardLoadError = null; + } catch (error, stackTrace) { + if (requestSerial != _userCardRequestSerial) { + return; + } + userCardInfo = null; + userCardLoadError = error; + _debugProfileLoadFailure( + scope: 'roomCard', + userId: normalizedUserId, + error: error, + stackTrace: stackTrace, + ); + } finally { + if (requestSerial == _userCardRequestSerial) { + isUserCardLoading = false; + notifyListeners(); + } + } + } + + void clearRoomUserCard() { + _userCardRequestSerial++; + userCardInfo = null; + userCardLoadError = null; + isUserCardLoading = false; } Future _withCurrentVipLevelForCard( @@ -189,6 +283,19 @@ class SocialChatUserProfileManager extends ChangeNotifier { return card.copyWith(userProfile: mergedProfile); } + void _debugProfileLoadFailure({ + required String scope, + required String userId, + required Object error, + required StackTrace stackTrace, + }) { + if (kDebugMode) { + debugPrint( + '[ProfileLoad][$scope] failed userId=$userId error=$error\n$stackTrace', + ); + } + } + Future _withCpCabinProfiles( SocialChatUserProfile profile, String userId, 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 422b918..28d1a20 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -6,6 +6,7 @@ import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/main.dart'; import 'package:provider/provider.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart'; +import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; import 'package:yumi/shared/tools/sc_entrance_vap_svga_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; @@ -579,7 +580,7 @@ class OverlayManager { case 0: return 83; case 1: - return 54; + return scIsCpGiftTab(message.giftTab) ? 65 : 54; case 3: return 68; case 4: diff --git a/lib/shared/data_sources/sources/remote/net/api.dart b/lib/shared/data_sources/sources/remote/net/api.dart index d404e80..1e23716 100644 --- a/lib/shared/data_sources/sources/remote/net/api.dart +++ b/lib/shared/data_sources/sources/remote/net/api.dart @@ -253,10 +253,30 @@ class BaseNetworkClient { ); // 处理基础响应 - final baseResponse = ResponseData.fromJson( - response.data as Map, - fromJsonT: fromJson, - ); + final responseData = response.data; + if (responseData is! Map) { + throw DioException( + requestOptions: response.requestOptions, + response: response, + type: DioExceptionType.badResponse, + error: '响应格式错误: ${responseData.runtimeType}', + ); + } + + final ResponseData baseResponse; + try { + baseResponse = ResponseData.fromJson( + Map.from(responseData), + fromJsonT: fromJson, + ); + } catch (error) { + throw DioException( + requestOptions: response.requestOptions, + response: response, + type: DioExceptionType.badResponse, + error: '响应解析失败: $error', + ); + } // 业务逻辑成功判断 if (baseResponse.success) { if (baseResponse.body != null) { diff --git a/lib/shared/data_sources/sources/remote/net/network_client.dart b/lib/shared/data_sources/sources/remote/net/network_client.dart index 68a404f..671a686 100644 --- a/lib/shared/data_sources/sources/remote/net/network_client.dart +++ b/lib/shared/data_sources/sources/remote/net/network_client.dart @@ -343,7 +343,36 @@ class ApiInterceptor extends Interceptor { @override void onResponse(Response response, ResponseInterceptorHandler handler) async { if (response.statusCode == 200) { - final ResponseData respData = ResponseData.fromJson(response.data); + final responseData = response.data; + if (responseData is! Map) { + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + type: DioExceptionType.badResponse, + error: '响应格式错误: ${responseData.runtimeType}', + ), + ); + return; + } + + final ResponseData respData; + try { + respData = ResponseData.fromJson( + Map.from(responseData), + ); + } catch (error) { + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + type: DioExceptionType.badResponse, + error: '响应解析失败: $error', + ), + ); + return; + } + if (respData.success) { handler.next(response); } else { @@ -378,11 +407,13 @@ class ApiInterceptor extends Interceptor { ); } } else { - Fluttertoast.showToast(msg: 'Network error, please check the status.'); + final message = _httpResponseErrorMessage(response); + Fluttertoast.showToast(msg: message); handler.reject( DioException( requestOptions: response.requestOptions, - error: 'HTTP ${response.statusCode} 错误', + type: DioExceptionType.badResponse, + error: message, response: response, ), ); @@ -390,6 +421,25 @@ class ApiInterceptor extends Interceptor { } } +String _httpResponseErrorMessage(Response response) { + final statusCode = response.statusCode; + final responseData = response.data; + if (responseData is Map) { + try { + final respData = ResponseData.fromJson( + Map.from(responseData), + ); + final code = respData.errorCode ?? statusCode; + final message = respData.errorMsg; + if (message != null && message.trim().isNotEmpty) { + return 'HTTP $statusCode: $message'; + } + return 'HTTP $statusCode 错误${code == null ? "" : " ($code)"}'; + } catch (_) {} + } + return 'HTTP $statusCode 错误'; +} + /// 响应数据结构 class ResponseData { final bool status; @@ -481,12 +531,23 @@ class ResponseData { } } - final code = json['code']?.toString().trim().toLowerCase(); - return code == 'success' || code == 'ok'; + final codeValue = json['code'] ?? json['errorCode']; + if (codeValue is num) { + return codeValue == 0 || codeValue == 200; + } + final code = codeValue?.toString().trim().toLowerCase(); + if (code == null || code.isEmpty) { + return false; + } + if (code == 'success' || code == 'ok' || code == 'true') { + return true; + } + final numericCode = int.tryParse(code); + return numericCode == 0 || numericCode == 200; } static int? _readErrorCode(Map json) { - final errorCode = json['errorCode']; + final errorCode = json['errorCode'] ?? json['code']; if (errorCode is int) { return errorCode; } diff --git a/lib/ui_kit/widgets/room/cp/room_cp_relation_formed_effect.dart b/lib/ui_kit/widgets/room/cp/room_cp_relation_formed_effect.dart index bd1d932..bdf93dc 100644 --- a/lib/ui_kit/widgets/room/cp/room_cp_relation_formed_effect.dart +++ b/lib/ui_kit/widgets/room/cp/room_cp_relation_formed_effect.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart'; @@ -56,161 +60,303 @@ class RoomCpRelationFormedEffect extends StatefulWidget { _RoomCpRelationFormedEffectState(); } -class _RoomCpRelationFormedEffectState - extends State { +class _RoomCpRelationFormedEffectState extends State + with SingleTickerProviderStateMixin { + static const bool _debugSlots = bool.fromEnvironment( + "CP_FORMED_EFFECT_DEBUG", + ); + final SCGiftMp4Controller _controller = SCGiftMp4Controller(); + late final AnimationController _timelineController; + late final Future _layoutFuture; Timer? _fallbackDismissTimer; - Timer? _avatarTimer; - bool _showAvatars = false; + bool _timelineStarted = false; @override void initState() { super.initState(); + _timelineController = AnimationController( + vsync: this, + duration: const Duration(seconds: 5), + ); + _layoutFuture = RoomCpRelationFormedVapLayout.loadFromAsset( + RoomCpRelationFormedEffect._assetPath, + ).then((layout) { + if (layout != null && mounted) { + _timelineController.duration = layout.duration; + if (_debugSlots) { + debugPrint( + "[RoomCpEffect] vap layout " + "canvas=${layout.canvasSize.width}x${layout.canvasSize.height} " + "fps=${layout.fps} frames=${layout.totalFrames} " + "dynamicFrames=${layout.firstDynamicFrame}-${layout.lastDynamicFrame} " + "leftTag=${layout.leftTag} rightTag=${layout.rightTag}", + ); + } + } + return layout; + }); _controller.setPlaybackListener( + onStart: _startTimeline, onComplete: _dismiss, onFailed: (_) => _dismiss(), ); _fallbackDismissTimer = Timer(const Duration(seconds: 7), _dismiss); - _avatarTimer = Timer(const Duration(milliseconds: 650), () { - if (!mounted) { - return; - } - setState(() { - _showAvatars = true; - }); - }); unawaited(_controller.playAsset(RoomCpRelationFormedEffect._assetPath)); } @override void dispose() { _fallbackDismissTimer?.cancel(); - _avatarTimer?.cancel(); + _timelineController.dispose(); _controller.dispose(); super.dispose(); } + void _startTimeline() { + _timelineStarted = true; + _timelineController + ..stop() + ..value = 0 + ..forward(); + } + void _dismiss() { _fallbackDismissTimer?.cancel(); + _timelineController.stop(); SmartDialog.dismiss(tag: RoomCpRelationFormedEffect.dialogTag); } @override Widget build(BuildContext context) { + final mediaSize = MediaQuery.sizeOf(context); final screenWidth = ScreenUtil().screenWidth; final screenHeight = ScreenUtil().screenHeight; - final videoWidth = screenWidth; - final videoHeight = videoWidth * 1280 / 1088; - final videoTop = (screenHeight - videoHeight) / 2; - final avatarSize = 55.w; - final avatarTop = videoTop + videoHeight * 0.56; + final width = mediaSize.width > 0 ? mediaSize.width : screenWidth; + final height = mediaSize.height > 0 ? mediaSize.height : screenHeight; return IgnorePointer( child: SizedBox( - width: screenWidth, - height: screenHeight, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - Positioned.fill(child: GiftMp4EffectView(controller: _controller)), - Positioned( - left: screenWidth / 2 - 112.w, - top: avatarTop, - child: _EffectAvatar( - avatarUrl: widget.leftAvatar, - name: widget.leftName, - size: avatarSize, - visible: _showAvatars, - ), - ), - Positioned( - left: screenWidth / 2 + 57.w, - top: avatarTop, - child: _EffectAvatar( - avatarUrl: widget.rightAvatar, - name: widget.rightName, - size: avatarSize, - visible: _showAvatars, - ), - ), - ], + width: width, + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + final bounds = _resolveBounds(constraints, Size(width, height)); + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: GiftMp4EffectView(controller: _controller), + ), + FutureBuilder( + future: _layoutFuture, + builder: (context, snapshot) { + final layout = snapshot.data; + if (layout == null) { + return const SizedBox.shrink(); + } + return _buildMetadataDrivenAvatars(layout, bounds); + }, + ), + ], + ); + }, ), ), ); } + + Size _resolveBounds(BoxConstraints constraints, Size fallback) { + final width = + constraints.hasBoundedWidth && constraints.maxWidth > 0 + ? constraints.maxWidth + : fallback.width; + final height = + constraints.hasBoundedHeight && constraints.maxHeight > 0 + ? constraints.maxHeight + : fallback.height; + return Size(width, height); + } + + Widget _buildMetadataDrivenAvatars( + RoomCpRelationFormedVapLayout layout, + Size bounds, + ) { + return AnimatedBuilder( + animation: _timelineController, + builder: (context, _) { + final frameIndex = + _timelineStarted + ? layout.frameForProgress(_timelineController.value) + : 0; + final leftPlacement = layout.leftPlacementForFrame(frameIndex); + final rightPlacement = layout.rightPlacementForFrame(frameIndex); + final contentRect = layout.contentRectForBounds(bounds); + final children = [ + if (leftPlacement != null) + ..._buildAvatarPlacement( + layout.scaledRectForPlacement(leftPlacement, bounds), + avatarUrl: widget.leftAvatar, + name: widget.leftName, + debugLabel: layout.leftTag ?? "left", + ), + if (rightPlacement != null) + ..._buildAvatarPlacement( + layout.scaledRectForPlacement(rightPlacement, bounds), + avatarUrl: widget.rightAvatar, + name: widget.rightName, + debugLabel: layout.rightTag ?? "right", + ), + ]; + + if (_debugSlots) { + children.addAll( + _buildDebugOverlay( + layout: layout, + bounds: bounds, + contentRect: contentRect, + frameIndex: frameIndex, + leftPlacement: leftPlacement, + rightPlacement: rightPlacement, + ), + ); + } + + if (children.isEmpty) { + return const SizedBox.shrink(); + } + return Stack(clipBehavior: Clip.none, children: children); + }, + ); + } + + List _buildAvatarPlacement( + Rect rect, { + required String avatarUrl, + required String name, + required String debugLabel, + }) { + final labelWidth = math.max(82.w, rect.width + 24.w); + return [ + Positioned.fromRect( + rect: rect, + child: _EffectAvatar( + avatarUrl: avatarUrl, + width: rect.width, + height: rect.height, + ), + ), + Positioned( + left: rect.center.dx - labelWidth / 2, + top: rect.bottom + 5.w, + width: labelWidth, + child: _EffectName(name: name), + ), + if (_debugSlots) + Positioned( + left: rect.left, + top: rect.top - 14.w, + child: _DebugLabel(text: debugLabel), + ), + ]; + } + + List _buildDebugOverlay({ + required RoomCpRelationFormedVapLayout layout, + required Size bounds, + required Rect contentRect, + required int frameIndex, + required RoomCpRelationFormedVapPlacement? leftPlacement, + required RoomCpRelationFormedVapPlacement? rightPlacement, + }) { + Rect? scaled(RoomCpRelationFormedVapPlacement? placement) => + placement == null + ? null + : layout.scaledRectForPlacement(placement, bounds); + final leftRect = scaled(leftPlacement); + final rightRect = scaled(rightPlacement); + return [ + Positioned.fromRect( + rect: contentRect, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: const Color(0x88FF00FF), width: 1), + ), + ), + ), + if (leftRect != null) + Positioned.fromRect(rect: leftRect, child: _DebugSlotBorder()), + if (rightRect != null) + Positioned.fromRect(rect: rightRect, child: _DebugSlotBorder()), + Positioned( + left: contentRect.left + 8.w, + top: contentRect.top + 8.w, + child: _DebugLabel( + text: + "frame $frameIndex / ${layout.firstDynamicFrame}-${layout.lastDynamicFrame}", + ), + ), + ]; + } } class _EffectAvatar extends StatelessWidget { const _EffectAvatar({ required this.avatarUrl, - required this.name, - required this.size, - required this.visible, + required this.width, + required this.height, }); final String avatarUrl; - final String name; - final double size; - final bool visible; + final double width; + final double height; @override Widget build(BuildContext context) { return AnimatedOpacity( - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 240), + opacity: 1, + duration: const Duration(milliseconds: 120), child: SizedBox( - width: 78.w, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: size, - height: size, - padding: EdgeInsets.all(2.w), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.5.w), - boxShadow: [ - BoxShadow( - color: const Color(0xffff4f9a).withValues(alpha: 0.45), - blurRadius: 12.w, - ), - ], - ), - child: ClipOval( - child: head( - url: avatarUrl, - width: size - 4.w, - showDefault: true, - ), - ), - ), - SizedBox(height: 5.w), - Text( - _shortName(name), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, - height: 1, - fontWeight: FontWeight.w700, - decoration: TextDecoration.none, - shadows: [ - Shadow( - color: Colors.black.withValues(alpha: 0.6), - blurRadius: 4.w, - ), - ], - ), - ), - ], + width: width, + height: height, + child: ClipOval( + child: head( + url: avatarUrl, + width: width, + height: height, + showDefault: true, + ), ), ), ); } +} + +class _EffectName extends StatelessWidget { + const _EffectName({required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + return Text( + _shortName(name), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 10.sp, + height: 1, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + shadows: [ + Shadow(color: Colors.black.withValues(alpha: 0.6), blurRadius: 4.w), + ], + ), + ); + } static String _shortName(String value) { final text = value.trim(); @@ -220,3 +366,398 @@ class _EffectAvatar extends StatelessWidget { return "${text.substring(0, 9)}..."; } } + +class _DebugSlotBorder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFF00F5FF), width: 1), + ), + ); + } +} + +class _DebugLabel extends StatelessWidget { + const _DebugLabel({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.56), + borderRadius: BorderRadius.circular(3.w), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 2.w), + child: Text( + text, + style: TextStyle( + color: const Color(0xFF00F5FF), + fontSize: 8.sp, + height: 1, + decoration: TextDecoration.none, + ), + ), + ), + ); + } +} + +@visibleForTesting +class RoomCpRelationFormedVapLayout { + const RoomCpRelationFormedVapLayout({ + required this.canvasSize, + required this.fps, + required this.totalFrames, + required this.placementsByTag, + required this.leftTag, + required this.rightTag, + }); + + final Size canvasSize; + final double fps; + final int totalFrames; + final Map> placementsByTag; + final String? leftTag; + final String? rightTag; + + int get firstDynamicFrame { + final frames = + placementsByTag.values.expand((placements) => placements).toList(); + if (frames.isEmpty) { + return 0; + } + return frames.map((placement) => placement.frameIndex).reduce(math.min); + } + + int get lastDynamicFrame { + final frames = + placementsByTag.values.expand((placements) => placements).toList(); + if (frames.isEmpty) { + return 0; + } + return frames.map((placement) => placement.frameIndex).reduce(math.max); + } + + Duration get duration { + final seconds = totalFrames > 0 && fps > 0 ? totalFrames / fps : 5.0; + return Duration(milliseconds: (seconds * 1000).round()); + } + + static Future loadFromAsset( + String assetPath, + ) async { + try { + final bytes = await rootBundle.load(assetPath); + return parseRoomCpRelationFormedVapLayout(bytes.buffer.asUint8List()); + } catch (error) { + debugPrint("[RoomCpEffect] failed to load vap metadata: $error"); + return null; + } + } + + int frameForProgress(double progress) { + final safeProgress = progress.clamp(0.0, 1.0); + final frame = (safeProgress * totalFrames).floor(); + return frame.clamp(0, math.max(totalFrames - 1, 0)); + } + + RoomCpRelationFormedVapPlacement? leftPlacementForFrame(int frameIndex) { + final tag = leftTag; + return tag == null ? null : placementForTag(tag, frameIndex); + } + + RoomCpRelationFormedVapPlacement? rightPlacementForFrame(int frameIndex) { + final tag = rightTag; + return tag == null ? null : placementForTag(tag, frameIndex); + } + + RoomCpRelationFormedVapPlacement? placementForTag( + String tag, + int frameIndex, + ) { + final placements = placementsByTag[tag]; + if (placements == null || placements.isEmpty) { + return null; + } + if (frameIndex < placements.first.frameIndex || + frameIndex > placements.last.frameIndex) { + return null; + } + + RoomCpRelationFormedVapPlacement? lastBefore; + for (final placement in placements) { + if (placement.frameIndex == frameIndex) { + return placement; + } + if (placement.frameIndex > frameIndex) { + return lastBefore; + } + lastBefore = placement; + } + return lastBefore; + } + + Rect contentRectForBounds(Size bounds) { + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return Offset.zero & bounds; + } + final scale = math.min( + bounds.width / canvasSize.width, + bounds.height / canvasSize.height, + ); + final width = canvasSize.width * scale; + final height = canvasSize.height * scale; + return Rect.fromLTWH( + (bounds.width - width) / 2, + (bounds.height - height) / 2, + width, + height, + ); + } + + Rect scaledRectForPlacement( + RoomCpRelationFormedVapPlacement placement, + Size bounds, + ) { + final contentRect = contentRectForBounds(bounds); + final scale = contentRect.width / canvasSize.width; + return Rect.fromLTWH( + contentRect.left + placement.frame.left * scale, + contentRect.top + placement.frame.top * scale, + placement.frame.width * scale, + placement.frame.height * scale, + ); + } +} + +@visibleForTesting +class RoomCpRelationFormedVapPlacement { + const RoomCpRelationFormedVapPlacement({ + required this.frameIndex, + required this.tag, + required this.frame, + }); + + final int frameIndex; + final String tag; + final Rect frame; +} + +@visibleForTesting +RoomCpRelationFormedVapLayout? parseRoomCpRelationFormedVapLayout( + Uint8List bytes, +) { + final jsonData = _extractVapcJsonData(bytes); + if (jsonData == null) { + return null; + } + final decoded = json.decode(utf8.decode(jsonData)); + if (decoded is! Map) { + return null; + } + final info = decoded["info"]; + if (info is! Map) { + return null; + } + + final canvasWidth = _doubleValue(info["w"]) ?? 0; + final canvasHeight = _doubleValue(info["h"]) ?? 0; + if (canvasWidth <= 0 || canvasHeight <= 0) { + return null; + } + + final srcIdToTag = {}; + final sources = decoded["src"]; + if (sources is List) { + for (final source in sources) { + if (source is! Map) { + continue; + } + final srcId = source["srcId"]?.toString().trim() ?? ""; + final tag = source["srcTag"]?.toString().trim() ?? ""; + if (srcId.isNotEmpty && tag.isNotEmpty) { + srcIdToTag[srcId] = tag; + } + } + } + + final placementsByTag = >{}; + final frames = decoded["frame"]; + if (frames is List) { + for (final frameEntry in frames) { + if (frameEntry is! Map) { + continue; + } + final frameIndex = _intValue(frameEntry["i"]); + if (frameIndex == null) { + continue; + } + final objects = frameEntry["obj"]; + if (objects is! List) { + continue; + } + for (final object in objects) { + if (object is! Map) { + continue; + } + final tag = srcIdToTag[object["srcId"]?.toString().trim() ?? ""]; + final frame = _rectValue(object["frame"]); + if (tag == null || frame == null || frame.isEmpty) { + continue; + } + placementsByTag + .putIfAbsent(tag, () => []) + .add( + RoomCpRelationFormedVapPlacement( + frameIndex: frameIndex, + tag: tag, + frame: frame, + ), + ); + } + } + } + + placementsByTag.updateAll((_, placements) { + placements.sort((a, b) => a.frameIndex.compareTo(b.frameIndex)); + return placements; + }); + + final orderedTags = + placementsByTag.keys.toList()..sort( + (a, b) => _averageCenterX( + placementsByTag[a]!, + ).compareTo(_averageCenterX(placementsByTag[b]!)), + ); + + return RoomCpRelationFormedVapLayout( + canvasSize: Size(canvasWidth, canvasHeight), + fps: _doubleValue(info["fps"]) ?? 25, + totalFrames: _intValue(info["f"]) ?? 125, + placementsByTag: placementsByTag, + leftTag: orderedTags.isEmpty ? null : orderedTags.first, + rightTag: orderedTags.length < 2 ? null : orderedTags.last, + ); +} + +Uint8List? _extractVapcJsonData(Uint8List bytes) { + final marker = utf8.encode("vapc"); + var searchStart = 0; + while (searchStart < bytes.length) { + final markerIndex = _indexOfBytes(bytes, marker, searchStart); + if (markerIndex == -1) { + return null; + } + final scanEnd = math.min(bytes.length, markerIndex + 128); + int? jsonStart; + for (var index = markerIndex + marker.length; index < scanEnd; index++) { + if (bytes[index] == 0x7B) { + jsonStart = index; + break; + } + } + if (jsonStart != null) { + final jsonEnd = _findJsonEnd(bytes, jsonStart); + if (jsonEnd != null) { + return Uint8List.fromList(bytes.sublist(jsonStart, jsonEnd + 1)); + } + } + searchStart = markerIndex + marker.length; + } + return null; +} + +int _indexOfBytes(Uint8List bytes, List marker, int start) { + if (marker.isEmpty || bytes.length < marker.length) { + return -1; + } + for (var index = start; index <= bytes.length - marker.length; index++) { + var matched = true; + for (var offset = 0; offset < marker.length; offset++) { + if (bytes[index + offset] != marker[offset]) { + matched = false; + break; + } + } + if (matched) { + return index; + } + } + return -1; +} + +int? _findJsonEnd(Uint8List bytes, int start) { + var depth = 0; + var inString = false; + var escaped = false; + for (var index = start; index < bytes.length; index++) { + final byte = bytes[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (byte == 0x5C) { + escaped = true; + } else if (byte == 0x22) { + inString = false; + } + continue; + } + if (byte == 0x22) { + inString = true; + } else if (byte == 0x7B) { + depth++; + } else if (byte == 0x7D) { + depth--; + if (depth == 0) { + return index; + } + } + } + return null; +} + +Rect? _rectValue(dynamic value) { + if (value is! List || value.length < 4) { + return null; + } + final x = _doubleValue(value[0]); + final y = _doubleValue(value[1]); + final width = _doubleValue(value[2]); + final height = _doubleValue(value[3]); + if (x == null || y == null || width == null || height == null) { + return null; + } + return Rect.fromLTWH(x, y, width, height); +} + +double _averageCenterX(List placements) { + if (placements.isEmpty) { + return 0; + } + final sum = placements.fold( + 0, + (value, placement) => value + placement.frame.center.dx, + ); + return sum / placements.length; +} + +int? _intValue(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.round(); + } + return int.tryParse(value?.toString() ?? ""); +} + +double? _doubleValue(dynamic value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? ""); +} diff --git a/lib/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart index 02350a1..0ea391b 100644 --- a/lib/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart +++ b/lib/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter_debouncer/flutter_debouncer.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; +import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/main.dart'; import 'package:marquee/marquee.dart'; @@ -117,7 +118,7 @@ class _FloatingGiftScreenWidgetState extends State return _buildGiftAnimation(); } - bool get _isCpGift => (widget.message.giftTab ?? "").toUpperCase() == "CP"; + bool get _isCpGift => scIsCpGiftTab(widget.message.giftTab); ///礼物总价钱达到10000 Widget _buildGiftAnimation() { diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index e362daa..e9fdbb1 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -75,13 +75,7 @@ class _RoomUserInfoCardState extends State { context, listen: false, ); - roomId = - Provider.of( - context, - listen: false, - ).currenRoom?.roomProfile?.roomProfile?.id ?? - ""; - userProvider?.roomUserCard(roomId, widget.userId ?? ""); + _loadRoomUserCard(); SCAccountRepository().userIdentity(userId: widget.userId).then((v) { if (!mounted) { return; @@ -91,10 +85,24 @@ class _RoomUserInfoCardState extends State { }); } + void _loadRoomUserCard() { + userProvider ??= Provider.of( + context, + listen: false, + ); + roomId = + Provider.of( + context, + listen: false, + ).currenRoom?.roomProfile?.roomProfile?.id ?? + ""; + userProvider?.roomUserCard(roomId, widget.userId ?? ""); + } + @override void dispose() { super.dispose(); - userProvider?.userCardInfo = null; + userProvider?.clearRoomUserCard(); } @override @@ -114,6 +122,10 @@ class _RoomUserInfoCardState extends State { : -1; final canLeaveMic = currentMicIndex > -1; + if (ref.userCardInfo == null && ref.userCardLoadError != null) { + return _buildErrorSheet(defaultSheetHeight); + } + if (ref.userCardInfo == null) { return _buildLoadingSheet(defaultSheetHeight); } @@ -138,6 +150,52 @@ class _RoomUserInfoCardState extends State { ); } + Widget _buildErrorSheet(double sheetHeight) { + final localizations = SCAppLocalizations.of(context); + return SizedBox( + width: double.infinity, + height: sheetHeight, + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: Stack( + children: [ + Positioned.fill(child: _buildDefaultSheetBackground()), + Center( + child: SCDebounceWidget( + onTap: _loadRoomUserCard, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 18.w, + vertical: 14.w, + ), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(10.w), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.refresh, color: Colors.white70, size: 28.w), + SizedBox(height: 8.w), + text( + localizations?.loadingFailedClickToRetry ?? + 'Loading failed, click to retry', + textAlign: TextAlign.center, + textColor: Colors.white70, + fontWeight: FontWeight.w600, + fontSize: 12.sp, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildLoadingSheet(double sheetHeight) { return SizedBox( width: double.infinity, diff --git a/test/network_response_data_test.dart b/test/network_response_data_test.dart new file mode 100644 index 0000000..4093280 --- /dev/null +++ b/test/network_response_data_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart'; + +void main() { + group('ResponseData', () { + test('treats numeric code 200 responses as successful', () { + final response = ResponseData>.fromJson({ + 'code': 200, + 'message': 'OK', + 'data': {'id': 'user-1'}, + }, fromJsonT: (json) => Map.from(json as Map)); + + expect(response.success, isTrue); + expect(response.body?['id'], 'user-1'); + }); + + test('treats numeric code 500 responses as failed business responses', () { + final response = ResponseData.fromJson({ + 'code': 500, + 'message': 'profile parse failed', + 'data': null, + }); + + expect(response.success, isFalse); + expect(response.errorCode, 500); + expect(response.errorMsg, 'profile parse failed'); + }); + }); +} diff --git a/test/room_cp_relation_formed_effect_layout_test.dart b/test/room_cp_relation_formed_effect_layout_test.dart new file mode 100644 index 0000000..0ad213a --- /dev/null +++ b/test/room_cp_relation_formed_effect_layout_test.dart @@ -0,0 +1,55 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/ui_kit/widgets/room/cp/room_cp_relation_formed_effect.dart'; + +void main() { + test('reads avatar slots from the CP formed MP4 vap metadata', () { + final bytes = + File( + 'sc_images/room/cp_invite/sc_cp_relation_formed_effect.mp4', + ).readAsBytesSync(); + final layout = parseRoomCpRelationFormedVapLayout(bytes); + + expect(layout, isNotNull); + expect(layout!.canvasSize.width, 720); + expect(layout.canvasSize.height, 1280); + expect(layout.fps, 25); + expect(layout.totalFrames, 125); + expect(layout.firstDynamicFrame, 44); + expect(layout.lastDynamicFrame, 95); + + final left = layout.leftPlacementForFrame(52); + final right = layout.rightPlacementForFrame(52); + expect(left?.frame.left, 230); + expect(left?.frame.top, 842); + expect(left?.frame.width, 121); + expect(left?.frame.height, 121); + expect(right?.frame.left, 365); + expect(right?.frame.top, 843); + expect(right?.frame.width, 121); + expect(right?.frame.height, 120); + }); + + test('scales avatar slots using the visible 720x1280 video canvas', () { + final bytes = + File( + 'sc_images/room/cp_invite/sc_cp_relation_formed_effect.mp4', + ).readAsBytesSync(); + final layout = parseRoomCpRelationFormedVapLayout(bytes)!; + final left = layout.leftPlacementForFrame(52)!; + + final contentRect = layout.contentRectForBounds(const Size(402, 874)); + final scaled = layout.scaledRectForPlacement(left, const Size(402, 874)); + + expect(contentRect.left, moreOrLessEquals(0, epsilon: 0.01)); + expect(contentRect.top, moreOrLessEquals(79.67, epsilon: 0.01)); + expect(contentRect.width, moreOrLessEquals(402, epsilon: 0.01)); + expect(contentRect.height, moreOrLessEquals(714.67, epsilon: 0.01)); + expect(scaled.left, moreOrLessEquals(128.42, epsilon: 0.01)); + expect(scaled.top, moreOrLessEquals(549.78, epsilon: 0.01)); + expect(scaled.width, moreOrLessEquals(67.56, epsilon: 0.01)); + expect(scaled.height, moreOrLessEquals(67.56, epsilon: 0.01)); + }); +}