接口异常处理

This commit is contained in:
roxy 2026-05-23 23:28:47 +08:00
parent b4c45e6400
commit 4d86236227
16 changed files with 1289 additions and 196 deletions

View File

@ -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

View File

@ -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<RoomCpRelationFormedEffectPreviewPage> createState() =>
_RoomCpRelationFormedEffectPreviewPageState();
}
class _RoomCpRelationFormedEffectPreviewPageState
extends State<RoomCpRelationFormedEffectPreviewPage> {
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"),
),
],
),
),
],
),
);
}
}

View File

@ -1164,6 +1164,8 @@ class _SCMessageChatPageState extends State<SCMessageChatPage> {
}
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) {

View File

@ -275,6 +275,8 @@ class _MessageSystemPageState extends State<MessageSystemPage> {
}
class _MessageItem extends StatelessWidget {
static bool _isOpeningCpInviteDialog = false;
final V2TimMessage message;
final V2TimMessage? preMessage;
List<V2TimMessage> 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) {

View File

@ -1871,7 +1871,7 @@ class _GiftPageState extends State<GiftPage> 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<GiftPage> 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<GiftPage> 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" ||

View File

@ -2046,15 +2046,21 @@ class _PersonDetailPageState extends State<PersonDetailPage> with RouteAware {
return Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) {
final bool isProfileLoading = ref.userProfile == null;
final bool profileLoadFailed =
ref.userProfileLoadError != null &&
!ref.isUserProfileLoading &&
ref.userProfile == null;
List<PersonPhoto> backgroundPhotos = [];
backgroundPhotos =
(isProfileLoading
(isProfileLoading || profileLoadFailed
? <PersonPhoto>[]
: (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<PersonDetailPage> 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<PersonDetailPage> 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",

View File

@ -4020,8 +4020,22 @@ class RealTimeMessagingManager extends ChangeNotifier {
String _resolveRegionGiftTab(Map<String, dynamic> 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,

View File

@ -132,6 +132,8 @@ class SocialChatUserProfileManager extends ChangeNotifier {
/// Me
SocialChatUserProfile? userProfile;
Object? userProfileLoadError;
bool isUserProfileLoading = false;
int _userInfoRequestSerial = 0;
Future<void> 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<UserCountGuardRes>? userCountGuardResList;
void roomUserCard(String roomId, String userId) async {
Future<void> 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<RoomUserCardRes?> _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<SocialChatUserProfile> _withCpCabinProfiles(
SocialChatUserProfile profile,
String userId,

View File

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

View File

@ -253,10 +253,30 @@ class BaseNetworkClient {
);
//
final baseResponse = ResponseData.fromJson(
response.data as Map<String, dynamic>,
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<T> baseResponse;
try {
baseResponse = ResponseData.fromJson(
Map<String, dynamic>.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) {

View File

@ -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<String, dynamic>.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<String, dynamic>.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<T> {
final bool status;
@ -481,12 +531,23 @@ class ResponseData<T> {
}
}
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<String, dynamic> json) {
final errorCode = json['errorCode'];
final errorCode = json['errorCode'] ?? json['code'];
if (errorCode is int) {
return errorCode;
}

View File

@ -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<RoomCpRelationFormedEffect> {
class _RoomCpRelationFormedEffectState extends State<RoomCpRelationFormedEffect>
with SingleTickerProviderStateMixin {
static const bool _debugSlots = bool.fromEnvironment(
"CP_FORMED_EFFECT_DEBUG",
);
final SCGiftMp4Controller _controller = SCGiftMp4Controller();
late final AnimationController _timelineController;
late final Future<RoomCpRelationFormedVapLayout?> _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<RoomCpRelationFormedVapLayout?>(
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 = <Widget>[
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<Widget> _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<Widget> _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<String, List<RoomCpRelationFormedVapPlacement>> 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<RoomCpRelationFormedVapLayout?> 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<String, dynamic>) {
return null;
}
final info = decoded["info"];
if (info is! Map<String, dynamic>) {
return null;
}
final canvasWidth = _doubleValue(info["w"]) ?? 0;
final canvasHeight = _doubleValue(info["h"]) ?? 0;
if (canvasWidth <= 0 || canvasHeight <= 0) {
return null;
}
final srcIdToTag = <String, String>{};
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 = <String, List<RoomCpRelationFormedVapPlacement>>{};
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, () => <RoomCpRelationFormedVapPlacement>[])
.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<int> 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<RoomCpRelationFormedVapPlacement> placements) {
if (placements.isEmpty) {
return 0;
}
final sum = placements.fold<double>(
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() ?? "");
}

View File

@ -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<FloatingGiftScreenWidget>
return _buildGiftAnimation();
}
bool get _isCpGift => (widget.message.giftTab ?? "").toUpperCase() == "CP";
bool get _isCpGift => scIsCpGiftTab(widget.message.giftTab);
///10000
Widget _buildGiftAnimation() {

View File

@ -75,13 +75,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
context,
listen: false,
);
roomId =
Provider.of<RtcProvider>(
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<RoomUserInfoCard> {
});
}
void _loadRoomUserCard() {
userProvider ??= Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
);
roomId =
Provider.of<RtcProvider>(
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<RoomUserInfoCard> {
: -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<RoomUserInfoCard> {
);
}
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,

View File

@ -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<Map<String, dynamic>>.fromJson({
'code': 200,
'message': 'OK',
'data': {'id': 'user-1'},
}, fromJsonT: (json) => Map<String, dynamic>.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');
});
});
}

View File

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