Compare commits

..

2 Commits

Author SHA1 Message Date
zhx
84b68fbb4a 优化火箭奖励展示和素材预加载 2026-05-16 00:02:09 +08:00
zhx
4b637d4137 修改礼物bag 2026-05-16 00:02:09 +08:00
11 changed files with 1214 additions and 321 deletions

View File

@ -676,6 +676,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
color: const Color(0xff09372E).withValues(alpha: 0.5), color: const Color(0xff09372E).withValues(alpha: 0.5),
constraints: BoxConstraints(maxHeight: 430.w), constraints: BoxConstraints(maxHeight: 430.w),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox(height: 12.w), SizedBox(height: 12.w),
Row( Row(
@ -782,7 +783,8 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
SizedBox(width: 5.w), SizedBox(width: 5.w),
], ],
), ),
Expanded( SizedBox(
height: kGiftTabPageHeight.w,
child: TabBarView( child: TabBarView(
physics: NeverScrollableScrollPhysics(), physics: NeverScrollableScrollPhysics(),
controller: tabController, controller: tabController,

View File

@ -18,6 +18,29 @@ enum _GiftGridPageStatus { idle, loading, ready }
const Duration _kGiftPageSkeletonMinDuration = Duration(milliseconds: 420); const Duration _kGiftPageSkeletonMinDuration = Duration(milliseconds: 420);
const Duration _kGiftPageSkeletonMaxDuration = Duration(milliseconds: 900); const Duration _kGiftPageSkeletonMaxDuration = Duration(milliseconds: 900);
const int _kGiftGridColumnCount = 4;
const int _kGiftGridRowCount = 2;
const double _kGiftGridCardHeight = 76;
const double _kGiftGridVerticalPadding = 5;
const double _kGiftGridMainAxisSpacing = 2;
const double _kGiftGridCrossAxisSpacing = 8;
const double _kGiftCardVerticalPadding = 5;
const double _kGiftIconPriceSpacing = 1;
const double _kGiftPriceRowHeight = 14;
const double _kGiftGridTopGap = 5;
const double _kGiftIndicatorTopGap = 5;
const double _kGiftPageIndicatorHeight = 6.5;
const double _kGiftGridBottomPadding = 6;
const double _kGiftGridHeight =
_kGiftGridCardHeight * _kGiftGridRowCount +
_kGiftGridMainAxisSpacing * (_kGiftGridRowCount - 1) +
_kGiftGridVerticalPadding * 2;
const double kGiftTabPageHeight =
_kGiftGridTopGap +
_kGiftGridHeight +
_kGiftIndicatorTopGap +
_kGiftPageIndicatorHeight +
_kGiftGridBottomPadding;
class GiftTabPage extends StatefulWidget { class GiftTabPage extends StatefulWidget {
final String type; final String type;
@ -72,8 +95,9 @@ class _GiftTabPageState extends State<GiftTabPage>
)) ))
: Column( : Column(
children: [ children: [
SizedBox(height: 23.w), SizedBox(height: _kGiftGridTopGap.w),
Expanded( SizedBox(
height: _kGiftGridHeight.w,
child: PageView.builder( child: PageView.builder(
controller: _giftPageController, controller: _giftPageController,
onPageChanged: (i) { onPageChanged: (i) {
@ -95,16 +119,7 @@ class _GiftTabPageState extends State<GiftTabPage>
_GiftGridPageStatus.ready) { _GiftGridPageStatus.ready) {
return _buildGiftPageSkeleton(); return _buildGiftPageSkeleton();
} }
return GridView.builder( return _buildGiftGrid(
shrinkWrap: true,
padding: EdgeInsets.symmetric(horizontal: 12.w),
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 0.75,
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
),
itemCount: size, itemCount: size,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return _bagItem(gifts[(i * 8) + index], ref); return _bagItem(gifts[(i * 8) + index], ref);
@ -114,8 +129,9 @@ class _GiftTabPageState extends State<GiftTabPage>
itemCount: (gifts.length / 8).ceil(), itemCount: (gifts.length / 8).ceil(),
), ),
), ),
SizedBox(height: _kGiftIndicatorTopGap.w),
_indicator(ref), _indicator(ref),
SizedBox(height: 10.w), SizedBox(height: _kGiftGridBottomPadding.w),
], ],
); );
}, },
@ -206,18 +222,11 @@ class _GiftTabPageState extends State<GiftTabPage>
), ),
), ),
), ),
GridView.builder( Positioned.fill(
shrinkWrap: true, child: _buildGiftGrid(
padding: EdgeInsets.symmetric(horizontal: 12.w), itemCount: 8,
physics: NeverScrollableScrollPhysics(), itemBuilder: (context, index) => _buildGiftSkeletonCard(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 0.75,
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
), ),
itemCount: 8,
itemBuilder: (context, index) => _buildGiftSkeletonCard(),
), ),
], ],
); );
@ -226,42 +235,71 @@ class _GiftTabPageState extends State<GiftTabPage>
Widget _buildGiftSkeletonCard() { Widget _buildGiftSkeletonCard() {
final baseColor = Colors.white.withValues(alpha: 0.12); final baseColor = Colors.white.withValues(alpha: 0.12);
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.w)),
borderRadius: BorderRadius.circular(12.w), child: Padding(
color: Colors.white.withValues(alpha: 0.08), padding: EdgeInsets.symmetric(vertical: _kGiftCardVerticalPadding.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.w),
color: baseColor,
),
child: Center(child: SCRotatingDotsLoading(size: 24.w)),
),
SizedBox(height: _kGiftIconPriceSpacing.w),
Container(
width: 52.w,
height: 8.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.w),
color: baseColor,
),
),
],
),
), ),
child: Column( );
mainAxisAlignment: MainAxisAlignment.center, }
children: [
Container( Widget _buildGiftGrid({
width: 48.w, required int itemCount,
height: 48.w, required IndexedWidgetBuilder itemBuilder,
decoration: BoxDecoration( }) {
borderRadius: BorderRadius.circular(12.w), return LayoutBuilder(
color: baseColor, builder: (context, constraints) {
), final horizontalPadding = 12.w;
child: Center(child: SCRotatingDotsLoading(size: 24.w)), final verticalPadding = _kGiftGridVerticalPadding.w;
final mainAxisSpacing = _kGiftGridMainAxisSpacing.w;
final crossAxisSpacing = _kGiftGridCrossAxisSpacing.w;
final itemHeight = _kGiftGridCardHeight.w;
final gridWidth =
constraints.maxWidth -
horizontalPadding * 2 -
crossAxisSpacing * (_kGiftGridColumnCount - 1);
final itemWidth = (gridWidth / _kGiftGridColumnCount).clamp(
1.0,
double.infinity,
);
return GridView.builder(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
), ),
SizedBox(height: 7.w), physics: const NeverScrollableScrollPhysics(),
Container( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
width: 52.w, crossAxisCount: _kGiftGridColumnCount,
height: 8.w, childAspectRatio: itemWidth / itemHeight,
decoration: BoxDecoration( mainAxisSpacing: mainAxisSpacing,
borderRadius: BorderRadius.circular(999.w), crossAxisSpacing: crossAxisSpacing,
color: baseColor,
),
), ),
SizedBox(height: 6.w), itemCount: itemCount,
Container( itemBuilder: itemBuilder,
width: 40.w, );
height: 8.w, },
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.w),
color: baseColor,
),
),
],
),
); );
} }
@ -297,40 +335,45 @@ class _GiftTabPageState extends State<GiftTabPage>
Widget _bagItem(SocialChatGiftRes gift, SCAppGeneralManager ref) { Widget _bagItem(SocialChatGiftRes gift, SCAppGeneralManager ref) {
return gift.id == "-1000" return gift.id == "-1000"
? GestureDetector( ? GestureDetector(
behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
children: [ children: [
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.w), borderRadius: BorderRadius.circular(12.w),
color: Colors.white10,
border: Border.all(color: Colors.transparent, width: 1.w), border: Border.all(color: Colors.transparent, width: 1.w),
), ),
child: Column( child: Padding(
mainAxisAlignment: MainAxisAlignment.center, padding: EdgeInsets.symmetric(
children: [ vertical: _kGiftCardVerticalPadding.w,
Image.asset( ),
_strategy.getGiftPageCustomizedRuleIcon(), child: Column(
fit: BoxFit.cover, mainAxisAlignment: MainAxisAlignment.start,
width: 48.w, children: [
height: 48.w, Image.asset(
), _strategy.getGiftPageCustomizedRuleIcon(),
SizedBox(height: 5.w), fit: BoxFit.cover,
Container( width: 48.w,
height: 23.w, height: 48.w,
margin: EdgeInsets.symmetric(horizontal: 3.w),
alignment: Alignment.center,
child: text(
SCAppLocalizations.of(context)!.rulesUpload,
maxLines: 2,
fontSize: 10.sp,
letterSpacing: 0.1,
lineHeight: 1,
textAlign: TextAlign.center,
fontWeight: FontWeight.w600,
textColor: Colors.white,
), ),
), SizedBox(height: _kGiftIconPriceSpacing.w),
], Container(
height: 12.w,
margin: EdgeInsets.symmetric(horizontal: 3.w),
alignment: Alignment.center,
child: text(
SCAppLocalizations.of(context)!.rulesUpload,
maxLines: 1,
fontSize: 10,
letterSpacing: 0.1,
lineHeight: 1,
textAlign: TextAlign.center,
fontWeight: FontWeight.w600,
textColor: Colors.white,
),
),
],
),
), ),
), ),
], ],
@ -403,10 +446,10 @@ class _GiftTabPageState extends State<GiftTabPage>
}, },
) )
: GestureDetector( : GestureDetector(
behavior: HitTestBehavior.opaque,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.w), borderRadius: BorderRadius.circular(12.w),
color: Colors.white10,
border: Border.all( border: Border.all(
color: color:
checkedIndex == ref.giftByTab[widget.type]!.indexOf(gift) checkedIndex == ref.giftByTab[widget.type]!.indexOf(gift)
@ -415,64 +458,61 @@ class _GiftTabPageState extends State<GiftTabPage>
width: 1.w, width: 1.w,
), ),
), ),
child: Column( child: Padding(
mainAxisAlignment: MainAxisAlignment.center, padding: EdgeInsets.symmetric(
children: [ vertical: _kGiftCardVerticalPadding.w,
netImage( ),
url: gift.giftPhoto ?? "", child: Column(
fit: BoxFit.cover, mainAxisAlignment: MainAxisAlignment.start,
width: 48.w, children: [
height: 48.w, netImage(
loadingWidget: _buildGiftCoverLoading(), url: gift.giftPhoto ?? "",
errorWidget: _buildGiftCoverNoData(), fit: BoxFit.cover,
), width: 48.w,
SizedBox(height: 5.w), height: 48.w,
Container( loadingWidget: _buildGiftCoverLoading(),
height: 23.w, errorWidget: _buildGiftCoverNoData(),
margin: EdgeInsets.symmetric(horizontal: 3.w),
alignment: Alignment.center,
child: text(
gift.giftName ?? "",
maxLines: 2,
fontSize: 10.sp,
letterSpacing: 0.1,
lineHeight: 1,
textAlign: TextAlign.center,
fontWeight: FontWeight.w600,
textColor: widget.isDark ? Colors.white : Colors.black,
), ),
), SizedBox(height: _kGiftIconPriceSpacing.w),
Row( SizedBox(
mainAxisAlignment: MainAxisAlignment.center, height: _kGiftPriceRowHeight.w,
children: child: Row(
_isBackpackTab mainAxisAlignment: MainAxisAlignment.center,
? [ children:
text( _isBackpackTab
"x${_formatBackpackQuantity(gift)}", ? [
fontSize: 10.sp, text(
fontWeight: FontWeight.w600, "x${_formatBackpackQuantity(gift)}",
textColor: fontSize: 10,
widget.isDark lineHeight: 1,
? SocialChatTheme.primaryLight fontWeight: FontWeight.w600,
: SocialChatTheme.primaryColor, textColor:
), widget.isDark
] ? SocialChatTheme.primaryLight
: [ : SocialChatTheme.primaryColor,
Image.asset( ),
_strategy.getGiftPageGoldCoinIcon(), ]
width: 14.w, : [
height: 14.w, Image.asset(
), _strategy.getGiftPageGoldCoinIcon(),
SizedBox(width: 3.w), width: 14.w,
text( height: 14.w,
"${gift.giftCandy}", ),
fontSize: 10.sp, SizedBox(width: 3.w),
textColor: text(
widget.isDark ? Colors.white : Colors.black, "${gift.giftCandy}",
), fontSize: 10,
], lineHeight: 1,
), textColor:
], widget.isDark
? Colors.white
: Colors.black,
),
],
),
),
],
),
), ),
), ),
onTap: () { onTap: () {

View File

@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/business_logic/models/res/gift_backpack_res.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/sc_page_list.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart';
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
const double _kBagsGiftGridAspectRatio = 0.86;
///-
class BagsGiftPage extends SCPageList {
const BagsGiftPage({super.key});
@override
SCPageListState<SocialChatGiftBackpackRes, BagsGiftPage> createState() =>
_BagsGiftPageState();
}
class _BagsGiftPageState
extends SCPageListState<SocialChatGiftBackpackRes, BagsGiftPage> {
SocialChatGiftBackpackRes? selectedGift;
@override
void initState() {
super.initState();
enablePullUp = false;
isGridView = true;
gridViewCount = 3;
padding = EdgeInsets.symmetric(horizontal: 6.w);
backgroundColor = Colors.transparent;
gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: gridViewCount,
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: _kBagsGiftGridAspectRatio,
);
loadData(1);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body:
items.isEmpty && isLoading
? const SCBagGridSkeleton()
: buildList(context),
);
}
@override
Widget buildItem(SocialChatGiftBackpackRes res) {
final gift = res.giftConfig;
final isSelected = selectedGift?.id == res.id;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
selectedGift = res;
setState(() {});
},
child: Container(
decoration: BoxDecoration(
color: const Color(0xff18F2B1).withValues(alpha: 0.1),
border: Border.all(
color:
isSelected ? SocialChatTheme.primaryLight : Colors.transparent,
width: 1.w,
),
borderRadius: BorderRadius.all(Radius.circular(8.w)),
),
child: Stack(
alignment: Alignment.center,
children: [
Column(
children: [
SizedBox(height: 25.w),
netImage(
url: gift?.giftPhoto ?? "",
width: 55.w,
height: 55.w,
fit: BoxFit.contain,
),
SizedBox(height: 8.w),
buildStoreBagItemTitle(
gift?.giftName ?? "",
textColor: Colors.white,
fontSize: 12,
),
SizedBox(height: 6.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
SCGlobalConfig.businessLogicStrategy
.getGiftPageGoldCoinIcon(),
width: 14.w,
height: 14.w,
),
SizedBox(width: 3.w),
text(
_formatNumber(gift?.giftCandy),
fontSize: 10,
lineHeight: 1,
textColor: Colors.white,
),
],
),
],
),
PositionedDirectional(
top: 0,
start: 0,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.w),
decoration: BoxDecoration(
color: SocialChatTheme.primaryLight,
borderRadius: BorderRadiusDirectional.only(
topStart: Radius.circular(6.w),
topEnd: Radius.circular(12.w),
bottomEnd: Radius.circular(12.w),
),
),
child: text(
"x${_formatNumber(res.quantity)}",
fontSize: 10,
lineHeight: 1,
fontWeight: FontWeight.w600,
textColor: Colors.white,
),
),
),
],
),
),
);
}
@override
loadPage({
required int page,
required Function(List<SocialChatGiftBackpackRes>) onSuccess,
Function? onErr,
}) async {
try {
final giftList = await SCChatRoomRepository().giftBackpack();
onSuccess(giftList.where((item) => (item.quantity ?? 0) > 0).toList());
} catch (e) {
if (onErr != null) {
onErr();
}
}
}
String _formatNumber(num? value) {
if (value == null) {
return "0";
}
if (value % 1 == 0) {
return value.toInt().toString();
}
return value.toString();
}
}

