幸运礼物相关UI 逻辑补全

This commit is contained in:
NIGGER SLAYER 2026-04-18 19:00:19 +08:00
parent f5bab34f19
commit 7d9893b4ef
47 changed files with 4165 additions and 1848 deletions

View File

@ -14,6 +14,7 @@ import 'package:yumi/app/constants/sc_screen.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_date_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
@ -633,6 +634,11 @@ class _SCEditProfilePageState extends State<SCEditProfilePage> {
}
AccountStorage().setCurrentUser(user);
userProvider?.syncCurrentUserProfile(user.userProfile);
await DataPersistence.clearPendingRegisterRewardDialog();
await DataPersistence.setAwaitRegisterRewardSocket(true);
if (!mounted) {
return;
}
SCLoadingManager.hide();
SCNavigatorUtils.push(context, SCRoutes.home, clearStack: true);
}

View File

@ -38,6 +38,7 @@ import 'package:yumi/ui_kit/widgets/gift/sc_gift_combo_send_button.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
import 'package:yumi/modules/wallet/wallet_route.dart';
import 'package:yumi/modules/gift/gift_tab_page.dart';
import 'package:yumi/services/gift/room_gift_combo_send_controller.dart';
import '../../shared/data_sources/models/enum/sc_gift_type.dart';
import '../../shared/data_sources/models/message/sc_floating_message.dart';
import '../../shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart';
@ -59,7 +60,6 @@ class GiftPage extends StatefulWidget {
}
class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
static const Duration _comboFeedbackDuration = Duration(seconds: 3);
static const Duration _comboSendBatchWindow = Duration(milliseconds: 200);
static const List<String> _preferredGiftTabOrder = <String>[
@ -102,12 +102,10 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
int giftType = 0;
Debouncer debouncer = Debouncer();
late final AnimationController _comboFeedbackController;
final ListQueue<_PendingGiftSendBatch> _comboSendBatchQueue =
ListQueue<_PendingGiftSendBatch>();
Timer? _comboSendBatchTimer;
bool _isComboSendBatchInFlight = false;
bool _showComboFeedback = false;
void _giftFxLog(String message) {
debugPrint('[GiftFX][Send] $message');
@ -213,16 +211,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
@override
void initState() {
super.initState();
_comboFeedbackController = AnimationController(
vsync: this,
duration: _comboFeedbackDuration,
)..addStatusListener((status) {
if (status == AnimationStatus.completed && mounted) {
setState(() {
_showComboFeedback = false;
});
}
});
rtcProvider = Provider.of<RtcProvider>(context, listen: false);
Provider.of<SCAppGeneralManager>(context, listen: false).giftList();
Provider.of<SCAppGeneralManager>(context, listen: false).giftActivityList();
@ -253,7 +241,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
}
_tabController?.removeListener(_handleTabChanged);
_tabController?.dispose();
_comboFeedbackController.dispose();
super.dispose();
}
@ -1158,18 +1145,27 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
return;
}
_activateComboFeedback(
gift: request.gift,
quantity: request.quantity,
acceptUserIds: request.acceptUserIds,
RoomGiftComboSendController().show(
request: RoomGiftComboSendRequest(
acceptUserIds: List<String>.from(request.acceptUserIds),
acceptUsers: List<MicRes>.from(request.acceptUsers),
gift: request.gift,
quantity: request.quantity,
clickCount: request.clickCount,
roomId: request.roomId,
roomAccount: request.roomAccount,
isLuckyGiftRequest: request.isLuckyGiftRequest,
),
executor: _performFloatingComboSend,
);
if (_supportsComboRequestBatching(request.gift)) {
_enqueueComboGiftSendRequest(request);
return;
} else {
unawaited(_performGiftSend(request, trigger: 'direct'));
}
await _performGiftSend(request, trigger: 'direct');
SmartDialog.dismiss(tag: "showGiftControl");
}
bool _supportsComboRequestBatching(SocialChatGiftRes gift) {
@ -1509,21 +1505,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
}
}
void _activateComboFeedback({
required SocialChatGiftRes gift,
required int quantity,
required List<String> acceptUserIds,
}) {
if (!_supportsComboFeedback(gift)) {
return;
}
setState(() {
_showComboFeedback = true;
});
_comboFeedbackController.forward(from: 0);
}
bool _supportsComboFeedback(SocialChatGiftRes gift) {
final giftTab = (gift.giftTab ?? '').trim();
return giftTab == "LUCK" ||
@ -1536,12 +1517,30 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
return SCGiftComboSendButton(
label: SCAppLocalizations.of(context)!.send,
onPressed: giveGifts,
showCountdown: _showComboFeedback,
countdownAnimation: _comboFeedbackController,
showCountdown: false,
width: 96.w,
);
}
Future<void> _performFloatingComboSend(
RoomGiftComboSendRequest request, {
required String trigger,
}) async {
await _performGiftSend(
_GiftSendRequest(
acceptUserIds: List<String>.from(request.acceptUserIds),
acceptUsers: List<MicRes>.from(request.acceptUsers),
gift: request.gift,
quantity: request.quantity,
clickCount: request.clickCount,
roomId: request.roomId,
roomAccount: request.roomAccount,
isLuckyGiftRequest: request.isLuckyGiftRequest,
),
trigger: trigger,
);
}
/// giftType转换为字符串类型
String _giftTypeToString(int giftType) {
switch (giftType) {

View File

@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_res.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/modules/home/index_home_page.dart';
import 'package:yumi/modules/chat/message/sc_message_page.dart';
@ -16,14 +19,15 @@ import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
import '../../shared/tools/sc_heartbeat_utils.dart';
import '../../shared/tools/sc_lk_event_bus.dart';
import '../../shared/data_sources/models/enum/sc_heartbeat_status.dart';
import '../../ui_kit/components/sc_float_ichart.dart';
import '../../ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart';
import '../../ui_kit/widgets/register_reward/register_reward_dialog.dart';
import '../home/popular/event/home_event_page.dart';
import '../user/me_page2.dart';
/**
*
*/
///
class SCIndexPage extends StatefulWidget {
const SCIndexPage({super.key});
@ -32,15 +36,26 @@ class SCIndexPage extends StatefulWidget {
}
class _SCIndexPageState extends State<SCIndexPage> {
static const Duration _registerRewardSocketWaitTimeout = Duration(seconds: 6);
int _currentIndex = 0;
final List<Widget> _pages = [];
final List<BottomNavigationBarItem> _bottomItems = [];
SCAppGeneralManager? generalProvider;
Locale? _lastLocale;
bool _hasShownEntryDialogs = false;
bool _hasShownRegisterRewardDialog = false;
bool _isShowingDailySignInDialog = false;
bool _showRegisterRewardAfterDailySignIn = false;
StreamSubscription<RegisterRewardGrantedEvent>? _registerRewardSubscription;
Completer<void>? _registerRewardSocketCompleter;
@override
void initState() {
super.initState();
_registerRewardSubscription = eventBus
.on<RegisterRewardGrantedEvent>()
.listen(_onRegisterRewardGranted);
_initializePages();
generalProvider = Provider.of<SCAppGeneralManager>(context, listen: false);
Provider.of<RtcProvider>(
@ -58,6 +73,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
OverlayManager().activate();
WidgetsBinding.instance.addPostFrameCallback((_) {
WakelockPlus.enable();
_showEntryDialogs();
});
String roomId = DataPersistence.getLastTimeRoomId();
if (roomId.isNotEmpty) {
@ -69,6 +85,7 @@ class _SCIndexPageState extends State<SCIndexPage> {
@override
void dispose() {
_registerRewardSubscription?.cancel();
WakelockPlus.disable();
super.dispose();
}
@ -82,8 +99,17 @@ class _SCIndexPageState extends State<SCIndexPage> {
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: _doubleExit,
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
return;
}
final shouldPop = await _doubleExit();
if (shouldPop && context.mounted) {
Navigator.of(context).pop(result);
}
},
child: Stack(
children: [
Image.asset(
@ -287,6 +313,113 @@ class _SCIndexPageState extends State<SCIndexPage> {
);
}
Future<void> _showEntryDialogs() async {
if (_hasShownEntryDialogs || !mounted) {
return;
}
_hasShownEntryDialogs = true;
await _waitForRegisterRewardSocketIfNeeded();
if (!mounted) {
return;
}
await _showRegisterRewardDialogIfNeeded();
if (!mounted) {
return;
}
final dialogData = await _loadDailySignInDialogData();
if (!mounted) {
return;
}
_isShowingDailySignInDialog = true;
await DailySignInDialog.show(context, data: dialogData);
_isShowingDailySignInDialog = false;
if (!mounted) {
return;
}
if (_showRegisterRewardAfterDailySignIn) {
_showRegisterRewardAfterDailySignIn = false;
await _showRegisterRewardDialogIfNeeded();
}
}
Future<void> _waitForRegisterRewardSocketIfNeeded() async {
if (!DataPersistence.getAwaitRegisterRewardSocket() ||
DataPersistence.getPendingRegisterRewardDialog()) {
return;
}
final completer = Completer<void>();
_registerRewardSocketCompleter = completer;
await Future.any([
completer.future,
Future<void>.delayed(_registerRewardSocketWaitTimeout),
]);
if (identical(_registerRewardSocketCompleter, completer)) {
_registerRewardSocketCompleter = null;
}
if (!DataPersistence.getPendingRegisterRewardDialog()) {
await DataPersistence.clearAwaitRegisterRewardSocket();
}
}
Future<void> _showRegisterRewardDialogIfNeeded() async {
if (_hasShownRegisterRewardDialog ||
!DataPersistence.getPendingRegisterRewardDialog()) {
return;
}
await DataPersistence.clearPendingRegisterRewardDialog();
await DataPersistence.clearAwaitRegisterRewardSocket();
if (!mounted) {
return;
}
_hasShownRegisterRewardDialog = true;
await RegisterRewardDialog.show(context);
}
void _onRegisterRewardGranted(RegisterRewardGrantedEvent event) {
final completer = _registerRewardSocketCompleter;
if (completer != null && !completer.isCompleted) {
completer.complete();
}
if (!mounted || _hasShownRegisterRewardDialog) {
return;
}
if (_isShowingDailySignInDialog) {
_showRegisterRewardAfterDailySignIn = true;
return;
}
if (_hasShownEntryDialogs) {
unawaited(_showRegisterRewardDialogIfNeeded());
}
}
Future<DailySignInDialogData> _loadDailySignInDialogData() async {
DailySignInDialogData dialogData;
try {
final result = await Future.wait<dynamic>([
SCAccountRepository().checkInToday(),
SCAccountRepository().sginListAward(),
]);
final checkedToday = result[0] as bool;
final signInRes = result[1] as SCSignInRes;
dialogData = DailySignInDialogData.fromLegacy(
signInRes: signInRes,
checkedToday: checkedToday,
);
} catch (_) {
dialogData = DailySignInDialogData.mock();
}
return dialogData;
}
Widget _buildBottomTabIcon({
required bool active,
required String svgaPath,

View File

@ -1,6 +1,3 @@
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
@ -8,12 +5,15 @@ import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_screen.dart';
import 'package:yumi/main.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import '../../../ui_kit/components/sc_compontent.dart';
import '../../../ui_kit/components/sc_page_list.dart';
import '../../../ui_kit/components/sc_tts.dart';
import '../../../ui_kit/components/text/sc_text.dart';
import '../../../shared/tools/sc_lk_dialog_util.dart';
import '../../../ui_kit/widgets/room/room_user_info_card.dart';
///线
class RoomOnlinePage extends SCPageList {
@ -31,6 +31,21 @@ class _RoomOnlinePageState
_RoomOnlinePageState(this.roomId);
void _openUserCard(SocialChatUserProfile userInfo) {
final userId = (userInfo.id ?? '').trim();
if (userId.isEmpty) {
return;
}
Navigator.of(context).pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
final dialogContext = navigatorKey.currentState?.context;
if (dialogContext == null) {
return;
}
showBottomInCenterDialog(dialogContext, RoomUserInfoCard(userId: userId));
});
}
@override
void initState() {
super.initState();
@ -87,15 +102,7 @@ class _RoomOnlinePageState
headdress: userInfo.getHeaddress()?.sourceUrl,
),
onTap: () {
Navigator.of(context).pop();
num index = Provider.of<RtcProvider>(
context,
listen: false,
).userOnMaiInIndex(userInfo.id ?? "");
Provider.of<RtcProvider>(
context,
listen: false,
).clickSite(index, clickUser: userInfo);
_openUserCard(userInfo);
},
),
SizedBox(width: 3.w),
@ -163,7 +170,9 @@ class _RoomOnlinePageState
Clipboard.setData(
ClipboardData(text: userInfo.getID() ?? ""),
);
SCTts.show(SCAppLocalizations.of(context)!.copiedToClipboard);
SCTts.show(
SCAppLocalizations.of(context)!.copiedToClipboard,
);
},
),
],
@ -171,7 +180,9 @@ class _RoomOnlinePageState
],
),
),
onTap: () {},
onTap: () {
_openUserCard(userInfo);
},
);
}
@ -218,7 +229,10 @@ class _RoomOnlinePageState
Function? onErr,
}) async {
// var roomList = await SCChatRoomRepository().roomOnlineUsers(roomId ?? "");
await Provider.of<RtcProvider>(context!, listen: false).fetchOnlineUsersList();
await Provider.of<RtcProvider>(
context!,
listen: false,
).fetchOnlineUsersList();
List<SocialChatUserProfile> userList =
Provider.of<RtcProvider>(context!, listen: false).onlineUsers;
onSuccess(userList);

View File

@ -27,6 +27,9 @@ class SCSeatItem extends StatefulWidget {
}
class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
static const String _seatHeartbeatValueIconAsset =
"sc_images/room/sc_icon_room_seat_heartbeat_value.png";
RtcProvider? provider;
JoinRoomRes? room;
MicRes? roomSeat;
@ -110,34 +113,60 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
widget.isGameModel
? Container()
: (roomSeat?.user != null
? Container(
? SizedBox(
width: 64.w,
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
msgRoleTag(
roomSeat?.user?.roles ?? "",
width: 15.w,
height: 15.w,
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
msgRoleTag(
roomSeat?.user?.roles ?? "",
width: 15.w,
height: 15.w,
),
Flexible(
child: socialchatNickNameText(
fontWeight: FontWeight.w600,
roomSeat?.user?.userNickname ?? "",
fontSize: 10.sp,
type: roomSeat?.user?.getVIP()?.name ?? "",
needScroll:
(roomSeat
?.user
?.userNickname
?.characters
.length ??
0) >
8,
),
),
],
),
//
Flexible(
child: socialchatNickNameText(
fontWeight: FontWeight.w600,
roomSeat?.user?.userNickname ?? "",
fontSize: 10.sp,
type: roomSeat?.user?.getVIP()?.name ?? "",
needScroll:
(roomSeat
?.user
?.userNickname
?.characters
.length ??
0) >
8,
SizedBox(height: 2.w),
SizedBox(
height: 10.w,
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(),
fontWeight: FontWeight.w600,
fontSize: 8.sp,
lineHeight: 1,
),
],
),
),
],
@ -155,32 +184,6 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
),
],
)),
widget.isGameModel
? Container()
: (room?.roomProfile?.roomSetting?.showHeartbeat == true
? Container(
padding: EdgeInsets.symmetric(
vertical: 1.w,
horizontal: 5.w,
),
margin: EdgeInsets.symmetric(horizontal: 5.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
"sc_images/room/sc_icon_gift_heartbeat.png",
width: 13.w,
),
SizedBox(width: 3.w),
text(
_heartbeatVaFormat(),
fontWeight: FontWeight.w600,
fontSize: 10.sp,
),
],
),
)
: Container()),
],
),
onTap: () {
@ -194,7 +197,10 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
String _heartbeatVaFormat() {
int value = (roomSeat?.user?.heartbeatVal ?? 0).toInt();
if (value > 99999) {
if (value >= 1000000) {
return "${(value / 1000000).toStringAsFixed(1)}M";
}
if (value >= 10000) {
return "${(value / 1000).toStringAsFixed(0)}k";
}
return "$value";

View File

@ -101,8 +101,10 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
context,
listen: false,
).setRoomVisualEffectsEnabled(true);
Provider.of<RtmProvider>(context, listen: false).msgFloatingGiftListener =
_floatingGiftListener;
final rtmProvider = Provider.of<RtmProvider>(context, listen: false);
rtmProvider.msgFloatingGiftListener = _floatingGiftListener;
rtmProvider.msgLuckyGiftRewardTickerListener =
_luckyGiftRewardTickerListener;
}
void _ensureRoomVisualEffectsEnabled() {
@ -122,6 +124,10 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) {
rtmProvider.msgFloatingGiftListener = null;
}
if (rtmProvider.msgLuckyGiftRewardTickerListener ==
_luckyGiftRewardTickerListener) {
rtmProvider.msgLuckyGiftRewardTickerListener = null;
}
RoomEntranceHelper.clearQueue();
_clearLuckyGiftComboSessions();
@ -320,15 +326,12 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
return;
}
var giftModel = LGiftModel();
final giftTab = (msg.gift?.giftTab ?? '').trim();
giftModel.labelId = "${msg.gift?.id}${msg.user?.id}${msg.toUser?.id}";
giftModel.sendUserName = msg.user?.userNickname ?? "";
giftModel.sendToUserName = msg.toUser?.userNickname ?? "";
giftModel.sendUserPic = msg.user?.userAvatar ?? "";
giftModel.giftPic = msg.gift?.giftPhoto ?? "";
giftModel.giftCount = msg.number ?? 0;
giftModel.isLuckyGift =
giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name;
Provider.of<GiftAnimationManager>(
context,
listen: false,
@ -347,6 +350,46 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_handleStandardGiftComboVisuals(msg, giftPhoto, targetUserId);
}
void _luckyGiftRewardTickerListener(Msg msg) {
if (!Provider.of<RtcProvider>(
context,
listen: false,
).shouldShowRoomVisualEffects) {
return;
}
if (Provider.of<GiftProvider>(context, listen: false).hideLGiftAnimal) {
return;
}
final awardAmount = msg.awardAmount ?? 0;
if (awardAmount <= 0) {
return;
}
final giftModel = LGiftModel();
giftModel.labelId = "${msg.gift?.id}${msg.user?.id}${msg.toUser?.id}";
giftModel.sendUserName = msg.user?.userNickname ?? "";
giftModel.sendToUserName = msg.toUser?.userNickname ?? "";
giftModel.sendUserPic = msg.user?.userAvatar ?? "";
giftModel.giftPic = msg.gift?.giftPhoto ?? "";
giftModel.giftCount = 0;
giftModel.showLuckyRewardFrame = true;
giftModel.rewardAmountText = _formatLuckyRewardAmount(awardAmount);
Provider.of<GiftAnimationManager>(
context,
listen: false,
).enqueueGiftAnimation(giftModel);
}
String _formatLuckyRewardAmount(num awardAmount) {
if (awardAmount > 9999) {
return "${(awardAmount / 1000).toStringAsFixed(0)}k";
}
if (awardAmount % 1 == 0) {
return awardAmount.toInt().toString();
}
return awardAmount.toString();
}
bool _isLuckyGiftMessage(Msg msg) {
final giftTab = (msg.gift?.giftTab ?? '').trim();
return giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name;

View File

@ -665,6 +665,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
///
void clickSite(num index, {SocialChatUserProfile? clickUser}) {
if (_handleDirectSeatInteraction(index, clickUser: clickUser)) {
return;
}
if (index == -1) {
if (clickUser != null) {
if (clickUser.id ==
@ -711,6 +715,59 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
}
bool _handleDirectSeatInteraction(
num index, {
SocialChatUserProfile? clickUser,
}) {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
final isRoomAdmin = isFz() || isGL();
if (index == -1) {
if (clickUser == null || (clickUser.id ?? '').isEmpty) {
return false;
}
if (clickUser.id == currentUserId || !isRoomAdmin) {
_openRoomUserInfoCard(clickUser.id);
return true;
}
return false;
}
final seat = roomWheatMap[index];
final seatUser = seat?.user;
if (seatUser == null) {
if (!(seat?.micLock ?? false) && !isRoomAdmin) {
shangMai(index);
return true;
}
return false;
}
if (seatUser.id == currentUserId) {
_openRoomUserInfoCard(seatUser.id);
return true;
}
if (!isRoomAdmin && (seatUser.id ?? '').isNotEmpty) {
_openRoomUserInfoCard(seatUser.id);
return true;
}
return false;
}
void _openRoomUserInfoCard(String? userId) {
final normalizedUserId = (userId ?? '').trim();
if (normalizedUserId.isEmpty) {
return;
}
showBottomInCenterDialog(
context!,
RoomUserInfoCard(userId: normalizedUserId),
);
}
addSoundVoiceChangeListener(OnSoundVoiceChange onSoundVoiceChange) {
_onSoundVoiceChangeList.add(onSoundVoiceChange);
print('添加监听:${_onSoundVoiceChangeList.length}');

View File

@ -13,6 +13,7 @@ import 'package:yumi/shared/tools/sc_message_utils.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:provider/provider.dart';
@ -86,6 +87,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
RoomNewMsgListener? msgGiftListener;
RoomNewMsgListener? msgFloatingGiftListener;
RoomNewMsgListener? msgLuckyGiftRewardTickerListener;
RoomNewMsgListener? msgUserJoinListener;
///
@ -957,6 +959,128 @@ class RealTimeMessagingManager extends ChangeNotifier {
messages.insert(0, target);
}
bool _shouldHighlightLuckyGiftReward(SCBroadCastLuckGiftPush broadCastRes) {
final rewardData = broadCastRes.data;
if (rewardData == null) {
return false;
}
if (rewardData.isBigReward) {
return true;
}
return (rewardData.multiple ?? 0) >= 5;
}
bool _isLuckyGiftInCurrentRoom(SCBroadCastLuckGiftPush broadCastRes) {
if (context == null) {
return false;
}
final currentRoomId =
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).currenRoom?.roomProfile?.roomProfile?.id ??
'';
final roomId = broadCastRes.data?.roomId ?? '';
return currentRoomId.isNotEmpty &&
roomId.isNotEmpty &&
currentRoomId == roomId;
}
SCFloatingMessage _buildLuckyGiftFloatingMessage(
SCBroadCastLuckGiftPush broadCastRes,
) {
final rewardData = broadCastRes.data;
return SCFloatingMessage(
type: 0,
userId: rewardData?.sendUserId,
roomId: rewardData?.roomId,
toUserId: rewardData?.acceptUserId,
userAvatarUrl: rewardData?.userAvatar,
userName: rewardData?.nickname,
toUserName: rewardData?.acceptNickname,
giftUrl: rewardData?.giftCover,
number: rewardData?.giftQuantity,
coins: rewardData?.awardAmount,
multiple: rewardData?.multiple,
priority: 1000,
);
}
void _handleLuckyGiftGlobalNews(
SCBroadCastLuckGiftPush broadCastRes, {
required String source,
}) {
final rewardData = broadCastRes.data;
if (rewardData == null || !rewardData.shouldShowGlobalNews) {
return;
}
if (source == 'broadcast' && _isLuckyGiftInCurrentRoom(broadCastRes)) {
_giftFxLog(
'skip global lucky gift overlay '
'reason=current_room_already_receives_group_msg '
'roomId=${rewardData.roomId} '
'giftId=${rewardData.giftId}',
);
return;
}
OverlayManager().addMessage(_buildLuckyGiftFloatingMessage(broadCastRes));
}
void _handleRoomLuckyGiftMessage(SCBroadCastLuckGiftPush broadCastRes) {
final rewardData = broadCastRes.data;
if (rewardData == null) {
return;
}
final roomMsg = Msg(
groupId: '',
msg: '',
type: SCRoomMsgType.gameLuckyGift,
);
roomMsg.gift = SocialChatGiftRes(
id: rewardData.giftId,
giftPhoto: rewardData.giftCover,
giftTab: 'LUCK',
);
roomMsg.number = 0;
roomMsg.awardAmount = rewardData.awardAmount;
roomMsg.user = SocialChatUserProfile(
id: rewardData.sendUserId,
userNickname: rewardData.nickname,
userAvatar: rewardData.userAvatar,
);
roomMsg.toUser = SocialChatUserProfile(
id: rewardData.acceptUserId,
userNickname: rewardData.acceptNickname,
);
addMsg(roomMsg);
msgLuckyGiftRewardTickerListener?.call(roomMsg);
if (_shouldHighlightLuckyGiftReward(broadCastRes)) {
final highlightMsg = Msg(
groupId: '',
msg: '${rewardData.multiple ?? 0}',
type: SCRoomMsgType.gameLuckyGift_5,
);
highlightMsg.awardAmount = rewardData.awardAmount;
highlightMsg.user = SocialChatUserProfile(
id: rewardData.sendUserId,
userNickname: rewardData.nickname,
);
addMsg(highlightMsg);
}
addluckGiftPushQueue(broadCastRes);
_handleLuckyGiftGlobalNews(broadCastRes, source: 'room_group');
if (rewardData.sendUserId ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
Provider.of<GiftProvider>(
context!,
listen: false,
).updateLuckyRewardAmount(roomMsg.awardAmount ?? 0);
}
}
bool isLogout = false;
logout() async {
@ -970,7 +1094,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
///广
_newBroadCastMsgRecv(String groupID, V2TimMessage message) {
_newBroadCastMsgRecv(String groupID, V2TimMessage message) async {
try {
String? customData = message.customElem?.data;
if (customData != null && customData.isNotEmpty) {
@ -1021,6 +1145,24 @@ class RealTimeMessagingManager extends ChangeNotifier {
OverlayManager().addMessage(msg);
}
} else if (type == "GAME_LUCKY_GIFT") {
final broadCastRes = SCBroadCastLuckGiftPush.fromJson(data);
_giftFxLog(
'recv GAME_LUCKY_GIFT broadcast '
'giftId=${broadCastRes.data?.giftId} '
'roomId=${broadCastRes.data?.roomId} '
'sendUserId=${broadCastRes.data?.sendUserId} '
'acceptUserId=${broadCastRes.data?.acceptUserId} '
'giftQuantity=${broadCastRes.data?.giftQuantity} '
'awardAmount=${broadCastRes.data?.awardAmount} '
'multiple=${broadCastRes.data?.multiple} '
'multipleType=${broadCastRes.data?.multipleType} '
'globalNews=${broadCastRes.data?.globalNews}',
);
_handleLuckyGiftGlobalNews(broadCastRes, source: 'broadcast');
} else if (type == "REGISTER_REWARD_GRANTED") {
await DataPersistence.setPendingRegisterRewardDialog(true);
await DataPersistence.clearAwaitRegisterRewardSocket();
eventBus.fire(RegisterRewardGrantedEvent(data: data["data"]));
} else if (type == "ROCKET_ENERGY_LAUNCH") {
///
var fdata = data["data"];
@ -1227,61 +1369,10 @@ class RealTimeMessagingManager extends ChangeNotifier {
'giftQuantity=${broadCastRes.data?.giftQuantity} '
'awardAmount=${broadCastRes.data?.awardAmount} '
'multiple=${broadCastRes.data?.multiple} '
'multipleType=${broadCastRes.data?.multipleType}',
'multipleType=${broadCastRes.data?.multipleType} '
'globalNews=${broadCastRes.data?.globalNews}',
);
msg.gift = SocialChatGiftRes(giftPhoto: broadCastRes.data?.giftCover);
msg.awardAmount = broadCastRes.data?.awardAmount;
msg.user = SocialChatUserProfile(
id: broadCastRes.data?.sendUserId,
userNickname: broadCastRes.data?.nickname,
);
msg.toUser = SocialChatUserProfile(
id: broadCastRes.data?.acceptUserId,
userNickname: broadCastRes.data?.acceptNickname,
);
addMsg(msg);
if ((broadCastRes.data?.multiple ?? 0) > 0) {
Msg msg2 = Msg(
groupId: '',
msg: '${broadCastRes.data?.multiple}',
type: SCRoomMsgType.gameLuckyGift_5,
);
///5
msg2.awardAmount = broadCastRes.data?.awardAmount;
msg2.user = SocialChatUserProfile(
id: broadCastRes.data?.sendUserId,
userNickname: broadCastRes.data?.nickname,
);
addMsg(msg2);
if ((broadCastRes.data?.multiple ?? 0) > 2) {
///3
SCFloatingMessage msg = SCFloatingMessage(
type: 0,
userId: broadCastRes.data?.sendUserId,
roomId: broadCastRes.data?.roomId,
toUserId: broadCastRes.data?.acceptUserId,
userAvatarUrl: broadCastRes.data?.userAvatar,
userName: broadCastRes.data?.nickname,
toUserName: broadCastRes.data?.acceptNickname,
giftUrl: broadCastRes.data?.giftCover,
number: broadCastRes.data?.giftQuantity,
coins: broadCastRes.data?.awardAmount,
multiple: broadCastRes.data?.multiple,
);
OverlayManager().addMessage(msg);
addluckGiftPushQueue(broadCastRes);
}
}
if (broadCastRes.data?.sendUserId ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
Provider.of<GiftProvider>(
context!,
listen: false,
).updateLuckyRewardAmount(msg.awardAmount ?? 0);
}
_handleRoomLuckyGiftMessage(broadCastRes);
} else {
if (msg.type == SCRoomMsgType.joinRoom) {
final shouldShowRoomVisualEffects =
@ -1587,20 +1678,14 @@ class RealTimeMessagingManager extends ChangeNotifier {
_luckGiftPushQueue.clear();
}
bool showLuckGiftBigHead = true;
void playLuckGiftBackCoins() {
if (currentPlayingLuckGift != null || _luckGiftPushQueue.isEmpty) {
return;
}
currentPlayingLuckGift = _luckGiftPushQueue.removeFirst();
notifyListeners();
Future.delayed(Duration(milliseconds: 2300), () {
showLuckGiftBigHead = false;
});
Future.delayed(Duration(milliseconds: 3000), () {
currentPlayingLuckGift = null;
showLuckGiftBigHead = true;
notifyListeners();
playLuckGiftBackCoins();
});

View File

@ -36,6 +36,26 @@ class GiftAnimationManager extends ChangeNotifier {
} else {
if (value.labelId == playGift.labelId) {
playGift.giftCount = value.giftCount + playGift.giftCount;
if (playGift.sendUserName.isEmpty) {
playGift.sendUserName = value.sendUserName;
}
if (playGift.sendToUserName.isEmpty) {
playGift.sendToUserName = value.sendToUserName;
}
if (playGift.sendUserPic.isEmpty) {
playGift.sendUserPic = value.sendUserPic;
}
if (playGift.giftPic.isEmpty) {
playGift.giftPic = value.giftPic;
}
if (playGift.giftName.isEmpty) {
playGift.giftName = value.giftName;
}
if (playGift.rewardAmountText.isEmpty) {
playGift.rewardAmountText = value.rewardAmountText;
}
playGift.showLuckyRewardFrame =
playGift.showLuckyRewardFrame || value.showLuckyRewardFrame;
giftMap[key] = playGift;
pendingAnimationsQueue.removeFirst();
notifyListeners();

View File

@ -0,0 +1,316 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:yumi/shared/business_logic/models/res/gift_res.dart';
import 'package:yumi/shared/business_logic/models/res/mic_res.dart';
typedef RoomGiftComboSendExecutor =
Future<void> Function(
RoomGiftComboSendRequest request, {
required String trigger,
});
class RoomGiftComboSendRequest {
const RoomGiftComboSendRequest({
required this.acceptUserIds,
required this.acceptUsers,
required this.gift,
required this.quantity,
required this.clickCount,
required this.roomId,
required this.roomAccount,
required this.isLuckyGiftRequest,
});
final List<String> acceptUserIds;
final List<MicRes> acceptUsers;
final SocialChatGiftRes gift;
final int quantity;
final int clickCount;
final String roomId;
final String roomAccount;
final bool isLuckyGiftRequest;
RoomGiftComboSendRequest copyWith({
List<String>? acceptUserIds,
List<MicRes>? acceptUsers,
SocialChatGiftRes? gift,
int? quantity,
int? clickCount,
String? roomId,
String? roomAccount,
bool? isLuckyGiftRequest,
}) {
return RoomGiftComboSendRequest(
acceptUserIds: List<String>.from(acceptUserIds ?? this.acceptUserIds),
acceptUsers: List<MicRes>.from(acceptUsers ?? this.acceptUsers),
gift: gift ?? this.gift,
quantity: quantity ?? this.quantity,
clickCount: clickCount ?? this.clickCount,
roomId: roomId ?? this.roomId,
roomAccount: roomAccount ?? this.roomAccount,
isLuckyGiftRequest: isLuckyGiftRequest ?? this.isLuckyGiftRequest,
);
}
String get batchKey {
final sortedAcceptUserIds = List<String>.from(acceptUserIds)..sort();
return '${isLuckyGiftRequest ? "lucky" : "gift"}'
'|${gift.id ?? ""}'
'|$roomId'
'|${sortedAcceptUserIds.join(",")}';
}
}
class RoomGiftComboSendController extends ChangeNotifier {
static final RoomGiftComboSendController _instance =
RoomGiftComboSendController._internal();
factory RoomGiftComboSendController() => _instance;
RoomGiftComboSendController._internal();
static const Duration comboFeedbackDuration = Duration(seconds: 3);
static const Duration _comboSendBatchWindow = Duration(milliseconds: 200);
static const Duration _countdownRefreshInterval = Duration(milliseconds: 50);
final ListQueue<_PendingRoomGiftComboSendBatch> _queue =
ListQueue<_PendingRoomGiftComboSendBatch>();
RoomGiftComboSendRequest? _activeRequest;
RoomGiftComboSendExecutor? _executor;
Timer? _countdownTimer;
Timer? _batchTimer;
DateTime? _countdownDeadline;
bool _isBatchInFlight = false;
bool _visible = false;
bool get isVisible => _visible && _activeRequest != null && _executor != null;
double get countdownProgress {
if (!isVisible) {
return 0;
}
final deadline = _countdownDeadline;
if (deadline == null) {
return 0;
}
final totalMs = comboFeedbackDuration.inMilliseconds;
if (totalMs <= 0) {
return 1;
}
final remainingMs = deadline.difference(DateTime.now()).inMilliseconds;
if (remainingMs <= 0) {
return 1;
}
return (1 - remainingMs / totalMs).clamp(0.0, 1.0).toDouble();
}
void show({
required RoomGiftComboSendRequest request,
required RoomGiftComboSendExecutor executor,
}) {
_activeRequest = request.copyWith();
_executor = executor;
_visible = true;
_restartCountdown();
notifyListeners();
}
Future<void> sendActiveRequest() async {
final request = _activeRequest;
final executor = _executor;
if (request == null || executor == null) {
return;
}
_visible = true;
_restartCountdown();
_enqueueRequest(request.copyWith(clickCount: 1));
notifyListeners();
}
void hide() {
_countdownTimer?.cancel();
_countdownTimer = null;
_countdownDeadline = null;
if (_visible) {
_visible = false;
notifyListeners();
}
}
void clear() {
_countdownTimer?.cancel();
_countdownTimer = null;
_batchTimer?.cancel();
_batchTimer = null;
_countdownDeadline = null;
_queue.clear();
_activeRequest = null;
_executor = null;
_isBatchInFlight = false;
_visible = false;
notifyListeners();
}
void _restartCountdown() {
_countdownDeadline = DateTime.now().add(comboFeedbackDuration);
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(_countdownRefreshInterval, (timer) {
final deadline = _countdownDeadline;
if (deadline == null) {
timer.cancel();
return;
}
if (!DateTime.now().isBefore(deadline)) {
timer.cancel();
_countdownDeadline = null;
if (_visible) {
_visible = false;
notifyListeners();
}
return;
}
notifyListeners();
});
}
void _enqueueRequest(RoomGiftComboSendRequest request) {
final now = DateTime.now();
_PendingRoomGiftComboSendBatch? existingBatch;
for (final batch in _queue) {
if (batch.batchKey == request.batchKey) {
existingBatch = batch;
break;
}
}
if (existingBatch != null) {
existingBatch.quantity += request.quantity;
existingBatch.clickCount += request.clickCount;
existingBatch.readyAt = now.add(_comboSendBatchWindow);
} else {
_queue.add(
_PendingRoomGiftComboSendBatch.fromRequest(
request,
readyAt: now.add(_comboSendBatchWindow),
),
);
}
_scheduleNextFlush();
}
void _scheduleNextFlush() {
_batchTimer?.cancel();
_batchTimer = null;
if (_isBatchInFlight || _queue.isEmpty) {
return;
}
final nextBatch = _queue.first;
final delay = nextBatch.readyAt.difference(DateTime.now());
if (delay <= Duration.zero) {
unawaited(_flushNextBatch());
return;
}
_batchTimer = Timer(delay, () {
unawaited(_flushNextBatch());
});
}
Future<void> _flushNextBatch() async {
_batchTimer?.cancel();
_batchTimer = null;
if (_isBatchInFlight || _queue.isEmpty) {
return;
}
final executor = _executor;
if (executor == null) {
_queue.clear();
return;
}
final batch = _queue.first;
if (batch.readyAt.isAfter(DateTime.now())) {
_scheduleNextFlush();
return;
}
_queue.removeFirst();
_isBatchInFlight = true;
try {
await executor(batch.toRequest(), trigger: 'floating_batched');
} finally {
_isBatchInFlight = false;
_scheduleNextFlush();
}
}
}
class _PendingRoomGiftComboSendBatch {
_PendingRoomGiftComboSendBatch({
required this.batchKey,
required this.acceptUserIds,
required this.acceptUsers,
required this.gift,
required this.quantity,
required this.clickCount,
required this.roomId,
required this.roomAccount,
required this.isLuckyGiftRequest,
required this.readyAt,
});
factory _PendingRoomGiftComboSendBatch.fromRequest(
RoomGiftComboSendRequest request, {
required DateTime readyAt,
}) {
return _PendingRoomGiftComboSendBatch(
batchKey: request.batchKey,
acceptUserIds: List<String>.from(request.acceptUserIds),
acceptUsers: List<MicRes>.from(request.acceptUsers),
gift: request.gift,
quantity: request.quantity,
clickCount: request.clickCount,
roomId: request.roomId,
roomAccount: request.roomAccount,
isLuckyGiftRequest: request.isLuckyGiftRequest,
readyAt: readyAt,
);
}
final String batchKey;
final List<String> acceptUserIds;
final List<MicRes> acceptUsers;
final SocialChatGiftRes gift;
int quantity;
int clickCount;
final String roomId;
final String roomAccount;
final bool isLuckyGiftRequest;
DateTime readyAt;
RoomGiftComboSendRequest toRequest() {
return RoomGiftComboSendRequest(
acceptUserIds: List<String>.from(acceptUserIds),
acceptUsers: List<MicRes>.from(acceptUsers),
gift: gift,
quantity: quantity,
clickCount: clickCount,
roomId: roomId,
roomAccount: roomAccount,
isLuckyGiftRequest: isLuckyGiftRequest,
);
}
}

View File

@ -2,12 +2,10 @@
/// type : "GAME_LUCKY_GIFT"
class SCBroadCastLuckGiftPush {
SCBroadCastLuckGiftPush({
Data? data,
String? type,}){
SCBroadCastLuckGiftPush({Data? data, String? type}) {
_data = data;
_type = type;
}
}
SCBroadCastLuckGiftPush.fromJson(dynamic json) {
_data = json['data'] != null ? Data.fromJson(json['data']) : null;
@ -26,7 +24,6 @@ class SCBroadCastLuckGiftPush {
map['type'] = _type;
return map;
}
}
/// acceptNickname : "Sukabuliete"
@ -51,27 +48,28 @@ class SCBroadCastLuckGiftPush {
class Data {
Data({
String? acceptNickname,
String? acceptUserId,
String? account,
String? avatarFrameCover,
String? avatarFrameSvg,
num? balance,
num? awardAmount,
num? giftCandy,
String? giftCover,
num? giftQuantity,
bool? globalNews,
num? multiple,
String? multipleType,
String? nickname,
String? regionCode,
String? roomAccount,
String? roomId,
String? giftId,
String? sendUserId,
String? sysOrigin,
String? userAvatar,}){
String? acceptNickname,
String? acceptUserId,
String? account,
String? avatarFrameCover,
String? avatarFrameSvg,
num? balance,
num? awardAmount,
num? giftCandy,
String? giftCover,
num? giftQuantity,
bool? globalNews,
num? multiple,
String? multipleType,
String? nickname,
String? regionCode,
String? roomAccount,
String? roomId,
String? giftId,
String? sendUserId,
String? sysOrigin,
String? userAvatar,
}) {
_acceptNickname = acceptNickname;
_acceptUserId = acceptUserId;
_account = account;
@ -93,30 +91,30 @@ class Data {
_giftId = giftId;
_sysOrigin = sysOrigin;
_userAvatar = userAvatar;
}
}
Data.fromJson(dynamic json) {
_acceptNickname = json['acceptNickname'];
_acceptUserId = json['acceptUserId'];
_account = json['account'];
_avatarFrameCover = json['avatarFrameCover'];
_avatarFrameSvg = json['avatarFrameSvg'];
_balance = json['balance'];
_awardAmount = json['awardAmount'];
_giftCandy = json['giftCandy'];
_giftCover = json['giftCover'];
_giftQuantity = json['giftQuantity'];
_globalNews = json['globalNews'];
_multiple = json['multiple'];
_multipleType = json['multipleType'];
_nickname = json['nickname'];
_regionCode = json['regionCode'];
_roomAccount = json['roomAccount'];
_roomId = json['roomId'];
_sendUserId = json['sendUserId'];
_sysOrigin = json['sysOrigin'];
_userAvatar = json['userAvatar'];
_giftId = json['giftId'];
_acceptNickname = _asString(json['acceptNickname']);
_acceptUserId = _asString(json['acceptUserId']);
_account = _asString(json['account']);
_avatarFrameCover = _asString(json['avatarFrameCover']);
_avatarFrameSvg = _asString(json['avatarFrameSvg']);
_balance = _asNum(json['balance']);
_awardAmount = _asNum(json['awardAmount']);
_giftCandy = _asNum(json['giftCandy']);
_giftCover = _asString(json['giftCover']);
_giftQuantity = _asNum(json['giftQuantity']);
_globalNews = _asBool(json['globalNews']);
_multiple = _asNum(json['multiple']);
_multipleType = _asString(json['multipleType']);
_nickname = _asString(json['nickname']);
_regionCode = _asString(json['regionCode']);
_roomAccount = _asString(json['roomAccount']);
_roomId = _asString(json['roomId']);
_sendUserId = _asString(json['sendUserId']);
_sysOrigin = _asString(json['sysOrigin']);
_userAvatar = _asString(json['userAvatar']);
_giftId = _asString(json['giftId']);
}
String? _acceptNickname;
String? _acceptUserId;
@ -160,6 +158,45 @@ class Data {
String? get sendUserId => _sendUserId;
String? get sysOrigin => _sysOrigin;
String? get userAvatar => _userAvatar;
String get normalizedMultipleType =>
(_multipleType ?? '').trim().toUpperCase();
bool get shouldShowGlobalNews => _globalNews ?? false;
bool get isBigReward =>
normalizedMultipleType == 'BIG_WIN' || normalizedMultipleType == 'SUPER';
static String? _asString(dynamic value) {
if (value == null) {
return null;
}
return value.toString();
}
static num? _asNum(dynamic value) {
if (value == null) {
return null;
}
if (value is num) {
return value;
}
return num.tryParse(value.toString());
}
static bool? _asBool(dynamic value) {
if (value == null) {
return null;
}
if (value is bool) {
return value;
}
final normalized = value.toString().trim().toLowerCase();
if (normalized == 'true' || normalized == '1') {
return true;
}
if (normalized == 'false' || normalized == '0') {
return false;
}
return null;
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
@ -186,5 +223,4 @@ class Data {
map['giftId'] = _giftId;
return map;
}
}

View File

@ -25,7 +25,6 @@ import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.
import 'package:yumi/shared/business_logic/models/res/sc_room_task_claimable_count_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_task_list_res.dart';
import 'package:yumi/shared/business_logic/models/res/room_user_card_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_top_four_with_reward_res.dart';
import 'package:yumi/shared/business_logic/models/res/user_count_guard_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_violation_handle_res.dart';
@ -48,7 +47,11 @@ abstract class SocialChatRoomRepository {
Future<SCRoomContributeLevelRes> roomContributionActivity(String roomId);
///
Future<SCMicGoUpRes> micGoUp(String roomId, num mickIndex, {String? eventType});
Future<SCMicGoUpRes> micGoUp(
String roomId,
num mickIndex, {
String? eventType,
});
///
Future micGoDown(String roomId, num mickIndex);
@ -150,7 +153,10 @@ abstract class SocialChatRoomRepository {
});
///
Future<List<SocialChatRoomMemberRes>> roomMember(String roomId, {String? lastId});
Future<List<SocialChatRoomMemberRes>> roomMember(
String roomId, {
String? lastId,
});
///
Future<List<RoomGiftRankRes>> roomContributionRank(
@ -166,7 +172,10 @@ abstract class SocialChatRoomRepository {
num quantity,
bool checkCombo, {
String? roomId,
SCGiveAwayGiftRoomAcceptsCmd? accepts,
List<SCGiveAwayGiftRoomAcceptsCmd>? accepts,
Object? gameId,
String? dynamicContentId,
String? songId,
});
///
@ -239,11 +248,4 @@ abstract class SocialChatRoomRepository {
///
Future<SCRoomTaskClaimableCountRes> roomTaskClaimableCount();
}

View File

@ -6,6 +6,10 @@ import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
class DataPersistence {
static const String _awaitRegisterRewardSocketKey =
"await_register_reward_socket";
static const String _pendingRegisterRewardDialogKey =
"pending_register_reward_dialog";
static SharedPreferences? _prefs;
static bool _isInitialized = false;
static Completer<void>? _initializationCompleter;
@ -145,12 +149,12 @@ class DataPersistence {
///
static Future<bool> setSearchHistroy(String userId, String userJson) async {
return await setString("${userId}-currentUser", userJson);
return await setString("$userId-currentUser", userJson);
}
///
static String getSearchHistroy(String userId) {
return getString("${userId}-currentUser");
return getString("$userId-currentUser");
}
///
@ -187,6 +191,30 @@ class DataPersistence {
return getBool("play_gift_music");
}
static Future<bool> setPendingRegisterRewardDialog(bool value) async {
return await setBool(_pendingRegisterRewardDialogKey, value);
}
static bool getPendingRegisterRewardDialog() {
return getBool(_pendingRegisterRewardDialogKey);
}
static Future<bool> clearPendingRegisterRewardDialog() async {
return await remove(_pendingRegisterRewardDialogKey);
}
static Future<bool> setAwaitRegisterRewardSocket(bool value) async {
return await setBool(_awaitRegisterRewardSocketKey, value);
}
static bool getAwaitRegisterRewardSocket() {
return getBool(_awaitRegisterRewardSocketKey);
}
static Future<bool> clearAwaitRegisterRewardSocket() async {
return await remove(_awaitRegisterRewardSocketKey);
}
///
static String getUserRoomMusic() {
final currentUser = AccountStorage().getCurrentUser()?.userProfile?.account;

View File

@ -609,7 +609,10 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
num quantity,
bool checkCombo, {
String? roomId,
SCGiveAwayGiftRoomAcceptsCmd? accepts,
List<SCGiveAwayGiftRoomAcceptsCmd>? accepts,
Object? gameId,
String? dynamicContentId,
String? songId,
}) async {
Map<String, dynamic> params = {};
if (roomId != null) {
@ -619,9 +622,13 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
params["acceptUserIds"] = acceptUserIds;
params["quantity"] = quantity;
params["checkCombo"] = checkCombo;
if (accepts != null) {
params["accepts"] = accepts.toJson();
}
params["gameId"] = gameId;
params["accepts"] =
(accepts ?? const <SCGiveAwayGiftRoomAcceptsCmd>[])
.map((item) => item.toJson())
.toList();
params["dynamicContentId"] = dynamicContentId;
params["songId"] = songId;
_giftRepoLog(
'request giveLuckyGift endpoint=/gift/give/lucky-gift '
@ -629,6 +636,9 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
'roomId=$roomId '
'quantity=$quantity '
'checkCombo=$checkCombo '
'gameId=$gameId '
'dynamicContentId=$dynamicContentId '
'songId=$songId '
'acceptUserIds=${acceptUserIds.join(",")} '
'payload=$params',
);

View File

@ -11,5 +11,10 @@ class GiveRoomLuckWithOtherEvent {
GiveRoomLuckWithOtherEvent(this.giftPic, this.acceptUserIds);
}
class UpdateDynamicEvent {
class UpdateDynamicEvent {}
class RegisterRewardGrantedEvent {
final Object? data;
RegisterRewardGrantedEvent({this.data});
}

View File

@ -0,0 +1,718 @@
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/business_logic/models/res/sc_sign_in_res.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/tools/sc_loading_manager.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
enum DailySignInRewardStatus { claimed, current, pending }
class DailySignInDialogItem {
const DailySignInDialogItem({
required this.day,
required this.title,
required this.status,
required this.isFinalDay,
this.subtitle = '',
this.cover = '',
this.rewardId = '',
this.resourceGroupId = '',
});
final int day;
final String title;
final String subtitle;
final String cover;
final DailySignInRewardStatus status;
final bool isFinalDay;
final String rewardId;
final String resourceGroupId;
bool get canClaim =>
status == DailySignInRewardStatus.current &&
rewardId.isNotEmpty &&
resourceGroupId.isNotEmpty;
DailySignInDialogItem copyWith({
int? day,
String? title,
String? subtitle,
String? cover,
DailySignInRewardStatus? status,
bool? isFinalDay,
String? rewardId,
String? resourceGroupId,
}) {
return DailySignInDialogItem(
day: day ?? this.day,
title: title ?? this.title,
subtitle: subtitle ?? this.subtitle,
cover: cover ?? this.cover,
status: status ?? this.status,
isFinalDay: isFinalDay ?? this.isFinalDay,
rewardId: rewardId ?? this.rewardId,
resourceGroupId: resourceGroupId ?? this.resourceGroupId,
);
}
}
class DailySignInDialogData {
const DailySignInDialogData({
required this.items,
required this.checkedToday,
});
final List<DailySignInDialogItem> items;
final bool checkedToday;
DailySignInDialogItem? get currentItem {
for (final item in items) {
if (item.status == DailySignInRewardStatus.current) {
return item;
}
}
return null;
}
factory DailySignInDialogData.mock() {
return const DailySignInDialogData(
checkedToday: false,
items: [
DailySignInDialogItem(
day: 1,
title: 'Profile Frame',
status: DailySignInRewardStatus.claimed,
isFinalDay: false,
),
DailySignInDialogItem(
day: 2,
title: 'Chat Bubble',
status: DailySignInRewardStatus.claimed,
isFinalDay: false,
),
DailySignInDialogItem(
day: 3,
title: 'Profile Frame',
status: DailySignInRewardStatus.current,
isFinalDay: false,
),
DailySignInDialogItem(
day: 4,
title: 'Avatar',
status: DailySignInRewardStatus.pending,
isFinalDay: false,
),
DailySignInDialogItem(
day: 5,
title: 'Mic Effect',
status: DailySignInRewardStatus.pending,
isFinalDay: false,
),
DailySignInDialogItem(
day: 6,
title: 'Entry Effect',
status: DailySignInRewardStatus.pending,
isFinalDay: false,
),
DailySignInDialogItem(
day: 7,
title: 'Special Avatar',
subtitle: 'Limited',
status: DailySignInRewardStatus.pending,
isFinalDay: true,
),
],
);
}
factory DailySignInDialogData.fromLegacy({
required SCSignInRes signInRes,
required bool checkedToday,
}) {
final rewards = List<Rewards>.from(
signInRes.rewards ?? const <Rewards>[],
)..sort(
(left, right) => (left.rule?.sort ?? 0).compareTo(right.rule?.sort ?? 0),
);
final signedDays = (signInRes.days ?? 0).toInt().clamp(0, 7);
final currentDay = checkedToday ? -1 : math.min(signedDays + 1, 7);
final items = List<DailySignInDialogItem>.generate(7, (index) {
final day = index + 1;
final reward = index < rewards.length ? rewards[index] : null;
final status =
day <= signedDays
? DailySignInRewardStatus.claimed
: day == currentDay
? DailySignInRewardStatus.current
: DailySignInRewardStatus.pending;
return DailySignInDialogItem(
day: day,
title: _resolveTitle(reward),
subtitle: _resolveSubtitle(reward),
cover: _resolveCover(reward),
status: status,
isFinalDay: day == 7,
rewardId: reward?.rule?.id?.trim() ?? '',
resourceGroupId: reward?.rule?.resourceGroupId?.trim() ?? '',
);
});
return DailySignInDialogData(items: items, checkedToday: checkedToday);
}
DailySignInDialogData copyWith({
List<DailySignInDialogItem>? items,
bool? checkedToday,
}) {
return DailySignInDialogData(
items: items ?? this.items,
checkedToday: checkedToday ?? this.checkedToday,
);
}
static ActivityRewardProps? _firstRewardProp(Rewards? reward) {
final props = reward?.propsGroup?.activityRewardProps;
if (props == null || props.isEmpty) {
return null;
}
return props.first;
}
static String _pickFirstNonEmpty(List<String?> values, String fallback) {
for (final value in values) {
final textValue = value?.trim() ?? '';
if (textValue.isNotEmpty) {
return textValue;
}
}
return fallback;
}
static String _resolveTitle(Rewards? reward) {
final prop = _firstRewardProp(reward);
return _pickFirstNonEmpty([
reward?.propsGroup?.name,
prop?.content,
prop?.remark,
prop?.detailType,
], 'Profile Frame');
}
static String _resolveSubtitle(Rewards? reward) {
final prop = _firstRewardProp(reward);
final quantity = prop?.quantity;
if (quantity != null && quantity > 1) {
return 'x${quantity.toInt()}';
}
final amount = prop?.amount;
if (amount != null && amount > 0) {
return amount == amount.toInt() ? '${amount.toInt()}' : '$amount';
}
final textValue = _pickFirstNonEmpty([
prop?.type,
prop?.detailType,
prop?.remark,
], '');
return textValue == 'Profile Frame' ? '' : textValue;
}
static String _resolveCover(Rewards? reward) {
return _firstRewardProp(reward)?.cover?.trim() ?? '';
}
}
class DailySignInDialog extends StatefulWidget {
const DailySignInDialog({super.key, required this.data});
static const String dialogTag = 'showDailySignInDialog';
static const double _frameWidth = 345;
static const double _frameHeight = 436;
static const double _titleWidth = 195;
static const double _titleHeight = 34;
static const double _titleToGridSpacing = 20;
static const double _buttonHeight = 42;
static const double _buttonBottomInset = 32;
static const double _gridBottomSpacing = 14;
static const double _rewardCardSize = 82;
static const double _rewardGridSpacing = 8;
final DailySignInDialogData data;
static Future<void> show(
BuildContext context, {
required DailySignInDialogData data,
}) async {
SmartDialog.dismiss(tag: dialogTag);
await SmartDialog.show(
tag: dialogTag,
alignment: Alignment.center,
maskColor: Colors.black.withValues(alpha: 0.56),
animationType: SmartAnimationType.fade,
clickMaskDismiss: true,
builder: (_) => DailySignInDialog(data: data),
);
}
@override
State<DailySignInDialog> createState() => _DailySignInDialogState();
}
class _DailySignInDialogState extends State<DailySignInDialog> {
late DailySignInDialogData _data;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
_data = widget.data;
}
Future<void> _handleCheckIn() async {
if (_isSubmitting) {
return;
}
if (_data.checkedToday) {
SmartDialog.dismiss(tag: DailySignInDialog.dialogTag);
return;
}
final currentItem = _data.currentItem;
if (currentItem == null || !currentItem.canClaim) {
SmartDialog.dismiss(tag: DailySignInDialog.dialogTag);
return;
}
setState(() {
_isSubmitting = true;
});
SCLoadingManager.show();
try {
await SCAccountRepository().checkInReceive(
currentItem.rewardId,
currentItem.resourceGroupId,
);
DailySignInDialogData latestData;
try {
final result = await Future.wait<dynamic>([
SCAccountRepository().checkInToday(),
SCAccountRepository().sginListAward(),
]);
latestData = DailySignInDialogData.fromLegacy(
signInRes: result[1] as SCSignInRes,
checkedToday: result[0] as bool,
);
} catch (_) {
latestData = _markCurrentItemAsClaimed(_data);
}
SCLoadingManager.hide();
if (!mounted) {
return;
}
setState(() {
_data = latestData;
});
SCTts.show(SCAppLocalizations.of(context)!.receiveSucc);
} catch (error) {
SCLoadingManager.hide();
if (!mounted) {
return;
}
final message = error.toString().replaceFirst('Exception: ', '').trim();
if (message.isNotEmpty) {
SCTts.show(message);
}
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
DailySignInDialogData _markCurrentItemAsClaimed(DailySignInDialogData data) {
return data.copyWith(
checkedToday: true,
items:
data.items.map((item) {
if (item.status == DailySignInRewardStatus.current) {
return item.copyWith(status: DailySignInRewardStatus.claimed);
}
return item;
}).toList(),
);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final dialogWidth = math.min(
screenWidth - 28.w,
DailySignInDialog._frameWidth.w,
);
return Material(
color: Colors.transparent,
child: SizedBox(
width: dialogWidth,
child: AspectRatio(
aspectRatio:
DailySignInDialog._frameWidth / DailySignInDialog._frameHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final scale =
constraints.maxWidth / DailySignInDialog._frameWidth;
final buttonTop =
DailySignInDialog._frameHeight -
DailySignInDialog._buttonBottomInset -
DailySignInDialog._buttonHeight;
final rewardGridHeight =
(DailySignInDialog._rewardCardSize * 2) +
DailySignInDialog._rewardGridSpacing;
final rewardGridTop =
buttonTop -
DailySignInDialog._gridBottomSpacing -
rewardGridHeight;
final titleTop =
rewardGridTop -
DailySignInDialog._titleToGridSpacing -
DailySignInDialog._titleHeight;
return Stack(
children: [
Positioned.fill(
child: Image.asset(
_DailySignInAssets.dialogFrame,
fit: BoxFit.fill,
),
),
Positioned(
top: titleTop * scale,
left: 0,
right: 0,
child: Center(
child: Image.asset(
_DailySignInAssets.title,
width: DailySignInDialog._titleWidth * scale,
height: DailySignInDialog._titleHeight * scale,
fit: BoxFit.fill,
),
),
),
Positioned(
top: rewardGridTop * scale,
left: 26 * scale,
right: 26 * scale,
child: _RewardsGrid(items: _data.items, scale: scale),
),
Positioned(
left: 54 * scale,
right: 54 * scale,
bottom: DailySignInDialog._buttonBottomInset * scale,
child: SCDebounceWidget(
onTap: _handleCheckIn,
child: SizedBox(
height: DailySignInDialog._buttonHeight * scale,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
_DailySignInAssets.checkInButton,
fit: BoxFit.fill,
),
),
text(
_buttonText(context),
fontSize: 13,
fontWeight: FontWeight.w700,
textColor: Colors.white,
),
],
),
),
),
),
],
);
},
),
),
),
);
}
String _buttonText(BuildContext context) {
if (_isSubmitting) {
return SCAppLocalizations.of(context)!.receive;
}
return _data.checkedToday
? SCAppLocalizations.of(context)!.signedin
: 'Check in';
}
}
class _RewardsGrid extends StatelessWidget {
const _RewardsGrid({required this.items, required this.scale});
final List<DailySignInDialogItem> items;
final double scale;
@override
Widget build(BuildContext context) {
final spacing = 8 * scale;
final cardWidth = 62 * scale;
final finalCardWidth = 132 * scale;
final cardHeight = 82 * scale;
final gridWidth = (cardWidth * 4) + (spacing * 3);
return Align(
alignment: Alignment.center,
child: SizedBox(
width: gridWidth,
child: Wrap(
spacing: spacing,
runSpacing: spacing,
textDirection: Directionality.of(context),
children:
items.map((item) {
return SizedBox(
width: item.isFinalDay ? finalCardWidth : cardWidth,
height: cardHeight,
child: _RewardCard(item: item, scale: scale),
);
}).toList(),
),
),
);
}
}
class _RewardCard extends StatelessWidget {
const _RewardCard({required this.item, required this.scale});
final DailySignInDialogItem item;
final double scale;
@override
Widget build(BuildContext context) {
final horizontalPadding = item.isFinalDay ? 12 * scale : 7 * scale;
final topPadding = item.isFinalDay ? 12 * scale : 18 * scale;
final bottomPadding = item.isFinalDay ? 5 * scale : 7 * scale;
final titleColor =
item.status == DailySignInRewardStatus.current
? const Color(0xFF6C4A11)
: Colors.white;
final subtitleColor =
item.status == DailySignInRewardStatus.current
? const Color(0xFF8A6220)
: const Color(0xFFE6EFE9);
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Image.asset(_backgroundAsset(item), fit: BoxFit.fill),
),
PositionedDirectional(
start: -2 * scale,
top: -1 * scale,
child: SizedBox(
width: 29 * scale,
height: 14 * scale,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
item.status == DailySignInRewardStatus.current
? _DailySignInAssets.currentDayBadge
: _DailySignInAssets.dayBadge,
fit: BoxFit.fill,
),
),
text(
'${item.day}Day',
fontSize: 7,
fontWeight: FontWeight.w700,
textAlign: TextAlign.center,
textColor:
item.status == DailySignInRewardStatus.current
? const Color(0xFF6C4A11)
: Colors.white,
),
],
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
horizontalPadding,
topPadding,
horizontalPadding,
bottomPadding,
),
child: Align(
alignment: Alignment.center,
child: SizedBox(
width: item.isFinalDay ? 90 * scale : double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_RewardCover(item: item, scale: scale),
SizedBox(height: 5 * scale),
text(
item.title,
fontSize: item.isFinalDay ? 9 : 8.2,
fontWeight: FontWeight.w700,
maxLines: 2,
lineHeight: 1.2,
textAlign: TextAlign.center,
textColor: titleColor,
),
if (item.subtitle.isNotEmpty) ...[
SizedBox(height: 2 * scale),
text(
item.subtitle,
fontSize: 7,
fontWeight: FontWeight.w600,
maxLines: 1,
textAlign: TextAlign.center,
textColor: subtitleColor,
),
],
],
),
),
),
),
],
);
}
static String _backgroundAsset(DailySignInDialogItem item) {
if (item.isFinalDay) {
return _DailySignInAssets.rewardFinalBackground;
}
switch (item.status) {
case DailySignInRewardStatus.claimed:
return _DailySignInAssets.rewardClaimedBackground;
case DailySignInRewardStatus.current:
return _DailySignInAssets.rewardCurrentBackground;
case DailySignInRewardStatus.pending:
return _DailySignInAssets.rewardPendingBackground;
}
}
}
class _RewardCover extends StatelessWidget {
const _RewardCover({required this.item, required this.scale});
final DailySignInDialogItem item;
final double scale;
@override
Widget build(BuildContext context) {
final size = item.isFinalDay ? 30 * scale : 28 * scale;
final cover = item.cover;
final borderColor =
item.status == DailySignInRewardStatus.current
? const Color(0xFFE5B965)
: const Color(0xFFB58E43);
Widget child;
if (cover.startsWith('http://') || cover.startsWith('https://')) {
child = CachedNetworkImage(
imageUrl: cover,
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (_, __) => _placeholder(size, borderColor),
errorWidget: (_, __, ___) => _placeholder(size, borderColor),
);
} else if (cover.isNotEmpty) {
child = Image.asset(
cover,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _placeholder(size, borderColor),
);
} else {
child = _placeholder(size, borderColor);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: borderColor, width: 1.2.w),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipOval(child: child),
);
}
Widget _placeholder(double size, Color borderColor) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0E6A66), Color(0xFF083D3B)],
),
),
child: Icon(
Icons.auto_awesome_rounded,
size: size * 0.56,
color: borderColor,
),
);
}
}
class _DailySignInAssets {
static const String dialogFrame = 'sc_images/daily_sign_in/dialog_frame.png';
static const String title =
'sc_images/daily_sign_in/weekly_sign_in_title.png';
static const String checkInButton =
'sc_images/daily_sign_in/check_in_button.png';
static const String dayBadge = 'sc_images/daily_sign_in/day_badge.png';
static const String currentDayBadge =
'sc_images/daily_sign_in/current_day_badge.png';
static const String rewardClaimedBackground =
'sc_images/daily_sign_in/reward_claimed_bg.png';
static const String rewardCurrentBackground =
'sc_images/daily_sign_in/reward_current_bg.png';
static const String rewardFinalBackground =
'sc_images/daily_sign_in/reward_final_bg.png';
static const String rewardPendingBackground =
'sc_images/daily_sign_in/reward_pending_bg.png';
}

View File

@ -0,0 +1,328 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
class SCGiftComboBadgeButton extends StatefulWidget {
const SCGiftComboBadgeButton({
super.key,
required this.assetPath,
required this.progress,
required this.onPressed,
this.size = 78,
this.ripplePadding = 14,
this.countdownStrokeWidth = 5,
this.themeColor = SocialChatTheme.primaryLight,
this.fallback,
});
final String assetPath;
final double progress;
final VoidCallback onPressed;
final double size;
final double ripplePadding;
final double countdownStrokeWidth;
final Color themeColor;
final Widget? fallback;
@override
State<SCGiftComboBadgeButton> createState() => _SCGiftComboBadgeButtonState();
}
class _SCGiftComboBadgeButtonState extends State<SCGiftComboBadgeButton>
with SingleTickerProviderStateMixin {
static const Duration _pressScaleDuration = Duration(milliseconds: 90);
static const Duration _waveDuration = Duration(milliseconds: 650);
static const double _pressedScale = 0.94;
late final AnimationController _waveController;
bool _pressed = false;
@override
void initState() {
super.initState();
_waveController = AnimationController(vsync: this, duration: _waveDuration);
}
@override
void dispose() {
_waveController.dispose();
super.dispose();
}
void _setPressed(bool value) {
if (_pressed == value) {
return;
}
setState(() {
_pressed = value;
});
}
void _triggerWave() {
_waveController.forward(from: 0);
}
@override
Widget build(BuildContext context) {
final buttonSize = widget.size.w;
final ripplePadding = widget.ripplePadding.w;
final hostSize = buttonSize + ripplePadding * 2;
final ringSize = buttonSize + widget.countdownStrokeWidth.w * 2;
return SizedBox(
width: hostSize,
height: hostSize,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
IgnorePointer(
child: AnimatedBuilder(
animation: _waveController,
builder: (context, child) {
return CustomPaint(
size: Size.square(hostSize),
painter: _ComboWavePainter(
progress: _waveController.value,
color: widget.themeColor,
baseRadius: buttonSize / 2,
),
);
},
),
),
IgnorePointer(
child: SizedBox(
width: ringSize,
height: ringSize,
child: CustomPaint(
painter: _ComboCountdownRingPainter(
progress: widget.progress,
color: widget.themeColor,
strokeWidth: widget.countdownStrokeWidth.w,
),
),
),
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) {
_setPressed(true);
_triggerWave();
},
onTapUp: (_) => _setPressed(false),
onTapCancel: () => _setPressed(false),
onTap: widget.onPressed,
child: AnimatedScale(
scale: _pressed ? _pressedScale : 1,
duration: _pressScaleDuration,
curve: Curves.easeOutCubic,
child: SizedBox(
width: buttonSize,
height: buttonSize,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: widget.themeColor.withValues(alpha: 0.24),
blurRadius: 20.w,
spreadRadius: 0,
offset: Offset(0, 8.w),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.24),
blurRadius: 16.w,
spreadRadius: 0,
offset: Offset(0, 8.w),
),
],
),
child: ClipOval(
child: Image.asset(
widget.assetPath,
fit: BoxFit.contain,
errorBuilder:
(_, __, ___) =>
widget.fallback ?? const SizedBox.shrink(),
),
),
),
),
),
),
],
),
);
}
}
class _ComboCountdownRingPainter extends CustomPainter {
const _ComboCountdownRingPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
final double progress;
final Color color;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final normalizedProgress = progress.clamp(0.0, 1.0).toDouble();
final remaining = (1 - normalizedProgress).clamp(0.0, 1.0).toDouble();
final rect = Offset.zero & size;
final arcRect = rect.deflate(strokeWidth / 2);
final trackPaint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.18);
canvas.drawArc(arcRect, -math.pi / 2, math.pi, false, trackPaint);
canvas.drawArc(arcRect, -math.pi / 2, -math.pi, false, trackPaint);
if (remaining <= 0) {
return;
}
final progressGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.96),
Color.lerp(color, Colors.white, 0.2)!.withValues(alpha: 0.92),
color.withValues(alpha: 0.98),
],
stops: const [0.0, 0.38, 1.0],
);
final progressPaint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..shader = progressGradient.createShader(arcRect);
canvas.drawArc(
arcRect,
-math.pi / 2,
math.pi * remaining,
false,
progressPaint,
);
canvas.drawArc(
arcRect,
-math.pi / 2,
-math.pi * remaining,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant _ComboCountdownRingPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
}
class _ComboWavePainter extends CustomPainter {
const _ComboWavePainter({
required this.progress,
required this.color,
required this.baseRadius,
});
final double progress;
final Color color;
final double baseRadius;
@override
void paint(Canvas canvas, Size size) {
if (progress <= 0) {
return;
}
final easedProgress = Curves.easeOutCubic.transform(progress);
final center = size.center(Offset.zero);
final glowRadius =
lerpDouble(baseRadius * 0.86, baseRadius * 1.42, easedProgress) ??
baseRadius;
final glowPaint =
Paint()
..shader = RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.22 * (1 - easedProgress)),
color.withValues(alpha: 0.12 * (1 - easedProgress)),
Colors.transparent,
],
stops: const [0.0, 0.58, 1.0],
).createShader(Rect.fromCircle(center: center, radius: glowRadius));
canvas.drawCircle(center, glowRadius, glowPaint);
_paintRing(
canvas,
center,
Curves.easeOut.transform(easedProgress),
opacityScale: 1,
radiusStartFactor: 0.76,
radiusEndFactor: 1.24,
);
final trailingProgress = ((progress - 0.18) / 0.82).clamp(0.0, 1.0);
if (trailingProgress > 0) {
_paintRing(
canvas,
center,
Curves.easeOut.transform(trailingProgress),
opacityScale: 0.72,
radiusStartFactor: 0.68,
radiusEndFactor: 1.08,
);
}
}
void _paintRing(
Canvas canvas,
Offset center,
double value, {
required double opacityScale,
required double radiusStartFactor,
required double radiusEndFactor,
}) {
final radius =
lerpDouble(
baseRadius * radiusStartFactor,
baseRadius * radiusEndFactor,
value,
) ??
baseRadius;
final strokeWidth = lerpDouble(10.w, 1.8.w, value) ?? 2.w;
final alpha = (1 - value) * 0.5 * opacityScale;
if (alpha <= 0) {
return;
}
final ringPaint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = color.withValues(alpha: alpha);
canvas.drawCircle(center, radius, ringPaint);
}
@override
bool shouldRepaint(covariant _ComboWavePainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.baseRadius != baseRadius;
}
}

