diff --git a/lib/modules/auth/edit/sc_edit_profile_page.dart b/lib/modules/auth/edit/sc_edit_profile_page.dart index 2e9677a..6aa5ae9 100644 --- a/lib/modules/auth/edit/sc_edit_profile_page.dart +++ b/lib/modules/auth/edit/sc_edit_profile_page.dart @@ -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 { } 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); } diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index 7057fce..7cafe62 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -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 with TickerProviderStateMixin { - static const Duration _comboFeedbackDuration = Duration(seconds: 3); static const Duration _comboSendBatchWindow = Duration(milliseconds: 200); static const List _preferredGiftTabOrder = [ @@ -102,12 +102,10 @@ class _GiftPageState extends State 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 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(context, listen: false); Provider.of(context, listen: false).giftList(); Provider.of(context, listen: false).giftActivityList(); @@ -253,7 +241,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { } _tabController?.removeListener(_handleTabChanged); _tabController?.dispose(); - _comboFeedbackController.dispose(); super.dispose(); } @@ -1158,18 +1145,27 @@ class _GiftPageState extends State with TickerProviderStateMixin { return; } - _activateComboFeedback( - gift: request.gift, - quantity: request.quantity, - acceptUserIds: request.acceptUserIds, + RoomGiftComboSendController().show( + request: RoomGiftComboSendRequest( + acceptUserIds: List.from(request.acceptUserIds), + acceptUsers: List.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 with TickerProviderStateMixin { } } - void _activateComboFeedback({ - required SocialChatGiftRes gift, - required int quantity, - required List 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 with TickerProviderStateMixin { return SCGiftComboSendButton( label: SCAppLocalizations.of(context)!.send, onPressed: giveGifts, - showCountdown: _showComboFeedback, - countdownAnimation: _comboFeedbackController, + showCountdown: false, width: 96.w, ); } + Future _performFloatingComboSend( + RoomGiftComboSendRequest request, { + required String trigger, + }) async { + await _performGiftSend( + _GiftSendRequest( + acceptUserIds: List.from(request.acceptUserIds), + acceptUsers: List.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) { diff --git a/lib/modules/index/index_page.dart b/lib/modules/index/index_page.dart index a5a0d10..674956c 100644 --- a/lib/modules/index/index_page.dart +++ b/lib/modules/index/index_page.dart @@ -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 { + static const Duration _registerRewardSocketWaitTimeout = Duration(seconds: 6); + int _currentIndex = 0; final List _pages = []; final List _bottomItems = []; SCAppGeneralManager? generalProvider; Locale? _lastLocale; + bool _hasShownEntryDialogs = false; + bool _hasShownRegisterRewardDialog = false; + bool _isShowingDailySignInDialog = false; + bool _showRegisterRewardAfterDailySignIn = false; + StreamSubscription? _registerRewardSubscription; + Completer? _registerRewardSocketCompleter; @override void initState() { super.initState(); + _registerRewardSubscription = eventBus + .on() + .listen(_onRegisterRewardGranted); _initializePages(); generalProvider = Provider.of(context, listen: false); Provider.of( @@ -58,6 +73,7 @@ class _SCIndexPageState extends State { OverlayManager().activate(); WidgetsBinding.instance.addPostFrameCallback((_) { WakelockPlus.enable(); + _showEntryDialogs(); }); String roomId = DataPersistence.getLastTimeRoomId(); if (roomId.isNotEmpty) { @@ -69,6 +85,7 @@ class _SCIndexPageState extends State { @override void dispose() { + _registerRewardSubscription?.cancel(); WakelockPlus.disable(); super.dispose(); } @@ -82,8 +99,17 @@ class _SCIndexPageState extends State { @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 { ); } + Future _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 _waitForRegisterRewardSocketIfNeeded() async { + if (!DataPersistence.getAwaitRegisterRewardSocket() || + DataPersistence.getPendingRegisterRewardDialog()) { + return; + } + + final completer = Completer(); + _registerRewardSocketCompleter = completer; + await Future.any([ + completer.future, + Future.delayed(_registerRewardSocketWaitTimeout), + ]); + if (identical(_registerRewardSocketCompleter, completer)) { + _registerRewardSocketCompleter = null; + } + if (!DataPersistence.getPendingRegisterRewardDialog()) { + await DataPersistence.clearAwaitRegisterRewardSocket(); + } + } + + Future _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 _loadDailySignInDialogData() async { + DailySignInDialogData dialogData; + try { + final result = await Future.wait([ + 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, diff --git a/lib/modules/room/online/room_online_page.dart b/lib/modules/room/online/room_online_page.dart index 2116294..44476e5 100644 --- a/lib/modules/room/online/room_online_page.dart +++ b/lib/modules/room/online/room_online_page.dart @@ -1,226 +1,240 @@ -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'; -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/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'; - -///房间用户在线列表 -class RoomOnlinePage extends SCPageList { - String? roomId = ""; - - RoomOnlinePage({super.key, this.roomId}); - - @override - _RoomOnlinePageState createState() => _RoomOnlinePageState(roomId); -} - -class _RoomOnlinePageState - extends SCPageListState { - String? roomId = ""; - - _RoomOnlinePageState(this.roomId); - - @override - void initState() { - super.initState(); - enablePullUp = false; - backgroundColor = Colors.transparent; - loadData(1); - } - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(12.w), - topRight: Radius.circular(12.w), - ), - ), - child: Column( - children: [ - SizedBox(height: 15.w), - text( - "User(${items.length})", - fontSize: 14.sp, - fontWeight: FontWeight.w600, - textColor: Colors.black, - ), - SizedBox(height: 10.w), - SizedBox(height: 350.w, child: buildList(context)), - ], - ), - ), - ); - } - - @override - Widget buildItem(SocialChatUserProfile userInfo) { - return GestureDetector( - child: Container( - margin: EdgeInsets.symmetric(vertical: 3.w), - padding: EdgeInsets.symmetric(horizontal: 16.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.w), - color: Colors.transparent, - ), - child: Row( - children: [ - GestureDetector( - child: head( - url: userInfo.userAvatar ?? "", - width: 55.w, - headdress: userInfo.getHeaddress()?.sourceUrl, - ), - onTap: () { - Navigator.of(context).pop(); - num index = Provider.of( - context, - listen: false, - ).userOnMaiInIndex(userInfo.id ?? ""); - Provider.of( - context, - listen: false, - ).clickSite(index, clickUser: userInfo); - }, - ), - SizedBox(width: 3.w), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - msgRoleTag(userInfo.roles ?? "", width: 20.w, height: 20.w), - SizedBox(width: 3.w), - socialchatNickNameText( - textColor: Colors.black, - maxWidth: 200.w, - userInfo.userNickname ?? "", - fontSize: 14.sp, - type: userInfo.getVIP()?.name ?? "", - needScroll: - (userInfo.userNickname?.characters.length ?? 0) > 16, - ), - getVIPBadge( - userInfo.getVIP()?.name, - width: 45.w, - height: 25.w, - ), - // ListView.separated( - // scrollDirection: Axis.horizontal, - // shrinkWrap: true, - // itemCount: userInfo.wearBadge?.length ?? 0, - // itemBuilder: (context, index) { - // return netImage( - // width: 25.w, - // height: 25.w, - // url: userInfo.wearBadge?[index].selectUrl ?? "", - // ); - // }, - // separatorBuilder: (BuildContext context, int index) { - // return SizedBox(width: 5.w); - // }, - // ), - ], - ), - SizedBox(height: 3.w), - GestureDetector( - child: Container( - padding: EdgeInsets.symmetric(vertical: 3.w), - child: Row( - textDirection: TextDirection.ltr, - children: [ - text( - "ID:${userInfo.getID()}", - fontSize: 12.sp, - textColor: Colors.black, - fontWeight: FontWeight.bold, - ), - SizedBox(width: 5.w), - Image.asset( - "sc_images/room/sc_icon_user_card_copy_id.png", - width: 12.w, - height: 12.w, - ), - ], - ), - ), - onTap: () { - Clipboard.setData( - ClipboardData(text: userInfo.getID() ?? ""), - ); - SCTts.show(SCAppLocalizations.of(context)!.copiedToClipboard); - }, - ), - ], - ), - ], - ), - ), - onTap: () {}, - ); - } - - @override - empty() { - List list = []; - list.add(SizedBox(height: height(30))); - list.add(Image.asset('sc_images/general/sc_icon_loading.png')); - list.add(SizedBox(height: height(15))); - list.add( - Text( - "No data", - style: TextStyle( - fontSize: sp(14), - color: Color(0xff999999), - fontWeight: FontWeight.w400, - decoration: TextDecoration.none, - height: 1, - ), - ), - ); - return Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: list, - ); - } - - @override - builderDivider() { - // return Divider( - // height: 1.w, - // color: Color(0xff3D3277).withOpacity(0.5), - // indent: 15.w, - // ); - return Container(height: 8.w); - } - - ///加载数据 - @override - loadPage({ - required int page, - required Function(List) onSuccess, - Function? onErr, - }) async { - // var roomList = await SCChatRoomRepository().roomOnlineUsers(roomId ?? ""); - await Provider.of(context!, listen: false).fetchOnlineUsersList(); - List userList = - Provider.of(context!, listen: false).onlineUsers; - onSuccess(userList); - } -} +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +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 { + String? roomId = ""; + + RoomOnlinePage({super.key, this.roomId}); + + @override + _RoomOnlinePageState createState() => _RoomOnlinePageState(roomId); +} + +class _RoomOnlinePageState + extends SCPageListState { + String? roomId = ""; + + _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(); + enablePullUp = false; + backgroundColor = Colors.transparent; + loadData(1); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12.w), + topRight: Radius.circular(12.w), + ), + ), + child: Column( + children: [ + SizedBox(height: 15.w), + text( + "User(${items.length})", + fontSize: 14.sp, + fontWeight: FontWeight.w600, + textColor: Colors.black, + ), + SizedBox(height: 10.w), + SizedBox(height: 350.w, child: buildList(context)), + ], + ), + ), + ); + } + + @override + Widget buildItem(SocialChatUserProfile userInfo) { + return GestureDetector( + child: Container( + margin: EdgeInsets.symmetric(vertical: 3.w), + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.w), + color: Colors.transparent, + ), + child: Row( + children: [ + GestureDetector( + child: head( + url: userInfo.userAvatar ?? "", + width: 55.w, + headdress: userInfo.getHeaddress()?.sourceUrl, + ), + onTap: () { + _openUserCard(userInfo); + }, + ), + SizedBox(width: 3.w), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + msgRoleTag(userInfo.roles ?? "", width: 20.w, height: 20.w), + SizedBox(width: 3.w), + socialchatNickNameText( + textColor: Colors.black, + maxWidth: 200.w, + userInfo.userNickname ?? "", + fontSize: 14.sp, + type: userInfo.getVIP()?.name ?? "", + needScroll: + (userInfo.userNickname?.characters.length ?? 0) > 16, + ), + getVIPBadge( + userInfo.getVIP()?.name, + width: 45.w, + height: 25.w, + ), + // ListView.separated( + // scrollDirection: Axis.horizontal, + // shrinkWrap: true, + // itemCount: userInfo.wearBadge?.length ?? 0, + // itemBuilder: (context, index) { + // return netImage( + // width: 25.w, + // height: 25.w, + // url: userInfo.wearBadge?[index].selectUrl ?? "", + // ); + // }, + // separatorBuilder: (BuildContext context, int index) { + // return SizedBox(width: 5.w); + // }, + // ), + ], + ), + SizedBox(height: 3.w), + GestureDetector( + child: Container( + padding: EdgeInsets.symmetric(vertical: 3.w), + child: Row( + textDirection: TextDirection.ltr, + children: [ + text( + "ID:${userInfo.getID()}", + fontSize: 12.sp, + textColor: Colors.black, + fontWeight: FontWeight.bold, + ), + SizedBox(width: 5.w), + Image.asset( + "sc_images/room/sc_icon_user_card_copy_id.png", + width: 12.w, + height: 12.w, + ), + ], + ), + ), + onTap: () { + Clipboard.setData( + ClipboardData(text: userInfo.getID() ?? ""), + ); + SCTts.show( + SCAppLocalizations.of(context)!.copiedToClipboard, + ); + }, + ), + ], + ), + ], + ), + ), + onTap: () { + _openUserCard(userInfo); + }, + ); + } + + @override + empty() { + List list = []; + list.add(SizedBox(height: height(30))); + list.add(Image.asset('sc_images/general/sc_icon_loading.png')); + list.add(SizedBox(height: height(15))); + list.add( + Text( + "No data", + style: TextStyle( + fontSize: sp(14), + color: Color(0xff999999), + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + height: 1, + ), + ), + ); + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: list, + ); + } + + @override + builderDivider() { + // return Divider( + // height: 1.w, + // color: Color(0xff3D3277).withOpacity(0.5), + // indent: 15.w, + // ); + return Container(height: 8.w); + } + + ///加载数据 + @override + loadPage({ + required int page, + required Function(List) onSuccess, + Function? onErr, + }) async { + // var roomList = await SCChatRoomRepository().roomOnlineUsers(roomId ?? ""); + await Provider.of( + context!, + listen: false, + ).fetchOnlineUsersList(); + List userList = + Provider.of(context!, listen: false).onlineUsers; + onSuccess(userList); + } +} diff --git a/lib/modules/room/seat/sc_seat_item.dart b/lib/modules/room/seat/sc_seat_item.dart index 078a991..a54583c 100644 --- a/lib/modules/room/seat/sc_seat_item.dart +++ b/lib/modules/room/seat/sc_seat_item.dart @@ -27,6 +27,9 @@ class SCSeatItem extends StatefulWidget { } class _SCSeatItemState extends State 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 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 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 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"; diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 40305e1..57d3914 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -101,8 +101,10 @@ class _VoiceRoomPageState extends State context, listen: false, ).setRoomVisualEffectsEnabled(true); - Provider.of(context, listen: false).msgFloatingGiftListener = - _floatingGiftListener; + final rtmProvider = Provider.of(context, listen: false); + rtmProvider.msgFloatingGiftListener = _floatingGiftListener; + rtmProvider.msgLuckyGiftRewardTickerListener = + _luckyGiftRewardTickerListener; } void _ensureRoomVisualEffectsEnabled() { @@ -122,6 +124,10 @@ class _VoiceRoomPageState extends State 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 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( context, listen: false, @@ -347,6 +350,46 @@ class _VoiceRoomPageState extends State _handleStandardGiftComboVisuals(msg, giftPhoto, targetUserId); } + void _luckyGiftRewardTickerListener(Msg msg) { + if (!Provider.of( + context, + listen: false, + ).shouldShowRoomVisualEffects) { + return; + } + if (Provider.of(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( + 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; diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index bb86445..bbad618 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -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}'); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 8841eac..ec19d27 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -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( + 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( + 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( - 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(); }); diff --git a/lib/services/gift/gift_animation_manager.dart b/lib/services/gift/gift_animation_manager.dart index 48bb10c..cd4c7ff 100644 --- a/lib/services/gift/gift_animation_manager.dart +++ b/lib/services/gift/gift_animation_manager.dart @@ -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(); diff --git a/lib/services/gift/room_gift_combo_send_controller.dart b/lib/services/gift/room_gift_combo_send_controller.dart new file mode 100644 index 0000000..a203db1 --- /dev/null +++ b/lib/services/gift/room_gift_combo_send_controller.dart @@ -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 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 acceptUserIds; + final List acceptUsers; + final SocialChatGiftRes gift; + final int quantity; + final int clickCount; + final String roomId; + final String roomAccount; + final bool isLuckyGiftRequest; + + RoomGiftComboSendRequest copyWith({ + List? acceptUserIds, + List? acceptUsers, + SocialChatGiftRes? gift, + int? quantity, + int? clickCount, + String? roomId, + String? roomAccount, + bool? isLuckyGiftRequest, + }) { + return RoomGiftComboSendRequest( + acceptUserIds: List.from(acceptUserIds ?? this.acceptUserIds), + acceptUsers: List.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.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 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 _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.from(request.acceptUserIds), + acceptUsers: List.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 acceptUserIds; + final List 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.from(acceptUserIds), + acceptUsers: List.from(acceptUsers), + gift: gift, + quantity: quantity, + clickCount: clickCount, + roomId: roomId, + roomAccount: roomAccount, + isLuckyGiftRequest: isLuckyGiftRequest, + ); + } +} diff --git a/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart b/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart index 6b8aa0d..3613ed8 100644 --- a/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart +++ b/lib/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart @@ -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 toJson() { final map = {}; @@ -186,5 +223,4 @@ class Data { map['giftId'] = _giftId; return map; } - -} \ No newline at end of file +} diff --git a/lib/shared/business_logic/repositories/room_repository.dart b/lib/shared/business_logic/repositories/room_repository.dart index 958e940..85f7cb4 100644 --- a/lib/shared/business_logic/repositories/room_repository.dart +++ b/lib/shared/business_logic/repositories/room_repository.dart @@ -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 roomContributionActivity(String roomId); ///上麦 - Future micGoUp(String roomId, num mickIndex, {String? eventType}); + Future micGoUp( + String roomId, + num mickIndex, { + String? eventType, + }); ///下麦 Future micGoDown(String roomId, num mickIndex); @@ -150,7 +153,10 @@ abstract class SocialChatRoomRepository { }); ///房间成员列表 - Future> roomMember(String roomId, {String? lastId}); + Future> roomMember( + String roomId, { + String? lastId, + }); ///房间贡献榜单 Future> roomContributionRank( @@ -166,7 +172,10 @@ abstract class SocialChatRoomRepository { num quantity, bool checkCombo, { String? roomId, - SCGiveAwayGiftRoomAcceptsCmd? accepts, + List? accepts, + Object? gameId, + String? dynamicContentId, + String? songId, }); ///查询榜单前三名 @@ -239,11 +248,4 @@ abstract class SocialChatRoomRepository { ///获取当前可领取奖励数量 Future roomTaskClaimableCount(); - - - - - - - } diff --git a/lib/shared/data_sources/sources/local/data_persistence.dart b/lib/shared/data_sources/sources/local/data_persistence.dart index 4d54db6..1c66b18 100644 --- a/lib/shared/data_sources/sources/local/data_persistence.dart +++ b/lib/shared/data_sources/sources/local/data_persistence.dart @@ -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? _initializationCompleter; @@ -145,12 +149,12 @@ class DataPersistence { ///保存搜索历史记录 static Future 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 setPendingRegisterRewardDialog(bool value) async { + return await setBool(_pendingRegisterRewardDialogKey, value); + } + + static bool getPendingRegisterRewardDialog() { + return getBool(_pendingRegisterRewardDialogKey); + } + + static Future clearPendingRegisterRewardDialog() async { + return await remove(_pendingRegisterRewardDialogKey); + } + + static Future setAwaitRegisterRewardSocket(bool value) async { + return await setBool(_awaitRegisterRewardSocketKey, value); + } + + static bool getAwaitRegisterRewardSocket() { + return getBool(_awaitRegisterRewardSocketKey); + } + + static Future clearAwaitRegisterRewardSocket() async { + return await remove(_awaitRegisterRewardSocketKey); + } + ///获取用户已经添加的房间音乐 static String getUserRoomMusic() { final currentUser = AccountStorage().getCurrentUser()?.userProfile?.account; diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 6086ad7..7e48c46 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -609,7 +609,10 @@ class SCChatRoomRepository implements SocialChatRoomRepository { num quantity, bool checkCombo, { String? roomId, - SCGiveAwayGiftRoomAcceptsCmd? accepts, + List? accepts, + Object? gameId, + String? dynamicContentId, + String? songId, }) async { Map 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 []) + .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', ); diff --git a/lib/shared/tools/sc_lk_event_bus.dart b/lib/shared/tools/sc_lk_event_bus.dart index 4b3fadb..2500eab 100644 --- a/lib/shared/tools/sc_lk_event_bus.dart +++ b/lib/shared/tools/sc_lk_event_bus.dart @@ -11,5 +11,10 @@ class GiveRoomLuckWithOtherEvent { GiveRoomLuckWithOtherEvent(this.giftPic, this.acceptUserIds); } -class UpdateDynamicEvent { +class UpdateDynamicEvent {} + +class RegisterRewardGrantedEvent { + final Object? data; + + RegisterRewardGrantedEvent({this.data}); } diff --git a/lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart b/lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart new file mode 100644 index 0000000..b3965e5 --- /dev/null +++ b/lib/ui_kit/widgets/daily_sign_in/daily_sign_in_dialog.dart @@ -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 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.from( + signInRes.rewards ?? const [], + )..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.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? 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 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 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 createState() => _DailySignInDialogState(); +} + +class _DailySignInDialogState extends State { + late DailySignInDialogData _data; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _data = widget.data; + } + + Future _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([ + 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 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'; +} diff --git a/lib/ui_kit/widgets/gift/sc_gift_combo_badge_button.dart b/lib/ui_kit/widgets/gift/sc_gift_combo_badge_button.dart new file mode 100644 index 0000000..055190a --- /dev/null +++ b/lib/ui_kit/widgets/gift/sc_gift_combo_badge_button.dart @@ -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 createState() => _SCGiftComboBadgeButtonState(); +} + +class _SCGiftComboBadgeButtonState extends State + 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; + } +} diff --git a/lib/ui_kit/widgets/register_reward/register_reward_dialog.dart b/lib/ui_kit/widgets/register_reward/register_reward_dialog.dart new file mode 100644 index 0000000..6743e68 --- /dev/null +++ b/lib/ui_kit/widgets/register_reward/register_reward_dialog.dart @@ -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 items; + final String title; + final String subtitle; + + static List defaultItems() { + return const [ + RegisterRewardDialogItem(title: 'small gifts'), + RegisterRewardDialogItem(title: 'small gifts'), + ]; + } + + static Future show( + BuildContext context, { + List? 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 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'; +} diff --git a/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart b/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart index dfb7541..981bdf1 100644 --- a/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart +++ b/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart @@ -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 with TickerProviderStateMixin { - static const Set _luckyGiftMilestones = {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 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 LGiftModel gift, double animatedSize, ) { + final showLuckyRewardFrame = gift.showLuckyRewardFrame; return SizedBox( height: 52.w, child: Stack( @@ -73,11 +78,12 @@ class _GiftAnimalPageState extends State 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 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 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 } 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 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(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(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 = ""; diff --git a/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart index 2c3eeae..51baf13 100644 --- a/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart +++ b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart @@ -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 late final AnimationController _controller; + RoomGiftSeatFlightRequest? _centerRequest; + ImageProvider? _centerImageProvider; RoomGiftSeatFlightRequest? _activeRequest; ImageProvider? _activeImageProvider; Offset? _activeTargetOffset; @@ -238,6 +239,7 @@ class _RoomGiftSeatFlightOverlayState extends State } void _enqueue(RoomGiftSeatFlightRequest request) { + _ensureCenterVisual(request); _queue.add(_QueuedRoomGiftSeatFlightRequest(request: request)); _scheduleNextAnimation(); } @@ -267,6 +269,8 @@ class _RoomGiftSeatFlightOverlayState extends State _controller.stop(); _controller.reset(); if (!mounted) { + _centerRequest = null; + _centerImageProvider = null; _activeRequest = null; _activeImageProvider = null; _activeTargetOffset = null; @@ -274,6 +278,8 @@ class _RoomGiftSeatFlightOverlayState extends State return; } setState(() { + _centerRequest = null; + _centerImageProvider = null; _activeRequest = null; _activeImageProvider = null; _activeTargetOffset = null; @@ -304,6 +310,17 @@ class _RoomGiftSeatFlightOverlayState extends State 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 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 } 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 _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 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 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 = []; - 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 = [0, 0.16, 0.32]; - const trailOpacities = [1, 0.5, 0.24]; - const trailScales = [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 } Widget _buildGiftNode({ + required ImageProvider 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 ], ), child: Image( - image: _activeImageProvider!, + image: imageProvider, fit: BoxFit.contain, filterQuality: FilterQuality.low, errorBuilder: (context, error, stackTrace) { diff --git a/lib/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart b/lib/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart index 54973ea..456f5e1 100644 --- a/lib/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart +++ b/lib/ui_kit/widgets/room/bottom/room_bottom_gift_button.dart @@ -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 createState() => _RoomBottomGiftButtonState(); -} - -class _RoomBottomGiftButtonState extends State - 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, + ), ), ); } diff --git a/lib/ui_kit/widgets/room/bottom/room_gift_combo_floating_button.dart b/lib/ui_kit/widgets/room/bottom/room_gift_combo_floating_button.dart new file mode 100644 index 0000000..ae74eba --- /dev/null +++ b/lib/ui_kit/widgets/room/bottom/room_gift_combo_floating_button.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart b/lib/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart index 92bdfbb..b8ca03e 100644 --- a/lib/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart +++ b/lib/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart @@ -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 { - @override - void initState() { - super.initState(); - Provider.of(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 { return IgnorePointer( child: Consumer( builder: (context, provider, child) { - return provider.currentPlayingLuckGift != null - ? Container( - height: 380.w, - margin: EdgeInsets.only(top: 10.w), - child: Stack( - children: [ - SCSvgaAssetWidget( - key: ValueKey( - '${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( + '${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('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(); + ], + ), + ); }, ), ); diff --git a/lib/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart b/lib/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart index e9582d5..3bfb63a 100644 --- a/lib/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart +++ b/lib/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart @@ -29,6 +29,7 @@ class FloatingLuckGiftScreenWidget extends StatefulWidget { class _FloatingLuckGiftScreenWidgetState extends State with TickerProviderStateMixin { + static const String _coinIconAssetPath = "sc_images/general/sc_icon_jb.png"; late AnimationController _controller; late Animation _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( diff --git a/lib/ui_kit/widgets/room/room_bottom_widget.dart b/lib/ui_kit/widgets/room/room_bottom_widget.dart index 9809f85..008a362 100644 --- a/lib/ui_kit/widgets/room/room_bottom_widget.dart +++ b/lib/ui_kit/widgets/room/room_bottom_widget.dart @@ -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 { 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( builder: (context, rtcProvider, child) { final showMic = _shouldShowMic(rtcProvider); @@ -40,37 +57,34 @@ class _RoomBottomWidgetState extends State { 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 { ); } + 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", diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index 9c74719..4827517 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -384,7 +384,7 @@ class _MsgItemState extends State { ); 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 { ); 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 { ), ); 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 { 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 { ); } + 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, diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index 4541317..811160b 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -1,957 +1,961 @@ -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'; -import 'package:yumi/main.dart'; -import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; -import 'package:yumi/modules/index/main_route.dart'; -import 'package:yumi/services/audio/rtm_manager.dart'; -import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart'; -import 'package:yumi/ui_kit/widgets/room/room_user_card_setting.dart'; -import 'package:marquee/marquee.dart'; -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'; -import 'package:yumi/modules/gift/gift_page.dart'; - -import '../../../shared/data_sources/models/enum/sc_room_roles_type.dart'; -import '../../../shared/business_logic/models/res/sc_user_identity_res.dart'; -import '../../../modules/chat/chat_route.dart'; - -class RoomUserInfoCard extends StatefulWidget { - String? userId; - - RoomUserInfoCard({super.key, this.userId}); - - @override - _RoomUserInfoCardState createState() => _RoomUserInfoCardState(); -} - -class _RoomUserInfoCardState extends State { - SocialChatUserProfileManager? userProvider; - String roomId = ""; - SCUserIdentityRes? userIdentity; - - @override - void initState() { - super.initState(); - userProvider = Provider.of( - context, - listen: false, - ); - roomId = - Provider.of( - context, - listen: false, - ).currenRoom?.roomProfile?.roomProfile?.id ?? - ""; - userProvider?.roomUserCard(roomId, widget.userId ?? ""); - SCAccountRepository().userIdentity(userId: widget.userId).then((v) { - userIdentity = v; - setState(() {}); - }); - } - - @override - void dispose() { - super.dispose(); - userProvider?.userCardInfo = null; - } - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - child: Consumer( - builder: (context, ref, child) { - if (ref.userCardInfo == null) { - return SizedBox( - height: ScreenUtil().screenHeight * 0.7, - child: Center( - child: Container( - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(8), - ), - width: 55.w, - height: 55.w, - child: CupertinoActivityIndicator(color: Colors.white24), - ), - ), - ); - } - return Container( - constraints: BoxConstraints( - maxHeight: ScreenUtil().screenHeight * 0.7, - ), - margin: EdgeInsets.symmetric(horizontal: 15.w), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - alignment: Alignment.center, - children: [ - Container( - margin: EdgeInsets.only( - top: - ref.userCardInfo?.userProfile - ?.getDataCard() - ?.sourceUrl != - null - ? 45.w - : 55.w, - ), - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: - ref.userCardInfo?.userProfile - ?.getDataCard() - ?.sourceUrl != - null - ? [ - Colors.transparent, - Colors.transparent, - ] - : [ - Color(0xff18F2B1), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffE4FFF6), - Color(0xffFFFFFF), - ], - ), - borderRadius: BorderRadius.all( - Radius.circular(12.w), - ), - ), - child: Stack( - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - SizedBox(height: 25.w), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - SizedBox(width: 20.w), - GestureDetector( - child: head( - url: - ref - .userCardInfo - ?.userProfile - ?.userAvatar ?? - "", - width: 55.w, - border: Border.all( - color: Colors.white, - width: 2, - ), - headdress: - ref.userCardInfo?.userProfile - ?.getHeaddress() - ?.sourceUrl, - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == widget.userId}&tageId=${widget.userId}", - ); - }, - ), - SizedBox(width: 3.w), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - msgRoleTag( - ref - .userCardInfo - ?.roomRole ?? - "", - width: 16.w, - height: 16.w, - ), - SizedBox(width: 2.w), - socialchatNickNameText( - maxWidth: 115.w, - ref - .userCardInfo - ?.userProfile - ?.userNickname ?? - "", - fontSize: 14.sp, - textColor: Colors.black, - fontWeight: FontWeight.bold, - type: "", - needScroll: - (ref - .userCardInfo - ?.userProfile - ?.userNickname - ?.characters - .length ?? - 0) > - 10, - ), - ], - ), - SizedBox(height: 2.w), - text( - "ID:${ref.userCardInfo?.userProfile?.getID() ?? 0}", - fontSize: 12.sp, - textColor: Colors.black, - fontWeight: FontWeight.bold, - ), - ], - ), - ], - ), - Row( - mainAxisAlignment: - MainAxisAlignment.start, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(15.w), - color: Colors.transparent, - ), - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 5.w, - ), - child: Row( - children: [ - SizedBox(width: 20.w,), - netImage( - url: - Provider.of< - SCAppGeneralManager - >( - context, - listen: false, - ) - .findCountryByName( - ref - .userCardInfo - ?.userProfile - ?.countryName ?? - "", - ) - ?.nationalFlag ?? - "", - borderRadius: - BorderRadius.all( - Radius.circular(3.w), - ), - width: 19.w, - height: 14.w, - ), - SizedBox(width: 5.w), - Container( - constraints: BoxConstraints( - maxWidth: 110.w, - ), - child: text( - ref - .userCardInfo - ?.userProfile - ?.countryName ?? - "", - textColor: Colors.black, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - SizedBox(width: 5.w), - Container( - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - ref - .userCardInfo - ?.userProfile - ?.userSex == - 0 - ? "sc_images/login/sc_icon_sex_woman_bg.png" - : "sc_images/login/sc_icon_sex_man_bg.png", - ), - ), - ), - padding: EdgeInsets.symmetric( - horizontal: 10.w, - vertical: 5.w, - ), - child: Row( - children: [ - xb( - ref - .userCardInfo - ?.userProfile - ?.userSex, - ), - SizedBox(width: 3.w), - text( - "${ref.userCardInfo?.userProfile?.age ?? 0}", - textColor: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.bold, - ), - ], - ), - ), - ], - ), - - SizedBox(height: 15.w), - Container( - 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( - 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 - >( - context, - listen: false, - ).startConversation( - conversation, - ); - 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, - ), - 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( - 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: - "${ref.userCardInfo?.userProfile?.userNickname}", - ), - ), - ); - } - }, - ) - : 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( - 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, - ); - }, - ); - }, - ) - : Container(), - ], - ), - ), - SizedBox(height: 35.w), - ], - ), - ], - ), - ), - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.id != - widget.userId - ? Positioned( - top:28.w, - right: 15.w, - child: GestureDetector( - child: Image.asset( - "sc_images/room/sc_icon_user_card_report.png", - width: 24.w, - height: 24.w, - color: Colors.black, - ), - onTap: () { - SCNavigatorUtils.push( - context, - "${SCMainRoute.report}?type=user&tageId=${widget.userId}", - replace: false, - ); - }, - ), - ) - : Container(), - ref.userCardInfo?.roomRole == - SCRoomRolesType.HOMEOWNER.name - ? Container() - : (AccountStorage() - .getCurrentUser() - ?.userProfile - ?.id != - widget.userId && - Provider.of( - context, - listen: false, - ).isFz() || - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.id != - widget.userId && - Provider.of( - context, - listen: false, - ).isGL()) - ? Positioned( - top:28.w, - right: 45.w, - child: GestureDetector( - child: Image.asset( - "sc_images/room/sc_icon_room_user_card_setting.png", - width: 24.w, - height: 24.w, - color: Colors.black, - ), - onTap: () { - if (userProvider?.userCardInfo != null) { - Navigator.of(context).pop(); - showBottomInBottomDialog( - context!, - RoomUserCardSetting( - roomId: roomId, - userCardInfo: - userProvider?.userCardInfo - ?.copyWith(), - ), - ); - } - }, - ), - ) - : Container(), - ], - ), - ), - ], - ), - ], - ), - ), - ); - }, - ), - ); - } - - String roleName(String role) { - if (role == SCRoomRolesType.HOMEOWNER.name) { - return SCAppLocalizations.of(context)!.owner; - } else if (role == SCRoomRolesType.ADMIN.name) { - return SCAppLocalizations.of(context)!.admin; - } else if (role == SCRoomRolesType.MEMBER.name) { - return SCAppLocalizations.of(context)!.member; - } - return SCAppLocalizations.of(context)!.guest; - } - - _buildEmptyCpItem(bool canAdd) { - return Container( - width: ScreenUtil().screenWidth, - margin: EdgeInsets.symmetric(horizontal: 12.w), - height: 105.w, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - canAdd - ? "sc_images/person/sc_icon_no_cp_item_bg.png" - : "sc_images/person/sc_icon_no_cp_item_bg4.png", - ), - fit: BoxFit.fill, - ), - ), - child: Container( - margin: EdgeInsets.only(bottom: 13.w), - alignment: AlignmentDirectional.bottomCenter, - child: text( - SCAppLocalizations.of(context)!.noMatchedCP, - fontWeight: FontWeight.w600, - fontSize: 12.sp, - textColor: canAdd ? Color(0xFFFF79A1) : Colors.white, - ), - ), - ); - } - - Widget _buildCpItem(CPRes item) { - return Stack( - alignment: AlignmentDirectional.center, - children: [ - Container( - width: ScreenUtil().screenWidth, - margin: EdgeInsets.symmetric(horizontal: 15.w), - height: 135.w, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage("sc_images/person/sc_icon_no_cp_item_bg2.png"), - fit: BoxFit.fill, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 68.w), - Container( - alignment: AlignmentDirectional.bottomCenter, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - "sc_images/person/sc_icon_cp_value_tag.png", - height: 18.w, - ), - SizedBox(width: 5.w), - text( - _cpVaFormat(item.cpValue ?? 0), - fontWeight: FontWeight.w600, - fontSize: 12.sp, - textColor: Color(0xFFFF79A1), - ), - ], - ), - ), - SizedBox(height: 8.w), - Container( - alignment: AlignmentDirectional.bottomCenter, - child: text( - SCAppLocalizations.of( - context, - )!.timeSpentTogether(item.days ?? ""), - fontWeight: FontWeight.w600, - fontSize: 12.sp, - textColor: Color(0xFFFF79A1), - ), - ), - Container( - alignment: AlignmentDirectional.bottomCenter, - child: text( - SCAppLocalizations.of(context)!.firstDay(item.firstDay ?? ""), - fontWeight: FontWeight.w600, - fontSize: 11.sp, - textColor: Colors.white, - ), - ), - ], - ), - ), - PositionedDirectional( - top: 30.w, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Column( - children: [ - GestureDetector( - child: SizedBox( - height: 50.w, - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - netImage( - url: item.meUserAvatar ?? "", - width: 48.w, - shape: BoxShape.circle, - defaultImg: - "sc_images/general/sc_icon_avar_defalt.png", - ), - Image.asset( - "sc_images/person/sc_icon_cp_head_ring.png", - ), - ], - ), - ), - onTap: () { - SCNavigatorUtils.push( - context, - replace: false, - "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == item.meUserId}&tageId=${item.meUserId}", - ); - }, - ), - - Container( - alignment: Alignment.center, - width: 80.w, - height: 15.w, - child: - (item.meUserNickname?.length ?? 0) > 8 - ? Marquee( - text: item.meUserNickname ?? "", - style: TextStyle( - fontSize: 10.sp, - color: Colors.white, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ), - scrollAxis: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.start, - blankSpace: 20.0, - velocity: 40.0, - pauseAfterRound: Duration(seconds: 1), - accelerationDuration: Duration(seconds: 1), - accelerationCurve: Curves.easeOut, - decelerationDuration: Duration(milliseconds: 550), - decelerationCurve: Curves.easeOut, - ) - : Text( - item.meUserNickname ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.sp, - color: Colors.white, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ), - ), - ), - ], - ), - SizedBox(width: 120.w), - Column( - children: [ - GestureDetector( - child: SizedBox( - height: 50.w, - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - netImage( - url: item.cpUserAvatar ?? "", - width: 48.w, - shape: BoxShape.circle, - defaultImg: - "sc_images/general/sc_icon_avar_defalt.png", - ), - Image.asset( - "sc_images/person/sc_icon_cp_head_ring.png", - ), - ], - ), - ), - onTap: () { - SCNavigatorUtils.push( - context, - replace: false, - "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == item.cpUserId}&tageId=${item.cpUserId}", - ); - }, - ), - Container( - alignment: Alignment.center, - width: 80.w, - height: 15.w, - child: - (item.cpUserNickname?.length ?? 0) > 8 - ? Marquee( - text: item.cpUserNickname ?? "", - style: TextStyle( - fontSize: 10.sp, - color: Colors.white, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ), - scrollAxis: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.start, - blankSpace: 20.0, - velocity: 40.0, - pauseAfterRound: Duration(seconds: 1), - accelerationDuration: Duration(seconds: 1), - accelerationCurve: Curves.easeOut, - decelerationDuration: Duration(milliseconds: 550), - decelerationCurve: Curves.easeOut, - ) - : Text( - item.cpUserNickname ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.sp, - color: Colors.white, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ); - } - - String getUserCardBg() { - return ""; - } - - String _cpVaFormat(double cpValue) { - int value = cpValue.toInt(); - if (value > 99999) { - return "${(value / 1000).toStringAsFixed(0)}k"; - } - return "$value"; - } -} +import 'dart:convert'; +import 'package:flutter/cupertino.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/main.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/components/text/sc_text.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; +import 'package:yumi/modules/index/main_route.dart'; +import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart'; +import 'package:yumi/ui_kit/widgets/room/room_user_card_setting.dart'; +import 'package:marquee/marquee.dart'; +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/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_user_repository_impl.dart'; +import 'package:yumi/shared/business_logic/models/res/login_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'; +import 'package:yumi/modules/gift/gift_page.dart'; + +import '../../../shared/data_sources/models/enum/sc_room_roles_type.dart'; +import '../../../shared/business_logic/models/res/sc_user_identity_res.dart'; +import '../../../modules/chat/chat_route.dart'; + +class RoomUserInfoCard extends StatefulWidget { + String? userId; + + RoomUserInfoCard({super.key, this.userId}); + + @override + _RoomUserInfoCardState createState() => _RoomUserInfoCardState(); +} + +class _RoomUserInfoCardState extends State { + 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(); + userProvider = Provider.of( + context, + listen: false, + ); + roomId = + Provider.of( + context, + listen: false, + ).currenRoom?.roomProfile?.roomProfile?.id ?? + ""; + userProvider?.roomUserCard(roomId, widget.userId ?? ""); + SCAccountRepository().userIdentity(userId: widget.userId).then((v) { + userIdentity = v; + setState(() {}); + }); + } + + @override + void dispose() { + super.dispose(); + userProvider?.userCardInfo = null; + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Consumer( + builder: (context, ref, child) { + final currentUserId = + AccountStorage().getCurrentUser()?.userProfile?.id; + final isSelf = widget.userId == currentUserId; + final rtcProvider = Provider.of(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, + child: Center( + child: Container( + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8), + ), + width: 55.w, + height: 55.w, + child: CupertinoActivityIndicator(color: Colors.white24), + ), + ), + ); + } + return Container( + constraints: BoxConstraints( + maxHeight: ScreenUtil().screenHeight * 0.7, + ), + margin: EdgeInsets.symmetric(horizontal: 15.w), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + alignment: Alignment.center, + children: [ + Container( + margin: EdgeInsets.only( + top: + ref.userCardInfo?.userProfile + ?.getDataCard() + ?.sourceUrl != + null + ? 45.w + : 55.w, + ), + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: + ref.userCardInfo?.userProfile + ?.getDataCard() + ?.sourceUrl != + null + ? [ + Colors.transparent, + Colors.transparent, + ] + : [ + Color(0xff18F2B1), + Color(0xffE4FFF6), + Color(0xffE4FFF6), + Color(0xffE4FFF6), + Color(0xffE4FFF6), + Color(0xffE4FFF6), + Color(0xffE4FFF6), + Color(0xffFFFFFF), + ], + ), + borderRadius: BorderRadius.all( + Radius.circular(12.w), + ), + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox(height: 25.w), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + SizedBox(width: 20.w), + GestureDetector( + child: head( + url: + ref + .userCardInfo + ?.userProfile + ?.userAvatar ?? + "", + width: 55.w, + border: Border.all( + color: Colors.white, + width: 2, + ), + headdress: + ref.userCardInfo?.userProfile + ?.getHeaddress() + ?.sourceUrl, + ), + onTap: () { + SCNavigatorUtils.push( + context, + "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == widget.userId}&tageId=${widget.userId}", + ); + }, + ), + SizedBox(width: 3.w), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + msgRoleTag( + ref + .userCardInfo + ?.roomRole ?? + "", + width: 16.w, + height: 16.w, + ), + SizedBox(width: 2.w), + socialchatNickNameText( + maxWidth: 115.w, + ref + .userCardInfo + ?.userProfile + ?.userNickname ?? + "", + fontSize: 14.sp, + textColor: Colors.black, + fontWeight: FontWeight.bold, + type: "", + needScroll: + (ref + .userCardInfo + ?.userProfile + ?.userNickname + ?.characters + .length ?? + 0) > + 10, + ), + ], + ), + SizedBox(height: 2.w), + text( + "ID:${ref.userCardInfo?.userProfile?.getID() ?? 0}", + fontSize: 12.sp, + textColor: Colors.black, + fontWeight: FontWeight.bold, + ), + ], + ), + ], + ), + Row( + mainAxisAlignment: + MainAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(15.w), + color: Colors.transparent, + ), + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 5.w, + ), + child: Row( + children: [ + SizedBox(width: 20.w), + netImage( + url: + Provider.of< + SCAppGeneralManager + >( + context, + listen: false, + ) + .findCountryByName( + ref + .userCardInfo + ?.userProfile + ?.countryName ?? + "", + ) + ?.nationalFlag ?? + "", + borderRadius: + BorderRadius.all( + Radius.circular(3.w), + ), + width: 19.w, + height: 14.w, + ), + SizedBox(width: 5.w), + Container( + constraints: BoxConstraints( + maxWidth: 110.w, + ), + child: text( + ref + .userCardInfo + ?.userProfile + ?.countryName ?? + "", + textColor: Colors.black, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + SizedBox(width: 5.w), + Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + ref + .userCardInfo + ?.userProfile + ?.userSex == + 0 + ? "sc_images/login/sc_icon_sex_woman_bg.png" + : "sc_images/login/sc_icon_sex_man_bg.png", + ), + ), + ), + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 5.w, + ), + child: Row( + children: [ + xb( + ref + .userCardInfo + ?.userProfile + ?.userSex, + ), + SizedBox(width: 3.w), + text( + "${ref.userCardInfo?.userProfile?.age ?? 0}", + textColor: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.bold, + ), + ], + ), + ), + ], + ), + + SizedBox(height: 15.w), + Container( + margin: EdgeInsets.symmetric( + horizontal: 25.w, + ), + 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, + onTap: () async { + Navigator.of( + context, + ).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(), + ); + 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, + ), + 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, + ), + ), + ], + ); + }, + ), + 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, + onTap: () async { + if (SCRoomUtils.touristCanMsg( + context, + )) { + if ((ref + .userCardInfo + ?.userProfile + ?.userNickname ?? + "") + .isEmpty) { + return; + } + Navigator.of( + context, + ).pop(); + Navigator.push( + context, + PopRoute( + child: RoomMsgInput( + atTextContent: + "${ref.userCardInfo?.userProfile?.userNickname}", + ), + ), + ); + } + }, + ), + _buildCardAction( + iconAsset: + "sc_images/room/sc_icon_send_user_gift.png", + label: + SCAppLocalizations.of( + context, + )!.send, + 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, + ); + }, + ); + }, + ), + ], + ), + ), + SizedBox( + height: + isSelf + ? (canLeaveMic ? 35.w : 15.w) + : 35.w, + ), + ], + ), + ], + ), + ), + AccountStorage() + .getCurrentUser() + ?.userProfile + ?.id != + widget.userId + ? Positioned( + top: 28.w, + right: 15.w, + child: GestureDetector( + child: Image.asset( + "sc_images/room/sc_icon_user_card_report.png", + width: 24.w, + height: 24.w, + color: Colors.black, + ), + onTap: () { + SCNavigatorUtils.push( + context, + "${SCMainRoute.report}?type=user&tageId=${widget.userId}", + replace: false, + ); + }, + ), + ) + : Container(), + ref.userCardInfo?.roomRole == + SCRoomRolesType.HOMEOWNER.name + ? Container() + : (AccountStorage() + .getCurrentUser() + ?.userProfile + ?.id != + widget.userId && + Provider.of( + context, + listen: false, + ).isFz() || + AccountStorage() + .getCurrentUser() + ?.userProfile + ?.id != + widget.userId && + Provider.of( + context, + listen: false, + ).isGL()) + ? Positioned( + top: 28.w, + right: 45.w, + child: GestureDetector( + child: Image.asset( + "sc_images/room/sc_icon_room_user_card_setting.png", + width: 24.w, + height: 24.w, + color: Colors.black, + ), + onTap: () { + if (userProvider?.userCardInfo != null) { + Navigator.of(context).pop(); + showBottomInBottomDialog( + context!, + RoomUserCardSetting( + roomId: roomId, + userCardInfo: + userProvider?.userCardInfo + ?.copyWith(), + ), + ); + } + }, + ), + ) + : Container(), + ], + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } + + String roleName(String role) { + if (role == SCRoomRolesType.HOMEOWNER.name) { + return SCAppLocalizations.of(context)!.owner; + } else if (role == SCRoomRolesType.ADMIN.name) { + return SCAppLocalizations.of(context)!.admin; + } else if (role == SCRoomRolesType.MEMBER.name) { + return SCAppLocalizations.of(context)!.member; + } + return SCAppLocalizations.of(context)!.guest; + } + + _buildEmptyCpItem(bool canAdd) { + return Container( + width: ScreenUtil().screenWidth, + margin: EdgeInsets.symmetric(horizontal: 12.w), + height: 105.w, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + canAdd + ? "sc_images/person/sc_icon_no_cp_item_bg.png" + : "sc_images/person/sc_icon_no_cp_item_bg4.png", + ), + fit: BoxFit.fill, + ), + ), + child: Container( + margin: EdgeInsets.only(bottom: 13.w), + alignment: AlignmentDirectional.bottomCenter, + child: text( + SCAppLocalizations.of(context)!.noMatchedCP, + fontWeight: FontWeight.w600, + fontSize: 12.sp, + textColor: canAdd ? Color(0xFFFF79A1) : Colors.white, + ), + ), + ); + } + + Widget _buildCpItem(CPRes item) { + return Stack( + alignment: AlignmentDirectional.center, + children: [ + Container( + width: ScreenUtil().screenWidth, + margin: EdgeInsets.symmetric(horizontal: 15.w), + height: 135.w, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage("sc_images/person/sc_icon_no_cp_item_bg2.png"), + fit: BoxFit.fill, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 68.w), + Container( + alignment: AlignmentDirectional.bottomCenter, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + "sc_images/person/sc_icon_cp_value_tag.png", + height: 18.w, + ), + SizedBox(width: 5.w), + text( + _cpVaFormat(item.cpValue ?? 0), + fontWeight: FontWeight.w600, + fontSize: 12.sp, + textColor: Color(0xFFFF79A1), + ), + ], + ), + ), + SizedBox(height: 8.w), + Container( + alignment: AlignmentDirectional.bottomCenter, + child: text( + SCAppLocalizations.of( + context, + )!.timeSpentTogether(item.days ?? ""), + fontWeight: FontWeight.w600, + fontSize: 12.sp, + textColor: Color(0xFFFF79A1), + ), + ), + Container( + alignment: AlignmentDirectional.bottomCenter, + child: text( + SCAppLocalizations.of(context)!.firstDay(item.firstDay ?? ""), + fontWeight: FontWeight.w600, + fontSize: 11.sp, + textColor: Colors.white, + ), + ), + ], + ), + ), + PositionedDirectional( + top: 30.w, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Column( + children: [ + GestureDetector( + child: SizedBox( + height: 50.w, + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + netImage( + url: item.meUserAvatar ?? "", + width: 48.w, + shape: BoxShape.circle, + defaultImg: + "sc_images/general/sc_icon_avar_defalt.png", + ), + Image.asset( + "sc_images/person/sc_icon_cp_head_ring.png", + ), + ], + ), + ), + onTap: () { + SCNavigatorUtils.push( + context, + replace: false, + "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == item.meUserId}&tageId=${item.meUserId}", + ); + }, + ), + + Container( + alignment: Alignment.center, + width: 80.w, + height: 15.w, + child: + (item.meUserNickname?.length ?? 0) > 8 + ? Marquee( + text: item.meUserNickname ?? "", + style: TextStyle( + fontSize: 10.sp, + color: Colors.white, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + scrollAxis: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.start, + blankSpace: 20.0, + velocity: 40.0, + pauseAfterRound: Duration(seconds: 1), + accelerationDuration: Duration(seconds: 1), + accelerationCurve: Curves.easeOut, + decelerationDuration: Duration(milliseconds: 550), + decelerationCurve: Curves.easeOut, + ) + : Text( + item.meUserNickname ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.sp, + color: Colors.white, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + SizedBox(width: 120.w), + Column( + children: [ + GestureDetector( + child: SizedBox( + height: 50.w, + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + netImage( + url: item.cpUserAvatar ?? "", + width: 48.w, + shape: BoxShape.circle, + defaultImg: + "sc_images/general/sc_icon_avar_defalt.png", + ), + Image.asset( + "sc_images/person/sc_icon_cp_head_ring.png", + ), + ], + ), + ), + onTap: () { + SCNavigatorUtils.push( + context, + replace: false, + "${SCMainRoute.person}?isMe=${AccountStorage().getCurrentUser()?.userProfile?.id == item.cpUserId}&tageId=${item.cpUserId}", + ); + }, + ), + Container( + alignment: Alignment.center, + width: 80.w, + height: 15.w, + child: + (item.cpUserNickname?.length ?? 0) > 8 + ? Marquee( + text: item.cpUserNickname ?? "", + style: TextStyle( + fontSize: 10.sp, + color: Colors.white, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + scrollAxis: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.start, + blankSpace: 20.0, + velocity: 40.0, + pauseAfterRound: Duration(seconds: 1), + accelerationDuration: Duration(seconds: 1), + accelerationCurve: Curves.easeOut, + decelerationDuration: Duration(milliseconds: 550), + decelerationCurve: Curves.easeOut, + ) + : Text( + item.cpUserNickname ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.sp, + color: Colors.white, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + String getUserCardBg() { + return ""; + } + + String _cpVaFormat(double cpValue) { + int value = cpValue.toInt(); + if (value > 99999) { + return "${(value / 1000).toStringAsFixed(0)}k"; + } + return "$value"; + } +} diff --git a/lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart b/lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart index fb400db..53cdfff 100644 --- a/lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart +++ b/lib/ui_kit/widgets/svga/sc_svga_asset_widget.dart @@ -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 } 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; diff --git a/pubspec.yaml b/pubspec.yaml index 69751e9..797ca6f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -156,12 +156,16 @@ flutter: - assets/l10n/ - sc_images/splash/ - sc_images/login/ - - sc_images/general/ - - sc_images/index/ - - sc_images/room/ - - sc_images/room/entrance/ - - sc_images/room/anim/ - - sc_images/person/ + - 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/ - sc_images/level/ diff --git a/sc_images/daily_sign_in/check_in_button.png b/sc_images/daily_sign_in/check_in_button.png new file mode 100644 index 0000000..ca3a7dc Binary files /dev/null and b/sc_images/daily_sign_in/check_in_button.png differ diff --git a/sc_images/daily_sign_in/current_day_badge.png b/sc_images/daily_sign_in/current_day_badge.png new file mode 100644 index 0000000..7c4c6aa Binary files /dev/null and b/sc_images/daily_sign_in/current_day_badge.png differ diff --git a/sc_images/daily_sign_in/day_badge.png b/sc_images/daily_sign_in/day_badge.png new file mode 100644 index 0000000..f565678 Binary files /dev/null and b/sc_images/daily_sign_in/day_badge.png differ diff --git a/sc_images/daily_sign_in/dialog_frame.png b/sc_images/daily_sign_in/dialog_frame.png new file mode 100644 index 0000000..5246912 Binary files /dev/null and b/sc_images/daily_sign_in/dialog_frame.png differ diff --git a/sc_images/daily_sign_in/reward_claimed_bg.png b/sc_images/daily_sign_in/reward_claimed_bg.png new file mode 100644 index 0000000..6e3a06a Binary files /dev/null and b/sc_images/daily_sign_in/reward_claimed_bg.png differ diff --git a/sc_images/daily_sign_in/reward_current_bg.png b/sc_images/daily_sign_in/reward_current_bg.png new file mode 100644 index 0000000..372a0a3 Binary files /dev/null and b/sc_images/daily_sign_in/reward_current_bg.png differ diff --git a/sc_images/daily_sign_in/reward_final_bg.png b/sc_images/daily_sign_in/reward_final_bg.png new file mode 100644 index 0000000..635f586 Binary files /dev/null and b/sc_images/daily_sign_in/reward_final_bg.png differ diff --git a/sc_images/daily_sign_in/reward_pending_bg.png b/sc_images/daily_sign_in/reward_pending_bg.png new file mode 100644 index 0000000..868b651 Binary files /dev/null and b/sc_images/daily_sign_in/reward_pending_bg.png differ diff --git a/sc_images/daily_sign_in/weekly_sign_in_title.png b/sc_images/daily_sign_in/weekly_sign_in_title.png new file mode 100644 index 0000000..20d1928 Binary files /dev/null and b/sc_images/daily_sign_in/weekly_sign_in_title.png differ diff --git a/sc_images/register_reward/close_icon.png b/sc_images/register_reward/close_icon.png new file mode 100644 index 0000000..41f849b Binary files /dev/null and b/sc_images/register_reward/close_icon.png differ diff --git a/sc_images/register_reward/dialog_background.png b/sc_images/register_reward/dialog_background.png new file mode 100644 index 0000000..7b569e6 Binary files /dev/null and b/sc_images/register_reward/dialog_background.png differ diff --git a/sc_images/register_reward/gold_glow_background.png b/sc_images/register_reward/gold_glow_background.png new file mode 100644 index 0000000..61ae662 Binary files /dev/null and b/sc_images/register_reward/gold_glow_background.png differ diff --git a/sc_images/room/anim/gift/room_bottom_gift_button.svga b/sc_images/room/anim/gift/room_bottom_gift_button.svga new file mode 100644 index 0000000..fd714aa Binary files /dev/null and b/sc_images/room/anim/gift/room_bottom_gift_button.svga differ diff --git a/sc_images/room/anim/gift/room_gift_combo_badge.png b/sc_images/room/anim/gift/room_gift_combo_badge.png new file mode 100644 index 0000000..3555a39 Binary files /dev/null and b/sc_images/room/anim/gift/room_gift_combo_badge.png differ diff --git a/sc_images/room/anim/luck_gift/luck_gift_reward_burst.svga b/sc_images/room/anim/luck_gift/luck_gift_reward_burst.svga new file mode 100644 index 0000000..92670e0 Binary files /dev/null and b/sc_images/room/anim/luck_gift/luck_gift_reward_burst.svga differ diff --git a/sc_images/room/sc_icon_room_seat_heartbeat_value.png b/sc_images/room/sc_icon_room_seat_heartbeat_value.png new file mode 100644 index 0000000..d1c597d Binary files /dev/null and b/sc_images/room/sc_icon_room_seat_heartbeat_value.png differ diff --git a/sc_images/room/sc_icon_room_user_card_leave_mic.png b/sc_images/room/sc_icon_room_user_card_leave_mic.png new file mode 100644 index 0000000..85302fc Binary files /dev/null and b/sc_images/room/sc_icon_room_user_card_leave_mic.png differ diff --git a/需求进度.md b/需求进度.md index e9e0f77..26390e1 100644 --- a/需求进度.md +++ b/需求进度.md @@ -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` 后缀,也会正常触发“中心停留 -> 飞向目标麦位”的补充动画。