View File

@ -1,142 +1,140 @@
import 'dart:async'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:yumi/app_localizations.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart';
import 'package:yumi/modules/user/my_items/theme/bags_tab_theme_page.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:tancent_vap/utils/constant.dart'; import 'package:yumi/modules/store/store_route.dart';
import 'package:tancent_vap/widgets/vap_view.dart'; import 'package:yumi/modules/user/my_items/chatbox/bags_chatbox_page.dart';
import 'package:yumi/modules/user/my_items/gift/bags_gift_page.dart';
import 'package:yumi/app_localizations.dart'; import 'package:yumi/modules/user/my_items/headdress/bags_headdress_page.dart';
import 'package:yumi/ui_kit/components/appbar/socialchat_appbar.dart'; import 'package:yumi/modules/user/my_items/mountains/bags_mountains_page.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/theme/socialchat_theme.dart'; import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart'; class MyItemsPage extends StatefulWidget {
import 'package:yumi/ui_kit/widgets/headdress/headdress_widget.dart'; const MyItemsPage({super.key});
import 'package:yumi/modules/store/store_route.dart';
import 'package:yumi/modules/user/my_items/chatbox/bags_chatbox_page.dart'; @override
import 'package:yumi/modules/user/my_items/headdress/bags_headdress_page.dart'; State<MyItemsPage> createState() => _MyItemsPageState();
import 'package:yumi/modules/user/my_items/mountains/bags_mountains_page.dart'; }
import '../../../app/constants/sc_global_config.dart'; class _MyItemsPageState extends State<MyItemsPage>
import '../../../shared/business_logic/usecases/sc_fixed_width_tabIndicator.dart'; with SingleTickerProviderStateMixin {
late TabController _tabController;
class MyItemsPage extends StatefulWidget { final List<Widget> _pages = [];
@override final List<Widget> _tabs = [];
_MyItemsPageState createState() => _MyItemsPageState();
} @override
void initState() {
class _MyItemsPageState extends State<MyItemsPage> super.initState();
with SingleTickerProviderStateMixin { _pages.add(BagsHeaddressPage());
late TabController _tabController; _pages.add(BagsMountainsPage());
final List<Widget> _pages = []; _pages.add(BagsGiftPage());
final List<Widget> _tabs = []; _pages.add(BagsChatboxPage());
_tabController = TabController(length: _pages.length, vsync: this);
@override _tabController.addListener(() {});
void initState() { }
super.initState();
_pages.add(BagsHeaddressPage()); @override
_pages.add( void dispose() {
BagsMountainsPage(), _tabController.dispose();
); super.dispose();
_pages.add( }
BagsTabThemePage(),
); @override
_pages.add(BagsChatboxPage()); Widget build(BuildContext context) {
_tabController = TabController(length: _pages.length, vsync: this); _tabs.clear();
_tabController.addListener(() { _tabs.add(Tab(text: SCAppLocalizations.of(context)!.headdress));
}); _tabs.add(Tab(text: SCAppLocalizations.of(context)!.mountains));
} _tabs.add(Tab(text: SCAppLocalizations.of(context)!.gift));
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.chatBox));
@override return Stack(
void dispose() { children: [
super.dispose(); Image.asset(
} SCGlobalConfig.businessLogicStrategy.getLanguagePageBackgroundImage(),
width: ScreenUtil().screenWidth,
@override height: ScreenUtil().screenHeight,
Widget build(BuildContext context) { fit: BoxFit.fill,
_tabs.clear(); ),
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.headdress)); Scaffold(
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.mountains)); backgroundColor: Colors.transparent,
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.theme)); resizeToAvoidBottomInset: false,
_tabs.add(Tab(text: SCAppLocalizations.of(context)!.chatBox)); appBar: SocialChatStandardAppBar(
return Stack( title: SCAppLocalizations.of(context)!.myItems,
children: [ actions: [
Image.asset( GestureDetector(
SCGlobalConfig.businessLogicStrategy.getLanguagePageBackgroundImage(), behavior: HitTestBehavior.opaque,
width: ScreenUtil().screenWidth, child: Container(
height: ScreenUtil().screenHeight, padding: EdgeInsets.all(5.w),
fit: BoxFit.fill, margin: EdgeInsetsDirectional.only(end: 10.w),
), child: Image.asset(
Scaffold( "sc_images/store/sc_icon_bag_shop.png",
backgroundColor: Colors.transparent, width: 20.w,
resizeToAvoidBottomInset: false, height: 20.w,
appBar: SocialChatStandardAppBar( ),
title: SCAppLocalizations.of(context)!.myItems, ),
actions: [ onTap: () {
GestureDetector( SCNavigatorUtils.push(
behavior: HitTestBehavior.opaque, context,
child: Container( StoreRoute.list,
padding: EdgeInsets.all(5.w), replace: true,
margin: EdgeInsetsDirectional.only(end: 10.w), );
child: Image.asset( },
"sc_images/store/sc_icon_bag_shop.png", ),
width: 20.w, ],
height: 20.w, ),
), body: SafeArea(
), top: false,
onTap: () { child: Column(
SCNavigatorUtils.push(context, StoreRoute.list, replace: true); children: [
}, Row(
), mainAxisAlignment: MainAxisAlignment.center,
], children: [
), TabBar(
body: SafeArea(top:false,child: Column( tabAlignment: TabAlignment.center,
children: [ isScrollable: true,
Row( labelColor: Colors.white,
mainAxisAlignment: MainAxisAlignment.center, unselectedLabelStyle: SCGlobalConfig.businessLogicStrategy
children: [ .getStorePageTabUnselectedLabelStyle()
TabBar( .copyWith(
tabAlignment: TabAlignment.center, fontSize:
isScrollable: true, SCGlobalConfig.businessLogicStrategy
labelColor: Colors.white, .getStorePageTabUnselectedLabelStyle()
unselectedLabelStyle: SCGlobalConfig.businessLogicStrategy .fontSize
.getStorePageTabUnselectedLabelStyle() ?.sp,
.copyWith( ),
fontSize: // indicatorPadding: EdgeInsets.symmetric(
SCGlobalConfig.businessLogicStrategy // vertical: 5.w,
.getStorePageTabUnselectedLabelStyle() // horizontal: 15.w,
.fontSize // ),
?.sp, indicator: SCFixedWidthTabIndicator(
), width: 15.w,
// indicatorPadding: EdgeInsets.symmetric( color:
// vertical: 5.w, SCGlobalConfig.businessLogicStrategy
// horizontal: 15.w, .getStorePageTabIndicatorColor(),
// ), ),
indicator: SCFixedWidthTabIndicator( dividerColor:
width: 15.w, SCGlobalConfig.businessLogicStrategy
color: .getStorePageTabDividerColor(),
SCGlobalConfig.businessLogicStrategy controller: _tabController,
.getStorePageTabIndicatorColor(), tabs: _tabs,
), ),
dividerColor: ],
SCGlobalConfig.businessLogicStrategy ),
.getStorePageTabDividerColor(), SizedBox(height: 5.w),
controller: _tabController, Expanded(
tabs: _tabs, child: TabBarView(
), controller: _tabController,
], children: _pages,
), ),
SizedBox(height: 5.w), ),
Expanded( ],
child: TabBarView(controller: _tabController, children: _pages), ),
), ),
], ),
),), ],
), );
], }
); }
}
}