View File

@ -0,0 +1,323 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
class RegisterRewardDialogItem {
const RegisterRewardDialogItem({required this.title});
final String title;
}
class RegisterRewardDialog extends StatelessWidget {
const RegisterRewardDialog({
super.key,
required this.items,
this.title = 'Welcome to Yumi Party',
this.subtitle = 'Here are some small gifts for you',
});
static const String dialogTag = 'showRegisterRewardDialog';
static const double _dialogWidth = 340;
static const double _dialogHeight = 250;
static const double _titleWidth = 147;
static const double _titleHeight = 22;
final List<RegisterRewardDialogItem> items;
final String title;
final String subtitle;
static List<RegisterRewardDialogItem> defaultItems() {
return const [
RegisterRewardDialogItem(title: 'small gifts'),
RegisterRewardDialogItem(title: 'small gifts'),
];
}
static Future<void> show(
BuildContext context, {
List<RegisterRewardDialogItem>? items,
String title = 'Welcome to Yumi Party',
String subtitle = 'Here are some small gifts for you',
}) async {
SmartDialog.dismiss(tag: dialogTag);
await SmartDialog.show(
tag: dialogTag,
alignment: Alignment.center,
maskColor: Colors.black.withValues(alpha: 0.56),
animationType: SmartAnimationType.fade,
clickMaskDismiss: true,
builder:
(_) => RegisterRewardDialog(
items: items ?? defaultItems(),
title: title,
subtitle: subtitle,
),
);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final dialogWidth = math.min(screenWidth - 36.w, _dialogWidth.w);
return Material(
color: Colors.transparent,
child: SizedBox(
width: dialogWidth,
child: AspectRatio(
aspectRatio: _dialogWidth / _dialogHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final scale = constraints.maxWidth / _dialogWidth;
return Stack(
children: [
Positioned.fill(
child: Image.asset(
_RegisterRewardAssets.dialogBackground,
fit: BoxFit.fill,
),
),
PositionedDirectional(
end: 28 * scale,
top: 52 * scale,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap:
() => SmartDialog.dismiss(
tag: RegisterRewardDialog.dialogTag,
),
child: Padding(
padding: EdgeInsets.all(6 * scale),
child: Image.asset(
_RegisterRewardAssets.closeIcon,
width: 14 * scale,
height: 14 * scale,
fit: BoxFit.contain,
),
),
),
),
Positioned(
top: 34 * scale,
left: 0,
right: 0,
child: Center(
child: _RegisterRewardTitle(
text: title,
width: _titleWidth * scale,
height: _titleHeight * scale,
),
),
),
Positioned(
top: 76 * scale,
left: 24 * scale,
right: 24 * scale,
child: text(
subtitle,
fontSize: 12,
fontWeight: FontWeight.w400,
textAlign: TextAlign.center,
maxLines: 2,
textColor: Colors.white,
),
),
Positioned(
left: 0,
right: 0,
bottom: 38 * scale,
child: _RegisterRewardItemsRow(items: items, scale: scale),
),
],
);
},
),
),
),
);
}
}
class _RegisterRewardTitle extends StatelessWidget {
const _RegisterRewardTitle({
required this.text,
required this.width,
required this.height,
});
final String text;
final double width;
final double height;
@override
Widget build(BuildContext context) {
const gradientColors = [
Color(0xFFFFF2BA),
Color(0xFFFFFAE3),
Color(0xFFFFF2BA),
Color(0xFFFFFBE8),
Color(0xFFFFF2BA),
];
final shadowTextStyle = TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
height: 1.0,
color: Colors.white,
shadows: const [
Shadow(color: Color(0xFF17614D), blurRadius: 4, offset: Offset.zero),
],
);
final gradientTextStyle = TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
height: 1.0,
color: Colors.white,
);
return SizedBox(
width: width,
height: height,
child: Stack(
alignment: Alignment.center,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
text,
maxLines: 1,
textAlign: TextAlign.center,
style: shadowTextStyle,
),
),
ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) {
return const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: gradientColors,
).createShader(bounds);
},
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
text,
maxLines: 1,
textAlign: TextAlign.center,
style: gradientTextStyle,
),
),
),
],
),
);
}
}
class _RegisterRewardItemsRow extends StatelessWidget {
const _RegisterRewardItemsRow({required this.items, required this.scale});
final List<RegisterRewardDialogItem> items;
final double scale;
@override
Widget build(BuildContext context) {
final visibleItems = items.take(2).toList();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < visibleItems.length; index++) ...[
_RegisterRewardItemCard(item: visibleItems[index], scale: scale),
if (index != visibleItems.length - 1) SizedBox(width: 30 * scale),
],
],
);
}
}
class _RegisterRewardItemCard extends StatelessWidget {
const _RegisterRewardItemCard({required this.item, required this.scale});
final RegisterRewardDialogItem item;
final double scale;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 88 * scale,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 88 * scale,
height: 88 * scale,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
_RegisterRewardAssets.goldGlowBackground,
width: 88 * scale,
height: 88 * scale,
fit: BoxFit.fill,
),
Container(
width: 44 * scale,
height: 44 * scale,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.10),
border: Border.all(
color: Colors.white.withValues(alpha: 0.62),
width: 1.2 * scale,
),
boxShadow: [
BoxShadow(
color: Colors.white.withValues(alpha: 0.10),
blurRadius: 8 * scale,
offset: Offset.zero,
),
],
),
child: Center(
child: Container(
width: 18 * scale,
height: 18 * scale,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.18),
),
),
),
),
],
),
),
SizedBox(height: 8 * scale),
text(
item.title,
fontSize: 12,
fontWeight: FontWeight.w400,
textAlign: TextAlign.center,
maxLines: 2,
lineHeight: 1.2,
textColor: Colors.white,
),
],
),
);
}
}
class _RegisterRewardAssets {
static const String dialogBackground =
'sc_images/register_reward/dialog_background.png';
static const String goldGlowBackground =
'sc_images/register_reward/gold_glow_background.png';
static const String closeIcon = 'sc_images/register_reward/close_icon.png';
}

