diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index 1a4bf7e..1cd11b2 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -32,7 +32,6 @@ import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/general/sc_app_general_manager.dart'; import 'package:yumi/ui_kit/widgets/badge/sc_user_badge_strip.dart'; -import 'package:yumi/ui_kit/widgets/props/sc_data_card_resource_view.dart'; import '../../../app/constants/sc_screen.dart'; import '../../../shared/business_logic/models/res/sc_user_counter_res.dart'; @@ -580,49 +579,8 @@ class _PersonDetailPageState extends State { ); } - Widget _buildProfileDataCardBackground(PropsResources? dataCard) { - if (dataCard == null) { - return const ColoredBox(color: _profileBg); - } - return Stack( - fit: StackFit.expand, - children: [ - SCDataCardResourceView( - resource: dataCard, - fit: BoxFit.cover, - fallback: DecoratedBox(decoration: _defaultDataCardDecoration()), - ), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.26), - _profileBg.withValues(alpha: 0.54), - _profileBg.withValues(alpha: 0.78), - ], - stops: const [0, 0.45, 1], - ), - ), - ), - ], - ); - } - - BoxDecoration _defaultDataCardDecoration() { - return const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xff18F2B1), - Color(0xffE4FFF6), - Color(0xffF7FFFC), - Color(0xffFFFFFF), - ], - ), - ); + Widget _buildProfileDataCardBackground(PropsResources? _) { + return const ColoredBox(color: _profileBg); } PropsResources? _activeDataCard(SocialChatUserProfile? profile) { diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 6e7fec3..8d30e8b 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/app_localizations.dart'; @@ -343,6 +344,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { Timer? _onlineUsersPollingTimer; Timer? _roomEntryEffectTimer; Timer? _roomRocketLaunchAnimationTimer; + ValueNotifier? _roomRocketLaunchTopAvatarNotifier; Timer? _roomRocketGiftRefreshTimer; final List _roomRocketPostLaunchRefreshTimers = []; Timer? _roomRedPacketPresenceTimer; @@ -490,6 +492,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomEntryEffectTimer = null; _roomRocketLaunchAnimationTimer?.cancel(); _roomRocketLaunchAnimationTimer = null; + _releaseRoomRocketLaunchTopAvatarNotifier(); _roomRocketGiftRefreshTimer?.cancel(); _roomRocketGiftRefreshTimer = null; _cancelRoomRocketPostLaunchStatusRefresh(); @@ -2980,6 +2983,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } _roomRocketLaunchAnimationTimer?.cancel(); + _releaseRoomRocketLaunchTopAvatarNotifier(); if (shouldShowRoomVisualEffects && isVoiceRoomRouteVisible) { final animationUrl = @@ -2997,6 +3001,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomRocketLaunchAnimationTimer = Timer(launch.displayDuration, () { _roomRocketLaunchAnimationTimer = null; SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); + _releaseRoomRocketLaunchTopAvatarNotifier(); unawaited(refreshRoomRocketStatus(roomId: launch.roomId)); scheduleRoomRocketPostLaunchStatusRefresh(roomId: launch.roomId); }); @@ -3022,6 +3027,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { required RoomRocketLaunchBroadcastMessage launch, }) { if (RoomRocketPagEffectOverlay.isPag(animationUrl)) { + final topAvatarNotifier = ValueNotifier( + _roomRocketTop1AvatarUrl(launch.safeLevel), + ); + _roomRocketLaunchTopAvatarNotifier = topAvatarNotifier; + unawaited(_loadRoomRocketTop1Avatar(launch, topAvatarNotifier)); SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); SmartDialog.show( tag: _roomRocketPagLaunchDialogTag, @@ -3033,11 +3043,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { builder: (_) => RoomRocketPagEffectOverlay( resource: animationUrl, - topAvatarUrl: _roomRocketTop1AvatarUrl(), + topAvatarUrlListenable: topAvatarNotifier, onCompleted: () { SmartDialog.dismiss(tag: _roomRocketPagLaunchDialogTag); _roomRocketLaunchAnimationTimer?.cancel(); _roomRocketLaunchAnimationTimer = null; + _releaseRoomRocketLaunchTopAvatarNotifier(topAvatarNotifier); unawaited(refreshRoomRocketStatus(roomId: launch.roomId)); scheduleRoomRocketPostLaunchStatusRefresh( roomId: launch.roomId, @@ -3054,8 +3065,21 @@ class RealTimeCommunicationManager extends ChangeNotifier { ); } - String? _roomRocketTop1AvatarUrl() { - final kings = roomRocketStatus?.rocketKings ?? const []; + String? _roomRocketTop1AvatarUrl(int level) { + final kings = + roomRocketStatus?.rocketKingsForLevel(level) ?? + const []; + return _roomRocketTop1AvatarUrlFromUsers(kings); + } + + String? _roomRocketTop1AvatarUrlFromKing(SCRoomRocketKingRes king) { + final source = king.podium.isNotEmpty ? king.podium : king.displayRecords; + return _roomRocketTop1AvatarUrlFromUsers(source); + } + + String? _roomRocketTop1AvatarUrlFromUsers( + List kings, + ) { if (kings.isEmpty) { return null; } @@ -3072,6 +3096,90 @@ class RealTimeCommunicationManager extends ChangeNotifier { return null; } + Future _loadRoomRocketTop1Avatar( + RoomRocketLaunchBroadcastMessage launch, + ValueNotifier notifier, + ) async { + final roomId = launch.roomId.trim(); + if (roomId.isEmpty) { + return; + } + try { + _roomRocketDebug( + 'request launch top1 avatar roomId=$roomId ' + 'level=${launch.safeLevel} roundNo=${launch.roundNo}', + ); + var king = await SCChatRoomRepository().roomRocketKing( + roomId, + level: launch.safeLevel, + roundNo: launch.roundNo, + cursor: 1, + limit: 3, + ); + var avatar = _roomRocketTop1AvatarUrlFromKing(king); + if (avatar == null && launch.roundNo > 0) { + _roomRocketDebug( + 'launch top1 avatar retry without roundNo ' + 'level=${launch.safeLevel} roundNo=${launch.roundNo}', + ); + king = await SCChatRoomRepository().roomRocketKing( + roomId, + level: launch.safeLevel, + cursor: 1, + limit: 3, + ); + avatar = _roomRocketTop1AvatarUrlFromKing(king); + } + if (_roomRocketLaunchTopAvatarNotifier != notifier) { + return; + } + _roomRocketDebug( + 'launch top1 avatar loaded level=${launch.safeLevel} ' + 'roundNo=${launch.roundNo} avatar=${_shortRoomRocketDebugUrl(avatar)} ' + 'podium=${king.podium.length} records=${king.records.length}', + ); + if (avatar != null && avatar != notifier.value) { + notifier.value = avatar; + } + } catch (error) { + _roomRocketDebug( + 'launch top1 avatar load failed level=${launch.safeLevel} ' + 'roundNo=${launch.roundNo}: $error', + ); + } + } + + void _releaseRoomRocketLaunchTopAvatarNotifier([ + ValueNotifier? expected, + ]) { + final notifier = _roomRocketLaunchTopAvatarNotifier; + if (notifier == null || (expected != null && notifier != expected)) { + return; + } + _roomRocketLaunchTopAvatarNotifier = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + notifier.dispose(); + }); + } + + void _roomRocketDebug(String message) { + if (!kDebugMode) { + return; + } + debugPrint('[RoomRocket] $message'); + } + + String _shortRoomRocketDebugUrl(String? url) { + final value = url?.trim() ?? ''; + if (value.isEmpty) { + return '-'; + } + if (value.length <= 96) { + return value; + } + return '${value.substring(0, 48)}...${value.substring(value.length - 24)}'; + } + RoomRocketRewardUserProfile? roomRocketRewardUserProfileForRecord( SCRoomRocketRewardRecordRes record, ) { @@ -3620,6 +3728,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomEntryEffectTimer = null; _roomRocketLaunchAnimationTimer?.cancel(); _roomRocketLaunchAnimationTimer = null; + _releaseRoomRocketLaunchTopAvatarNotifier(); _roomRocketGiftRefreshTimer?.cancel(); _roomRocketGiftRefreshTimer = null; _cancelRoomRocketPostLaunchStatusRefresh(); diff --git a/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart b/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart index 2db127e..f7d8a1a 100644 --- a/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart +++ b/lib/ui_kit/widgets/props/sc_data_card_resource_view.dart @@ -23,6 +23,8 @@ class SCDataCardResourceView extends StatelessWidget { this.fit = BoxFit.cover, this.loop = true, this.fallback, + this.showCoverFallback = true, + this.allowCoverAsResource = true, }); final PropsResources? resource; @@ -33,6 +35,8 @@ class SCDataCardResourceView extends StatelessWidget { final BoxFit fit; final bool loop; final Widget? fallback; + final bool showCoverFallback; + final bool allowCoverAsResource; static bool isSupported(String? url) { return _isSvga(url) || @@ -50,15 +54,22 @@ class SCDataCardResourceView extends StatelessWidget { final resolvedHeight = _resolvedSize(height, constraints.maxHeight); final cover = _firstNonBlank([coverUrl, resource?.cover]); final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]); - final resourceUrl = source.isNotEmpty ? source : cover; + final resourceUrl = + source.isNotEmpty || !allowCoverAsResource ? source : cover; + final emptyFallback = SizedBox( + width: resolvedWidth, + height: resolvedHeight, + ); final fallbackView = fallback ?? - _buildImage( - cover, - width: resolvedWidth, - height: resolvedHeight, - fit: fit, - ); + (showCoverFallback + ? _buildImage( + cover, + width: resolvedWidth, + height: resolvedHeight, + fit: fit, + ) + : emptyFallback); if (resourceUrl.isEmpty) { return fallbackView; @@ -330,10 +341,12 @@ class _LoopingDataCardVapVideo extends StatefulWidget { class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { static const int _legacyRgbLeftAlphaRightMode = 3; + static const int _longRunningLoopCount = 999999; VapController? _controller; int _playToken = 0; bool _failed = false; + bool _started = false; @override void didUpdateWidget(covariant _LoopingDataCardVapVideo oldWidget) { @@ -343,8 +356,11 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { oldWidget.fit != widget.fit) { _playToken++; _stop(); - if (_failed) { - setState(() => _failed = false); + if (_failed || _started) { + setState(() { + _failed = false; + _started = false; + }); } _play(); } @@ -364,8 +380,11 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { onFailed: (_, __, ___) => _markFailed(), onVideoComplete: _handleVideoComplete, onVideoStart: () { - if (mounted && _failed) { - setState(() => _failed = false); + if (mounted && (_failed || !_started)) { + setState(() { + _failed = false; + _started = true; + }); } }, ); @@ -404,7 +423,7 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { await controller.setMute(true); await controller.enableVersion1(true); await controller.setVideoMode(_legacyRgbLeftAlphaRightMode); - await controller.setLoop(widget.loop ? -1 : 0); + await controller.setLoop(widget.loop ? _longRunningLoopCount : 0); await controller.setScaleType(_scaleTypeForFit(widget.fit).key); if (!_isCurrent(token, controller)) { return; @@ -465,7 +484,10 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { if (!mounted || _failed) { return; } - setState(() => _failed = true); + setState(() { + _failed = true; + _started = false; + }); } ScaleType _scaleTypeForFit(BoxFit fit) { @@ -492,11 +514,14 @@ class _LoopingDataCardVapVideoState extends State<_LoopingDataCardVapVideo> { child: Stack( fit: StackFit.expand, children: [ - widget.fallback, + if (_failed || !_started) + widget.fallback + else + const ColoredBox(color: Colors.black), if (!_failed) VapView( scaleType: _scaleTypeForFit(widget.fit), - repeat: widget.loop ? -1 : 0, + repeat: widget.loop ? _longRunningLoopCount : 0, mute: true, onViewCreated: _handleViewCreated, ), diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart index 2bd5610..c527af1 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart @@ -11,17 +11,21 @@ import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; const Duration _pagMemoryTtl = Duration(minutes: 5); +const Size _roomRocketFallbackPagRawSize = Size(750, 1624); +const Rect _roomRocketTopAvatarRawRect = Rect.fromLTWH(291, 164, 168, 168); class RoomRocketPagEffectOverlay extends StatefulWidget { const RoomRocketPagEffectOverlay({ super.key, required this.resource, this.topAvatarUrl, + this.topAvatarUrlListenable, this.onCompleted, }); final String resource; final String? topAvatarUrl; + final ValueListenable? topAvatarUrlListenable; final VoidCallback? onCompleted; static bool isPag(String? resource) { @@ -50,6 +54,7 @@ class RoomRocketPagEffectOverlay extends StatefulWidget { class _RoomRocketPagEffectOverlayState extends State { bool _completed = false; + Size? _pagRawSize; @override Widget build(BuildContext context) { @@ -65,10 +70,7 @@ class _RoomRocketPagEffectOverlayState child: Stack( clipBehavior: Clip.none, children: [ - _RoomRocketPagTopAvatar( - avatarUrl: widget.topAvatarUrl, - canvasSize: size, - ), + _buildTopAvatar(size), Positioned.fill( child: _RoomRocketPagView( resource: resource, @@ -79,6 +81,7 @@ class _RoomRocketPagEffectOverlayState displayScale: 1, clipBehavior: Clip.hardEdge, memoryTtl: _pagMemoryTtl, + onReady: _updatePagRawSize, onCompleted: _complete, defaultBuilder: (_) => const Center(child: SCRotatingDotsLoading()), @@ -99,16 +102,48 @@ class _RoomRocketPagEffectOverlayState _completed = true; widget.onCompleted?.call(); } + + void _updatePagRawSize(Size rawSize) { + if (_pagRawSize == rawSize) { + return; + } + setState(() => _pagRawSize = rawSize); + } + + Widget _buildTopAvatar(Size canvasSize) { + final listenable = widget.topAvatarUrlListenable; + if (listenable == null) { + return _RoomRocketPagTopAvatar( + avatarUrl: widget.topAvatarUrl, + canvasSize: canvasSize, + pagRawSize: _pagRawSize, + ); + } + return ValueListenableBuilder( + valueListenable: listenable, + builder: + (context, avatarUrl, _) => _RoomRocketPagTopAvatar( + avatarUrl: _firstNonBlankPagAvatar([ + avatarUrl, + widget.topAvatarUrl, + ]), + canvasSize: canvasSize, + pagRawSize: _pagRawSize, + ), + ); + } } class _RoomRocketPagTopAvatar extends StatelessWidget { const _RoomRocketPagTopAvatar({ required this.avatarUrl, required this.canvasSize, + required this.pagRawSize, }); final String? avatarUrl; final Size canvasSize; + final Size? pagRawSize; @override Widget build(BuildContext context) { @@ -116,12 +151,13 @@ class _RoomRocketPagTopAvatar extends StatelessWidget { if (url.isEmpty) { return const SizedBox.shrink(); } - final size = (canvasSize.width * 0.15).clamp(46.0, 66.0).toDouble(); - return Positioned( - left: (canvasSize.width - size) / 2, - top: canvasSize.height * 0.098, - width: size, - height: size, + final rect = _mapRoomRocketPagRawRectToCoverCanvas( + rawRect: _roomRocketTopAvatarRawRect, + rawSize: pagRawSize ?? _roomRocketFallbackPagRawSize, + canvasSize: canvasSize, + ); + return Positioned.fromRect( + rect: rect, child: ClipOval( child: CustomCachedImage( imageUrl: url, @@ -200,6 +236,7 @@ class _RoomRocketPagView extends StatefulWidget { required this.clipBehavior, required this.memoryTtl, this.underlayBuilder, + this.onReady, this.onCompleted, this.defaultBuilder, }); @@ -213,6 +250,7 @@ class _RoomRocketPagView extends StatefulWidget { final Clip clipBehavior; final Duration memoryTtl; final WidgetBuilder? underlayBuilder; + final ValueChanged? onReady; final VoidCallback? onCompleted; final WidgetBuilder? defaultBuilder; @@ -414,6 +452,7 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { 'viewSize=${widget.width?.toStringAsFixed(1)}x' '${widget.height?.toStringAsFixed(1)} fit=${widget.fit.name}', ); + widget.onReady?.call(Size(rawWidth, rawHeight)); unawaited(_debugLayers(state, rawWidth, rawHeight)); if (!_initialized) { setState(() => _initialized = true); @@ -587,6 +626,55 @@ Timer? _schedulePagMemoryReleaseTimer( return Timer(memoryTtl, onRelease); } +@visibleForTesting +Rect debugMapRoomRocketPagRawRectToCoverCanvas({ + required Rect rawRect, + required Size rawSize, + required Size canvasSize, +}) { + return _mapRoomRocketPagRawRectToCoverCanvas( + rawRect: rawRect, + rawSize: rawSize, + canvasSize: canvasSize, + ); +} + +Rect _mapRoomRocketPagRawRectToCoverCanvas({ + required Rect rawRect, + required Size rawSize, + required Size canvasSize, +}) { + if (rawSize.width <= 0 || + rawSize.height <= 0 || + canvasSize.width <= 0 || + canvasSize.height <= 0) { + return Rect.zero; + } + final scaleX = canvasSize.width / rawSize.width; + final scaleY = canvasSize.height / rawSize.height; + final scale = scaleX > scaleY ? scaleX : scaleY; + final fittedWidth = rawSize.width * scale; + final fittedHeight = rawSize.height * scale; + final offsetX = (canvasSize.width - fittedWidth) / 2; + final offsetY = (canvasSize.height - fittedHeight) / 2; + return Rect.fromLTWH( + offsetX + rawRect.left * scale, + offsetY + rawRect.top * scale, + rawRect.width * scale, + rawRect.height * scale, + ); +} + +String? _firstNonBlankPagAvatar(Iterable values) { + for (final value in values) { + final text = value?.trim() ?? ''; + if (text.isNotEmpty) { + return text; + } + } + return null; +} + @visibleForTesting String debugNormalizeRoomRocketPagResource(String resource) { return _normalizePagResource(resource); 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 51968a3..4d54854 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -49,6 +49,11 @@ class RoomUserInfoCard extends StatefulWidget { class _RoomUserInfoCardState extends State { static const String _leaveMicActionIconAsset = "sc_images/room/sc_icon_room_user_card_leave_mic.png"; + static const String _defaultProfileCardBackgroundAsset = + "sc_images/room/sc_bg_room_user_profile_card_default.png"; + static const double _profileSheetHeightFactor = 0.4; + static const double _dataCardTranslateDownFactor = 0.4; + static const double _defaultBackgroundImageFactor = 0.68; SocialChatUserProfileManager? userProvider; String roomId = ""; @@ -85,7 +90,8 @@ class _RoomUserInfoCardState extends State { @override Widget build(BuildContext context) { - final sheetHeight = MediaQuery.of(context).size.height * 0.6; + final sheetHeight = + MediaQuery.of(context).size.height * _profileSheetHeightFactor; return Consumer( builder: (context, ref, child) { @@ -123,19 +129,21 @@ class _RoomUserInfoCardState extends State { height: sheetHeight, child: ClipRRect( borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), - child: DecoratedBox( - decoration: _sheetDecoration(), - child: Center( - child: Container( - width: 55.w, - height: 55.w, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(8.w), + child: Stack( + children: [ + Positioned.fill(child: _buildDefaultSheetBackground()), + Center( + child: Container( + width: 55.w, + height: 55.w, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8.w), + ), + child: CupertinoActivityIndicator(color: Colors.white24), ), - child: CupertinoActivityIndicator(color: Colors.white24), ), - ), + ], ), ), ); @@ -160,135 +168,209 @@ class _RoomUserInfoCardState extends State { ref.userCardInfo?.roomRole != SCRoomRolesType.HOMEOWNER.name && (rtcProvider.isFz() || rtcProvider.isGL()); + final profileContent = _buildProfileContentLayer( + context: context, + ref: ref, + rtcProvider: rtcProvider, + profile: profile, + bottomPadding: bottomPadding, + isSelf: isSelf, + canLeaveMic: canLeaveMic, + currentMicIndex: currentMicIndex, + canShowReport: canShowReport, + canShowSetting: canShowSetting, + ); + + if (dataCard == null) { + return SizedBox( + width: double.infinity, + height: sheetHeight, + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: Stack( + children: [ + Positioned.fill(child: _buildDefaultSheetBackground()), + Positioned.fill(child: profileContent), + ], + ), + ), + ); + } + + return _buildDataCardFrame( + sheetHeight: sheetHeight, + dataCard: dataCard, + child: profileContent, + ); + } + + Widget _buildDataCardFrame({ + required double sheetHeight, + required PropsResources dataCard, + required Widget child, + }) { return SizedBox( width: double.infinity, height: sheetHeight, - child: ClipRRect( - borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), - child: Stack( - children: [ - Positioned.fill(child: _buildSheetBackground(dataCard)), - Positioned.fill( - child: SingleChildScrollView( - physics: const ClampingScrollPhysics(), - padding: EdgeInsets.fromLTRB( - 18.w, - 16.w, - 18.w, - bottomPadding + 10.w, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildProfileHeader(context, profile, ref), - SizedBox(height: 6.w), - _buildInfoChips(context, profile), - SizedBox(height: 9.w), - _buildBadgeStrip( - profile?.id ?? widget.userId, - ref, - profile, - ), - SizedBox(height: 10.w), - _buildActions( - context: context, - ref: ref, - rtcProvider: rtcProvider, - isSelf: isSelf, - canLeaveMic: canLeaveMic, - currentMicIndex: currentMicIndex, - ), - ], - ), - ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: DecoratedBox(decoration: _sheetDecoration()), ), - if (canShowReport) - Positioned( - top: 14.w, - right: 16.w, - child: _buildHeaderIconButton( - iconAsset: "sc_images/room/sc_icon_user_card_report.png", - onTap: () { - final route = - "${SCMainRoute.report}?type=user&tageId=${widget.userId}"; - Navigator.of(context).pop(); - SCNavigatorUtils.push( - navigatorKey.currentState?.context ?? context, - route, - replace: false, - ); - }, - ), - ), - if (canShowSetting) - Positioned( - top: 14.w, - right: 52.w, - child: _buildHeaderIconButton( - iconAsset: - "sc_images/room/sc_icon_room_user_card_setting.png", - onTap: () { - if (userProvider?.userCardInfo == null) { - return; - } - Navigator.of(context).pop(); - showBottomInBottomDialog( - navigatorKey.currentState?.context ?? context, - RoomUserCardSetting( - roomId: roomId, - userCardInfo: userProvider?.userCardInfo?.copyWith(), - ), - ); - }, - ), - ), - ], - ), + ), + Positioned( + left: 0, + right: 0, + top: sheetHeight * _dataCardTranslateDownFactor, + height: sheetHeight, + child: IgnorePointer(child: _buildSheetBackground(dataCard)), + ), + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + child: child, + ), + ), + ], ), ); } + Widget _buildProfileContentLayer({ + required BuildContext context, + required SocialChatUserProfileManager ref, + required RtcProvider rtcProvider, + required SocialChatUserProfile? profile, + required double bottomPadding, + required bool isSelf, + required bool canLeaveMic, + required num currentMicIndex, + required bool canShowReport, + required bool canShowSetting, + }) { + return Stack( + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + return Align( + alignment: Alignment.topCenter, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: SizedBox( + width: constraints.maxWidth, + child: Padding( + padding: EdgeInsets.fromLTRB( + 18.w, + 10.w, + 18.w, + bottomPadding > 6.w ? bottomPadding : 6.w, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildProfileHeader(context, profile, ref), + SizedBox(height: 6.w), + _buildBadgeStrip( + profile?.id ?? widget.userId, + ref, + profile, + ), + SizedBox(height: 8.w), + _buildActions( + context: context, + ref: ref, + rtcProvider: rtcProvider, + isSelf: isSelf, + canLeaveMic: canLeaveMic, + currentMicIndex: currentMicIndex, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + if (canShowReport) + Positioned( + top: 14.w, + right: 16.w, + child: _buildHeaderIconButton( + iconAsset: "sc_images/room/sc_icon_user_card_report.png", + onTap: () { + final route = + "${SCMainRoute.report}?type=user&tageId=${widget.userId}"; + Navigator.of(context).pop(); + SCNavigatorUtils.push( + navigatorKey.currentState?.context ?? context, + route, + replace: false, + ); + }, + ), + ), + if (canShowSetting) + Positioned( + top: 14.w, + right: 52.w, + child: _buildHeaderIconButton( + iconAsset: "sc_images/room/sc_icon_room_user_card_setting.png", + onTap: () { + if (userProvider?.userCardInfo == null) { + return; + } + Navigator.of(context).pop(); + showBottomInBottomDialog( + navigatorKey.currentState?.context ?? context, + RoomUserCardSetting( + roomId: roomId, + userCardInfo: userProvider?.userCardInfo?.copyWith(), + ), + ); + }, + ), + ), + ], + ); + } + BoxDecoration _sheetDecoration() { - return const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xff18F2B1), - Color(0xffE4FFF6), - Color(0xffF7FFFC), - Color(0xffFFFFFF), - ], + return const BoxDecoration(color: Color(0xff061F1E)); + } + + Widget _buildDefaultSheetBackground() { + return DecoratedBox( + decoration: _sheetDecoration(), + child: Center( + child: FractionallySizedBox( + widthFactor: _defaultBackgroundImageFactor, + heightFactor: _defaultBackgroundImageFactor, + child: Image.asset( + _defaultProfileCardBackgroundAsset, + fit: BoxFit.contain, + ), + ), ), ); } Widget _buildSheetBackground(PropsResources? dataCard) { if (dataCard == null) { - return DecoratedBox(decoration: _sheetDecoration()); + return _buildDefaultSheetBackground(); } - return Stack( - fit: StackFit.expand, - children: [ - SCDataCardResourceView( - resource: dataCard, - fit: BoxFit.fill, - fallback: DecoratedBox(decoration: _sheetDecoration()), - ), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.white.withValues(alpha: 0.08), - Colors.white.withValues(alpha: 0.22), - Colors.white.withValues(alpha: 0.58), - ], - ), - ), - ), - ], + + return SCDataCardResourceView( + resource: dataCard, + fit: BoxFit.cover, + showCoverFallback: false, + allowCoverAsResource: false, ); } @@ -329,8 +411,8 @@ class _RoomUserInfoCardState extends State { onTap: () => _openProfile(context), child: head( url: profile?.userAvatar ?? "", - width: 70.w, - border: Border.all(color: Colors.white, width: 2), + width: 105.w, + border: Border.all(color: Colors.white, width: 3), headdress: profile?.getHeaddress()?.sourceUrl, headdressCover: profile?.getHeaddress()?.cover, ), @@ -349,7 +431,7 @@ class _RoomUserInfoCardState extends State { maxWidth: 116.w, profile?.userNickname ?? "", fontSize: 18.sp, - textColor: Colors.black, + textColor: Colors.white, fontWeight: FontWeight.bold, type: "", needScroll: (profile?.userNickname?.characters.length ?? 0) > 12, @@ -381,6 +463,22 @@ class _RoomUserInfoCardState extends State { ], ), SizedBox(height: 2.w), + _buildSpecialIdMetaRow(context, profile), + ], + ); + } + + Widget _buildSpecialIdMetaRow( + BuildContext context, + SocialChatUserProfile? profile, + ) { + final flagUrl = _countryFlagUrl(context, profile); + final hasSex = profile?.userSex != null; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ SCSpecialIdBadge( idText: profile?.getID() ?? "", showAnimated: profile?.hasSpecialId() ?? false, @@ -392,106 +490,62 @@ class _RoomUserInfoCardState extends State { showAnimatedGradientText: profile?.shouldShowColoredSpecialIdText() ?? false, animationTextStyle: TextStyle( - color: Colors.black, + color: Colors.white, fontSize: 12.sp, fontWeight: FontWeight.bold, ), normalTextStyle: TextStyle( - color: Colors.black, + color: Colors.white, fontSize: 12.sp, fontWeight: FontWeight.bold, ), animationFit: BoxFit.contain, ), + if (flagUrl.isNotEmpty) ...[ + SizedBox(width: 8.w), + _buildCountryFlag(flagUrl), + ], + if (hasSex) ...[SizedBox(width: 7.w), _buildSexIcon(profile)], ], ); } - Widget _buildInfoChips(BuildContext context, SocialChatUserProfile? profile) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildCountryChip(context, profile), - SizedBox(width: 7.w), - _buildSexAgeChip(profile), - ], - ); - } - - Widget _buildCountryChip( - BuildContext context, - SocialChatUserProfile? profile, - ) { + String _countryFlagUrl(BuildContext context, SocialChatUserProfile? profile) { final countryName = profile?.countryName ?? ""; - final flagUrl = - Provider.of( - context, - listen: false, - ).findCountryByName(countryName)?.nationalFlag ?? - ""; + try { + return Provider.of( + context, + listen: false, + ).findCountryByName(countryName)?.nationalFlag ?? + ""; + } catch (_) { + return ""; + } + } - return Container( - constraints: BoxConstraints(maxWidth: 145.w), - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(16.w), - border: Border.all( - color: const Color(0xff18F2B1).withValues(alpha: 0.18), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - netImage( - url: flagUrl, - borderRadius: BorderRadius.all(Radius.circular(3.w)), - width: 19.w, - height: 14.w, - ), - SizedBox(width: 5.w), - Flexible( - child: text( - countryName, - maxLines: 1, - textColor: Colors.black, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ), - ], + Widget _buildCountryFlag(String flagUrl) { + final trimmedUrl = flagUrl.trim(); + if (trimmedUrl.isEmpty) { + return const SizedBox.shrink(); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(2.w), + child: netImage( + url: trimmedUrl, + width: 22.w, + height: 15.w, + fit: BoxFit.cover, + noDefaultImg: true, ), ); } - Widget _buildSexAgeChip(SocialChatUserProfile? profile) { - return Container( - height: 26.w, - padding: EdgeInsets.symmetric(horizontal: 10.w), - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - profile?.userSex == 0 - ? "sc_images/login/sc_icon_sex_woman_bg.png" - : "sc_images/login/sc_icon_sex_man_bg.png", - ), - fit: BoxFit.fill, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - xb(profile?.userSex), - SizedBox(width: 3.w), - text( - "${profile?.age ?? 0}", - textColor: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ], - ), - ); + Widget _buildSexIcon(SocialChatUserProfile? profile) { + if (profile?.userSex == null) { + return const SizedBox.shrink(); + } + return xb(profile?.userSex, height: 15.w, color: null); } Widget _buildBadgeStrip( @@ -502,12 +556,12 @@ class _RoomUserInfoCardState extends State { return SCUserBadgeStrip( userId: userId, fallbackBadges: _roomCardFallbackBadges(ref, profile), - height: 82.w, - badgeHeight: 34.5.w, - longBadgeWidth: 87.w, - spacing: 10.w, + height: 70.w, + badgeHeight: 57.5.w, + longBadgeWidth: 145.w, + spacing: 20.w, maxBadges: 12, - wrap: true, + wrap: false, reserveSpace: true, ); } @@ -525,7 +579,7 @@ class _RoomUserInfoCardState extends State { return wearBadges; } return ref.userCardInfo?.useBadge - ?.map(_roomUseBadgeToWearBadge) + ?.map(_roomUseBadgeToWearBadge) .where(_hasBadgeUrl) .toList() ?? const []; @@ -640,7 +694,7 @@ class _RoomUserInfoCardState extends State { label, maxLines: 2, textAlign: TextAlign.center, - textColor: const Color(0xff666666), + textColor: Colors.white, fontWeight: FontWeight.w600, fontSize: 12.sp, lineHeight: 1.1, @@ -695,7 +749,7 @@ class _RoomUserInfoCardState extends State { : SCAppLocalizations.of(context)!.follow, maxLines: 2, textAlign: TextAlign.center, - textColor: const Color(0xff666666), + textColor: Colors.white, fontWeight: FontWeight.w600, fontSize: 12.sp, lineHeight: 1.1, diff --git a/sc_images/room/sc_bg_room_user_profile_card_default.png b/sc_images/room/sc_bg_room_user_profile_card_default.png new file mode 100644 index 0000000..e509d89 Binary files /dev/null and b/sc_images/room/sc_bg_room_user_profile_card_default.png differ diff --git a/test/room_rocket_pag_resource_test.dart b/test/room_rocket_pag_resource_test.dart index 5c2802a..c1c4a3f 100644 --- a/test/room_rocket_pag_resource_test.dart +++ b/test/room_rocket_pag_resource_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; import 'package:yumi/app/config/app_config.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart'; @@ -60,6 +61,19 @@ void main() { timer?.cancel(); }); + test('maps top avatar frame from pag raw canvas using cover fit', () { + final rect = debugMapRoomRocketPagRawRectToCoverCanvas( + rawRect: const Rect.fromLTWH(291, 164, 168, 168), + rawSize: const Size(750, 1624), + canvasSize: const Size(392.7272727, 829.090909), + ); + + expect(rect.left, closeTo(152.38, 0.01)); + expect(rect.top, closeTo(75.23, 0.01)); + expect(rect.width, closeTo(87.97, 0.01)); + expect(rect.height, closeTo(87.97, 0.01)); + }); + test('extracts launch pag urls from nested resource payloads', () { final launch = RoomRocketLaunchBroadcastMessage.fromJson({ 'rocketAnimationResource': {