View File

@ -56,6 +56,7 @@ import '../../shared/business_logic/models/res/sc_room_theme_list_res.dart';
import '../../shared/business_logic/models/res/sc_vip_res.dart'; import '../../shared/business_logic/models/res/sc_vip_res.dart';
import '../../shared/tools/sc_room_profile_cache.dart'; import '../../shared/tools/sc_room_profile_cache.dart';
import '../../ui_kit/components/sc_float_ichart.dart'; import '../../ui_kit/components/sc_float_ichart.dart';
import '../../ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart';
import '../../ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart';
import '../../ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart';
import '../../ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart';
@ -2765,6 +2766,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
.then((res) { .then((res) {
roomRocketStatus = res; roomRocketStatus = res;
notifyListeners(); notifyListeners();
_scheduleRoomRocketAssetPreload(roomId, res, afterRoomEntry: true);
}) })
.catchError((e) {}); .catchError((e) {});
_loadRoomRocketRewardPopups(roomId); _loadRoomRocketRewardPopups(roomId);
@ -2825,6 +2827,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
roomRocketStatus = res; roomRocketStatus = res;
notifyListeners(); notifyListeners();
_publishRoomRocketLaunchNoticeIfNeeded(previousStatus, res); _publishRoomRocketLaunchNoticeIfNeeded(previousStatus, res);
_scheduleRoomRocketAssetPreload(
(res.roomId ?? currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(),
res,
);
} }
/// ///
@ -2843,6 +2849,35 @@ class RealTimeCommunicationManager extends ChangeNotifier {
roomRocketStatus = res; roomRocketStatus = res;
notifyListeners(); notifyListeners();
_publishRoomRocketLaunchNoticeIfNeeded(previousStatus, res); _publishRoomRocketLaunchNoticeIfNeeded(previousStatus, res);
_scheduleRoomRocketAssetPreload(resolvedRoomId, res);
}
void _scheduleRoomRocketAssetPreload(
String roomId,
SCRoomRocketStatusRes status, {
bool afterRoomEntry = false,
}) {
final resolvedRoomId = roomId.trim();
if (resolvedRoomId.isEmpty) {
return;
}
if (afterRoomEntry) {
RoomRocketAssetPreloader.preloadAfterRoomEntry(
roomId: resolvedRoomId,
status: status,
);
} else {
RoomRocketAssetPreloader.preloadForStatus(
roomId: resolvedRoomId,
status: status,
);
}
if (RoomRocketAssetPreloader.shouldForceCurrentLaunchPreload(status)) {
RoomRocketAssetPreloader.forcePreloadCurrentLaunchAssets(
roomId: resolvedRoomId,
status: status,
);
}
} }
void scheduleRoomRocketStatusRefreshForGift({String? roomId}) { void scheduleRoomRocketStatusRefreshForGift({String? roomId}) {
@ -3646,6 +3681,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_roomRocketGiftRefreshTimer = null; _roomRocketGiftRefreshTimer = null;
_cancelRoomRocketPostLaunchStatusRefresh(); _cancelRoomRocketPostLaunchStatusRefresh();
_lastRoomRocketLaunchNoticeKey = null; _lastRoomRocketLaunchNoticeKey = null;
RoomRocketAssetPreloader.cancelCurrentRoom();
_stopRoomRedPacketPresenceHeartbeat(); _stopRoomRedPacketPresenceHeartbeat();
_previewRoomSeatCount = null; _previewRoomSeatCount = null;
_finishRoomStartupSeatLoading(executePendingAction: false); _finishRoomStartupSeatLoading(executePendingAction: false);

View File

@ -231,6 +231,8 @@ abstract class SocialChatRoomRepository {
String roomId, { String roomId, {
int cursor = 1, int cursor = 1,
int limit = 10, int limit = 10,
String? launchNo,
bool roomScope = false,
}); });
/// ///

View File

@ -1133,11 +1133,16 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
String roomId, { String roomId, {
int cursor = 1, int cursor = 1,
int limit = 10, int limit = 10,
String? launchNo,
bool roomScope = false,
}) async { }) async {
final resolvedLaunchNo = launchNo?.trim() ?? '';
final queryParams = { final queryParams = {
if (roomId.trim().isNotEmpty) "roomId": roomId, if (roomId.trim().isNotEmpty) "roomId": roomId,
"cursor": cursor, "cursor": cursor,
"limit": limit, "limit": limit,
if (resolvedLaunchNo.isNotEmpty) "launchNo": resolvedLaunchNo,
if (roomScope || resolvedLaunchNo.isNotEmpty) "scope": "room",
}; };
const path = "/go/app/voice-room/rocket/reward-records"; const path = "/go/app/voice-room/rocket/reward-records";
_rocketApiDebug('request GET $path', query: queryParams); _rocketApiDebug('request GET $path', query: queryParams);

View File

@ -0,0 +1,426 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart';
const int _roomRocketAssetPreloadMaxConcurrent = 2;
const Duration _roomRocketAssetPreloadDelay = Duration(milliseconds: 350);
const Duration _roomRocketAssetPreloadTimeout = Duration(seconds: 12);
const double _roomRocketAssetPreloadFallbackThreshold = 95;
enum _RoomRocketPreloadKind { image, pag }
class _RoomRocketPreloadAsset {
const _RoomRocketPreloadAsset({
required this.resource,
required this.kind,
required this.roomId,
required this.generation,
});
final String resource;
final _RoomRocketPreloadKind kind;
final String roomId;
final int generation;
String get key {
final uri = _networkUri(resource);
return '${kind.name}:${uri?.toString() ?? resource.trim()}';
}
}
class RoomRocketAssetPreloader {
const RoomRocketAssetPreloader._();
static final Queue<_RoomRocketPreloadAsset> _queue =
Queue<_RoomRocketPreloadAsset>();
static final Set<String> _scheduledKeys = <String>{};
static int _generation = 0;
static int _running = 0;
static String _activeRoomId = '';
static void preloadAfterRoomEntry({
required String roomId,
required SCRoomRocketStatusRes status,
}) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
return;
}
final generation = _syncRoom(normalizedRoomId);
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(
Future<void>.delayed(_roomRocketAssetPreloadDelay, () {
if (!_isCurrentRoom(normalizedRoomId, generation)) {
return;
}
_enqueueAssets(
_assetsForStatus(
status,
roomId: normalizedRoomId,
generation: generation,
includeNextLevel: true,
includeCurrentRewardCovers: true,
),
);
}),
);
});
}
static void preloadForStatus({
required String roomId,
required SCRoomRocketStatusRes status,
}) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
return;
}
final generation = _syncRoom(normalizedRoomId);
_enqueueAssets(
_assetsForStatus(
status,
roomId: normalizedRoomId,
generation: generation,
includeNextLevel: true,
includeCurrentRewardCovers: true,
),
);
}
static void forcePreloadCurrentLaunchAssets({
required String roomId,
required SCRoomRocketStatusRes status,
}) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
return;
}
final generation = _syncRoom(normalizedRoomId);
_enqueueAssets(
_assetsForStatus(
status,
roomId: normalizedRoomId,
generation: generation,
includeNextLevel: false,
includeCurrentRewardCovers: true,
onlyCurrentLaunchAssets: true,
),
priority: true,
forceRetry: true,
);
}
static void cancelCurrentRoom() {
_generation += 1;
_activeRoomId = '';
_queue.clear();
_scheduledKeys.clear();
}
static bool shouldForceCurrentLaunchPreload(SCRoomRocketStatusRes status) {
return _statusPercent(status) >= _forceThreshold(status);
}
static int _syncRoom(String roomId) {
if (_activeRoomId != roomId) {
_activeRoomId = roomId;
_generation += 1;
_queue.clear();
_scheduledKeys.clear();
}
return _generation;
}
static bool _isCurrentRoom(String roomId, int generation) {
return _activeRoomId == roomId &&
_generation == generation &&
_isAppResumed();
}
static bool _isAppResumed() {
final state = WidgetsBinding.instance.lifecycleState;
return state == null || state == AppLifecycleState.resumed;
}
static void _enqueueAssets(
List<_RoomRocketPreloadAsset> assets, {
bool priority = false,
bool forceRetry = false,
}) {
if (assets.isEmpty) {
return;
}
final normalizedAssets = priority ? assets.reversed : assets;
for (final asset in normalizedAssets) {
if (_networkUri(asset.resource) == null) {
continue;
}
if (forceRetry) {
_queue.removeWhere((item) => item.key == asset.key);
_scheduledKeys.remove(asset.key);
}
if (!_scheduledKeys.add(asset.key)) {
continue;
}
if (priority) {
_queue.addFirst(asset);
} else {
_queue.addLast(asset);
}
}
_pump();
}
static void _pump() {
if (!_isAppResumed()) {
return;
}
while (_running < _roomRocketAssetPreloadMaxConcurrent &&
_queue.isNotEmpty) {
final asset = _queue.removeFirst();
if (!_isCurrentRoom(asset.roomId, asset.generation)) {
_scheduledKeys.remove(asset.key);
continue;
}
_running += 1;
unawaited(
_preloadAsset(asset)
.then((ok) {
if (!ok) {
_scheduledKeys.remove(asset.key);
}
})
.whenComplete(() {
_running = (_running - 1).clamp(0, 999).toInt();
_pump();
}),
);
}
}
static Future<bool> _preloadAsset(_RoomRocketPreloadAsset asset) async {
if (!_isCurrentRoom(asset.roomId, asset.generation)) {
return false;
}
final uri = _networkUri(asset.resource);
if (uri == null) {
return true;
}
final url = uri.toString();
try {
if (asset.kind == _RoomRocketPreloadKind.pag) {
await FileCacheManager.getInstance()
.getFile(url: url, headers: buildNetworkImageHeaders(url))
.timeout(_roomRocketAssetPreloadTimeout);
} else {
await DefaultCacheManager()
.getSingleFile(url, headers: buildNetworkImageHeaders(url))
.timeout(_roomRocketAssetPreloadTimeout);
}
_debug('preloaded ${asset.kind.name} ${_shortResource(url)}');
return true;
} catch (error) {
_debug(
'preload failed ${asset.kind.name} ${_shortResource(url)}: $error',
);
return false;
}
}
}
List<_RoomRocketPreloadAsset> _assetsForStatus(
SCRoomRocketStatusRes status, {
required String roomId,
required int generation,
required bool includeNextLevel,
required bool includeCurrentRewardCovers,
bool onlyCurrentLaunchAssets = false,
}) {
final currentLevel = _currentLevel(status);
final result = <_RoomRocketPreloadAsset>[];
final current = _levelFor(status, currentLevel);
if (current != null) {
if (!onlyCurrentLaunchAssets) {
_addImage(result, current.rocketIconUrl, roomId, generation);
}
_addPag(result, current.rocketAnimationUrl, roomId, generation);
}
if (!onlyCurrentLaunchAssets && includeNextLevel) {
final next = _levelFor(status, currentLevel + 1);
if (next != null) {
_addImage(result, next.rocketIconUrl, roomId, generation);
_addPag(result, next.rocketAnimationUrl, roomId, generation);
}
}
if (includeCurrentRewardCovers) {
final preview = status.rewardPreviewForLevel(currentLevel);
for (final reward in preview?.all ?? const <SCRoomRocketRewardRes>[]) {
_addImage(result, reward.rewardCover, roomId, generation);
}
}
return _dedupeAssets(result);
}
void _addImage(
List<_RoomRocketPreloadAsset> result,
String resource,
String roomId,
int generation,
) {
final value = resource.trim();
if (value.isEmpty || RoomRocketPagEffectOverlay.isPag(value)) {
return;
}
result.add(
_RoomRocketPreloadAsset(
resource: value,
kind: _RoomRocketPreloadKind.image,
roomId: roomId,
generation: generation,
),
);
}
void _addPag(
List<_RoomRocketPreloadAsset> result,
String resource,
String roomId,
int generation,
) {
final value = resource.trim();
if (!RoomRocketPagEffectOverlay.isPag(value)) {
return;
}
result.add(
_RoomRocketPreloadAsset(
resource: value,
kind: _RoomRocketPreloadKind.pag,
roomId: roomId,
generation: generation,
),
);
}
List<_RoomRocketPreloadAsset> _dedupeAssets(
List<_RoomRocketPreloadAsset> assets,
) {
final seen = <String>{};
final result = <_RoomRocketPreloadAsset>[];
for (final asset in assets) {
final key = asset.key;
if (!seen.add(key)) {
continue;
}
result.add(asset);
}
return result;
}
int _currentLevel(SCRoomRocketStatusRes status) {
return (status.currentLevel ?? status.level?.toInt() ?? 1)
.clamp(1, 99)
.toInt();
}
SCRoomRocketLevelRes? _levelFor(SCRoomRocketStatusRes status, int level) {
final normalizedLevel = level.clamp(1, 99).toInt();
for (final item in status.levels ?? const <SCRoomRocketLevelRes>[]) {
if (item.level == normalizedLevel) {
return item;
}
}
return null;
}
double _statusPercent(SCRoomRocketStatusRes status) {
final value = status.displayPercent ?? status.energyPercent;
if (value != null) {
final raw = value.toDouble();
final percent = raw > 0 && raw <= 1 ? raw * 100 : raw;
return percent.clamp(0, 100).toDouble();
}
final currentEnergy = status.currentEnergy ?? 0;
final maxEnergy = status.maxEnergy ?? status.needEnergy ?? 0;
if (maxEnergy <= 0) {
return 0;
}
return ((currentEnergy / maxEnergy) * 100).clamp(0, 100).toDouble();
}
double _forceThreshold(SCRoomRocketStatusRes status) {
final level = _levelFor(status, _currentLevel(status));
final threshold = level?.shakeThresholdPercent.toDouble();
if (threshold == null || threshold <= 0 || threshold > 100) {
return _roomRocketAssetPreloadFallbackThreshold;
}
return threshold.clamp(80, 99.5).toDouble();
}
Uri? _networkUri(String resource) {
final normalized = normalizeImageResourceUrl(resource.trim());
if (normalized.isEmpty) {
return null;
}
final direct = Uri.tryParse(normalized);
if (_isHttpUri(direct)) {
return direct;
}
final encoded = Uri.tryParse(Uri.encodeFull(normalized));
if (_isHttpUri(encoded)) {
return encoded;
}
return null;
}
bool _isHttpUri(Uri? uri) {
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return false;
}
final scheme = uri.scheme.toLowerCase();
return scheme == 'http' || scheme == 'https';
}
String _shortResource(String value) {
final text = value.replaceAll(RegExp(r'\s+'), ' ').trim();
if (text.length <= 120) {
return text;
}
return '${text.substring(0, 117)}...';
}
void _debug(String message) {
if (!kDebugMode) {
return;
}
debugPrint('[RoomRocketAssetPreloader] $message');
}
@visibleForTesting
List<String> debugRoomRocketAssetPreloadResources(
SCRoomRocketStatusRes status, {
bool includeNextLevel = true,
bool includeCurrentRewardCovers = true,
bool onlyCurrentLaunchAssets = false,
}) {
return _assetsForStatus(
status,
roomId: 'debug-room',
generation: 1,
includeNextLevel: includeNextLevel,
includeCurrentRewardCovers: includeCurrentRewardCovers,
onlyCurrentLaunchAssets: onlyCurrentLaunchAssets,
).map((item) => item.resource).toList(growable: false);
}
@visibleForTesting
bool debugShouldForceRoomRocketAssetPreload(SCRoomRocketStatusRes status) {
return RoomRocketAssetPreloader.shouldForceCurrentLaunchPreload(status);
}