View File

@ -6,6 +6,7 @@ import 'package:yumi/main.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
///
class LGiftAnimalPage extends StatefulWidget {
@ -19,7 +20,8 @@ class LGiftAnimalPage extends StatefulWidget {
class _GiftAnimalPageState extends State<LGiftAnimalPage>
with TickerProviderStateMixin {
static const Set<int> _luckyGiftMilestones = <int>{10, 20, 50, 100, 200, 300};
static const String _luckyGiftRewardFrameAssetPath =
"sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga";
@override
Widget build(BuildContext context) {
@ -52,9 +54,11 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
return AnimatedBuilder(
animation: bean.controller,
builder: (context, child) {
final showLuckyRewardFrame = gift.showLuckyRewardFrame;
return Container(
margin: bean.verticalAnimation.value,
width: ScreenUtil().screenWidth * 0.78,
width:
ScreenUtil().screenWidth * (showLuckyRewardFrame ? 0.96 : 0.78),
child: _buildGiftTickerCard(context, gift, bean.sizeAnimation.value),
);
},
@ -66,6 +70,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
LGiftModel gift,
double animatedSize,
) {
final showLuckyRewardFrame = gift.showLuckyRewardFrame;
return SizedBox(
height: 52.w,
child: Stack(
@ -73,11 +78,12 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
clipBehavior: Clip.none,
children: [
Container(
width: ScreenUtil().screenWidth * 0.67,
width:
ScreenUtil().screenWidth * (showLuckyRewardFrame ? 0.69 : 0.67),
height: 42.w,
padding: EdgeInsetsDirectional.only(
start: 3.w,
end: 76.w,
end: showLuckyRewardFrame ? 174.w : 76.w,
top: 3.w,
bottom: 3.w,
),
@ -145,7 +151,9 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
end: 0,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: ScreenUtil().screenWidth * 0.30,
maxWidth:
ScreenUtil().screenWidth *
(showLuckyRewardFrame ? 0.48 : 0.30),
minHeight: 40.w,
),
child: Row(
@ -159,14 +167,20 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
width: 34.w,
height: 34.w,
),
SizedBox(width: 8.w),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: _buildGiftCountLabel(gift, animatedSize),
if (gift.giftCount > 0) ...[
SizedBox(width: 8.w),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: _buildGiftCountLabel(gift, animatedSize),
),
),
),
],
if (showLuckyRewardFrame) ...[
SizedBox(width: 6.w),
_buildLuckyGiftRewardFrame(gift),
],
],
),
),
@ -177,9 +191,8 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
}
Widget _buildGiftCountLabel(LGiftModel gift, double animatedSize) {
final isMilestone = _isLuckyGiftMilestone(gift);
final xFontSize = animatedSize * (isMilestone ? 1.1 : 1.0);
final countFontSize = animatedSize * (isMilestone ? 1.45 : 1.0);
final xFontSize = animatedSize;
final countFontSize = animatedSize;
return Directionality(
textDirection: TextDirection.ltr,
child: Row(
@ -191,108 +204,97 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
style: TextStyle(
fontSize: xFontSize,
fontStyle: FontStyle.italic,
color:
isMilestone
? const Color(0xFFFFE08A)
: const Color(0xFFFFD400),
color: const Color(0xFFFFD400),
fontWeight: FontWeight.bold,
height: 1,
shadows:
isMilestone
? const [
Shadow(
color: Color(0xCC7A3E00),
blurRadius: 8,
offset: Offset(0, 2),
),
]
: null,
),
),
SizedBox(width: 2.w),
isMilestone
? _buildMilestoneGiftCountText(
_giftCountText(gift.giftCount),
fontSize: countFontSize,
)
: Text(
_giftCountText(gift.giftCount),
style: TextStyle(
fontSize: countFontSize,
fontStyle: FontStyle.italic,
color: const Color(0xFFFFD400),
fontWeight: FontWeight.bold,
height: 1,
),
),
Text(
_giftCountText(gift.giftCount),
style: TextStyle(
fontSize: countFontSize,
fontStyle: FontStyle.italic,
color: const Color(0xFFFFD400),
fontWeight: FontWeight.bold,
height: 1,
),
),
],
),
);
}
Widget _buildMilestoneGiftCountText(
String countText, {
required double fontSize,
}) {
return Stack(
children: [
Text(
countText,
style: TextStyle(
fontSize: fontSize,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w900,
height: 1,
foreground:
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..color = const Color(0xFF7A3E00),
Widget _buildLuckyGiftRewardFrame(LGiftModel gift) {
return SizedBox(
width: 108.w,
height: 52.w,
child: Stack(
alignment: Alignment.center,
children: [
SCSvgaAssetWidget(
assetPath: _luckyGiftRewardFrameAssetPath,
width: 108.w,
height: 52.w,
fit: BoxFit.cover,
loop: true,
allowDrawingOverflow: true,
fallback: _buildLuckyGiftRewardFrameFallback(),
),
),
ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback:
(bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Color(0xFFFFF6C7),
Color(0xFFFFE07A),
Color(0xFFFFB800),
],
).createShader(bounds),
child: Text(
countText,
style: TextStyle(
fontSize: fontSize,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w900,
color: Colors.white,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC6A3700),
blurRadius: 10,
offset: Offset(0, 3),
Padding(
padding: EdgeInsetsDirectional.only(
start: 12.w,
end: 12.w,
top: 1.w,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"+${gift.rewardAmountText}",
maxLines: 1,
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFFFFF2AE),
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC6A3700),
blurRadius: 10,
offset: Offset(0, 2),
),
],
),
],
),
),
),
),
],
],
),
);
}
bool _isLuckyGiftMilestone(LGiftModel gift) {
if (!gift.isLuckyGift) {
return false;
}
final count = gift.giftCount;
if (count % 1 != 0) {
return false;
}
return _luckyGiftMilestones.contains(count.toInt());
Widget _buildLuckyGiftRewardFrameFallback() {
return Container(
width: 108.w,
height: 52.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.w),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[Color(0xCC7C4300), Color(0xE6D99A36)],
),
border: Border.all(color: const Color(0xFFF7D87B), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x663F1E00),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
);
}
String _giftCountText(num count) {
@ -425,8 +427,11 @@ class LGiftModel {
//
num giftCount = 0;
//
bool isLuckyGift = false;
//
String rewardAmountText = "";
//
bool showLuckyRewardFrame = false;
//id
String labelId = "";

View File

@ -1,6 +1,5 @@
import 'dart:collection';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
@ -205,6 +204,8 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
late final AnimationController _controller;
RoomGiftSeatFlightRequest? _centerRequest;
ImageProvider<Object>? _centerImageProvider;
RoomGiftSeatFlightRequest? _activeRequest;
ImageProvider<Object>? _activeImageProvider;
Offset? _activeTargetOffset;
@ -238,6 +239,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
}
void _enqueue(RoomGiftSeatFlightRequest request) {
_ensureCenterVisual(request);
_queue.add(_QueuedRoomGiftSeatFlightRequest(request: request));
_scheduleNextAnimation();
}
@ -267,6 +269,8 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_controller.stop();
_controller.reset();
if (!mounted) {
_centerRequest = null;
_centerImageProvider = null;
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
@ -274,6 +278,8 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
return;
}
setState(() {
_centerRequest = null;
_centerImageProvider = null;
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
@ -304,6 +310,17 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
for (final queuedRequest in requestsToRemove) {
_queue.remove(queuedRequest);
}
if (!_isPlaying && _queue.isEmpty) {
if (!mounted) {
_centerRequest = null;
_centerImageProvider = null;
return;
}
setState(() {
_centerRequest = null;
_centerImageProvider = null;
});
}
}
int _countTrackedRequests(String queueTag) {
@ -368,11 +385,11 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
queuedRequest.request.imagePath,
);
_isPlaying = true;
_controller.duration =
queuedRequest.request.holdDuration +
queuedRequest.request.flightDuration;
_controller.duration = queuedRequest.request.flightDuration;
setState(() {
_centerRequest = queuedRequest.request;
_centerImageProvider = imageProvider;
_activeRequest = queuedRequest.request;
_activeTargetOffset = targetOffset;
_activeImageProvider = imageProvider;
@ -394,10 +411,23 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
}
void _finishCurrentAnimation() {
final hasPendingRequests = _queue.isNotEmpty;
if (!mounted) {
if (!hasPendingRequests) {
_centerRequest = null;
_centerImageProvider = null;
}
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
return;
}
setState(() {
if (!hasPendingRequests) {
_centerRequest = null;
_centerImageProvider = null;
}
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
@ -406,6 +436,22 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_scheduleNextAnimation();
}
void _ensureCenterVisual(RoomGiftSeatFlightRequest request) {
if (_centerRequest != null && _centerImageProvider != null) {
return;
}
final imageProvider = _resolveImageProvider(request.imagePath);
if (!mounted) {
_centerRequest = request;
_centerImageProvider = imageProvider;
return;
}
setState(() {
_centerRequest = request;
_centerImageProvider = imageProvider;
});
}
Offset? _resolveTargetOffset(String targetUserId) {
final overlayContext = _overlayKey.currentContext;
final targetKey = widget.resolveTargetKey(targetUserId);
@ -445,9 +491,10 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
child: SizedBox.expand(
key: _overlayKey,
child:
_activeRequest == null ||
_activeImageProvider == null ||
_activeTargetOffset == null
(_centerRequest == null || _centerImageProvider == null) &&
(_activeRequest == null ||
_activeImageProvider == null ||
_activeTargetOffset == null)
? const SizedBox.shrink()
: AnimatedBuilder(
animation: _controller,
@ -458,82 +505,59 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
final overlaySize =
renderBox?.size ?? MediaQuery.sizeOf(context);
final center = overlaySize.center(Offset.zero);
final request = _activeRequest!;
final totalDuration =
request.holdDuration + request.flightDuration;
final holdRatio =
totalDuration.inMilliseconds == 0
? 0.0
: request.holdDuration.inMilliseconds /
totalDuration.inMilliseconds;
final progress = _controller.value;
final currentCenterRequest =
_centerRequest ?? _activeRequest;
final children = <Widget>[];
if (progress <= holdRatio) {
final normalizedHold =
holdRatio == 0
? 1.0
: (progress / holdRatio).clamp(0.0, 1.0);
final pulseScale =
1 + math.sin(normalizedHold * math.pi) * 0.05;
return Stack(
clipBehavior: Clip.none,
children: [
_buildGiftNode(
center: center,
size: request.beginSize,
opacity: 1,
scale: pulseScale,
),
],
if (currentCenterRequest != null &&
_centerImageProvider != null) {
children.add(
_buildGiftNode(
imageProvider: _centerImageProvider!,
center: center,
size: currentCenterRequest.beginSize,
opacity: 1,
),
);
}
final flightProgress = ((progress - holdRatio) /
(1 - holdRatio))
.clamp(0.0, 1.0);
final travelProgress = Curves.easeInOutCubic.transform(
flightProgress,
);
const trailGaps = <double>[0, 0.16, 0.32];
const trailOpacities = <double>[1, 0.5, 0.24];
const trailScales = <double>[1, 0.92, 0.84];
final activeRequest = _activeRequest;
final activeImageProvider = _activeImageProvider;
final activeTargetOffset = _activeTargetOffset;
if (activeRequest != null &&
activeImageProvider != null &&
activeTargetOffset != null) {
final travelProgress = Curves.easeInOutCubic.transform(
_controller.value.clamp(0.0, 1.0),
);
final nodeCenter =
Offset.lerp(
center,
activeTargetOffset,
travelProgress,
)!;
final nodeSize =
lerpDouble(
activeRequest.beginSize,
activeRequest.endSize,
travelProgress,
)!;
final fadeOut =
1 -
Curves.easeOut.transform(
((travelProgress - 0.82) / 0.18).clamp(0.0, 1.0),
);
children.add(
_buildGiftNode(
imageProvider: activeImageProvider,
center: nodeCenter,
size: nodeSize,
opacity: fadeOut,
),
);
}
return Stack(
clipBehavior: Clip.none,
children:
List.generate(trailGaps.length, (index) {
final trailingProgress = (travelProgress -
trailGaps[index])
.clamp(0.0, 1.0);
final nodeCenter =
Offset.lerp(
center,
_activeTargetOffset,
trailingProgress,
)!;
final nodeSize =
lerpDouble(
request.beginSize,
request.endSize,
trailingProgress,
)!;
final fadeOut =
1 -
Curves.easeOut.transform(
((travelProgress - 0.88) / 0.12).clamp(
0.0,
1.0,
),
);
return _buildGiftNode(
center: nodeCenter,
size: nodeSize,
opacity: trailOpacities[index] * fadeOut,
scale: trailScales[index],
);
}).reversed.toList(),
);
return Stack(clipBehavior: Clip.none, children: children);
},
),
),
@ -541,12 +565,13 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
}
Widget _buildGiftNode({
required ImageProvider<Object> imageProvider,
required Offset center,
required double size,
required double opacity,
double scale = 1,
}) {
if (_activeImageProvider == null || opacity <= 0 || size <= 0) {
if (opacity <= 0 || size <= 0) {
return const SizedBox.shrink();
}
@ -571,7 +596,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
],
),
child: Image(
image: _activeImageProvider!,
image: imageProvider,
fit: BoxFit.contain,
filterQuality: FilterQuality.low,
errorBuilder: (context, error, stackTrace) {

View File

@ -1,79 +1,41 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/ui_kit/components/sc_debounce_widget.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class RoomBottomGiftButton extends StatefulWidget {
class RoomBottomGiftButton extends StatelessWidget {
const RoomBottomGiftButton({super.key, required this.onTap});
final VoidCallback onTap;
@override
State<RoomBottomGiftButton> createState() => _RoomBottomGiftButtonState();
}
class _RoomBottomGiftButtonState extends State<RoomBottomGiftButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2600),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
static const String _giftButtonAssetPath =
'sc_images/room/anim/gift/room_bottom_gift_button.svga';
@override
Widget build(BuildContext context) {
final disableAnimations =
MediaQuery.maybeOf(context)?.disableAnimations ??
WidgetsBinding
.instance
.platformDispatcher
.accessibilityFeatures
.disableAnimations;
return SizedBox(
width: 52.w,
height: 52.w,
child: SCDebounceWidget(
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _controller,
child: _buildGiftCore(),
builder: (context, child) {
final progress = disableAnimations ? 0.0 : _controller.value;
final rotation = math.sin(progress * math.pi * 2) * 0.07;
final offsetY = math.sin(progress * math.pi * 4) * 1.4;
return Transform.translate(
offset: Offset(0, offsetY),
child: Transform.rotate(angle: rotation, child: child),
);
},
),
),
child: SCDebounceWidget(onTap: onTap, child: _buildGiftCore()),
);
}
Widget _buildGiftCore() {
return SizedBox(
width: 48.w,
height: 48.w,
child: Image.asset(
'sc_images/room/sc_icon_botton_gift.png',
width: 48.w,
height: 48.w,
width: 52.w,
height: 52.w,
child: SCSvgaAssetWidget(
assetPath: _giftButtonAssetPath,
width: 52.w,
height: 52.w,
active: true,
loop: true,
fit: BoxFit.contain,
fallback: Image.asset(
'sc_images/room/sc_icon_botton_gift.png',
width: 48.w,
height: 48.w,
fit: BoxFit.contain,
),
),
);
}

View File

@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/services/gift/room_gift_combo_send_controller.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/ui_kit/widgets/gift/sc_gift_combo_badge_button.dart';
class RoomGiftComboFloatingButton extends StatelessWidget {
const RoomGiftComboFloatingButton({super.key});
static const double hostSize = 106;
static const double badgeSize = 78;
static const String assetPath =
'sc_images/room/anim/gift/room_gift_combo_badge.png';
@override
Widget build(BuildContext context) {
final controller = RoomGiftComboSendController();
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
if (!controller.isVisible) {
return const SizedBox.shrink();
}
return SizedBox(
width: hostSize.w,
height: hostSize.w,
child: Center(
child: SCGiftComboBadgeButton(
assetPath: assetPath,
progress: controller.countdownProgress,
size: badgeSize,
themeColor: SocialChatTheme.primaryLight,
onPressed: () {
unawaited(controller.sendActiveRequest());
},
fallback: _buildFallback(),
),
),
);
},
);
}
Widget _buildFallback() {
return DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color.lerp(SocialChatTheme.primaryLight, Colors.white, 0.18)!,
SocialChatTheme.primaryLight,
const Color(0xFF0E8C64),
],
),
),
child: Center(
child: Text(
'combo',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFFFFE7A3),
fontWeight: FontWeight.w700,
),
),
),
);
}
}

