diff --git a/lib/modules/room/seat/sc_seat_item.dart b/lib/modules/room/seat/sc_seat_item.dart index d0b3097..7a1e8ee 100644 --- a/lib/modules/room/seat/sc_seat_item.dart +++ b/lib/modules/room/seat/sc_seat_item.dart @@ -1,849 +1,873 @@ -import 'dart:ui'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.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/ui_kit/theme/socialchat_theme.dart'; -import 'package:yumi/ui_kit/widgets/room/room_emoji_asset_image.dart'; -import 'package:yumi/app/constants/sc_room_msg_type.dart'; -import 'package:provider/provider.dart'; -import 'package:yumi/app/constants/sc_screen.dart'; -import 'package:yumi/shared/tools/sc_path_utils.dart'; -import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/services/music/room_music_manager.dart'; -import 'package:yumi/shared/data_sources/models/enum/sc_room_special_mike_type.dart'; - -///麦位 -class SCSeatItem extends StatefulWidget { - final num index; - final bool isGameModel; - - const SCSeatItem({Key? key, required this.index, this.isGameModel = false}) - : super(key: key); - - @override - _SCSeatItemState createState() => _SCSeatItemState(); -} - -class _SCSeatItemState extends State with TickerProviderStateMixin { - static const String _seatHeartbeatValueIconAsset = - "sc_images/room/sc_icon_room_seat_heartbeat_value.png"; - static const double _seatHeaddressScaleMultiplier = 1.3; - static const double _seatPlainAvatarScale = 0.86; - static const double _seatLabelBlockWidth = 64; - static const double _seatLabelRowHeight = 16; - static const double _seatLabelGap = 2; - static const double _seatHeartbeatRowHeight = 10; - - RtcProvider? provider; - _SeatRenderSnapshot? _cachedSnapshot; - final GlobalKey _targetKey = GlobalKey(); - - @override - void initState() { - super.initState(); - provider = Provider.of(context, listen: false); - provider?.bindTargetKey(widget.index, _targetKey); - } - - @override - Widget build(BuildContext context) { - return Selector( - selector: - (context, provider) => - _SeatRenderSnapshot.fromProvider(provider, widget.index), - builder: (context, liveSnapshot, child) { - final seatSnapshot = _resolveSeatSnapshot(liveSnapshot); - final resolvedHeaddress = - SCPathUtils.getFileExtension( - seatSnapshot.headdressSourceUrl, - ).toLowerCase() == - ".mp4" && - window.locale.languageCode == "ar" - ? "" - : seatSnapshot.headdressSourceUrl; - final double seatBaseSize = widget.isGameModel ? 40.w : 55.w; - final bool hasHeaddress = resolvedHeaddress.isNotEmpty; - final double seatAvatarSize = - hasHeaddress ? seatBaseSize : seatBaseSize * _seatPlainAvatarScale; - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - context.read().clickSite(widget.index); - }, - child: Column( - children: [ - SizedBox( - key: _targetKey, - width: seatBaseSize, - height: seatBaseSize, - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - Sonic( - index: widget.index, - specialMikeType: seatSnapshot.specialMikeType, - isGameModel: widget.isGameModel, - ), - seatSnapshot.hasUser - ? Transform.scale( - scale: - hasHeaddress ? _seatHeaddressScaleMultiplier : 1, - child: head( - url: seatSnapshot.userAvatar, - width: seatAvatarSize, - height: seatAvatarSize, - headdress: resolvedHeaddress, - ), - ) - : (seatSnapshot.micLock - ? Image.asset( - "sc_images/room/sc_icon_seat_lock.png", - width: widget.isGameModel ? 38.w : 52.w, - height: widget.isGameModel ? 38.w : 52.w, - ) - : Image.asset( - "sc_images/room/sc_icon_seat_open.png", - width: widget.isGameModel ? 38.w : 52.w, - height: widget.isGameModel ? 38.w : 52.w, - )), - Positioned( - bottom: widget.isGameModel ? 2.w : 5.w, - left: widget.isGameModel ? 2.w : 5.w, - child: - seatSnapshot.isCurrentUser - ? Selector( - selector: - (_, manager) => manager.isPublishingToRoom, - builder: (context, isPlayingBgm, child) { - return isPlayingBgm - ? const _BgmPlayingIndicator() - : const SizedBox.shrink(); - }, - ) - : const SizedBox.shrink(), - ), - Positioned( - bottom: widget.isGameModel ? 2.w : 5.w, - right: widget.isGameModel ? 2.w : 5.w, - child: - seatSnapshot.micMute - ? Image.asset( - "sc_images/room/sc_icon_room_seat_mic_mute.png", - width: 14.w, - height: 14.w, - ) - : Container(), - ), - IgnorePointer( - child: Emoticons( - index: widget.index, - isGameModel: widget.isGameModel, - ), - ), - ], - ), - ), - widget.isGameModel - ? Container() - : (seatSnapshot.hasUser - ? SizedBox( - width: _seatLabelBlockWidth.w, - height: - (_seatLabelRowHeight + - _seatLabelGap + - _seatHeartbeatRowHeight) - .w, - child: Column( - children: [ - SizedBox( - height: _seatLabelRowHeight.w, - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - msgRoleTag( - seatSnapshot.userRoles, - width: 15.w, - height: 15.w, - ), - Flexible( - child: socialchatNickNameText( - fontWeight: FontWeight.w600, - seatSnapshot.userNickname, - fontSize: 10.sp, - type: seatSnapshot.userVipName, - needScroll: - seatSnapshot - .userNickname - .characters - .length > - 8, - ), - ), - ], - ), - ), - ), - SizedBox(height: _seatLabelGap.w), - SizedBox( - height: _seatHeartbeatRowHeight.w, - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - _seatHeartbeatValueIconAsset, - width: 8.w, - height: 8.w, - fit: BoxFit.contain, - ), - SizedBox(width: 2.w), - text( - _heartbeatVaFormat( - seatSnapshot.userHeartbeatValue, - ), - fontWeight: FontWeight.w600, - fontSize: 8.sp, - lineHeight: 1, - ), - ], - ), - ), - ), - ], - ), - ) - : SizedBox( - width: _seatLabelBlockWidth.w, - height: - (_seatLabelRowHeight + - _seatLabelGap + - _seatHeartbeatRowHeight) - .w, - child: Column( - children: [ - SizedBox( - height: _seatLabelRowHeight.w, - child: Center( - child: text( - "NO.${widget.index}", - fontSize: 10.sp, - fontWeight: FontWeight.w600, - ), - ), - ), - SizedBox(height: _seatLabelGap.w), - SizedBox(height: _seatHeartbeatRowHeight.w), - ], - ), - )), - ], - ), - ); - }, - ); - } - - _SeatRenderSnapshot _resolveSeatSnapshot(_SeatRenderSnapshot liveSnapshot) { - if (liveSnapshot.isExitingCurrentVoiceRoomSession) { - return _cachedSnapshot ?? liveSnapshot; - } - _cachedSnapshot = liveSnapshot; - return liveSnapshot; - } - - String _heartbeatVaFormat(num value) { - final resolvedValue = value.toInt(); - if (resolvedValue >= 1000000) { - return "${(resolvedValue / 1000000).toStringAsFixed(1)}M"; - } - if (resolvedValue >= 10000) { - return "${(resolvedValue / 1000).toStringAsFixed(0)}k"; - } - return "$resolvedValue"; - } -} - -class _BgmPlayingIndicator extends StatefulWidget { - const _BgmPlayingIndicator(); - - @override - State<_BgmPlayingIndicator> createState() => _BgmPlayingIndicatorState(); -} - -class _BgmPlayingIndicatorState extends State<_BgmPlayingIndicator> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - width: 12.w, - height: 12.w, - decoration: const BoxDecoration( - color: Color(0xFF1E1A27), - shape: BoxShape.circle, - ), - child: Center( - child: RotationTransition( - turns: _controller, - child: Image.asset( - "sc_images/room/sc_music_user_bgm.png", - width: 8.w, - height: 8.w, - ), - ), - ), - ); - } -} - -class _SeatRenderSnapshot { - const _SeatRenderSnapshot({ - required this.isExitingCurrentVoiceRoomSession, - required this.specialMikeType, - required this.userId, - required this.userAvatar, - required this.headdressSourceUrl, - required this.userNickname, - required this.userRoles, - required this.userVipName, - required this.userHeartbeatValue, - required this.micLock, - required this.micMute, - required this.isCurrentUser, - }); - - factory _SeatRenderSnapshot.fromProvider(RtcProvider provider, num index) { - final roomSeat = provider.micAtIndexForDisplay(index); - final user = roomSeat?.user; - return _SeatRenderSnapshot( - isExitingCurrentVoiceRoomSession: - provider.isExitingCurrentVoiceRoomSession, - specialMikeType: - provider.currenRoom?.roomProfile?.roomSetting?.roomSpecialMikeType ?? - "", - userId: user?.id ?? "", - userAvatar: user?.userAvatar ?? "", - headdressSourceUrl: user?.getHeaddress()?.sourceUrl ?? "", - userNickname: user?.userNickname ?? "", - userRoles: user?.roles ?? "", - userVipName: user?.getVIP()?.name ?? "", - userHeartbeatValue: user?.heartbeatVal ?? 0, - micLock: roomSeat?.micLock ?? false, - micMute: roomSeat?.micMute ?? false, - isCurrentUser: provider.isOnMaiInIndex(index), - ); - } - - final bool isExitingCurrentVoiceRoomSession; - final String specialMikeType; - final String userId; - final String userAvatar; - final String headdressSourceUrl; - final String userNickname; - final String userRoles; - final String userVipName; - final num userHeartbeatValue; - final bool micLock; - final bool micMute; - final bool isCurrentUser; - - bool get hasUser => userId.isNotEmpty; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is _SeatRenderSnapshot && - other.isExitingCurrentVoiceRoomSession == - isExitingCurrentVoiceRoomSession && - other.specialMikeType == specialMikeType && - other.userId == userId && - other.userAvatar == userAvatar && - other.headdressSourceUrl == headdressSourceUrl && - other.userNickname == userNickname && - other.userRoles == userRoles && - other.userVipName == userVipName && - other.userHeartbeatValue == userHeartbeatValue && - other.micLock == micLock && - other.micMute == micMute && - other.isCurrentUser == isCurrentUser; - } - - @override - int get hashCode => Object.hash( - isExitingCurrentVoiceRoomSession, - specialMikeType, - userId, - userAvatar, - headdressSourceUrl, - userNickname, - userRoles, - userVipName, - userHeartbeatValue, - micLock, - micMute, - isCurrentUser, - ); -} - -class _SeatEmojiSnapshot { - const _SeatEmojiSnapshot({ - required this.eventVersion, - required this.userId, - required this.emojiPath, - required this.type, - required this.number, - }); - - factory _SeatEmojiSnapshot.fromProvider(RtcProvider provider, num index) { - final mic = provider.micAtIndexForDisplay(index); - final emojiPath = mic?.emojiPath ?? ""; - return _SeatEmojiSnapshot( - eventVersion: provider.emojiEventVersionForSeat(index), - userId: mic?.user?.id ?? "", - emojiPath: emojiPath.isEmpty ? null : emojiPath, - type: mic?.type ?? "", - number: mic?.number ?? "", - ); - } - - final int eventVersion; - final String userId; - final String? emojiPath; - final String type; - final String number; - - bool get hasUser => userId.isNotEmpty; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is _SeatEmojiSnapshot && - other.eventVersion == eventVersion && - other.userId == userId && - other.emojiPath == emojiPath && - other.type == type && - other.number == number; - } - - @override - int get hashCode => - Object.hash(eventVersion, userId, emojiPath, type, number); -} - -class _SeatEmojiEvent { - const _SeatEmojiEvent({required this.type, required this.number}); - - final String type; - final String number; - - @override - String toString() => "type=$type number=$number"; -} - -class Emoticons extends StatefulWidget { - final num index; - final bool isGameModel; - - const Emoticons({Key? key, required this.index, this.isGameModel = false}) - : super(key: key); - - @override - _EmoticonsState createState() => _EmoticonsState(); -} - -class _EmoticonsState extends State with TickerProviderStateMixin { - late AnimationController emoticonsAniCtr; - late CurvedAnimation curvedAnimation; - - bool showIn = false; - _SeatEmojiEvent? playingEvent; - - List<_SeatEmojiEvent> pathList = []; - String? giftPath; - List giftList = []; - bool showResult = false; - - @override - void initState() { - // TODO: implement initState - emoticonsAniCtr = AnimationController( - vsync: this, - duration: Duration(milliseconds: 350), - ); - curvedAnimation = CurvedAnimation( - curve: Curves.ease, - parent: emoticonsAniCtr, - ); - emoticonsAniCtr.addStatusListener((status) { - if (status == AnimationStatus.dismissed) { - _checkStart(); - } - }); - super.initState(); - } - - @override - void dispose() { - emoticonsAniCtr.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Selector( - selector: - (context, provider) => - _SeatEmojiSnapshot.fromProvider(provider, widget.index), - builder: ( - BuildContext context, - _SeatEmojiSnapshot snapshot, - Widget? child, - ) { - if (!snapshot.hasUser) { - return Container(); - } - if (snapshot.emojiPath != null) { - pathList.add( - _SeatEmojiEvent(type: snapshot.type, number: snapshot.number), - ); - context.read().consumeSeatEmojiEvent( - widget.index, - snapshot.eventVersion, - ); - _checkStart(); - } - if (playingEvent != null) { - return FadeTransition( - opacity: curvedAnimation, - child: - playingEvent?.type == SCRoomMsgType.roomDice - ? FutureBuilder( - future: Future.delayed(Duration(milliseconds: 2000)), - // 传入Future - builder: ( - BuildContext context, - AsyncSnapshot snapshot, - ) { - // 根据snapshot的状态来构建UI - if (snapshot.connectionState == ConnectionState.done) { - return Image.asset( - "sc_images/room/sc_icon_dice_${playingEvent?.number}.png", - height: widget.isGameModel ? 38.w : 45.w, - ); - } else { - return Image.asset( - "sc_images/room/sc_icon_dice_animl.webp", - height: widget.isGameModel ? 38.w : 45.w, - ); - } - }, - ) - : (playingEvent?.type == SCRoomMsgType.roomRPS - ? FutureBuilder( - future: Future.delayed(Duration(milliseconds: 2000)), - // 传入Future - builder: ( - BuildContext context, - AsyncSnapshot snapshot, - ) { - // 根据snapshot的状态来构建UI - if (snapshot.connectionState == - ConnectionState.done) { - return Image.asset( - "sc_images/room/sc_icon_rps_${playingEvent?.number}.png", - height: widget.isGameModel ? 38.w : 45.w, - ); - } else { - return Image.asset( - "sc_images/room/sc_icon_rps_animal.webp", - height: widget.isGameModel ? 38.w : 45.w, - ); - } - }, - ) - : _buildEmojiImage( - playingEvent?.number ?? "", - width: widget.isGameModel ? 65.w : 75.w, - )), - ); - } else if (giftPath != null) { - return FadeTransition( - opacity: curvedAnimation, - child: netImage( - url: "$giftPath", - width: widget.isGameModel ? 65.w : 75.w, - borderRadius: BorderRadius.circular(4.w), - ), - ); - } - return Container(); - }, - ); - } - - Widget _buildEmojiImage(String path, {required double width}) { - if (path.isEmpty) { - return SizedBox(width: width, height: width); - } - if (path.startsWith("assets/") || path.startsWith("sc_images/")) { - return RoomEmojiAssetImage( - key: ValueKey(path), - asset: path, - width: width, - height: width, - ); - } - return netImage(url: path, width: width); - } - - void _checkStart() { - if (!showIn) { - if (pathList.isNotEmpty) { - playingEvent = pathList.first; - pathList.removeAt(0); - emoticonsAniCtr.forward(); - Future.delayed(Duration(milliseconds: 5)).then((value) { - if (!mounted) { - return; - } - setState(() { - showIn = true; - }); - }); - - /// 延迟3秒移除表情包 - Future.delayed(Duration(seconds: 3)).then((value) { - if (!mounted) { - return; - } - emoticonsAniCtr.reverse(); - showIn = false; - playingEvent = null; - _checkStart(); - }); - } else if (giftList.isNotEmpty) { - giftPath = giftList.first; - giftList.removeAt(0); - emoticonsAniCtr.forward(); - Future.delayed(Duration(milliseconds: 10)).then((value) { - if (!mounted) { - return; - } - setState(() { - showIn = true; - }); - }); - - /// 延迟3秒移除表情包 - Future.delayed(Duration(seconds: 1)).then((value) { - if (!mounted) { - return; - } - emoticonsAniCtr.reverse(); - showIn = false; - giftPath = null; - _checkStart(); - }); - } - } - } -} - -class Sonic extends StatefulWidget { - final num index; - final bool isGameModel; - final String specialMikeType; - - const Sonic({ - Key? key, - required this.index, - required this.specialMikeType, - this.isGameModel = false, - }) : super(key: key); - - @override - _SonicState createState() => _SonicState(); -} - -class _SonicState extends State with TickerProviderStateMixin { - List ctrList = []; - int lastIndex = 0; - late RtcProvider provider; - - @override - void initState() { - // TODO: implement initState - super.initState(); - for (int i = 0; i < 4; i++) { - ctrList.add( - AnimationController(duration: Duration(milliseconds: 900), vsync: this), - ); - } - for (var element in ctrList) { - element.addStatusListener((status) { - if (status == AnimationStatus.completed) { - element.reset(); - } - }); - } - provider = Provider.of(context, listen: false); - - provider.addSoundVoiceChangeListener(_onVoiceChange); - } - - @override - void dispose() { - // TODO: implement dispose - provider.removeSoundVoiceChangeListener(_onVoiceChange); - for (var element in ctrList) { - element.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - children: [ - SonicItem( - ctrList[0], - widget.specialMikeType, - isGameModel: widget.isGameModel, - ), - SonicItem( - ctrList[1], - widget.specialMikeType, - isGameModel: widget.isGameModel, - ), - SonicItem( - ctrList[2], - widget.specialMikeType, - isGameModel: widget.isGameModel, - ), - SonicItem( - ctrList[3], - widget.specialMikeType, - isGameModel: widget.isGameModel, - ), - ], - ); - } - - void _checkSoundAni(int volume) async { - if (volume <= 20) return; - for (final element in ctrList) { - if (element.value == 0) { - if (lastIndex == 0) { - element.forward(); - lastIndex = ctrList.indexOf(element); - return; - } else { - if (ctrList[lastIndex].value >= 0.35 || - ctrList[lastIndex].status == AnimationStatus.dismissed) { - element.forward(); - lastIndex = ctrList.indexOf(element); - return; - } - } - } - } - } - - _onVoiceChange(num index, int volum) { - if (widget.index == index) { - _checkSoundAni(volum); - } - } -} - -class SonicItem extends AnimatedWidget { - final Animation animation; - final String specialMikeType; - final bool isGameModel; - - SonicItem( - this.animation, - this.specialMikeType, { - super.key, - this.isGameModel = false, - }) : super(listenable: animation); - - @override - Widget build(BuildContext context) { - return Visibility( - visible: animation.value > 0, - child: ScaleTransition( - scale: Tween(begin: 1.0, end: 1.4).animate(animation), - child: Container( - height: width(isGameModel ? 34 : 52), - width: width(isGameModel ? 34 : 52), - decoration: - specialMikeType.isEmpty || - (specialMikeType != - SCRoomSpecialMikeType.TYPE_VIP3.name && - specialMikeType != - SCRoomSpecialMikeType.TYPE_VIP4.name && - specialMikeType != - SCRoomSpecialMikeType.TYPE_VIP5.name && - specialMikeType != - SCRoomSpecialMikeType.TYPE_VIP6.name) - ? BoxDecoration( - shape: BoxShape.circle, - color: SocialChatTheme.primaryColor.withValues( - alpha: 1 - animation.value, - ), - ) - : BoxDecoration(), - child: - specialMikeType == SCRoomSpecialMikeType.TYPE_VIP3.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip3_sonic_anim.webp", - ) - : (specialMikeType == SCRoomSpecialMikeType.TYPE_VIP4.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip4_sonic_anim.webp", - ) - : (specialMikeType == SCRoomSpecialMikeType.TYPE_VIP5.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip5_sonic_anim.webp", - ) - : (specialMikeType == - SCRoomSpecialMikeType.TYPE_VIP6.name - ? Image.asset( - "sc_images/room/sc_icon_room_vip6_sonic_anim.webp", - ) - : Container()))), - ), - ), - ); - } -} +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.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/ui_kit/theme/socialchat_theme.dart'; +import 'package:yumi/ui_kit/widgets/room/room_emoji_asset_image.dart'; +import 'package:yumi/app/constants/sc_room_msg_type.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/app/constants/sc_screen.dart'; +import 'package:yumi/shared/tools/sc_path_utils.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/services/music/room_music_manager.dart'; +import 'package:yumi/shared/data_sources/models/enum/sc_room_special_mike_type.dart'; + +///麦位 +class SCSeatItem extends StatefulWidget { + final num index; + final bool isGameModel; + + const SCSeatItem({super.key, required this.index, this.isGameModel = false}); + + @override + State createState() => _SCSeatItemState(); +} + +class _SCSeatItemState extends State with TickerProviderStateMixin { + static const String _seatHeartbeatValueIconAsset = + "sc_images/room/sc_icon_room_seat_heartbeat_value.png"; + static const double _seatHeaddressScaleMultiplier = 1.3; + static const double _seatPlainAvatarScale = 0.86; + static const double _seatLabelBlockWidth = 64; + static const double _seatLabelRowHeight = 16; + static const double _seatLabelGap = 2; + static const double _seatHeartbeatRowHeight = 10; + + RtcProvider? provider; + _SeatRenderSnapshot? _cachedSnapshot; + final GlobalKey _targetKey = GlobalKey(); + + @override + void initState() { + super.initState(); + provider = Provider.of(context, listen: false); + provider?.bindTargetKey(widget.index, _targetKey); + } + + @override + Widget build(BuildContext context) { + return Selector( + selector: + (context, provider) => + _SeatRenderSnapshot.fromProvider(provider, widget.index), + builder: (context, liveSnapshot, child) { + final seatSnapshot = _resolveSeatSnapshot(liveSnapshot); + final resolvedHeaddress = + SCPathUtils.getFileExtension( + seatSnapshot.headdressSourceUrl, + ).toLowerCase() == + ".mp4" && + View.of(context).platformDispatcher.locale.languageCode == + "ar" + ? "" + : seatSnapshot.headdressSourceUrl; + final double seatBaseSize = widget.isGameModel ? 40.w : 55.w; + final bool hasHeaddress = resolvedHeaddress.isNotEmpty; + final double seatAvatarSize = + hasHeaddress ? seatBaseSize : seatBaseSize * _seatPlainAvatarScale; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + context.read().clickSite(widget.index); + }, + child: Column( + children: [ + SizedBox( + key: _targetKey, + width: seatBaseSize, + height: seatBaseSize, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Sonic( + index: widget.index, + specialMikeType: seatSnapshot.specialMikeType, + enabled: seatSnapshot.soundWaveEnabled, + isGameModel: widget.isGameModel, + ), + seatSnapshot.hasUser + ? Transform.scale( + scale: + hasHeaddress ? _seatHeaddressScaleMultiplier : 1, + child: head( + url: seatSnapshot.userAvatar, + width: seatAvatarSize, + height: seatAvatarSize, + headdress: resolvedHeaddress, + ), + ) + : (seatSnapshot.micLock + ? Image.asset( + "sc_images/room/sc_icon_seat_lock.png", + width: widget.isGameModel ? 38.w : 52.w, + height: widget.isGameModel ? 38.w : 52.w, + ) + : Image.asset( + "sc_images/room/sc_icon_seat_open.png", + width: widget.isGameModel ? 38.w : 52.w, + height: widget.isGameModel ? 38.w : 52.w, + )), + Positioned( + bottom: widget.isGameModel ? 2.w : 5.w, + left: widget.isGameModel ? 2.w : 5.w, + child: + seatSnapshot.isCurrentUser + ? Selector( + selector: + (_, manager) => manager.isPublishingToRoom, + builder: (context, isPlayingBgm, child) { + return isPlayingBgm + ? const _BgmPlayingIndicator() + : const SizedBox.shrink(); + }, + ) + : const SizedBox.shrink(), + ), + Positioned( + bottom: widget.isGameModel ? 2.w : 5.w, + right: widget.isGameModel ? 2.w : 5.w, + child: + seatSnapshot.micMute + ? Image.asset( + "sc_images/room/sc_icon_room_seat_mic_mute.png", + width: 14.w, + height: 14.w, + ) + : Container(), + ), + IgnorePointer( + child: Emoticons( + index: widget.index, + isGameModel: widget.isGameModel, + ), + ), + ], + ), + ), + widget.isGameModel + ? Container() + : (seatSnapshot.hasUser + ? SizedBox( + width: _seatLabelBlockWidth.w, + height: + (_seatLabelRowHeight + + _seatLabelGap + + _seatHeartbeatRowHeight) + .w, + child: Column( + children: [ + SizedBox( + height: _seatLabelRowHeight.w, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + msgRoleTag( + seatSnapshot.userRoles, + width: 15.w, + height: 15.w, + ), + Flexible( + child: socialchatNickNameText( + fontWeight: FontWeight.w600, + seatSnapshot.userNickname, + fontSize: 10.sp, + type: seatSnapshot.userVipName, + needScroll: + seatSnapshot + .userNickname + .characters + .length > + 8, + ), + ), + ], + ), + ), + ), + SizedBox(height: _seatLabelGap.w), + SizedBox( + height: _seatHeartbeatRowHeight.w, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + _seatHeartbeatValueIconAsset, + width: 8.w, + height: 8.w, + fit: BoxFit.contain, + ), + SizedBox(width: 2.w), + text( + _heartbeatVaFormat( + seatSnapshot.userHeartbeatValue, + ), + fontWeight: FontWeight.w600, + fontSize: 8.sp, + lineHeight: 1, + ), + ], + ), + ), + ), + ], + ), + ) + : SizedBox( + width: _seatLabelBlockWidth.w, + height: + (_seatLabelRowHeight + + _seatLabelGap + + _seatHeartbeatRowHeight) + .w, + child: Column( + children: [ + SizedBox( + height: _seatLabelRowHeight.w, + child: Center( + child: text( + "NO.${widget.index}", + fontSize: 10.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(height: _seatLabelGap.w), + SizedBox(height: _seatHeartbeatRowHeight.w), + ], + ), + )), + ], + ), + ); + }, + ); + } + + _SeatRenderSnapshot _resolveSeatSnapshot(_SeatRenderSnapshot liveSnapshot) { + if (liveSnapshot.isExitingCurrentVoiceRoomSession) { + return _cachedSnapshot ?? liveSnapshot; + } + _cachedSnapshot = liveSnapshot; + return liveSnapshot; + } + + String _heartbeatVaFormat(num value) { + final resolvedValue = value.toInt(); + if (resolvedValue >= 1000000) { + return "${(resolvedValue / 1000000).toStringAsFixed(1)}M"; + } + if (resolvedValue >= 10000) { + return "${(resolvedValue / 1000).toStringAsFixed(0)}k"; + } + return "$resolvedValue"; + } +} + +class _BgmPlayingIndicator extends StatefulWidget { + const _BgmPlayingIndicator(); + + @override + State<_BgmPlayingIndicator> createState() => _BgmPlayingIndicatorState(); +} + +class _BgmPlayingIndicatorState extends State<_BgmPlayingIndicator> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 12.w, + height: 12.w, + decoration: const BoxDecoration( + color: Color(0xFF1E1A27), + shape: BoxShape.circle, + ), + child: Center( + child: RotationTransition( + turns: _controller, + child: Image.asset( + "sc_images/room/sc_music_user_bgm.png", + width: 8.w, + height: 8.w, + ), + ), + ), + ); + } +} + +class _SeatRenderSnapshot { + const _SeatRenderSnapshot({ + required this.isExitingCurrentVoiceRoomSession, + required this.specialMikeType, + required this.userId, + required this.userAvatar, + required this.headdressSourceUrl, + required this.userNickname, + required this.userRoles, + required this.userVipName, + required this.userHeartbeatValue, + required this.micLock, + required this.micMute, + required this.soundWaveEnabled, + required this.isCurrentUser, + }); + + factory _SeatRenderSnapshot.fromProvider(RtcProvider provider, num index) { + final roomSeat = provider.micAtIndexForDisplay(index); + final user = roomSeat?.user; + return _SeatRenderSnapshot( + isExitingCurrentVoiceRoomSession: + provider.isExitingCurrentVoiceRoomSession, + specialMikeType: + provider.currenRoom?.roomProfile?.roomSetting?.roomSpecialMikeType ?? + "", + userId: user?.id ?? "", + userAvatar: user?.userAvatar ?? "", + headdressSourceUrl: user?.getHeaddress()?.sourceUrl ?? "", + userNickname: user?.userNickname ?? "", + userRoles: user?.roles ?? "", + userVipName: user?.getVIP()?.name ?? "", + userHeartbeatValue: user?.heartbeatVal ?? 0, + micLock: roomSeat?.micLock ?? false, + micMute: roomSeat?.micMute ?? false, + soundWaveEnabled: provider.shouldShowSeatSoundWave(index), + isCurrentUser: provider.isOnMaiInIndex(index), + ); + } + + final bool isExitingCurrentVoiceRoomSession; + final String specialMikeType; + final String userId; + final String userAvatar; + final String headdressSourceUrl; + final String userNickname; + final String userRoles; + final String userVipName; + final num userHeartbeatValue; + final bool micLock; + final bool micMute; + final bool soundWaveEnabled; + final bool isCurrentUser; + + bool get hasUser => userId.isNotEmpty; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is _SeatRenderSnapshot && + other.isExitingCurrentVoiceRoomSession == + isExitingCurrentVoiceRoomSession && + other.specialMikeType == specialMikeType && + other.userId == userId && + other.userAvatar == userAvatar && + other.headdressSourceUrl == headdressSourceUrl && + other.userNickname == userNickname && + other.userRoles == userRoles && + other.userVipName == userVipName && + other.userHeartbeatValue == userHeartbeatValue && + other.micLock == micLock && + other.micMute == micMute && + other.soundWaveEnabled == soundWaveEnabled && + other.isCurrentUser == isCurrentUser; + } + + @override + int get hashCode => Object.hash( + isExitingCurrentVoiceRoomSession, + specialMikeType, + userId, + userAvatar, + headdressSourceUrl, + userNickname, + userRoles, + userVipName, + userHeartbeatValue, + micLock, + micMute, + soundWaveEnabled, + isCurrentUser, + ); +} + +class _SeatEmojiSnapshot { + const _SeatEmojiSnapshot({ + required this.eventVersion, + required this.userId, + required this.emojiPath, + required this.type, + required this.number, + }); + + factory _SeatEmojiSnapshot.fromProvider(RtcProvider provider, num index) { + final mic = provider.micAtIndexForDisplay(index); + final emojiPath = mic?.emojiPath ?? ""; + return _SeatEmojiSnapshot( + eventVersion: provider.emojiEventVersionForSeat(index), + userId: mic?.user?.id ?? "", + emojiPath: emojiPath.isEmpty ? null : emojiPath, + type: mic?.type ?? "", + number: mic?.number ?? "", + ); + } + + final int eventVersion; + final String userId; + final String? emojiPath; + final String type; + final String number; + + bool get hasUser => userId.isNotEmpty; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is _SeatEmojiSnapshot && + other.eventVersion == eventVersion && + other.userId == userId && + other.emojiPath == emojiPath && + other.type == type && + other.number == number; + } + + @override + int get hashCode => + Object.hash(eventVersion, userId, emojiPath, type, number); +} + +class _SeatEmojiEvent { + const _SeatEmojiEvent({required this.type, required this.number}); + + final String type; + final String number; + + @override + String toString() => "type=$type number=$number"; +} + +class Emoticons extends StatefulWidget { + final num index; + final bool isGameModel; + + const Emoticons({super.key, required this.index, this.isGameModel = false}); + + @override + State createState() => _EmoticonsState(); +} + +class _EmoticonsState extends State with TickerProviderStateMixin { + late AnimationController emoticonsAniCtr; + late CurvedAnimation curvedAnimation; + + bool showIn = false; + _SeatEmojiEvent? playingEvent; + + List<_SeatEmojiEvent> pathList = []; + String? giftPath; + List giftList = []; + bool showResult = false; + + @override + void initState() { + // TODO: implement initState + emoticonsAniCtr = AnimationController( + vsync: this, + duration: Duration(milliseconds: 350), + ); + curvedAnimation = CurvedAnimation( + curve: Curves.ease, + parent: emoticonsAniCtr, + ); + emoticonsAniCtr.addStatusListener((status) { + if (status == AnimationStatus.dismissed) { + _checkStart(); + } + }); + super.initState(); + } + + @override + void dispose() { + emoticonsAniCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Selector( + selector: + (context, provider) => + _SeatEmojiSnapshot.fromProvider(provider, widget.index), + builder: ( + BuildContext context, + _SeatEmojiSnapshot snapshot, + Widget? child, + ) { + if (!snapshot.hasUser) { + return Container(); + } + if (snapshot.emojiPath != null) { + pathList.add( + _SeatEmojiEvent(type: snapshot.type, number: snapshot.number), + ); + context.read().consumeSeatEmojiEvent( + widget.index, + snapshot.eventVersion, + ); + _checkStart(); + } + if (playingEvent != null) { + return FadeTransition( + opacity: curvedAnimation, + child: + playingEvent?.type == SCRoomMsgType.roomDice + ? FutureBuilder( + future: Future.delayed(Duration(milliseconds: 2000)), + // 传入Future + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + // 根据snapshot的状态来构建UI + if (snapshot.connectionState == ConnectionState.done) { + return Image.asset( + "sc_images/room/sc_icon_dice_${playingEvent?.number}.png", + height: widget.isGameModel ? 38.w : 45.w, + ); + } else { + return Image.asset( + "sc_images/room/sc_icon_dice_animl.webp", + height: widget.isGameModel ? 38.w : 45.w, + ); + } + }, + ) + : (playingEvent?.type == SCRoomMsgType.roomRPS + ? FutureBuilder( + future: Future.delayed(Duration(milliseconds: 2000)), + // 传入Future + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + // 根据snapshot的状态来构建UI + if (snapshot.connectionState == + ConnectionState.done) { + return Image.asset( + "sc_images/room/sc_icon_rps_${playingEvent?.number}.png", + height: widget.isGameModel ? 38.w : 45.w, + ); + } else { + return Image.asset( + "sc_images/room/sc_icon_rps_animal.webp", + height: widget.isGameModel ? 38.w : 45.w, + ); + } + }, + ) + : _buildEmojiImage( + playingEvent?.number ?? "", + width: widget.isGameModel ? 65.w : 75.w, + )), + ); + } else if (giftPath != null) { + return FadeTransition( + opacity: curvedAnimation, + child: netImage( + url: "$giftPath", + width: widget.isGameModel ? 65.w : 75.w, + borderRadius: BorderRadius.circular(4.w), + ), + ); + } + return Container(); + }, + ); + } + + Widget _buildEmojiImage(String path, {required double width}) { + if (path.isEmpty) { + return SizedBox(width: width, height: width); + } + if (path.startsWith("assets/") || path.startsWith("sc_images/")) { + return RoomEmojiAssetImage( + key: ValueKey(path), + asset: path, + width: width, + height: width, + ); + } + return netImage(url: path, width: width); + } + + void _checkStart() { + if (!showIn) { + if (pathList.isNotEmpty) { + playingEvent = pathList.first; + pathList.removeAt(0); + emoticonsAniCtr.forward(); + Future.delayed(Duration(milliseconds: 5)).then((value) { + if (!mounted) { + return; + } + setState(() { + showIn = true; + }); + }); + + /// 延迟3秒移除表情包 + Future.delayed(Duration(seconds: 3)).then((value) { + if (!mounted) { + return; + } + emoticonsAniCtr.reverse(); + showIn = false; + playingEvent = null; + _checkStart(); + }); + } else if (giftList.isNotEmpty) { + giftPath = giftList.first; + giftList.removeAt(0); + emoticonsAniCtr.forward(); + Future.delayed(Duration(milliseconds: 10)).then((value) { + if (!mounted) { + return; + } + setState(() { + showIn = true; + }); + }); + + /// 延迟3秒移除表情包 + Future.delayed(Duration(seconds: 1)).then((value) { + if (!mounted) { + return; + } + emoticonsAniCtr.reverse(); + showIn = false; + giftPath = null; + _checkStart(); + }); + } + } + } +} + +class Sonic extends StatefulWidget { + final num index; + final bool isGameModel; + final String specialMikeType; + final bool enabled; + + const Sonic({ + super.key, + required this.index, + required this.specialMikeType, + required this.enabled, + this.isGameModel = false, + }); + + @override + State createState() => _SonicState(); +} + +class _SonicState extends State with TickerProviderStateMixin { + List ctrList = []; + int lastIndex = 0; + late RtcProvider provider; + + @override + void initState() { + // TODO: implement initState + super.initState(); + for (int i = 0; i < 4; i++) { + ctrList.add( + AnimationController(duration: Duration(milliseconds: 900), vsync: this), + ); + } + for (var element in ctrList) { + element.addStatusListener((status) { + if (status == AnimationStatus.completed) { + element.reset(); + } + }); + } + provider = Provider.of(context, listen: false); + + provider.addSoundVoiceChangeListener(_onVoiceChange); + } + + @override + void didUpdateWidget(covariant Sonic oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.enabled && !widget.enabled) { + _resetSoundAni(); + } + } + + @override + void dispose() { + // TODO: implement dispose + provider.removeSoundVoiceChangeListener(_onVoiceChange); + for (var element in ctrList) { + element.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled) { + return const SizedBox.shrink(); + } + return Stack( + clipBehavior: Clip.none, + children: [ + SonicItem( + ctrList[0], + widget.specialMikeType, + isGameModel: widget.isGameModel, + ), + SonicItem( + ctrList[1], + widget.specialMikeType, + isGameModel: widget.isGameModel, + ), + SonicItem( + ctrList[2], + widget.specialMikeType, + isGameModel: widget.isGameModel, + ), + SonicItem( + ctrList[3], + widget.specialMikeType, + isGameModel: widget.isGameModel, + ), + ], + ); + } + + void _checkSoundAni(int volume) async { + if (volume <= 20) return; + for (final element in ctrList) { + if (element.value == 0) { + if (lastIndex == 0) { + element.forward(); + lastIndex = ctrList.indexOf(element); + return; + } else { + if (ctrList[lastIndex].value >= 0.35 || + ctrList[lastIndex].status == AnimationStatus.dismissed) { + element.forward(); + lastIndex = ctrList.indexOf(element); + return; + } + } + } + } + } + + void _resetSoundAni() { + lastIndex = 0; + for (final element in ctrList) { + element.reset(); + } + } + + _onVoiceChange(num index, int volum) { + if (widget.enabled && widget.index == index) { + _checkSoundAni(volum); + } + } +} + +class SonicItem extends AnimatedWidget { + final Animation animation; + final String specialMikeType; + final bool isGameModel; + + const SonicItem( + this.animation, + this.specialMikeType, { + super.key, + this.isGameModel = false, + }) : super(listenable: animation); + + @override + Widget build(BuildContext context) { + return Visibility( + visible: animation.value > 0, + child: ScaleTransition( + scale: Tween(begin: 1.0, end: 1.4).animate(animation), + child: Container( + height: width(isGameModel ? 34 : 52), + width: width(isGameModel ? 34 : 52), + decoration: + specialMikeType.isEmpty || + (specialMikeType != + SCRoomSpecialMikeType.TYPE_VIP3.name && + specialMikeType != + SCRoomSpecialMikeType.TYPE_VIP4.name && + specialMikeType != + SCRoomSpecialMikeType.TYPE_VIP5.name && + specialMikeType != + SCRoomSpecialMikeType.TYPE_VIP6.name) + ? BoxDecoration( + shape: BoxShape.circle, + color: SocialChatTheme.primaryColor.withValues( + alpha: 1 - animation.value, + ), + ) + : BoxDecoration(), + child: + specialMikeType == SCRoomSpecialMikeType.TYPE_VIP3.name + ? Image.asset( + "sc_images/room/sc_icon_room_vip3_sonic_anim.webp", + ) + : (specialMikeType == SCRoomSpecialMikeType.TYPE_VIP4.name + ? Image.asset( + "sc_images/room/sc_icon_room_vip4_sonic_anim.webp", + ) + : (specialMikeType == SCRoomSpecialMikeType.TYPE_VIP5.name + ? Image.asset( + "sc_images/room/sc_icon_room_vip5_sonic_anim.webp", + ) + : (specialMikeType == + SCRoomSpecialMikeType.TYPE_VIP6.name + ? Image.asset( + "sc_images/room/sc_icon_room_vip6_sonic_anim.webp", + ) + : Container()))), + ), + ), + ); + } +} diff --git a/lib/modules/user/level/user/user_level_page.dart b/lib/modules/user/level/user/user_level_page.dart index 7bb4e41..4b7e800 100644 --- a/lib/modules/user/level/user/user_level_page.dart +++ b/lib/modules/user/level/user/user_level_page.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/app_localizations.dart'; @@ -15,8 +14,10 @@ import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import '../../../../shared/data_sources/models/enum/sc_level_type.dart'; class UserLevelPage extends StatefulWidget { + const UserLevelPage({super.key}); + @override - _UserLevelPageState createState() => _UserLevelPageState(); + State createState() => _UserLevelPageState(); } class _UserLevelPageState extends State { @@ -78,26 +79,26 @@ class _UserLevelPageState extends State { maxWidth: 115.w, fontWeight: FontWeight.bold, AccountStorage() - .getCurrentUser() - ?.userProfile - ?.userNickname ?? + .getCurrentUser() + ?.userProfile + ?.userNickname ?? "", fontSize: 15.sp, type: - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.getVIP() - ?.name ?? + AccountStorage() + .getCurrentUser() + ?.userProfile + ?.getVIP() + ?.name ?? "", needScroll: - (AccountStorage() - .getCurrentUser() - ?.userProfile - ?.userNickname - ?.characters - .length ?? - 0) > + (AccountStorage() + .getCurrentUser() + ?.userProfile + ?.userNickname + ?.characters + .length ?? + 0) > 10, ), SizedBox(width: 3.w), @@ -162,7 +163,6 @@ class _UserLevelPageState extends State { textColor: Colors.white, ), Image.asset("sc_images/level/sc_icon_user_level_center_bg_1.png"), - Image.asset("sc_images/level/sc_icon_user_level_center_bg_2.png"), text( SCAppLocalizations.of(context)!.howToUpgrade, fontSize: 20.sp, @@ -171,7 +171,9 @@ class _UserLevelPageState extends State { ), SizedBox(height: 3), text( - SCAppLocalizations.of(context)!.spendCoinsToGainExperiencePoints, + SCAppLocalizations.of( + context, + )!.spendCoinsToGainExperiencePoints, fontSize: 12.sp, fontWeight: FontWeight.w600, textColor: Colors.white, @@ -196,7 +198,7 @@ class _UserLevelPageState extends State { SCNavigatorUtils.push(context, StoreRoute.list); }, ), - SizedBox(height: 20.w,) + SizedBox(height: 20.w), ], ), ) diff --git a/lib/modules/user/level/wealth/wealth_level_page.dart b/lib/modules/user/level/wealth/wealth_level_page.dart index 9575839..8488206 100644 --- a/lib/modules/user/level/wealth/wealth_level_page.dart +++ b/lib/modules/user/level/wealth/wealth_level_page.dart @@ -1,7 +1,5 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:marquee/marquee.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; @@ -16,8 +14,10 @@ import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import '../../../../shared/data_sources/models/enum/sc_level_type.dart'; class WealthLevelPage extends StatefulWidget { + const WealthLevelPage({super.key}); + @override - _WealthLevelPageState createState() => _WealthLevelPageState(); + State createState() => _WealthLevelPageState(); } class _WealthLevelPageState extends State { @@ -79,26 +79,26 @@ class _WealthLevelPageState extends State { maxWidth: 125.w, fontWeight: FontWeight.bold, AccountStorage() - .getCurrentUser() - ?.userProfile - ?.userNickname ?? + .getCurrentUser() + ?.userProfile + ?.userNickname ?? "", fontSize: 15.sp, type: - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.getVIP() - ?.name ?? + AccountStorage() + .getCurrentUser() + ?.userProfile + ?.getVIP() + ?.name ?? "", needScroll: - (AccountStorage() - .getCurrentUser() - ?.userProfile - ?.userNickname - ?.characters - .length ?? - 0) > + (AccountStorage() + .getCurrentUser() + ?.userProfile + ?.userNickname + ?.characters + .length ?? + 0) > 10, ), SizedBox(width: 3.w), @@ -163,16 +163,17 @@ class _WealthLevelPageState extends State { textColor: Colors.white, ), Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_1.png"), - Image.asset("sc_images/level/sc_icon_user_wealth_center_bg_2.png"), text( SCAppLocalizations.of(context)!.howToUpgrade, fontSize: 20.sp, fontWeight: FontWeight.bold, - textColor:SocialChatTheme.primaryLight, + textColor: SocialChatTheme.primaryLight, ), SizedBox(height: 3), text( - SCAppLocalizations.of(context)!.spendCoinsToGainExperiencePoints, + SCAppLocalizations.of( + context, + )!.spendCoinsToGainExperiencePoints, fontSize: 12.sp, fontWeight: FontWeight.w600, textColor: Colors.white, @@ -197,7 +198,7 @@ class _WealthLevelPageState extends State { SCNavigatorUtils.push(context, StoreRoute.list); }, ), - SizedBox(height: 20.w,) + SizedBox(height: 20.w), ], ), ) diff --git a/lib/modules/user/me_page2.dart b/lib/modules/user/me_page2.dart index 4c6d865..6c77808 100644 --- a/lib/modules/user/me_page2.dart +++ b/lib/modules/user/me_page2.dart @@ -12,8 +12,10 @@ import 'package:yumi/modules/user/vip/vip_route.dart'; import 'package:yumi/modules/wallet/wallet_route.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/shared/business_logic/models/res/sc_user_counter_res.dart'; +import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; +import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/widgets/id/sc_special_id_badge.dart'; @@ -32,6 +34,8 @@ class _MePage2State extends State { static const double _inviteActivityHorizontalScale = 750 / 662; Map _counterMap = {}; + SCVipStatusRes? _vipStatus; + bool _isVipStatusLoading = false; @override void initState() { @@ -49,6 +53,7 @@ class _MePage2State extends State { profileManager.fetchUserProfileData(); profileManager.balance(); _loadCounter(); + _loadVipStatus(); } Future _loadCounter() async { @@ -68,6 +73,47 @@ class _MePage2State extends State { } catch (_) {} } + Future _loadVipStatus() async { + if (mounted) { + setState(() => _isVipStatusLoading = true); + } + + try { + final status = await _loadVipEntryStatus(); + debugPrint( + '[VIP][MeEntry] status loaded active=${status.active} ' + 'level=${status.level} levelCode=${status.levelCode} ' + 'expireAt=${status.expireAt} ' + 'badge=${_vipEntryBadgeLogValue(status.badge)}', + ); + if (!mounted) return; + setState(() { + _vipStatus = status; + _isVipStatusLoading = false; + }); + } catch (error) { + debugPrint('[VIP][MeEntry] status load failed error=$error'); + if (!mounted) return; + setState(() => _isVipStatusLoading = false); + } + } + + Future _loadVipEntryStatus() async { + final repository = SCVipRepositoryImp(); + try { + final home = await repository.vipHome(); + final state = home.state; + if (state != null && state.levelInt > 0) { + debugPrint('[VIP][MeEntry] use vip home state'); + return state; + } + debugPrint('[VIP][MeEntry] vip home state empty, fallback status'); + } catch (error) { + debugPrint('[VIP][MeEntry] vip home load failed error=$error'); + } + return repository.vipStatus(); + } + void _openInviteActivity() { final inviteActivityUrl = _appendToken(_inviteActivityUrl); if (inviteActivityUrl.isEmpty) return; @@ -108,7 +154,7 @@ class _MePage2State extends State { SizedBox(height: 12.w), _buildEntryRow(), SizedBox(height: 12.w), - _buildWalletVipRow(), + _buildWalletVipRow(profileManager), _buildInviteActivityBanner(), _buildMenuCard2(), SizedBox(height: 12.w), @@ -268,10 +314,10 @@ class _MePage2State extends State { ); } - Widget _buildWalletVipRow() { + Widget _buildWalletVipRow(SocialChatUserProfileManager profileManager) { return Row( children: [ - Expanded(child: _buildVipEntryCard()), + Expanded(child: _buildVipEntryCard(profileManager)), SizedBox(width: 10.w), Expanded(child: _buildWalletCard()), ], @@ -354,9 +400,17 @@ class _MePage2State extends State { ); } - Widget _buildVipEntryCard() { + Widget _buildVipEntryCard(SocialChatUserProfileManager profileManager) { + final vipLevel = _vipEntryLevel(profileManager); + final isActiveVip = vipLevel > 0; + final badgeResource = isActiveVip ? _vipStatus?.badge : null; return SCDebounceWidget( - onTap: () => SCNavigatorUtils.push(context, VipRoute.detail), + onTap: () async { + await SCNavigatorUtils.push(context, VipRoute.detail); + if (mounted) { + _loadVipStatus(); + } + }, child: Container( height: 68.w, decoration: BoxDecoration( @@ -394,16 +448,38 @@ class _MePage2State extends State { PositionedDirectional( start: 13.w, bottom: 10.w, - child: _buildVipLevelMark(5), + child: + _isVipStatusLoading && _vipStatus == null + ? SizedBox( + width: 16.w, + height: 16.w, + child: CircularProgressIndicator( + color: const Color(0xFFFFD155), + strokeWidth: 2.w, + ), + ) + : isActiveVip + ? _buildVipLevelMark(vipLevel) + : Text( + 'Not activated', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: const Color( + 0xFFFFE8A7, + ).withValues(alpha: 0.82), + fontSize: 11.sp, + fontWeight: FontWeight.w600, + height: 1, + ), + ), ), PositionedDirectional( end: 2.w, top: 8.w, - child: Image.asset( - 'sc_images/vip/sc_vip_badge_5.png', - width: 52.w, - height: 52.w, - fit: BoxFit.contain, + child: Opacity( + opacity: isActiveVip ? 1 : 0.52, + child: _buildVipEntryBadge(resource: badgeResource), ), ), ], @@ -412,6 +488,64 @@ class _MePage2State extends State { ); } + Widget _buildVipEntryBadge({required SCVipResourceRes? resource}) { + final empty = SizedBox(width: 52.w, height: 52.w); + final url = resource?.previewUrl?.trim(); + if (url == null || url.isEmpty) { + return empty; + } + return netImage( + url: url, + width: 52.w, + height: 52.w, + fit: BoxFit.contain, + noDefaultImg: true, + errorWidget: empty, + ); + } + + String _vipEntryBadgeLogValue(SCVipResourceRes? resource) { + if (resource == null) { + return 'null'; + } + return '{id:${resource.resourceId},name:${resource.name},' + 'hasPreviewUrl:${resource.hasPreviewUrl},' + 'previewUrl:${resource.previewUrl}}'; + } + + int _vipEntryLevel(SocialChatUserProfileManager profileManager) { + final status = _vipStatus; + if (status != null && status.isActive && status.levelInt > 0) { + return status.levelInt.clamp(1, 5).toInt(); + } + + final vipResource = profileManager.currentUserProfile?.getVIP(); + return _parseVipLevel(vipResource?.name) ?? + _parseVipLevel(vipResource?.code) ?? + 0; + } + + int? _parseVipLevel(String? value) { + final text = value?.trim(); + if (text == null || text.isEmpty) { + return null; + } + + final vipMatch = RegExp( + r'VIP\s*([1-5])', + caseSensitive: false, + ).firstMatch(text); + if (vipMatch != null) { + return int.tryParse(vipMatch.group(1) ?? ''); + } + + final trailingLevelMatch = RegExp(r'([1-5])$').firstMatch(text); + if (trailingLevelMatch != null) { + return int.tryParse(trailingLevelMatch.group(1) ?? ''); + } + return null; + } + Widget _buildVipLevelMark(int level) { return Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/modules/user/vip/vip_detail_page.dart b/lib/modules/user/vip/vip_detail_page.dart index 28bd00a..5e17a8b 100644 --- a/lib/modules/user/vip/vip_detail_page.dart +++ b/lib/modules/user/vip/vip_detail_page.dart @@ -10,6 +10,7 @@ import 'package:yumi/shared/business_logic/models/res/sc_vip_res.dart'; import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; @@ -56,7 +57,7 @@ class _VipDetailPageState extends State { ]); final home = results[0] as SCVipHomeRes; final status = results[1] as SCVipStatusRes; - final resolvedStatus = status.levelInt > 0 ? status : home.state; + final resolvedStatus = _resolveStatus(status, home.state); final selectedLevel = _resolveInitialLevel(home, resolvedStatus); _logLoadedVipData(home, resolvedStatus, selectedLevel); @@ -80,6 +81,18 @@ class _VipDetailPageState extends State { } } + SCVipStatusRes? _resolveStatus( + SCVipStatusRes status, + SCVipStatusRes? homeState, + ) { + if (homeState != null && homeState.levelInt > 0) { + if (status.levelInt <= 0 || status.levelInt == homeState.levelInt) { + return homeState; + } + } + return status.levelInt > 0 ? status : homeState; + } + int _resolveInitialLevel(SCVipHomeRes home, SCVipStatusRes? status) { if (status?.isActive == true && status!.levelInt > 0) { return status.levelInt.clamp(1, 5).toInt(); @@ -100,8 +113,8 @@ class _VipDetailPageState extends State { return 1; } - SCVipLevelConfigRes? _levelConfigFor(int level) { - final levels = _home?.levels ?? []; + SCVipLevelConfigRes? _levelConfigFromHome(SCVipHomeRes? home, int level) { + final levels = home?.levels ?? []; for (final config in levels) { if (config.levelInt == level) { return config; @@ -110,11 +123,30 @@ class _VipDetailPageState extends State { return null; } + SCVipLevelConfigRes? _levelConfigFor(int level) => + _levelConfigFromHome(_home, level); + + SCVipLevelConfigRes? get _selectedLevelConfig => + _levelConfigFor(_selectedLevel); + + SCVipStatusRes? get _selectedLevelState { + final status = _status; + if (status != null && status.levelInt == _selectedLevel) { + return status; + } + return null; + } + void _selectLevel(int level) { setState(() { _selectedLevel = level; _preview = null; }); + _logSelectedLevelResources( + level, + config: _levelConfigFor(level), + status: _status, + ); _loadPreviewForLevel(level); } @@ -216,6 +248,58 @@ class _VipDetailPageState extends State { 'frontend has no level data to render.', ); } + _logSelectedLevelResources( + selectedLevel, + config: _levelConfigFromHome(home, selectedLevel), + status: status, + ); + } + + void _logSelectedLevelResources( + int level, { + required SCVipLevelConfigRes? config, + required SCVipStatusRes? status, + }) { + final state = status?.levelInt == level ? status : null; + if (config == null && state == null) { + debugPrint( + '[VIP][Page][Warn] selected level=$level has no config or state', + ); + return; + } + final badge = _preferResource(config?.badge, state?.badge); + final avatarFrame = _preferResource( + config?.avatarFrame, + state?.avatarFrame, + ); + final entryEffect = _preferResource( + config?.entryEffect, + state?.entryEffect, + ); + final chatBubble = _preferResource(config?.chatBubble, state?.chatBubble); + final floatPicture = _preferResource( + config?.floatPicture, + state?.floatPicture, + ); + debugPrint( + '[VIP][Page] selected level resources level=$level ' + 'badge=${_resourceLogValue(badge)} ' + 'avatarFrame=${_resourceLogValue(avatarFrame)} ' + 'entryEffect=${_resourceLogValue(entryEffect)} ' + 'chatBubble=${_resourceLogValue(chatBubble)} ' + 'floatPicture=${_resourceLogValue(floatPicture)}', + ); + } + + String _resourceLogValue(SCVipResourceRes? resource) { + if (!_hasResource(resource)) { + return 'null'; + } + return '{id:${resource?.resourceId},name:${resource?.name},' + 'hasPreviewUrl:${resource?.hasPreviewUrl},' + 'hasSourceUrl:${resource?.hasSourceUrl},' + 'previewUrl:${resource?.previewUrl},' + 'sourceUrl:${resource?.sourceResourceUrl}}'; } @override @@ -331,6 +415,9 @@ class _VipDetailPageState extends State { ); } + final decorationItems = _decorationPrivilegeItems; + final privilegeItems = _vipPrivilegeItems; + return SingleChildScrollView( physics: const BouncingScrollPhysics(), padding: EdgeInsets.fromLTRB(12.w, 40.w, 12.w, 18.w), @@ -340,13 +427,17 @@ class _VipDetailPageState extends State { SizedBox(height: 30.w), _buildLevelDivider(), SizedBox(height: 3.w), - _buildSectionTitle('Decoration Privileges'), - SizedBox(height: 10.w), - _buildDecorationGrid(), - SizedBox(height: 20.w), - _buildSectionTitle('VIP Privileges'), - SizedBox(height: 14.w), - _buildPrivilegeGrid(), + if (decorationItems.isNotEmpty) ...[ + _buildSectionTitle('Decoration Privileges'), + SizedBox(height: 10.w), + _buildDecorationGrid(decorationItems), + SizedBox(height: 20.w), + ], + if (privilegeItems.isNotEmpty) ...[ + _buildSectionTitle('VIP Privileges'), + SizedBox(height: 14.w), + _buildPrivilegeGrid(privilegeItems), + ], SizedBox(height: 76.w), ], ), @@ -519,11 +610,11 @@ class _VipDetailPageState extends State { PositionedDirectional( end: 24.w, top: -36.w, - child: Image.asset( - 'sc_images/vip/sc_vip_badge_$assetLevel.png', + child: _buildResourceImage( + resource: _selectedBadge, width: 135.w, height: 135.w, - fit: BoxFit.contain, + fallbackIcon: Icons.workspace_premium_outlined, ), ), ], @@ -601,17 +692,7 @@ class _VipDetailPageState extends State { ); } - Widget _buildDecorationGrid() { - final items = [ - _VipFeatureData( - 'SVIP Badge', - 'sc_images/vip/sc_vip_badge_$_assetLevel.png', - ), - _VipFeatureData('Frame', null, Icons.person_pin_circle_outlined), - _VipFeatureData('Mount', null, Icons.auto_awesome_motion), - _VipFeatureData('Mic Animation', null, Icons.radio_button_checked), - _VipFeatureData('Entry Effect', null, Icons.airline_seat_recline_extra), - ]; + Widget _buildDecorationGrid(List<_VipFeatureData> items) { return Wrap( spacing: 10.w, runSpacing: 10.w, @@ -620,7 +701,7 @@ class _VipDetailPageState extends State { .map( (item) => _buildFeatureTile( item, - width: item.title == 'Entry Effect' ? 206.w : 98.w, + width: item.wide ? 206.w : 98.w, height: 100.w, ), ) @@ -628,63 +709,293 @@ class _VipDetailPageState extends State { ); } - Widget _buildPrivilegeGrid() { - final items = [ - _VipFeatureData('Gif Profile\nPicture', null, Icons.badge_outlined), - _VipFeatureData('Colorful\nNickname', null, Icons.credit_card), - _VipFeatureData('Gif Room\nPicture', null, Icons.home_rounded), - _VipFeatureData('Colorful ID', null, Icons.pin_rounded), - ]; + Widget _buildPrivilegeGrid(List<_VipFeatureData> items) { return Wrap( spacing: 34.w, runSpacing: 24.w, children: items.asMap().entries.map((entry) { final item = entry.value; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: - () => SCNavigatorUtils.push( - context, - '${VipRoute.benefit}?index=${entry.key}', + return SizedBox( + width: 74.w, + child: Column( + children: [ + Container( + width: 46.w, + height: 46.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.08), + ), + alignment: Alignment.center, + child: _buildFeatureIcon(item, size: 23.w), ), - child: SizedBox( - width: 74.w, - child: Column( - children: [ - Container( - width: 46.w, - height: 46.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.08), - ), - alignment: Alignment.center, - child: Icon( - item.icon, - color: const Color(0xFFFF8E3D), - size: 23.w, - ), + SizedBox(height: 7.w), + Text( + item.title, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.w400, + height: 1.1, ), - SizedBox(height: 7.w), - Text( - item.title, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.w400, - height: 1.1, - ), - ), - ], - ), + ), + ], ), ); }).toList(), ); } + SCVipResourceRes? get _selectedBadge => _selectedResource( + fromConfig: (config) => config.badge, + fromState: (status) => status.badge, + ); + + SCVipResourceRes? get _selectedAvatarFrame => _selectedResource( + fromConfig: (config) => config.avatarFrame, + fromState: (status) => status.avatarFrame, + ); + + SCVipResourceRes? get _selectedEntryEffect => _selectedResource( + fromConfig: (config) => config.entryEffect, + fromState: (status) => status.entryEffect, + ); + + SCVipResourceRes? get _selectedChatBubble => _selectedResource( + fromConfig: (config) => config.chatBubble, + fromState: (status) => status.chatBubble, + ); + + SCVipResourceRes? get _selectedFloatPicture => _selectedResource( + fromConfig: (config) => config.floatPicture, + fromState: (status) => status.floatPicture, + ); + + SCVipResourceRes? _selectedResource({ + required SCVipResourceRes? Function(SCVipLevelConfigRes config) fromConfig, + required SCVipResourceRes? Function(SCVipStatusRes status) fromState, + }) { + final config = _selectedLevelConfig; + final state = _selectedLevelState; + return _preferResource( + config == null ? null : fromConfig(config), + state == null ? null : fromState(state), + ); + } + + SCVipResourceRes? _preferResource( + SCVipResourceRes? configResource, + SCVipResourceRes? stateResource, + ) { + if (_hasResourceUrl(configResource) || !_hasResourceUrl(stateResource)) { + return _hasResource(configResource) ? configResource : stateResource; + } + return stateResource; + } + + List<_VipFeatureData> get _decorationPrivilegeItems { + if (_selectedLevelConfig == null && _selectedLevelState == null) { + return const <_VipFeatureData>[]; + } + + final items = <_VipFeatureData>[]; + _addResourceItem( + items, + resource: _selectedBadge, + fallbackTitle: 'VIP Badge', + fallbackIcon: Icons.workspace_premium_outlined, + ); + _addResourceItem( + items, + resource: _selectedAvatarFrame, + fallbackTitle: 'Avatar Frame', + fallbackIcon: Icons.person_pin_circle_outlined, + ); + _addResourceItem( + items, + resource: _selectedChatBubble, + fallbackTitle: 'Chat Bubble', + fallbackIcon: Icons.chat_bubble_outline_rounded, + ); + _addResourceItem( + items, + resource: _selectedFloatPicture, + fallbackTitle: 'Float Picture', + fallbackIcon: Icons.filter_frames_outlined, + ); + _addResourceItem( + items, + resource: _selectedEntryEffect, + fallbackTitle: 'Entry Effect', + fallbackIcon: Icons.airline_seat_recline_extra, + wide: true, + ); + return items; + } + + List<_VipFeatureData> get _vipPrivilegeItems { + // The VIP home API currently returns resource perks only: + // badge/avatarFrame/entryEffect/chatBubble/floatPicture. + // Do not render demo-only functional privileges without backend data. + return const <_VipFeatureData>[]; + } + + void _addResourceItem( + List<_VipFeatureData> items, { + required SCVipResourceRes? resource, + required String fallbackTitle, + IconData? fallbackIcon, + bool wide = false, + }) { + if (!_hasResource(resource)) { + return; + } + items.add( + _VipFeatureData( + _resourceTitle(resource, fallbackTitle), + null, + icon: fallbackIcon, + resourceUrl: resource?.previewUrl, + wide: wide, + ), + ); + } + + bool _hasResource(SCVipResourceRes? resource) { + return resource?.hasContent == true; + } + + bool _hasResourceUrl(SCVipResourceRes? resource) { + return resource?.hasAnyUrl == true; + } + + String _resourceTitle(SCVipResourceRes? resource, String fallbackTitle) { + final name = resource?.name?.trim(); + if (name != null && name.isNotEmpty) { + return name; + } + return fallbackTitle; + } + + Widget _buildResourceImage({ + required SCVipResourceRes? resource, + required double width, + required double height, + required IconData fallbackIcon, + }) { + final url = resource?.previewUrl?.trim(); + if (url != null && url.isNotEmpty) { + return netImage( + url: url, + width: width, + height: height, + fit: BoxFit.contain, + noDefaultImg: true, + loadingWidget: SizedBox( + width: width, + height: height, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + errorWidget: _buildLargeResourcePlaceholder( + fallbackIcon, + width: width, + height: height, + ), + ); + } + + if (!_hasResource(resource)) { + return SizedBox(width: width, height: height); + } + + return _buildLargeResourcePlaceholder( + fallbackIcon, + width: width, + height: height, + ); + } + + Widget _buildLargeResourcePlaceholder( + IconData icon, { + required double width, + required double height, + }) { + return SizedBox( + width: width, + height: height, + child: Center( + child: Container( + width: (width * 0.48).clamp(46.w, 74.w).toDouble(), + height: (height * 0.48).clamp(46.w, 74.w).toDouble(), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.20), + border: Border.all( + color: const Color(0xFFFFD155).withValues(alpha: 0.45), + width: 1.w, + ), + ), + alignment: Alignment.center, + child: Icon(icon, color: const Color(0xFFFFD155), size: 32.w), + ), + ), + ); + } + + Widget _buildFeatureIcon(_VipFeatureData item, {required double size}) { + final url = item.resourceUrl?.trim(); + if (url != null && url.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(6.w), + child: netImage( + url: url, + width: size, + height: size, + fit: BoxFit.contain, + noDefaultImg: true, + errorWidget: Icon( + item.icon ?? Icons.auto_awesome, + color: const Color(0xFFFF8E3D), + size: size, + ), + ), + ); + } + + return Icon( + item.icon ?? Icons.auto_awesome, + color: const Color(0xFFFF8E3D), + size: size, + ); + } + + Widget _buildFeatureVisual(_VipFeatureData item, {required bool isWide}) { + final url = item.resourceUrl?.trim(); + if (url != null && url.isNotEmpty) { + return netImage( + url: url, + fit: BoxFit.contain, + noDefaultImg: true, + errorWidget: + item.assetPath != null + ? Image.asset(item.assetPath!, fit: BoxFit.contain) + : isWide + ? Center(child: _buildWideRewardPlaceholder()) + : _buildRewardPlaceholder(item.icon ?? Icons.auto_awesome), + ); + } + + if (item.assetPath != null) { + return Image.asset(item.assetPath!, fit: BoxFit.contain); + } + + return isWide + ? Center(child: _buildWideRewardPlaceholder()) + : _buildRewardPlaceholder(item.icon ?? Icons.auto_awesome); + } + Widget _buildRewardPlaceholder(IconData icon) { return Center( child: Container( @@ -749,7 +1060,7 @@ class _VipDetailPageState extends State { required double width, required double height, }) { - final isWide = item.title == 'Entry Effect'; + final isWide = item.wide; return Container( width: width, height: height, @@ -762,17 +1073,7 @@ class _VipDetailPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (item.assetPath != null) - Expanded(child: Image.asset(item.assetPath!, fit: BoxFit.contain)) - else - Expanded( - child: - isWide - ? Center(child: _buildWideRewardPlaceholder()) - : _buildRewardPlaceholder( - item.icon ?? Icons.auto_awesome, - ), - ), + Expanded(child: _buildFeatureVisual(item, isWide: isWide)), SizedBox(height: 5.w), Text( item.title, @@ -1107,11 +1408,19 @@ class _VipOrnamentLayout { } class _VipFeatureData { - const _VipFeatureData(this.title, this.assetPath, [this.icon]); + const _VipFeatureData( + this.title, + this.assetPath, { + this.icon, + this.resourceUrl, + this.wide = false, + }); final String title; final String? assetPath; final IconData? icon; + final String? resourceUrl; + final bool wide; } class _VipActionButtonConfig { diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 2fc2d41..11db985 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/app_localizations.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; @@ -13,6 +13,7 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository import 'package:yumi/services/room/rc_room_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; +import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:provider/provider.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; @@ -75,6 +76,30 @@ class _RoomStartupAsyncResult { } } +enum _PendingRoomStartupSeatActionType { clickSeat, goUpMic } + +class _PendingRoomStartupSeatAction { + const _PendingRoomStartupSeatAction.clickSeat({ + required this.index, + this.clickUser, + }) : type = _PendingRoomStartupSeatActionType.clickSeat, + eventType = null, + inviterId = null; + + const _PendingRoomStartupSeatAction.goUpMic({ + required this.index, + this.eventType, + this.inviterId, + }) : type = _PendingRoomStartupSeatActionType.goUpMic, + clickUser = null; + + final _PendingRoomStartupSeatActionType type; + final num index; + final SocialChatUserProfile? clickUser; + final String? eventType; + final String? inviterId; +} + class RealTimeCommunicationManager extends ChangeNotifier { static const Duration _micListPollingInterval = Duration(seconds: 2); static const Duration _onlineUsersPollingInterval = Duration(seconds: 3); @@ -86,6 +111,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { ); static const Duration _agoraJoinTimeout = Duration(seconds: 8); static const Duration _agoraDisconnectedGracePeriod = Duration(seconds: 12); + static const String _roomStartupSeatLoadingTag = + 'roomStartupSeatInteractionLoading'; + static const String _selfMicGoUpRetryLoadingTag = 'selfMicGoUpRetryLoading'; + static const int _selfMicGoUpFailureLimit = 3; bool needUpDataUserInfo = false; bool _roomVisualEffectsEnabled = false; @@ -113,6 +142,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool _disableMicListRefreshForCurrentSession = false; bool _disableOnlineUsersRefreshForCurrentSession = false; bool _isSelfMicActionInFlight = false; + bool _roomStartupSeatLoadingVisible = false; + bool _selfMicGoUpRetryLoadingVisible = false; + int _selfMicGoUpConsecutiveFailureCount = 0; + _PendingRoomStartupSeatAction? _pendingRoomStartupSeatAction; bool _pendingMicListRefresh = false; bool _pendingMicListRefreshNotifyIfUnchanged = false; Timer? _deferredMicListRefreshTimer; @@ -157,6 +190,22 @@ class RealTimeCommunicationManager extends ChangeNotifier { ///禁音开关 默认关闭 bool isMic = true; + bool shouldShowSeatSoundWave(num index) { + final seat = roomWheatMap[index]; + final userId = (seat?.user?.id ?? "").trim(); + if (userId.isEmpty || (seat?.micMute ?? false)) { + return false; + } + + final currentUserId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + if (userId == currentUserId && isMic) { + return false; + } + + return true; + } + ///在线用户列表 List onlineUsers = []; @@ -191,6 +240,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool get shouldShowRoomVisualEffects => currenRoom != null && _roomVisualEffectsEnabled; + bool get _shouldDeferSeatInteractionForRoomStartup => + _roomStartupStatus == RoomStartupStatus.loading; + void setRoomVisualEffectsEnabled(bool enabled) { if (_roomVisualEffectsEnabled == enabled) { return; @@ -217,6 +269,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } _roomStartupStatus = RoomStartupStatus.ready; notifyListeners(); + _finishRoomStartupSeatLoading(executePendingAction: true); } void _setRoomStartupFailed(RoomStartupFailureType failureType) { @@ -225,6 +278,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomStartupFailureType = failureType; _roomStartupFailureToken += 1; _roomStartupStatus = RoomStartupStatus.failed; + _finishRoomStartupSeatLoading(executePendingAction: false); notifyListeners(); } @@ -238,6 +292,125 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } + void _deferRoomStartupSeatAction(_PendingRoomStartupSeatAction action) { + _pendingRoomStartupSeatAction = action; + _showRoomStartupSeatLoading(); + } + + void _showRoomStartupSeatLoading() { + if (_roomStartupSeatLoadingVisible) { + return; + } + _roomStartupSeatLoadingVisible = true; + SmartDialog.dismiss(tag: _roomStartupSeatLoadingTag); + SmartDialog.show( + tag: _roomStartupSeatLoadingTag, + alignment: Alignment.center, + animationType: SmartAnimationType.fade, + backType: SmartBackType.block, + clickMaskDismiss: false, + maskColor: Colors.transparent, + builder: (_) { + return Center( + child: Container( + width: 55, + height: 55, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8), + ), + child: const Center(child: SCRotatingDotsLoading(size: 28)), + ), + ); + }, + ); + } + + void _showSelfMicGoUpRetryLoading() { + if (_selfMicGoUpRetryLoadingVisible) { + return; + } + _selfMicGoUpRetryLoadingVisible = true; + SmartDialog.dismiss(tag: _selfMicGoUpRetryLoadingTag); + SmartDialog.show( + tag: _selfMicGoUpRetryLoadingTag, + alignment: Alignment.center, + animationType: SmartAnimationType.fade, + backType: SmartBackType.normal, + clickMaskDismiss: true, + maskColor: Colors.transparent, + onDismiss: () { + _selfMicGoUpRetryLoadingVisible = false; + }, + builder: (_) { + return Center( + child: Container( + width: 55, + height: 55, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8), + ), + child: const Center(child: SCRotatingDotsLoading(size: 28)), + ), + ); + }, + ); + } + + void _dismissSelfMicGoUpRetryLoading() { + if (!_selfMicGoUpRetryLoadingVisible) { + return; + } + _selfMicGoUpRetryLoadingVisible = false; + SmartDialog.dismiss(tag: _selfMicGoUpRetryLoadingTag); + } + + void _resetSelfMicGoUpFailureState() { + _selfMicGoUpConsecutiveFailureCount = 0; + _dismissSelfMicGoUpRetryLoading(); + } + + void _handleSelfMicGoUpFailure(String message) { + _selfMicGoUpConsecutiveFailureCount += 1; + if (_selfMicGoUpConsecutiveFailureCount >= _selfMicGoUpFailureLimit) { + SCTts.show( + 'Failed to put on the microphone after multiple attempts, please try again later', + ); + return; + } + SCTts.show(message); + } + + void _finishRoomStartupSeatLoading({required bool executePendingAction}) { + final pendingAction = _pendingRoomStartupSeatAction; + _pendingRoomStartupSeatAction = null; + if (_roomStartupSeatLoadingVisible) { + _roomStartupSeatLoadingVisible = false; + SmartDialog.dismiss(tag: _roomStartupSeatLoadingTag); + } + if (!executePendingAction || pendingAction == null) { + return; + } + Future.microtask(() { + if (_roomStartupStatus != RoomStartupStatus.ready) { + return; + } + switch (pendingAction.type) { + case _PendingRoomStartupSeatActionType.clickSeat: + clickSite(pendingAction.index, clickUser: pendingAction.clickUser); + break; + case _PendingRoomStartupSeatActionType.goUpMic: + shangMai( + pendingAction.index, + eventType: pendingAction.eventType, + inviterId: pendingAction.inviterId, + ); + break; + } + }); + } + void _startRoomStatePolling() { _stopRoomStatePolling(); if ((currenRoom?.roomProfile?.roomProfile?.id ?? "").isEmpty) { @@ -1188,6 +1361,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { Future setSelfMicMutedFromBottom(bool muted) async { isMic = muted; + notifyListeners(); final currentUserId = (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); final seatIndex = userOnMaiInIndex(currentUserId); @@ -1523,6 +1697,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (seatIndex == null) { continue; } + if (!shouldShowSeatSoundWave(seatIndex)) { + continue; + } roomWheatMap[seatIndex]?.setVolume = volume; for (final listener in listeners) { @@ -1640,6 +1817,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { return; } rtmProvider = Provider.of(context, listen: false); + _clearRoomContributionLevelData(context); rtmProvider?.seedSystemRoomTips( SCAppLocalizations.of(context)!.systemRoomTips, ); @@ -2205,6 +2383,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { void _clearData() { _roomEntryRequestSerial += 1; _previewRoomSeatCount = null; + _finishRoomStartupSeatLoading(executePendingAction: false); + _resetSelfMicGoUpFailureState(); + _clearRoomContributionLevelData(); _stopRoomStatePolling(); _resetHeartbeatTracking(); _resetAgoraTracking(); @@ -2245,6 +2426,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { SCGlobalConfig.resetVisualEffectSwitchesToRecommendedDefaults(); } + void _clearRoomContributionLevelData([BuildContext? targetContext]) { + final currentContext = targetContext ?? context; + if (currentContext == null || !currentContext.mounted) { + return; + } + Provider.of( + currentContext, + listen: false, + ).clearContributionLevelData(); + } + @override void dispose() { _stopRoomStatePolling(); @@ -2260,6 +2452,16 @@ class RealTimeCommunicationManager extends ChangeNotifier { ///点击的位置 void clickSite(num index, {SocialChatUserProfile? clickUser}) { + if (_shouldDeferSeatInteractionForRoomStartup) { + _deferRoomStartupSeatAction( + _PendingRoomStartupSeatAction.clickSeat( + index: index, + clickUser: clickUser, + ), + ); + return; + } + if (_handleDirectSeatInteraction(index, clickUser: clickUser)) { return; } @@ -2396,6 +2598,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { } void shangMai(num index, {String? eventType, String? inviterId}) async { + if (_shouldDeferSeatInteractionForRoomStartup) { + _deferRoomStartupSeatAction( + _PendingRoomStartupSeatAction.goUpMic( + index: index, + eventType: eventType, + inviterId: inviterId, + ), + ); + return; + } + var myUser = AccountStorage().getCurrentUser()?.userProfile; if (currenRoom?.roomProfile?.roomSetting?.touristMike ?? false) { } else { @@ -2407,9 +2620,16 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } + final shouldShowRetryLoading = _selfMicGoUpConsecutiveFailureCount > 0; if (_isSelfMicActionInFlight) { + if (shouldShowRetryLoading) { + _showSelfMicGoUpRetryLoading(); + } return; } + if (shouldShowRetryLoading) { + _showSelfMicGoUpRetryLoading(); + } _isSelfMicActionInFlight = true; try { var micGoUpRes = await SCChatRoomRepository().micGoUp( @@ -2441,7 +2661,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { _syncSelfMicRuntimeState(); notifyListeners(); _refreshMicListSilently(notifyIfUnchanged: true); - SCTts.show('Failed to put on the microphone, Agora switch failed'); + _handleSelfMicGoUpFailure( + 'Failed to put on the microphone, Agora switch failed', + ); return; } @@ -2489,13 +2711,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { notifyListeners(); _refreshMicListSilently(notifyIfUnchanged: true); + _resetSelfMicGoUpFailureState(); } catch (ex) { if (_isCancelledRequest(ex)) { _refreshMicListSilently(notifyIfUnchanged: true); return; } - SCTts.show('Failed to put on the microphone, ${_errorMessageFrom(ex)}'); + _handleSelfMicGoUpFailure( + 'Failed to put on the microphone, ${_errorMessageFrom(ex)}', + ); } finally { + _dismissSelfMicGoUpRetryLoading(); _isSelfMicActionInFlight = false; } } diff --git a/lib/services/room/rc_room_manager.dart b/lib/services/room/rc_room_manager.dart index 1d7702a..07ba832 100644 --- a/lib/services/room/rc_room_manager.dart +++ b/lib/services/room/rc_room_manager.dart @@ -9,11 +9,12 @@ import 'package:yumi/shared/business_logic/models/res/sc_edit_room_info_res.dart import 'package:yumi/shared/business_logic/models/res/my_room_res.dart'; import 'package:yumi/shared/business_logic/models/res/sc_room_contribute_level_res.dart'; -class SocialChatRoomManager extends ChangeNotifier { - MyRoomRes? myRoom; - SCRoomContributeLevelRes? roomContributeLevelRes; - bool isMyRoomLoading = false; - bool hasLoadedMyRoom = false; +class SocialChatRoomManager extends ChangeNotifier { + MyRoomRes? myRoom; + SCRoomContributeLevelRes? roomContributeLevelRes; + String? _roomContributeLevelRoomId; + bool isMyRoomLoading = false; + bool hasLoadedMyRoom = false; Future fetchMyRoomData() async { if (isMyRoomLoading) { @@ -73,13 +74,41 @@ class SocialChatRoomManager extends ChangeNotifier { SCLoadingManager.hide(); } - void fetchContributionLevelData(String roomId, {bool forceRefresh = false}) { - if (forceRefresh) { - roomContributeLevelRes = null; - } - SCChatRoomRepository().roomContributionActivity(roomId).then((value) { - roomContributeLevelRes = value; - notifyListeners(); - }); - } -} + void clearContributionLevelData({bool notify = true}) { + _roomContributeLevelRoomId = null; + if (roomContributeLevelRes == null) { + return; + } + roomContributeLevelRes = null; + if (notify) { + notifyListeners(); + } + } + + void fetchContributionLevelData(String roomId, {bool forceRefresh = false}) { + final resolvedRoomId = roomId.trim(); + if (resolvedRoomId.isEmpty) { + clearContributionLevelData(); + return; + } + + final shouldClear = + forceRefresh || _roomContributeLevelRoomId != resolvedRoomId; + _roomContributeLevelRoomId = resolvedRoomId; + if (shouldClear && roomContributeLevelRes != null) { + roomContributeLevelRes = null; + notifyListeners(); + } + + SCChatRoomRepository() + .roomContributionActivity(resolvedRoomId) + .then((value) { + if (_roomContributeLevelRoomId != resolvedRoomId) { + return; + } + roomContributeLevelRes = value; + notifyListeners(); + }) + .catchError((_) {}); + } +} diff --git a/lib/shared/business_logic/models/res/sc_vip_res.dart b/lib/shared/business_logic/models/res/sc_vip_res.dart index 9335686..ad941a9 100644 --- a/lib/shared/business_logic/models/res/sc_vip_res.dart +++ b/lib/shared/business_logic/models/res/sc_vip_res.dart @@ -1,8 +1,20 @@ class SCVipResourceRes { - SCVipResourceRes({String? resourceId, String? name, String? url}) { + SCVipResourceRes({ + String? resourceId, + String? name, + String? url, + String? sourceUrl, + String? resourceUrl, + String? cover, + String? coverUrl, + }) { _resourceId = resourceId; _name = name; _url = url; + _sourceUrl = sourceUrl; + _resourceUrl = resourceUrl; + _cover = cover; + _coverUrl = coverUrl; } SCVipResourceRes.fromJson(dynamic json) { @@ -11,11 +23,19 @@ class SCVipResourceRes { _resourceId = _stringValue(map['resourceId']); _name = _stringValue(map['name']); _url = _stringValue(map['url']); + _sourceUrl = _stringValue(map['sourceUrl']); + _resourceUrl = _stringValue(map['resourceUrl']); + _cover = _stringValue(map['cover']); + _coverUrl = _stringValue(map['coverUrl']); } String? _resourceId; String? _name; String? _url; + String? _sourceUrl; + String? _resourceUrl; + String? _cover; + String? _coverUrl; String? get resourceId => _resourceId; @@ -23,11 +43,57 @@ class SCVipResourceRes { String? get url => _url; + String? get sourceUrl => _sourceUrl; + + String? get resourceUrl => _resourceUrl; + + String? get cover => _cover; + + String? get coverUrl => _coverUrl; + + String? get previewUrl { + final cover = _firstNonBlank([_coverUrl, _cover]); + if (cover != null) return cover; + + final staticUrl = _firstNonBlank( + [_url, _resourceUrl, _sourceUrl].where((value) { + final text = value?.trim(); + return text != null && text.isNotEmpty && !_isSvgaUrl(text); + }), + ); + if (staticUrl != null) return staticUrl; + + return _firstNonBlank([_url, _resourceUrl, _sourceUrl]); + } + + String? get sourceResourceUrl => + _firstNonBlank([_sourceUrl, _resourceUrl, _url]); + + bool get hasIdentity { + final id = _resourceId?.trim(); + return id != null && id.isNotEmpty && id != '0'; + } + + bool get hasAnyUrl => + _firstNonBlank([_url, _sourceUrl, _resourceUrl, _cover, _coverUrl]) != + null; + + bool get hasPreviewUrl => previewUrl != null; + + bool get hasSourceUrl => sourceResourceUrl != null; + + bool get hasContent => + hasAnyUrl || hasIdentity || (_name?.trim().isNotEmpty ?? false); + Map toJson() { final map = {}; map['resourceId'] = _resourceId; map['name'] = _name; map['url'] = _url; + map['sourceUrl'] = _sourceUrl; + map['resourceUrl'] = _resourceUrl; + map['cover'] = _cover; + map['coverUrl'] = _coverUrl; return map; } } @@ -660,3 +726,18 @@ bool? _boolValue(dynamic value) { if (lower == 'false') return false; return null; } + +String? _firstNonBlank(Iterable values) { + for (final value in values) { + final text = value?.trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return null; +} + +bool _isSvgaUrl(String value) { + final lower = value.toLowerCase(); + return lower.endsWith('.svga') || lower.contains('.svga?'); +} diff --git a/lib/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart index 1466e96..cdf5069 100644 --- a/lib/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart @@ -213,11 +213,11 @@ class SCVipRepositoryImp implements SocialChatVipRepository { 'level': result.level, 'levelCode': result.levelCode, 'expireAt': result.expireAt, - 'hasBadge': result.badge?.url?.isNotEmpty == true, - 'hasAvatarFrame': result.avatarFrame?.url?.isNotEmpty == true, - 'hasEntryEffect': result.entryEffect?.url?.isNotEmpty == true, - 'hasChatBubble': result.chatBubble?.url?.isNotEmpty == true, - 'hasFloatPicture': result.floatPicture?.url?.isNotEmpty == true, + 'badge': _resourceSummary(result.badge), + 'avatarFrame': _resourceSummary(result.avatarFrame), + 'entryEffect': _resourceSummary(result.entryEffect), + 'chatBubble': _resourceSummary(result.chatBubble), + 'floatPicture': _resourceSummary(result.floatPicture), }; } @@ -237,13 +237,31 @@ class SCVipRepositoryImp implements SocialChatVipRepository { 'priceGold': level.priceGold, 'canPurchase': level.canPurchase, 'payableGold': level.payableGold, - 'hasBadge': level.badge?.url?.isNotEmpty == true, + 'badge': _resourceSummary(level.badge), + 'avatarFrame': _resourceSummary(level.avatarFrame), + 'entryEffect': _resourceSummary(level.entryEffect), + 'chatBubble': _resourceSummary(level.chatBubble), + 'floatPicture': _resourceSummary(level.floatPicture), }, ) .toList(), }; } + static Map? _resourceSummary(SCVipResourceRes? resource) { + if (resource?.hasContent != true) { + return null; + } + return { + 'id': resource?.resourceId, + 'name': resource?.name, + 'hasPreviewUrl': resource?.hasPreviewUrl == true, + 'hasSourceUrl': resource?.hasSourceUrl == true, + 'previewUrl': resource?.previewUrl, + 'sourceUrl': resource?.sourceResourceUrl, + }; + } + static Map _previewSummary(SCVipPreviewRes result) { return { 'orderType': result.orderType,