View File

@ -49,11 +49,11 @@ class _RoomRocketRewardDialogLoaderState
void initState() { void initState() {
super.initState(); super.initState();
if (widget.initialPopupRecords.isNotEmpty) { if (widget.initialPopupRecords.isNotEmpty) {
_popupRecords.addAll(widget.initialPopupRecords); _appendUniqueRewardRecords(_popupRecords, widget.initialPopupRecords);
_rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords); _rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords);
} }
if (widget.initialRoomRecords.isNotEmpty) { if (widget.initialRoomRecords.isNotEmpty) {
_roomRecords.addAll(widget.initialRoomRecords); _appendUniqueRewardRecords(_roomRecords, widget.initialRoomRecords);
} }
_winners = RoomRocketApiMapper.winnersFromRecords( _winners = RoomRocketApiMapper.winnersFromRecords(
_visibleRoomRewardRecords(), _visibleRoomRewardRecords(),
@ -85,34 +85,28 @@ class _RoomRocketRewardDialogLoaderState
? widget.initialPopupRecords ? widget.initialPopupRecords
: await _loadPopupRecords(requestCursor) : await _loadPopupRecords(requestCursor)
: const <SCRoomRocketRewardRecordRes>[]; : const <SCRoomRocketRewardRecordRes>[];
if (reset && useInitialPopupRecords) { if (useInitialPopupRecords) {
_usedInitialPopupRecords = true; _usedInitialPopupRecords = true;
} }
final nextRoomRecords = await _loadRoomRewardRecords(requestCursor); final launchNo = _launchNoFromRecords(
nextPopupRecords.isNotEmpty ? nextPopupRecords : _popupRecords,
);
final nextRoomRecords = await _loadRoomRewardRecords(
requestCursor,
launchNo: launchNo,
);
if (!mounted) { if (!mounted) {
return; return;
} }
setState(() { setState(() {
if (reset) { if (reset) {
_popupRecords _replaceUniqueRewardRecords(_popupRecords, nextPopupRecords);
..clear() _replaceUniqueRewardRecords(_roomRecords, [
..addAll(nextPopupRecords); ...widget.initialRoomRecords,
_roomRecords
..clear()
..addAll(
_mergeRewardRecords([
...widget.initialRoomRecords,
...nextRoomRecords,
]),
);
} else {
final mergedRoomRecords = _mergeRewardRecords([
..._roomRecords,
...nextRoomRecords, ...nextRoomRecords,
]); ]);
_roomRecords } else {
..clear() _appendUniqueRewardRecords(_roomRecords, nextRoomRecords);
..addAll(mergedRoomRecords);
} }
_rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords); _rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords);
_winners = RoomRocketApiMapper.winnersFromRecords( _winners = RoomRocketApiMapper.winnersFromRecords(
@ -164,8 +158,9 @@ class _RoomRocketRewardDialogLoaderState
} }
Future<List<SCRoomRocketRewardRecordRes>> _loadRoomRewardRecords( Future<List<SCRoomRocketRewardRecordRes>> _loadRoomRewardRecords(
int cursor, int cursor, {
) async { String launchNo = '',
}) async {
if (widget.roomId.trim().isEmpty) { if (widget.roomId.trim().isEmpty) {
return const <SCRoomRocketRewardRecordRes>[]; return const <SCRoomRocketRewardRecordRes>[];
} }
@ -178,6 +173,8 @@ class _RoomRocketRewardDialogLoaderState
widget.roomId, widget.roomId,
cursor: cursor, cursor: cursor,
limit: _rewardDialogPageSize, limit: _rewardDialogPageSize,
launchNo: launchNo,
roomScope: true,
); );
return res.records; return res.records;
} catch (error) { } catch (error) {
@ -204,20 +201,6 @@ class _RoomRocketRewardDialogLoaderState
return matched.isEmpty ? _roomRecords : matched; return matched.isEmpty ? _roomRecords : matched;
} }
List<SCRoomRocketRewardRecordRes> _mergeRewardRecords(
List<SCRoomRocketRewardRecordRes> records,
) {
final result = <SCRoomRocketRewardRecordRes>[];
final seen = <String>{};
for (final record in records) {
final id = record.id.trim();
if (id.isEmpty || seen.add(id)) {
result.add(record);
}
}
return result;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RoomRocketRewardDialog( return RoomRocketRewardDialog(
@ -234,6 +217,76 @@ class _RoomRocketRewardDialogLoaderState
} }
} }
String _launchNoFromRecords(List<SCRoomRocketRewardRecordRes> records) {
for (final record in records) {
final launchNo = record.launchNo.trim();
if (launchNo.isNotEmpty) {
return launchNo;
}
}
return '';
}
void _replaceUniqueRewardRecords(
List<SCRoomRocketRewardRecordRes> target,
List<SCRoomRocketRewardRecordRes> records,
) {
target
..clear()
..addAll(debugUniqueRoomRocketRewardRecords(records));
}
void _appendUniqueRewardRecords(
List<SCRoomRocketRewardRecordRes> target,
List<SCRoomRocketRewardRecordRes> records,
) {
final seen = target.map(_roomRocketRewardRecordUniqueKey).toSet();
for (final record in records) {
final key = _roomRocketRewardRecordUniqueKey(record);
if (!seen.add(key)) {
continue;
}
target.add(record);
}
}
@visibleForTesting
List<SCRoomRocketRewardRecordRes> debugUniqueRoomRocketRewardRecords(
List<SCRoomRocketRewardRecordRes> records,
) {
final seen = <String>{};
final uniqueRecords = <SCRoomRocketRewardRecordRes>[];
for (final record in records) {
final key = _roomRocketRewardRecordUniqueKey(record);
if (!seen.add(key)) {
continue;
}
uniqueRecords.add(record);
}
return uniqueRecords;
}
String _roomRocketRewardRecordUniqueKey(SCRoomRocketRewardRecordRes record) {
final id = record.id.trim();
if (id.isNotEmpty) {
return 'id:$id';
}
return [
'fallback',
record.launchNo.trim(),
record.roomId.trim(),
record.roundNo.toString(),
record.level.toString(),
record.userId.trim(),
record.rewardScene.trim().toUpperCase(),
record.rewardType.trim().toUpperCase(),
record.rewardItemId.trim(),
record.rewardName.trim(),
record.rewardAmount.toString(),
record.expireDays.toString(),
].join('|');
}
void _rewardDialogDebug(String message) { void _rewardDialogDebug(String message) {
if (!kDebugMode) { if (!kDebugMode) {
return; return;

View File

@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_status_res.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart';
void main() {
group('room rocket asset preloader', () {
test('collects current and next level disk preload resources', () {
final resources = debugRoomRocketAssetPreloadResources(_status());
expect(resources, [
'https://cdn.example.com/rocket-l2.png',
'https://cdn.example.com/rocket-l2.pag',
'https://cdn.example.com/rocket-l3.png',
'https://cdn.example.com/rocket-l3.pag',
'https://cdn.example.com/reward-top1.png',
'https://cdn.example.com/reward-in-room.png',
]);
});
test('force mode only collects current launch pag and reward covers', () {
final resources = debugRoomRocketAssetPreloadResources(
_status(),
includeNextLevel: false,
onlyCurrentLaunchAssets: true,
);
expect(resources, [
'https://cdn.example.com/rocket-l2.pag',
'https://cdn.example.com/reward-top1.png',
'https://cdn.example.com/reward-in-room.png',
]);
});
test('uses shake threshold to decide force preload', () {
expect(
debugShouldForceRoomRocketAssetPreload(_status(percent: 94)),
false,
);
expect(
debugShouldForceRoomRocketAssetPreload(_status(percent: 95)),
true,
);
});
});
}
SCRoomRocketStatusRes _status({num percent = 95}) {
return SCRoomRocketStatusRes(
roomId: 'room-1',
currentLevel: 2,
displayPercent: percent,
levels: [
_level(
level: 2,
icon: 'https://cdn.example.com/rocket-l2.png',
animation: 'https://cdn.example.com/rocket-l2.pag',
rewardPreview: SCRoomRocketRewardPreviewRes(
top1: [
_reward('https://cdn.example.com/reward-top1.png'),
_reward('https://cdn.example.com/reward-top1.png'),
],
ignite: const <SCRoomRocketRewardRes>[],
inRoom: [_reward('https://cdn.example.com/reward-in-room.png')],
),
),
_level(
level: 3,
icon: 'https://cdn.example.com/rocket-l3.png',
animation: 'https://cdn.example.com/rocket-l3.pag',
),
],
);
}
SCRoomRocketLevelRes _level({
required int level,
required String icon,
required String animation,
SCRoomRocketRewardPreviewRes? rewardPreview,
}) {
return SCRoomRocketLevelRes(
id: level.toString(),
level: level,
needEnergy: level * 100,
rocketIconUrl: icon,
rocketPreviewUrl: '',
rocketAnimationUrl: animation,
progressBarUrl: '',
shakeThresholdPercent: 95,
enabled: true,
sort: level,
rewardPreview: rewardPreview,
);
}
SCRoomRocketRewardRes _reward(String cover) {
return SCRoomRocketRewardRes(
rewardScene: 'TOP1',
rewardType: 'GOLD',
rewardItemId: '',
rewardName: 'Yumi',
rewardCover: cover,
rocketIconUrl: '',
rewardAmount: 1000,
expireDays: 0,
);
}

View File

@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_rocket_api_res.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart';
void main() {
group('room rocket reward dialog loader', () {
test('deduplicates reward records by id', () {
final records = debugUniqueRoomRocketRewardRecords([
_rewardRecord(id: 'record-1', rewardAmount: 5000),
_rewardRecord(id: 'record-1', rewardAmount: 5200),
_rewardRecord(id: 'record-2', rewardAmount: 5200),
]);
expect(records, hasLength(2));
expect(records.map((item) => item.id), ['record-1', 'record-2']);
expect(records.first.rewardAmount, 5000);
});
test('deduplicates id-less reward records by reward identity', () {
final records = debugUniqueRoomRocketRewardRecords([
_rewardRecord(id: '', rewardAmount: 5000),
_rewardRecord(id: '', rewardAmount: 5000),
_rewardRecord(id: '', rewardAmount: 5200),
]);
expect(records, hasLength(2));
expect(records.map((item) => item.rewardAmount), [5000, 5200]);
});
});
}
SCRoomRocketRewardRecordRes _rewardRecord({
required String id,
required int rewardAmount,
}) {
return SCRoomRocketRewardRecordRes.fromJson({
'id': id,
'launchNo': 'launch-1',
'roomId': 'room-1',
'dayKey': '2026-05-15',
'roundNo': 1,
'level': 2,
'userId': 'user-1',
'userNickname': 'User 1',
'rewardScene': 'TOP1',
'rewardType': 'GOLD',
'rewardItemId': 'gold',
'rewardName': 'Yumi',
'rewardCover': '',
'rewardAmount': rewardAmount,
'expireDays': 0,
'grantStatus': 'SUCCESS',
'popupStatus': 'UNREAD',
});
}