View File

@ -1,17 +1,15 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class LuckGiftNomorAnimWidget extends StatefulWidget {
static const String _rewardFrameAssetPath =
"sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga";
static const String _rewardFrameFallbackAssetPath =
"sc_images/room/sc_icon_luck_gift_nomore.webp";
static const num _rewardBurstMinAwardAmount = 5000;
static const num _rewardBurstMinMultiple = 10;
static const String _rewardBurstAssetPath =
"sc_images/room/anim/luck_gift/luck_gift_reward_burst.svga";
const LuckGiftNomorAnimWidget({super.key});
@ -21,15 +19,21 @@ class LuckGiftNomorAnimWidget extends StatefulWidget {
}
class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
@override
void initState() {
super.initState();
Provider.of<RtmProvider>(context, listen: false).showLuckGiftBigHead = true;
bool _shouldPlayRewardBurst(Data rewardData) {
final awardAmount = rewardData.awardAmount ?? 0;
final multiple = rewardData.multiple ?? 0;
return awardAmount > LuckGiftNomorAnimWidget._rewardBurstMinAwardAmount ||
multiple > LuckGiftNomorAnimWidget._rewardBurstMinMultiple;
}
@override
void dispose() {
super.dispose();
String _formatAwardAmount(num awardAmount) {
if (awardAmount > 9999) {
return "${(awardAmount / 1000).toStringAsFixed(0)}k";
}
if (awardAmount % 1 == 0) {
return awardAmount.toInt().toString();
}
return awardAmount.toString();
}
@override
@ -37,101 +41,65 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
return IgnorePointer(
child: Consumer<RtmProvider>(
builder: (context, provider, child) {
return provider.currentPlayingLuckGift != null
? Container(
height: 380.w,
margin: EdgeInsets.only(top: 10.w),
child: Stack(
children: [
SCSvgaAssetWidget(
key: ValueKey<String>(
'${provider.currentPlayingLuckGift?.data?.sendUserId ?? ""}'
'|${provider.currentPlayingLuckGift?.data?.acceptUserId ?? ""}'
'|${provider.currentPlayingLuckGift?.data?.awardAmount ?? 0}'
'|${provider.currentPlayingLuckGift?.data?.multiple ?? 0}',
),
assetPath: LuckGiftNomorAnimWidget._rewardFrameAssetPath,
width: ScreenUtil().screenWidth,
height: 380.w,
fit: BoxFit.fitWidth,
allowDrawingOverflow: true,
fallback: Image.asset(
LuckGiftNomorAnimWidget._rewardFrameFallbackAssetPath,
fit: BoxFit.fitWidth,
),
final rewardData = provider.currentPlayingLuckGift?.data;
if (rewardData == null || !_shouldPlayRewardBurst(rewardData)) {
return const SizedBox.shrink();
}
final rewardAnimationKey = ValueKey<String>(
'${rewardData.sendUserId ?? ""}'
'|${rewardData.acceptUserId ?? ""}'
'|${rewardData.awardAmount ?? 0}'
'|${rewardData.multiple ?? 0}'
'|${rewardData.normalizedMultipleType}',
);
return SizedBox(
height: 380.w,
child: Stack(
alignment: Alignment.center,
children: [
Padding(
padding: EdgeInsets.only(top: 10.w),
child: SCSvgaAssetWidget(
key: ValueKey<String>('burst|${rewardAnimationKey.value}'),
assetPath: LuckGiftNomorAnimWidget._rewardBurstAssetPath,
width: ScreenUtil().screenWidth,
height: 380.w,
fit: BoxFit.fitWidth,
allowDrawingOverflow: true,
),
),
Positioned(
top: 154.w,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: ScreenUtil().screenWidth * 0.56,
),
provider.showLuckGiftBigHead
? Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 125.w),
netImage(
url:
provider
.currentPlayingLuckGift
?.data
?.userAvatar ??
"",
shape: BoxShape.circle,
height: 105.w,
width: 105.w,
),
SizedBox(height: 16.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
"sc_images/general/sc_icon_jb.png",
height: 35.w,
),
SizedBox(width: 3.w),
Image.asset(
"sc_images/general/sc_icon_game_numxx.png",
height: 20.w,
),
Transform.translate(
offset: Offset(-5, 0),
child: buildNumForGame(
(provider
.currentPlayingLuckGift
?.data
?.awardAmount ??
0) >
9999
? "${((provider.currentPlayingLuckGift?.data?.awardAmount ?? 0) / 1000).toStringAsFixed(0)}k"
: "${(provider.currentPlayingLuckGift?.data?.awardAmount ?? 0)}",
size: 22.w,
),
),
],
),
SizedBox(height: 12.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(width: 15.w),
buildNumForGame(
"${provider.currentPlayingLuckGift?.data?.multiple ?? 0}",
size: 22.w,
),
Transform.translate(
offset: Offset(-3, 3),
child: Image.asset(
SCGlobalConfig.lang == "ar"
? "sc_images/room/sc_icon_times_text_ar.png"
: "sc_images/room/sc_icon_times_text_en.png",
height: 12.w,
),
),
],
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"+${_formatAwardAmount(rewardData.awardAmount ?? 0)}",
maxLines: 1,
style: TextStyle(
fontSize: 28.sp,
color: const Color(0xFFFFF3B6),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 1,
shadows: const [
Shadow(
color: Color(0xCC7A3E00),
blurRadius: 12,
offset: Offset(0, 3),
),
],
)
: Container(),
],
),
),
),
),
),
)
: Container();
],
),
);
},
),
);

View File

@ -29,6 +29,7 @@ class FloatingLuckGiftScreenWidget extends StatefulWidget {
class _FloatingLuckGiftScreenWidgetState
extends State<FloatingLuckGiftScreenWidget>
with TickerProviderStateMixin {
static const String _coinIconAssetPath = "sc_images/general/sc_icon_jb.png";
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late AnimationController _swipeController; //
@ -226,51 +227,7 @@ class _FloatingLuckGiftScreenWidgetState
),
),
SizedBox(width: 3.w),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: SCAppLocalizations.of(context)!.get,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.1,
),
),
TextSpan(
text:
" ${widget.message.coins} ${SCAppLocalizations.of(context)!.coins3} ",
style: TextStyle(
fontSize: 12.sp,
color: Color(0xffFEF129),
fontWeight: FontWeight.bold,
letterSpacing: 0.1,
),
),
TextSpan(
text:
SCAppLocalizations.of(
context,
)!.fromLuckyGifts,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.1,
),
),
],
),
textAlign: TextAlign.start,
strutStyle: StrutStyle(
height: 1.1, //
fontWeight: FontWeight.bold,
forceStrutHeight: true, //
),
),
),
Expanded(child: _buildRewardLine(context)),
SizedBox(width: 6.w),
Container(
width: 80.w,
@ -310,6 +267,61 @@ class _FloatingLuckGiftScreenWidgetState
);
}
Widget _buildRewardLine(BuildContext context) {
final baseStyle = TextStyle(
fontSize: 12.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.1,
decoration: TextDecoration.none,
);
final amountStyle = baseStyle.copyWith(color: const Color(0xffFEF129));
return Text.rich(
TextSpan(
children: [
TextSpan(text: SCAppLocalizations.of(context)!.get, style: baseStyle),
TextSpan(
text: " ${_formatCoins(widget.message.coins)} ",
style: amountStyle,
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsetsDirectional.only(end: 3.w),
child: Image.asset(_coinIconAssetPath, width: 16.w, height: 16.w),
),
),
TextSpan(text: "from ", style: baseStyle),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: netImage(
url: widget.message.giftUrl ?? "",
width: 18.w,
height: 18.w,
),
),
],
),
textAlign: TextAlign.start,
strutStyle: StrutStyle(
height: 1.1,
fontWeight: FontWeight.bold,
forceStrutHeight: true,
),
);
}
String _formatCoins(num? coins) {
final value = (coins ?? 0);
if (value > 9999) {
return "${(value / 1000).toStringAsFixed(0)}k";
}
if (value % 1 == 0) {
return value.toInt().toString();
}
return value.toString();
}
///10000
Widget _buildGiftAnimation() {
return GestureDetector(

View File

@ -4,9 +4,11 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/gift/gift_page.dart';
import 'package:yumi/services/gift/room_gift_combo_send_controller.dart';
import 'package:yumi/ui_kit/widgets/room/bottom/room_bottom_chat_entry.dart';
import 'package:yumi/ui_kit/widgets/room/bottom/room_bottom_circle_action.dart';
import 'package:yumi/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart';
import 'package:yumi/ui_kit/widgets/room/bottom/room_gift_combo_floating_button.dart';
import 'package:yumi/ui_kit/widgets/room/room_menu_dialog.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
@ -28,11 +30,26 @@ class RoomBottomWidget extends StatefulWidget {
class _RoomBottomWidgetState extends State<RoomBottomWidget> {
int roomMenuStime1 = 0;
static const double _bottomBarHeight = 72;
static const double _floatingButtonHostHeight = 122;
static const double _floatingButtonWidth =
RoomGiftComboFloatingButton.hostSize;
static const double _floatingButtonBottomOffset = 54;
static const double _giftActionWidth = 52;
static const double _circleActionWidth = 46;
static const double _compactGap = 14;
static const double _horizontalPadding = 16;
@override
void dispose() {
RoomGiftComboSendController().hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 72.w,
height: _floatingButtonHostHeight.w,
child: Consumer<RtcProvider>(
builder: (context, rtcProvider, child) {
final showMic = _shouldShowMic(rtcProvider);
@ -40,37 +57,34 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
return LayoutBuilder(
builder: (context, constraints) {
final inputWidth = constraints.maxWidth / 3;
final giftAction = RoomBottomGiftButton(onTap: _showGiftPanel);
final messageAction = _buildMessageAction();
final menuAction = _buildMenuAction();
final giftCenterX = _resolveGiftCenterX(
maxWidth: constraints.maxWidth,
inputWidth: inputWidth,
showMic: showMic,
);
return Padding(
padding: EdgeInsetsDirectional.only(start: 16.w, end: 16.w),
child:
showMic
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
giftAction,
_buildMicAction(rtcProvider),
menuAction,
messageAction,
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
const Spacer(),
giftAction,
SizedBox(width: 14.w),
menuAction,
SizedBox(width: 14.w),
messageAction,
],
),
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: giftCenterX - (_floatingButtonWidth.w / 2),
bottom: _floatingButtonBottomOffset.w,
child: const RoomGiftComboFloatingButton(),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: SizedBox(
height: _bottomBarHeight.w,
child: _buildBottomBar(
rtcProvider: rtcProvider,
inputWidth: inputWidth,
showMic: showMic,
),
),
),
],
);
},
);
@ -90,6 +104,85 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
);
}
Widget _buildBottomBar({
required RtcProvider rtcProvider,
required double inputWidth,
required bool showMic,
}) {
final giftAction = _buildGiftAction();
final messageAction = _buildMessageAction();
final menuAction = _buildMenuAction();
return Padding(
padding: EdgeInsetsDirectional.only(
start: _horizontalPadding.w,
end: _horizontalPadding.w,
),
child:
showMic
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
giftAction,
_buildMicAction(rtcProvider),
menuAction,
messageAction,
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildChatEntry(inputWidth),
const Spacer(),
giftAction,
SizedBox(width: _compactGap.w),
menuAction,
SizedBox(width: _compactGap.w),
messageAction,
],
),
);
}
Widget _buildGiftAction() {
return SizedBox(
width: _giftActionWidth.w,
height: _giftActionWidth.w,
child: RoomBottomGiftButton(onTap: _showGiftPanel),
);
}
double _resolveGiftCenterX({
required double maxWidth,
required double inputWidth,
required bool showMic,
}) {
final contentWidth = maxWidth - (_horizontalPadding.w * 2);
if (showMic) {
final occupiedWidth =
inputWidth + _giftActionWidth.w + _circleActionWidth.w * 3;
final gapCount = 4;
final gap = ((contentWidth - occupiedWidth) / gapCount).clamp(
0.0,
double.infinity,
);
return _horizontalPadding.w + inputWidth + gap + (_giftActionWidth.w / 2);
}
final fixedWidth =
inputWidth +
_giftActionWidth.w +
_circleActionWidth.w * 2 +
_compactGap.w * 2;
final spacerWidth = (contentWidth - fixedWidth).clamp(0.0, double.infinity);
return _horizontalPadding.w +
inputWidth +
spacerWidth +
(_giftActionWidth.w / 2);
}
void _showGiftPanel() {
SmartDialog.show(
tag: "showGiftControl",

View File

@ -384,7 +384,7 @@ class _MsgItemState extends State<MsgItem> {
);
spans.add(
TextSpan(
text: " ${widget.msg.awardAmount}",
text: " ${_formatAwardAmount(widget.msg.awardAmount)}",
style: TextStyle(
fontSize: sp(12),
color: SocialChatTheme.primaryColor,
@ -460,7 +460,7 @@ class _MsgItemState extends State<MsgItem> {
);
spans.add(
TextSpan(
text: " ${widget.msg.awardAmount} ",
text: " ${_formatAwardAmount(widget.msg.awardAmount)} ",
style: TextStyle(
fontSize: sp(12),
color: Color(0xffFEF129),
@ -470,19 +470,18 @@ class _MsgItemState extends State<MsgItem> {
),
);
spans.add(
TextSpan(
text: SCAppLocalizations.of(context)!.coins3,
style: TextStyle(
fontSize: sp(12),
color: Color(0xffFEF129),
fontWeight: FontWeight.w500,
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Image.asset(
"sc_images/general/sc_icon_jb.png",
width: 18.w,
height: 18.w,
),
recognizer: TapGestureRecognizer()..onTap = () {},
),
);
spans.add(
TextSpan(
text: " ${SCAppLocalizations.of(context)!.receivedFromALuckyGift}",
text: " from ",
style: TextStyle(
fontSize: sp(12),
color: Colors.white,
@ -491,6 +490,16 @@ class _MsgItemState extends State<MsgItem> {
recognizer: TapGestureRecognizer()..onTap = () {},
),
);
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: netImage(
url: widget.msg.gift?.giftPhoto ?? "",
width: 18.w,
height: 18.w,
),
),
);
Widget text = Text.rich(
TextSpan(children: spans),
textAlign: TextAlign.left,
@ -553,6 +562,16 @@ class _MsgItemState extends State<MsgItem> {
);
}
String _formatAwardAmount(num? awardAmount) {
if (awardAmount == null) {
return "0";
}
if (awardAmount % 1 == 0) {
return awardAmount.toInt().toString();
}
return awardAmount.toString();
}
Widget _buildGiftMsg(BuildContext context) {
return Container(
alignment: AlignmentDirectional.topStart,

View File

@ -1,8 +1,6 @@
import 'dart:convert';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
@ -19,14 +17,11 @@ import 'package:provider/provider.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart';
import 'package:tencent_cloud_chat_sdk/enum/conversation_type.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/business_logic/models/res/user_count_guard_res.dart';
import 'package:yumi/services/general/sc_app_general_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
@ -46,10 +41,42 @@ class RoomUserInfoCard extends StatefulWidget {
}
class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
static const String _leaveMicActionIconAsset =
"sc_images/room/sc_icon_room_user_card_leave_mic.png";
SocialChatUserProfileManager? userProvider;
String roomId = "";
SCUserIdentityRes? userIdentity;
Widget _buildCardAction({
required String iconAsset,
required String label,
required VoidCallback onTap,
}) {
return SCDebounceWidget(
onTap: onTap,
child: Column(
children: [
Image.asset(iconAsset, width: 32.w, height: 32.w),
SizedBox(height: 5.w),
Container(
alignment: Alignment.center,
width: 70.w,
child: text(
label,
maxLines: 2,
textAlign: TextAlign.center,
textColor: Color(0xff808080),
fontWeight: FontWeight.w600,
fontSize: 14.sp,
lineHeight: 1.1,
),
),
],
),
);
}
@override
void initState() {
super.initState();
@ -82,6 +109,15 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
top: false,
child: Consumer<SocialChatUserProfileManager>(
builder: (context, ref, child) {
final currentUserId =
AccountStorage().getCurrentUser()?.userProfile?.id;
final isSelf = widget.userId == currentUserId;
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currentMicIndex =
isSelf && currentUserId != null
? rtcProvider.userOnMaiInIndex(currentUserId)
: -1;
final canLeaveMic = currentMicIndex > -1;
if (ref.userCardInfo == null) {
return SizedBox(
height: ScreenUtil().screenHeight * 0.7,
@ -255,7 +291,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
),
child: Row(
children: [
SizedBox(width: 20.w,),
SizedBox(width: 20.w),
netImage(
url:
Provider.of<
@ -344,265 +380,233 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
margin: EdgeInsets.symmetric(
horizontal: 25.w,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
widget.userId !=
AccountStorage()
.getCurrentUser()
?.userProfile
?.id
? SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_send_user_message.png",
width: 32.w,
height: 32.w,
),
SizedBox(height: 5.w),
Container(
alignment:
Alignment.center,
width: 70.w,
child: text(
child:
isSelf
? (canLeaveMic
? Row(
mainAxisAlignment:
MainAxisAlignment
.center,
children: [
_buildCardAction(
iconAsset:
_leaveMicActionIconAsset,
label:
SCAppLocalizations.of(
context,
)!.leavelTheMic,
onTap: () {
Navigator.of(
context,
).pop();
rtcProvider.xiaMai(
currentMicIndex,
);
},
),
],
)
: const SizedBox.shrink())
: Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
_buildCardAction(
iconAsset:
"sc_images/room/sc_icon_send_user_message.png",
label:
SCAppLocalizations.of(
context,
)!.sayHi2,
textColor: Color(
0xff808080,
),
fontWeight:
FontWeight.w600,
fontSize: 14.sp,
),
),
],
),
onTap: () async {
Navigator.of(context).pop();
var conversation =
V2TimConversation(
type:
ConversationType
.V2TIM_C2C,
userID: widget.userId,
conversationID: '',
);
var bool =
await Provider.of<
RtmProvider
>(
onTap: () async {
Navigator.of(
context,
listen: false,
).startConversation(
conversation,
).pop();
var conversation =
V2TimConversation(
type:
ConversationType
.V2TIM_C2C,
userID:
widget.userId,
conversationID:
'',
);
var bool =
await Provider.of<
RtmProvider
>(
context,
listen: false,
).startConversation(
conversation,
);
if (!bool) return;
var json = jsonEncode(
conversation.toJson(),
);
if (!bool) return;
var json = jsonEncode(
conversation.toJson(),
);
SCNavigatorUtils.push(
navigatorKey.currentState!.context,
"${SCChatRouter.chat}?conversation=${Uri.encodeComponent(json)}",
);
},
)
: Container(),
widget.userId !=
AccountStorage()
.getCurrentUser()
?.userProfile
?.id
? SCDebounceWidget(
child: Selector<
SocialChatUserProfileManager,
bool
>(
selector:
(context, provider) =>
provider
.userCardInfo
?.follow ??
false,
shouldRebuild:
(prev, next) =>
prev != next,
builder: (
context,
follow,
_,
) {
return Column(
children: [
Image.asset(
follow
? "sc_images/room/sc_icon_user_un_follow.png"
: "sc_images/room/sc_icon_user_follow.png",
width: 32.w,
height: 32.w,
),
SizedBox(height: 5.w),
Container(
alignment:
Alignment
.center,
width: 70.w,
child: text(
// 使 Flutter Text
follow
? SCAppLocalizations.of(
context,
)!.followed
: SCAppLocalizations.of(
context,
)!.follow, //
fontSize: 14.sp,
textColor: Color(
0xff808080,
SCNavigatorUtils.push(
navigatorKey
.currentState!
.context,
"${SCChatRouter.chat}?conversation=${Uri.encodeComponent(json)}",
);
},
),
SCDebounceWidget(
child: Selector<
SocialChatUserProfileManager,
bool
>(
selector:
(
context,
provider,
) =>
provider
.userCardInfo
?.follow ??
false,
shouldRebuild:
(prev, next) =>
prev != next,
builder: (
context,
follow,
_,
) {
return Column(
children: [
Image.asset(
follow
? "sc_images/room/sc_icon_user_un_follow.png"
: "sc_images/room/sc_icon_user_follow.png",
width: 32.w,
height: 32.w,
),
fontWeight:
FontWeight
.bold,
),
),
],
);
},
),
onTap: () {
// 使 Provider 访
final provider = Provider.of<
SocialChatUserProfileManager
>(context, listen: false);
provider.followUser(
widget.userId ?? "",
);
},
)
: Container(),
widget.userId !=
AccountStorage()
.getCurrentUser()
?.userProfile
?.id
? SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_at_tag_user.png",
width: 32.w,
height: 32.w,
SizedBox(
height: 5.w,
),
Container(
alignment:
Alignment
.center,
width: 70.w,
child: text(
follow
? SCAppLocalizations.of(
context,
)!.followed
: SCAppLocalizations.of(
context,
)!.follow,
fontSize:
14.sp,
textColor: Color(
0xff808080,
),
fontWeight:
FontWeight
.bold,
),
),
],
);
},
),
SizedBox(height: 5.w),
Container(
alignment:
Alignment.center,
width: 70.w,
child: text(
onTap: () {
final provider =
Provider.of<
SocialChatUserProfileManager
>(
context,
listen: false,
);
provider.followUser(
widget.userId ?? "",
);
},
),
_buildCardAction(
iconAsset:
"sc_images/room/sc_icon_at_tag_user.png",
label:
SCAppLocalizations.of(
context,
)!.atTag,
textColor: Color(
0xff808080,
),
fontWeight:
FontWeight.w600,
fontSize: 14.sp,
),
),
],
),
onTap: () async {
if (SCRoomUtils.touristCanMsg(
context,
)) {
if ((ref
.userCardInfo
?.userProfile
?.userNickname ??
"")
.isEmpty) {
return;
}
Navigator.of(
context,
).pop();
Navigator.push(
context,
PopRoute(
child: RoomMsgInput(
atTextContent:
"<user id=\"${widget.userId}\">${ref.userCardInfo?.userProfile?.userNickname}</user>",
),
),
);
}
},
)
: Container(),
widget.userId !=
AccountStorage()
.getCurrentUser()
?.userProfile
?.id
? SCDebounceWidget(
child: Column(
children: [
Image.asset(
"sc_images/room/sc_icon_send_user_gift.png",
width: 32.w,
height: 32.w,
),
SizedBox(height: 5.w),
Container(
alignment:
Alignment.center,
width: 70.w,
child: text(
onTap: () async {
if (SCRoomUtils.touristCanMsg(
context,
)) {
if ((ref
.userCardInfo
?.userProfile
?.userNickname ??
"")
.isEmpty) {
return;
}
Navigator.of(
context,
).pop();
Navigator.push(
context,
PopRoute(
child: RoomMsgInput(
atTextContent:
"<user id=\"${widget.userId}\">${ref.userCardInfo?.userProfile?.userNickname}</user>",
),
),
);
}
},
),
_buildCardAction(
iconAsset:
"sc_images/room/sc_icon_send_user_gift.png",
label:
SCAppLocalizations.of(
context,
)!.send,
textColor: Color(
0xff808080,
),
fontWeight:
FontWeight.w600,
fontSize: 14.sp,
),
),
],
),
onTap: () {
Navigator.of(context).pop();
SmartDialog.show(
tag: "showGiftControl",
alignment:
Alignment
.bottomCenter,
maskColor:
Colors.transparent,
animationType:
SmartAnimationType
.fade,
clickMaskDismiss: true,
builder: (_) {
return GiftPage(
toUser:
ref
.userCardInfo
?.userProfile,
onTap: () {
Navigator.of(
context,
).pop();
SmartDialog.show(
tag:
"showGiftControl",
alignment:
Alignment
.bottomCenter,
maskColor:
Colors
.transparent,
animationType:
SmartAnimationType
.fade,
clickMaskDismiss:
true,
builder: (_) {
return GiftPage(
toUser:
ref
.userCardInfo
?.userProfile,
);
},
);
},
);
},
)
: Container(),
],
),
),
],
),
),
SizedBox(
height:
isSelf
? (canLeaveMic ? 35.w : 15.w)
: 35.w,
),
SizedBox(height: 35.w),
],
),
],
@ -614,7 +618,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
?.id !=
widget.userId
? Positioned(
top:28.w,
top: 28.w,
right: 15.w,
child: GestureDetector(
child: Image.asset(
@ -655,7 +659,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
listen: false,
).isGL())
? Positioned(
top:28.w,
top: 28.w,
right: 45.w,
child: GestureDetector(
child: Image.asset(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svga/flutter_svga.dart';
class SCSvgaAssetWidget extends StatefulWidget {
@ -98,7 +99,15 @@ class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
}
final future = () async {
final entity = await SVGAParser.shared.decodeFromAssets(assetPath);
MovieEntity entity;
try {
entity = await SVGAParser.shared.decodeFromAssets(assetPath);
} catch (_) {
final byteData = await rootBundle.load(assetPath);
entity = await SVGAParser.shared.decodeFromBuffer(
byteData.buffer.asUint8List(),
);
}
entity.autorelease = false;
_cache[assetPath] = entity;
return entity;

View File

@ -158,9 +158,13 @@ flutter:
- sc_images/login/
- sc_images/general/
- sc_images/index/
- sc_images/daily_sign_in/
- sc_images/register_reward/
- sc_images/room/
- sc_images/room/entrance/
- sc_images/room/anim/
- sc_images/room/anim/gift/
- sc_images/room/anim/luck_gift/
- sc_images/person/
- sc_images/store/
- sc_images/msg/

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

View File

@ -13,11 +13,22 @@
- 本轮按需求暂未处理网络链路上的启动等待,例如审核态检查或远端启动页配置请求。
## 已完成模块
- 已按 2026-04-18 联调要求继续收口幸运礼物链路:项目默认 API host 已临时切到 `http://192.168.110.43:1100/` 方便直连测试环境;`/gift/give/lucky-gift` 请求体也已补齐为最新结构,除原有 `giftId/quantity/roomId/acceptUserIds/checkCombo` 外,会稳定携带 `gameId: null``accepts: []``dynamicContentId: null``songId: null` 这几组字段,便于和当前后端参数定义保持一致。
- 已继续补齐 `GAME_LUCKY_GIFT` 的 socket 播报与中奖动效:房间群消息收到该类型后,现在会统一落聊天室中奖消息、奖励弹层队列与发送者余额回写,不再只在 `3x+` 时才触发顶部奖励动画;同时全局飘窗已改为按服务端 `globalNews` 字段决定是否展示,避免再用前端本地倍率硬编码去猜。
- 已按最新 UI 口径重排幸运礼物中奖展示:顶部 `LuckGiftNomorAnimWidget` 不再叠加大头像、倍率和奖励框,只在“单个礼物倍率 `> 10x`”或“单次中奖金币 `> 5000`”时负责播全屏 `luck_gift_reward_burst.svga`;原本的 `luck_gift_reward_frame.svga` 已改挂到房间礼物播报条最右侧,并在框内显示 `+formattedAwardAmount`,金额文案直接复用此前大头像下方那一份中奖金币额。
- 已按最新确认撤掉播报条里的 lucky combo 特殊样式:房间礼物播报条右侧的 `xN` 数字现已恢复普通礼物样式,不再在这里做幸运礼物 10/20/50/100... 的特殊放大/描边展示;这些 `lucky_gift_combo_count_xxx.svga` 资源仍保留在幸运礼物倍率/数量命中时走全屏特效链,不再和播报条 UI 混在一起。
- 已继续修正幸运礼物中奖播报条右侧奖励框不显示的问题:根因是 `pubspec.yaml` 之前只显式收录了 `sc_images/room/anim/gift/`,但没有把新的 `sc_images/room/anim/luck_gift/` 子目录单独打进 Flutter 资源清单,导致 `luck_gift_reward_frame.svga` 在运行时可引用但未被实际打包;当前已补齐资源目录声明,并为播报条奖励框增加本地 fallback避免资源异常时再次只剩金额裸字。
- 已按最新文案口径继续收紧幸运礼物中奖消息:聊天室里高倍率幸运礼物提示不再显示冗长的 `Coins / a lucky(magic) gift` 文字,当前已改为直接展示“中奖金额 + 金币图标 + 对应礼物图”,和房间礼物播报条的视觉表达保持一致。
- 已继续微调幸运礼物中奖视觉:房间礼物播报条右侧的 `luck_gift_reward_frame.svga` 已进一步放大,并给右侧区域和主条正文预留了更宽的排版空间,避免资源已经正常显示但因为画布比例和透明边距看起来过小;同时聊天室高亮中奖消息与房间顶部幸运礼物横幅现已统一改成“中奖金额 + 金币图标 + from + 礼物图”的表达,去掉原先 `Coins / a lucky(magic) gift` 这类冗长英文文案;另外全屏 `luck_gift_reward_burst.svga` 也已补上中部金额文案,避免播发时只见特效不见本次实际中奖金币数。
- 已优化语言房麦位/头像的二次确认交互:普通用户点击可上麦的空麦位时,当前会直接执行上麦,不再先弹出只有 `Take the mic / Cancel` 的确认层;普通用户点击房间头像或已占麦位上的用户头像时,也会直接打开个人卡片,不再额外弹出仅含 `Open user profile card / Cancel` 的底部确认。房主/管理员仍保留原有带禁麦、锁麦、邀请上麦等管理动作的底部菜单,避免误删管理能力。
- 已继续收窄语言房个人卡片前的“确认意义”弹层:当前用户在麦位上点击自己的头像时,也会直接打开自己的个人卡片,不再先弹出仅包含 `Leave the mic / Open user profile card / Cancel` 的底部菜单;同时个人卡片内的“离开麦位”入口已替换为新的 `leave` 视觉素材,和最新房间交互稿保持一致。
- 已继续微调语言房个人卡片与送礼 UI个人卡片底部动作文案现已支持两行居中展示避免 `Leave the mic` 这类英文按钮被硬截断;房间底部礼物入口也已切换为新的本地 `SVGA` 资源 `room_bottom_gift_button.svga`,保持房间底栏视觉和最新动效稿一致。
- 已按最新要求调整普通静态礼物的自制飞向麦位动画:当前不再对队列里的每一份礼物都重复播放一整段“中央停留 + 飞行”流程,而是改为屏幕中央持续保留一张静态礼物图作为母体;队列中每累积 `1` 个请求,就从中央额外发射 `1` 次飞向目标麦位的动画,同时整体仍维持最多 `5` 个待播/在播请求的上限,避免高频赠送时中央动画反复闪动。
- 已继续优化幸运/CP 礼物连击体验:房间 `Gift/All` 消息面板现在会对短时间内同一发送者、同一目标、同一礼物的连续赠送做聚合更新,不再每点一次就追加一条新消息,而是复用同一条礼物播报并持续刷新成 `xN`同时礼物页底部发送按钮已补上本地连击反馈Lucky/CP/Magic 类礼物连续点击时会显示一条从右向左收缩的浅色倒计时渐变条,并同步累加当前连击数量,用户能更直观看到连击窗口是否还在持续。
- 已继续给发送端补齐连击请求聚合:礼物页当前会对 Lucky/CP/Magic 这类连击型礼物启用约 `200ms` 的本地批量窗口,用户在短时间内连续点击 `Send` 时,前端会先把同一礼物、同一目标集合、同一房间的点击数量累加到同一批次里,再统一发一次接口和一次房间 RTM 消息;这样高连击时不再按点击次数直冲 `/gift/batch``/gift/give/lucky-gift`,同时也避免本地回显和房间消息量被线性放大。
- 已按最新确认回调幸运礼物的“自制飞向麦位动画”策略:不再等整轮连击结束后只飞一次,而是改回按点击次数触发;为兼顾连击体感和队列安全,当前会通过 RTM 额外携带本批次对应的点击次数,让房间端按点击数补齐自制动画,同时同一轮幸运礼物连击的飞行动画队列最多只保留 `5` 个;当连击在 `3s` 内停止后,如果队列里还有待播动画,则只再给 `3s` 消化时间,超时后剩余待播动画会直接清掉。
- 已继续收敛幸运礼物高连击时的播放策略:房间内幸运礼物仍按 3 秒连击会话累计总次数,用来判断当前命中的最高有效档位特效;但自制飞向麦位动画已改回按点击次数触发,并通过“同会话最多保留 5 个待播/在播动画 + 连击结束后额外 3 秒清尾”的方式限制队列膨胀,不再让 `1000` 连击把静态礼物飞行动画排成超长队列;同时幸运礼物的档位特效仍保持只命中当前会话累计数量对应的最高有效档位,不再把中间跨过的 `10/20/30/...` 全部补播一遍。
- 已定位到幸运礼物“中奖通知”使用的是房间页顶层 `LuckGiftNomorAnimWidget`,此前背景一直是静态 `sc_icon_luck_gift_nomore.webp`;当前已改为优先播放本地 `sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga`,并保留原 `webp` 作为失败兜底,这样幸运礼物中奖弹层会直接复用新的奖励边框动效资源。
- 已定位到幸运礼物“中奖通知”使用的是房间页顶层 `LuckGiftNomorAnimWidget`,此前背景一直是静态 `sc_icon_luck_gift_nomore.webp`;当前已改为优先播放本地 `sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga`而旧 `webp` 实际已不在当前仓库资源内,因此兜底已改为空白占位,避免在 SVGA 初始加载阶段反向触发 `Unable to load asset` 异常
- 已开始接入幸运礼物连击档位的新动效资源:桌面“幸运礼物相关”目录下的 SVGA 已统一导入到 `sc_images/room/anim/luck_gift/`,并按规范重命名为 `luck_gift_combo_count_10.svga / luck_gift_combo_count_666.svga / luck_gift_combo_count_10000.svga` 这一类统一英文命名;当前连击阈值触发仍复用 `GiftSystemManager.playVisualEffect()`,命中已提供素材的档位时优先播放新的本地 SVGA未提供素材的档位继续保留旧兜底逻辑避免影响现有幸运礼物连击链路。
- 已将语言房送礼链路接入新的“中心停留后飞向目标麦位”组件,但只对无自带特效的静态 PNG 礼物生效:当前带自身 `SVGA/MP4/VAP` 动画或被识别为全屏礼物特效的礼物保持原有播放逻辑不变;只有普通 PNG 礼物会额外触发“屏幕中央停留 -> 三连残影飞向被赠送麦位”的补充动效,避免和自带礼物特效重复叠播。
- 已继续收敛语言房送礼飞行动效的命中条件:上一版对普通礼物的过滤过严,既依赖 `giftPhoto` 必须显式以 `.png` 结尾,也会被部分礼物的 `special` 标记误伤,导致不少实际没有自带动画的礼物被提前跳过;当前已改为只排除真实带 `SVGA/MP4/VAP` 动画源的礼物,普通静态封面礼物即使是带 query 的图片 URL、或不是严格 `.png` 后缀,也会正常触发“中心停留 -> 飞向目标麦位”的补充动画。