初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,199 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../widgets/gradient_text.dart';
class UserChargeView extends StatelessWidget {
final bool isFromCoin; // true: 来源金币充值, vip时间为灰色
/// true:旧版文案/配色(会员中心 A / DISABLED
final bool classic;
const UserChargeView(
{super.key, this.isFromCoin = false, this.classic = false});
@override
Widget build(BuildContext context) {
return Consumer<GlobalStore>(
builder: (_, provider, __) {
if (classic) return _buildClassic(provider);
return _buildModern(provider);
},
);
}
Widget _buildModern(GlobalStore provider) {
final vipIcon = provider.meInfo?.vipImageName ?? '';
return Row(
children: [
NetworkImageLoader(
imageUrl: provider.meInfo?.portrait ?? '',
width: 60,
height: 60,
borderRadius: 30,
),
12.sizeBoxW,
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
provider.meInfo == null
? '未知'
: provider.meInfo?.name?.substring(
0, min(provider.meInfo?.name?.length ?? 0, 9)) ??
'',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: const TextStyle(
fontSize: 18,
color: Color(0xffF6EEDC),
fontWeight: FontWeight.w600,
),
),
8.sizeBoxW,
if (!provider.isVIP)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Color(0x14FFFFFF), width: 1),
),
child: const Text(
'未开通',
style: TextStyle(
color: Color(0xff9A927C),
fontSize: 10,
height: 1.2,
),
),
)
else if (vipIcon.isNotEmpty)
Image.asset(vipIcon, height: 20),
],
),
4.sizeBoxH,
globalStore.isVIP
? Consumer<PreSaleProvider>(
builder: (context, provider, child) {
if (isFromCoin) {
return Text(
'会员到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
style: TextStyle(
color: Color(0xff9A927C),
fontSize: 12,
),
);
} else {
return GradientText(
'会员到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
gradient: LinearGradient(
colors: [
Color(0xffFFE8BE),
Color(0xffE6B764),
],
),
style: TextStyle(fontSize: 12),
);
}
},
)
: Text(
'开通会员 · 解锁全站尊享特权',
style: TextStyle(
color: Color(0xff9A927C),
fontSize: 12,
),
),
],
)
],
).paddingSymmetric(horizontal: 16);
}
Widget _buildClassic(GlobalStore provider) {
final vipIcon = provider.meInfo?.vipImageName ?? '';
return Row(
children: [
NetworkImageLoader(
imageUrl: provider.meInfo?.portrait ?? '',
width: 60,
height: 60,
borderRadius: 30,
),
12.sizeBoxW,
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
provider.meInfo == null
? '未知'
: provider.meInfo?.name?.substring(
0, min(provider.meInfo?.name?.length ?? 0, 9)) ??
'',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 18,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
12.sizeBoxW,
if (provider.isVIP && vipIcon.isNotEmpty)
Image.asset(vipIcon, height: 20),
],
),
4.sizeBoxH,
globalStore.isVIP
? Consumer<PreSaleProvider>(
builder: (context, provider, child) {
if (isFromCoin) {
return Text(
'到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
style: TextStyle(
color: Color(0xff989898),
fontSize: 12,
),
);
} else {
return GradientText(
'到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
gradient: LinearGradient(
colors: [
Color(0xffFFE8BE),
Color(0xffE6B764),
],
),
style: TextStyle(fontSize: 12),
);
}
},
)
: Text(
'您还不是会员 开通会员 畅享特权',
style: TextStyle(
color: Color(0xff989898),
fontSize: 12,
),
),
],
)
],
).paddingSymmetric(horizontal: 16);
}
}
@@ -0,0 +1,56 @@
class AICouponModel {
String? createTime;
String? expiredTime;
String? goodsDesc;
String? goodsName;
String? goodsOrigin;
int? goodsType;
int? goodsValue;
String? id;
int? status;
int? uid;
String? useTime;
AICouponModel(
{this.createTime,
this.expiredTime,
this.goodsDesc,
this.goodsName,
this.goodsOrigin,
this.goodsType,
this.goodsValue,
this.id,
this.status,
this.uid,
this.useTime});
AICouponModel.fromJson(Map<String, dynamic> json) {
createTime = json['createTime'];
expiredTime = json['expiredTime'];
goodsDesc = json['goodsDesc'];
goodsName = json['goodsName'];
goodsOrigin = json['goodsOrigin'];
goodsType = json['goodsType'];
goodsValue = json['goodsValue'];
id = json['id'];
status = json['status'];
uid = json['uid'];
useTime = json['useTime'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['createTime'] = this.createTime;
data['expiredTime'] = this.expiredTime;
data['goodsDesc'] = this.goodsDesc;
data['goodsName'] = this.goodsName;
data['goodsOrigin'] = this.goodsOrigin;
data['goodsType'] = this.goodsType;
data['goodsValue'] = this.goodsValue;
data['id'] = this.id;
data['status'] = this.status;
data['uid'] = this.uid;
data['useTime'] = this.useTime;
return data;
}
}
@@ -0,0 +1,67 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../hj_model/mine/exchange/recharge_list_model.dart';
import '../../../hj_model/mine/exchange/recharge_type_list_model.dart';
import 'online_pay_page.dart';
import 'pay_order_source.dart';
class MineChargeCoinLogic extends GetxController {
/// 下单来源(透传 /mine/topay 的 sourcePage),由入口传入
final PaySourcePage sourcePage;
/// 下单埋点上下文(短剧付费墙要带 mediaId/contentId/checkoutContextId 做归因)
final PayOrderTrackInfo? orderTrack;
MineChargeCoinLogic(
{this.sourcePage = PaySourcePage.unknown, this.orderTrack});
bool isInitLoading = true; // 首屏加载中
RechargeListModel? model; // 金币充值档位列表
RechargeTypeModel? selectedCoin; // 当前选中的金币档位
@override
void onReady() {
super.onReady();
loadData();
}
// 拉取金币充值档位,默认选中第一档
Future<void> loadData() async {
final res = await MineService.getChatRechargeTypes(1);
isInitLoading = false;
model = res;
res?.list ??= [];
if (res?.list?.isNotEmpty == true) {
selectedCoin = res!.list!.first;
}
update();
}
// 选择金币档位
void onSelectCoin(int index) {
selectedCoin = model?.list?[index];
update();
}
// 去支付(购买金币)
void onGotoPay() {
if (selectedCoin == null) {
showToast("请选择产品");
return;
}
if (selectedCoin!.rechargeTypeListUI.isEmpty) {
showToast("未配置支付方式,请联系客服");
return;
}
Get.bottomSheet(
OnlinePayPage(
coinRcModel: selectedCoin,
orderTrack: (orderTrack ?? const PayOrderTrackInfo())
.copyWith(sourcePage: sourcePage),
),
isScrollControlled: true,
);
}
}
@@ -0,0 +1,240 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:provider/provider.dart';
import '../../../hj_utils/widget_util.dart';
import '../../../routers/jump_router.dart';
import '../make_money/mine_withdrawal_record_page.dart';
import 'charge_user_header.dart';
import 'mine_charge_coin_logic.dart';
import 'pay_order_source.dart';
import 'widgets/coin_item.dart';
//金币充值页面
class MineChargeCoinPage extends StatelessWidget {
/// 下单来源,入口不传则 UNKNOWN(金币没有专属来源枚举,别拿会员的顶上)
final PaySourcePage sourcePage;
const MineChargeCoinPage(
{super.key, this.sourcePage = PaySourcePage.unknown});
@override
Widget build(BuildContext context) {
return GetBuilder<MineChargeCoinLogic>(
init: MineChargeCoinLogic(sourcePage: sourcePage),
global: false, // 多入口各自独立:叠栈时别复用上一个页面的 controller(来源会串成上一次的)
builder: (controller) {
return Scaffold(
body: () {
if (controller.isInitLoading) return const LoadingCenterWidget();
if (controller.model == null)
return CErrorWidget(retryOnTap: () => controller.loadData());
return Column(
children: [
Expanded(
child: CustomScrollView(
slivers: <Widget>[
// 头像 + 余额卡片:随金币列表上滑,自带折叠淡出动画
SliverAppBar(
pinned: true,
backgroundColor: Color(0xff0F0F0F),
surfaceTintColor: Colors.transparent,
expandedHeight: kToolbarHeight + 200,
title: Text(
'金币充值',
style: textStyle(16, Colors.white, FontWeight.w600),
),
actions: [
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(RecordsPage(RecordType.recharge)),
child: Text(
'充值记录',
style: TextStyle(
color: Color(0xff666666), fontSize: 16.sp),
),
),
18.w.sizeBoxW
],
flexibleSpace: FlexibleSpaceBar(
background: SafeArea(
bottom: false,
child: Column(
children: [
kToolbarHeight.sizeBoxH,
UserChargeView(isFromCoin: true),
18.sizeBoxH,
_buildWallet(),
],
),
),
),
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 111 / 138,
),
itemCount: controller.model?.list?.length ?? 0,
itemBuilder: (BuildContext context, int index) {
final model = controller.model!.list![index];
final isSelected =
controller.selectedCoin?.id == model.id;
return CoinItem(
model,
onTap: () => controller.onSelectCoin(index),
isSelected: isSelected,
);
},
),
),
SliverToBoxAdapter(child: 18.sizeBoxH),
SliverToBoxAdapter(
child: EasyRichText(
'*如提示【交易失败】【账户风险】等,可重新发起订单,或在15分钟后重试支付。如支付未到账,请反馈客服订单号',
defaultStyle:
TextStyle(color: Color(0xff666666), fontSize: 12),
patternList: [
EasyRichTextPattern(
targetString: '反馈客服订单号',
style: TextStyle(
color: Color(0xffFFD460),
fontWeight: FontWeight.w500),
recognizer: TapGestureRecognizer()
..onTap = () => pushToCustomService(),
)
],
).paddingSymmetric(horizontal: 16),
),
],
),
),
_buildBottomBar(controller),
],
);
}(),
);
},
);
}
// 底部支付按钮 + 客服入口
Widget _buildBottomBar(MineChargeCoinLogic controller) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => controller.onGotoPay(),
child: Container(
height: 44,
margin: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3)),
alignment: Alignment.center,
child: Text(
'¥${controller.selectedCoin?.moneyYuan ?? 0}/立即支付',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
),
12.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('支付问题反馈,点击联系 ',
style: textStyle(12, Color(0xffBFBFC1), FontWeight.w400)),
GestureDetector(
onTap: () {
pushToCustomService();
},
child: Text('在线客服',
style: textStyle(12, Color(0xffFFD460), FontWeight.w400)),
)
],
),
// 垫上虚拟导航栏高度,避免底部内容被遮挡(edge-to-edge
(16 + screen.paddingBottom).sizeBoxH,
],
);
}
// 我的金币余额卡片
Widget _buildWallet() {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
padding: EdgeInsets.fromLTRB(16, 18, 16, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'我的金币余额',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500),
),
8.sizeBoxH,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('coin_icon.webp'.mineImgPath, width: 36),
4.sizeBoxW,
Consumer<GlobalStore>(builder: (_, store, __) {
final wallet = store.wallet;
final total = (wallet?.amount ?? 0) + (wallet?.income ?? 0);
return Text(
"$total",
style: TextStyle(
color: Color(0xffFFD460),
fontSize: 32,
fontWeight: FontWeight.w600),
);
}),
Spacer(),
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(RecordsPage(RecordType.bill), opaque: false),
child: Container(
alignment: Alignment.center,
height: 30,
width: 90,
decoration: BoxDecoration(
color: Color(0xffFFD460),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'余额明细',
style: TextStyle(color: Color(0xff3D3D3D), fontSize: 14),
),
),
)
],
),
],
));
}
}
@@ -0,0 +1,224 @@
import 'package:carousel_slider/carousel_controller.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/alert/vip_guide/guide_manager.dart';
import 'package:hgdj/alert/vip_guide/timed_popup_manager.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../alert/vip_guide/guide_countdown_dialog.dart';
import '../../../alert/vip_guide/guide_config.dart';
import 'online_pay_page.dart';
import 'pay_order_source.dart';
import 'vip_card_analytics.dart';
import 'vip_card_item.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
/// 会员卡页/购买弹窗的页面级 Logic:只管「当前选中卡 / 轮播 / 支付触发」。
/// 会员卡列表数据(请求/缓存/组装/选卡)统一在 [VipProductManager]UI 用 Consumer 消费。
class MineChargeVipLogic extends GetxController {
/// 局部刷新 id:只有「跟随选中卡变化」的区域订阅它(卡片选中态 / 价格 / 权益区 / 支付按钮),
/// 滑卡时不必整页重建(背景图、轮播本体、用户信息条、AppBar 都跟选中卡无关)
static const kSelection = 'vip_selection';
/// 跳转指定选中的会员卡ID(构造传入,替代 Get.arguments
final String? vipID;
/// 下单来源(透传 /mine/topay 的 sourcePage
final PaySourcePage sourcePage;
/// 播放页拉起时带上在看的那条内容 id,服务端按它归因;会员中心进来为空
final String? videoId;
/// 入口透传的整份下单上下文,字段比 [videoId] 全时以它为准(短剧付费墙带 mediaId/contentId/checkoutContextId
final PayOrderTrackInfo? orderTrack;
MineChargeVipLogic(
{this.vipID,
this.sourcePage = PaySourcePage.vipCenter,
this.videoId,
this.orderTrack});
/// 当前选中的会员卡(页面级状态,多入口各自独立)
VipProductModel? currentProductModel;
/// 引导弹窗点「立即开通」指定的卡ID,优先级高于构造传入的 vipID(用户显式选择)
String? _guideCardId;
/// 会员卡轮播控制器(页面级,两个入口不能共享)
late final pageCtr = CarouselSliderController();
/// 本页 VIP 卡片统计会话
VipCardAnalyticsSession? _analytics;
//轮播视口占比 / 高度:B 组固定卡面 148×157 + 横向间距 10;高度含角标与底部发光
double get itemRatio => vipProductManager.isNewVipUi
? (148 + 10) / screen.screenWidth
: 137.6 / screen.screenWidth;
double get cardHeight => vipProductManager.isNewVipUi
? 189 // 8角标 + 157卡面 + 24底部 Glow
: (screen.screenWidth * itemRatio) * 150 / 110.6;
/// 进页面是否强制拉最新:会员中心页 true(每次刷新),购买弹窗覆写为 false(优先用缓存)
bool get preferFreshData => true;
/// 进入本页是否触发「优惠倒计时」引导弹窗(VIP_CENTER)。购买弹窗(BuyVipAlertLogic)覆写为 false,避免叠弹。
bool get enableEntryGuidePopup => true;
@override
void onReady() {
super.onReady();
PreSaleProvider().refreshConfig();
loadCards(force: preferFreshData);
if (enableEntryGuidePopup) _tryShowEntryGuide();
}
@override
void onClose() {
// 页面/弹窗销毁 → GetBuilder 按 tag 走 Get.delete → onDelete → 这里补报 CLOSE
_analytics?.reportCloseWithoutPurchaseIfNeeded();
_analytics = null;
super.onClose();
}
/// VIP_CENTER:开关允许时**每次**进会员页都弹优惠倒计时引导
/// (含从别的引导弹窗点「开通」跳进来的那次,运营要追单,不做去重)。
void _tryShowEntryGuide() {
TimedPopupManager().trigger(
canShow: () => GuideManager().canShow(GuideScene.vipCenter),
onShow: () => GuideCountdownDialog.show(
scene: GuideScene.vipCenter,
// 已在会员页:关弹窗 + 就地选中弹窗配置的那张卡,让底部支付按钮直接对上该卡价格
onConfirm: (card) {
Get.back(result: true); // 对齐 show() 的返回语义:true=点了开通
_selectCard(card);
},
),
);
}
/// 选中并滚到指定会员卡:只驱动轮播,选中态由 [onPageChanged] 统一更新(和点卡片一致)。
/// 列表还在加载时轮播没渲染、动不了,先记下 id 交给 [_syncSelection] 落位
void _selectCard(VipProductModel card) {
_guideCardId = card.productID;
if (vipProductManager.isLoading || !pageCtr.ready) return;
final index = vipProductManager.vipCards
.indexWhere((e) => e.productID == card.productID);
if (index >= 0) pageCtr.animateToPage(index, curve: Curves.fastOutSlowIn);
}
/// 拉取会员卡列表并同步选中态
Future<void> loadCards({bool force = false}) async {
await vipProductManager.loadVipCards(force: force);
_syncSelection();
}
/// 数据就绪后按优先级确定默认选中卡,并滚动到对应位置;UI 展示后再上报曝光
void _syncSelection() {
currentProductModel = vipProductManager.defaultVipCard(
vipID: _guideCardId ?? vipID, current: currentProductModel);
final pid = currentProductModel?.productID;
final index = pid == null
? -1
: vipProductManager.vipCards.indexWhere((e) => e.productID == pid);
// 会话尽早创建:避免仅在 post-frame 里建,用户快速返回时 onClose 拿不到 session、CLOSE 丢报。
// 必须等有卡数据再建:首次请求失败时卡列表还是空,会把「无实验」快照锁进 session,
// 之后错误页重试成功也不会上报(快照 late final 不可变)。
if (vipProductManager.vipCards.isNotEmpty)
_analytics ??= VipCardAnalyticsSession();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
// 卡列表为空时页面渲染的是错误页,轮播压根没 buildcontroller 没 attach
// 而 jumpToPage 内部是 _state!.pageController!.page! 三层空断言,会直接崩
if (index >= 0 && pageCtr.ready) pageCtr.jumpToPage(index);
// 曝光必须在 UI 实际展示后上报
_analytics?.reportPageViewAfterPaint();
_analytics?.reportProductImpression(currentProductModel,
afterPaint: true);
});
update();
}
Widget instanceChildItem(int index) {
final data = vipProductManager.vipCards;
if (index > data.length - 1) {
return SizedBox(
height: cardHeight,
width: screen.screenWidth,
);
}
final model = data[index];
final card = VipCardItem(
model,
isSelect: currentProductModel?.productID != null &&
currentProductModel?.productID == model.productID,
callback: () {
pageCtr.animateToPage(index, curve: Curves.fastOutSlowIn);
},
);
// B 组:卡槽 = 148 + 右侧 10 间距
if (vipProductManager.isNewVipUi) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: Align(alignment: Alignment.centerLeft, child: card),
);
}
return card;
}
/// 滑动切卡
onPageChanged(int index) {
final data = vipProductManager.vipCards;
if (data.isEmpty) return;
// 轮播 itemCount = 卡数+2(尾部占位,让最后一张能滑到最左)。padEnds:false 时 PageView 的
// 最大页码 = itemCount - 1/viewportFractionB 组卡槽 158 在 <395 宽的机型上会四舍五入到
// 占位项索引,直接取 dataSource[index] 会越界,这里钳到最后一张真实卡。
currentProductModel = data[index.clamp(0, data.length - 1)];
_analytics?.reportProductImpression(currentProductModel);
update([kSelection]);
}
onInitiatePayAction() async {
if (currentProductModel == null) {
showToast("请选择充值的会员卡~");
return;
}
if (currentProductModel!.rchgTypeUI.isEmpty) {
showToast("未配置支付方式,请联系客服");
return;
}
await Get.bottomSheet(
OnlinePayPage(
vipProductModel: currentProductModel,
orderTrack: buildOrderTrack(),
),
isScrollControlled: true,
);
await PreSaleProvider().refreshConfig();
update();
}
/// 下单埋点:来源 + 卡皮 session + ACTIVE 时的实验字段(普通卡 / 预售卡共用)
PayOrderTrackInfo buildOrderTrack() {
final base = orderTrack;
return PayOrderTrackInfo(
sourcePage: base?.sourcePage ?? sourcePage,
sourceRef: base?.sourceRef ?? videoId,
videoId: base?.videoId ?? videoId,
mediaId: base?.mediaId,
contentId: base?.contentId,
checkoutContextId: base?.checkoutContextId,
sessionId: base?.sessionId ?? _analytics?.sessionId,
experimentId: vipProductManager.isExperimentActive
? (currentProductModel?.experimentId ??
vipProductManager.experimentId)
: null,
experimentVariant: vipProductManager.isExperimentActive
? (currentProductModel?.variant ?? vipProductManager.variant)
: null,
);
}
}
@@ -0,0 +1,471 @@
import 'dart:math' as math;
import 'package:carousel_slider/carousel_slider.dart';
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../../routers/jump_router.dart';
import '../../pre_sale/pre_sale_entry.dart';
import '../make_money/mine_withdrawal_record_page.dart';
import 'charge_user_header.dart';
import 'mine_charge_vip_logic.dart';
import 'pay_order_source.dart';
import 'vip_card_item.dart';
import 'vip_pay_button.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
import 'vip_ui_kit.dart';
class MineChargeVipPage extends StatefulWidget {
final String? vipID; //跳转指定选中的会员卡ID
/// 下单来源,默认会员中心
final PaySourcePage sourcePage;
const MineChargeVipPage({
super.key,
this.vipID,
this.sourcePage = PaySourcePage.vipCenter,
});
@override
State<MineChargeVipPage> createState() => _MineChargeVipPageState();
}
// 多入口可叠栈,用 per-实例唯一 tag 隔离 controller,避免轮播/选中卡/onReady 串味
class _MineChargeVipPageState extends State<MineChargeVipPage>
with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
init: MineChargeVipLogic(
vipID: widget.vipID, sourcePage: widget.sourcePage),
builder: (logic) => Consumer<VipProductManager>(
builder: (_, mgr, __) {
// ACTIVE+B → 改版 UIA / DISABLED / 无实验 → 旧版 UI
final useNew = mgr.isNewVipUi;
return Scaffold(
extendBodyBehindAppBar: true,
backgroundColor: useNew ? const Color(0xff0F0F0F) : null,
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Colors.white),
title: Text('会员中心', style: TextStyle(color: Colors.white)),
actions: [
InkWell(
enableFeedback: false,
onTap: () => Get.to(RecordsPage(RecordType.recharge)),
child: Text('充值记录',
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontSize: 12)),
),
16.sizeBoxW,
],
),
body: useNew
? Stack(
fit: StackFit.expand,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: _buildPageBackground(mgr),
),
_buildBody(logic, classic: false),
],
)
: _buildBody(logic, classic: true),
);
},
),
);
}
/// B 组页顶背景:优先 uiConfig.backgroundImage,空则本地默认图
Widget _buildPageBackground(VipProductManager mgr) {
final url = mgr.vipBgImage;
if (url != null) {
return NetworkImageLoader(
imageUrl: url,
width: double.infinity,
fit: BoxFit.fitWidth,
);
}
return Image.asset(
'mine_vip_bg.webp'.mineImgPath,
width: double.infinity,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
);
}
Widget _buildBody(MineChargeVipLogic logic, {required bool classic}) {
if (vipProductManager.isLoading) return LoadingCenterWidget();
if (vipProductManager.vipCards.isEmpty) {
return CErrorWidget(retryOnTap: () => logic.loadCards(force: true));
}
return Stack(
children: [
Column(
children: [
(kToolbarHeight + screen.paddingTop).sizeBoxH,
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
12.sizeBoxH,
UserChargeView(classic: classic),
26.sizeBoxH,
_buildCardSlider(logic),
if (vipProductManager.presaleGroup != null) ...[
20.sizeBoxH,
PreSaleVipEntry().paddingSymmetric(horizontal: 16),
],
// B:卡皮与核心权益标题间距略收;A 保持原 14
(classic ? 14 : 4).sizeBoxH,
_buildPrivileges(classic: classic),
100.sizeBoxH,
],
),
),
),
],
),
Positioned(
bottom: 0, left: 0, right: 0, child: _buildPay(classic: classic)),
],
);
}
//会员卡轮播
Widget _buildCardSlider(MineChargeVipLogic logic) {
return CarouselSlider.builder(
carouselController: logic.pageCtr,
itemCount: vipProductManager.vipCards.length + 2,
//选中态跟着 kSelection 单卡刷新,避免滑一次卡把整个轮播和页面重建一遍
itemBuilder: (_, index, __) => GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
id: MineChargeVipLogic.kSelection,
builder: (l) => l.instanceChildItem(index),
),
options: CarouselOptions(
height: logic.cardHeight,
viewportFraction: logic.itemRatio,
enableInfiniteScroll: false,
enlargeCenterPage: false,
padEnds: false,
// 选中卡底部 VIP/Glow Gold 不被 PageView 裁切
clipBehavior: Clip.none,
onPageChanged: (index, __) => logic.onPageChanged(index),
),
);
}
//权益区:跟随选中卡,订阅 kSelection 局部刷新;newPrivilege 只遍历一次按 isCore 分组
Widget _buildPrivileges({required bool classic}) {
return GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
id: MineChargeVipLogic.kSelection,
builder: (logic) {
final privileges = logic.currentProductModel?.newPrivilege ?? [];
final coreList = <NewPrivilege>[];
final moreList = <NewPrivilege>[];
for (final p in privileges) {
(p.isCore == true ? coreList : moreList).add(p);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
classic
? _buildClassicCorePrivileges(coreList)
: _buildCorePrivileges(coreList),
classic
? _buildClassicMorePrivileges(moreList)
: _buildMorePrivileges(moreList),
],
);
},
);
}
//核心权益:横向滚动卡片(左图标 + 右文案)—— B 改版
Widget _buildCorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const VipCoreSectionTitleImage(),
16.sizeBoxH,
SizedBox(
height: 92,
child: ListView.separated(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 2, 16, 26),
itemCount: list.length,
separatorBuilder: (_, __) => 8.sizeBoxW,
itemBuilder: (_, index) => VipCorePrivilegeCard(list[index]),
),
),
16.sizeBoxH,
],
);
}
//核心权益:4 列金边方卡 —— A / DISABLED 旧版
Widget _buildClassicCorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const VipSectionTitle("我的核心权益", classic: true),
16.sizeBoxH,
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 14),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1,
),
itemCount: list.length,
itemBuilder: (_, index) => FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: 72,
height: 72,
child: VipCorePrivilegeCard(list[index], classic: true)),
),
),
16.sizeBoxH,
],
);
}
//更多权益:深色圆角容器 + 四列网格 + 展开/收起 —— B 改版
Widget _buildMorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return _VipMorePrivilegesPanel(
list: list,
title: const VipMoreSectionTitleImage(),
);
}
//会员特权:直接四列网格 —— A / DISABLED 旧版
Widget _buildClassicMorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const VipSectionTitle("我的会员特权", classic: true),
12.sizeBoxH,
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 18,
childAspectRatio: 63 / 100,
),
itemCount: list.length,
itemBuilder: (_, index) =>
VipProductPrivilegeItem(list[index], classic: true),
),
],
);
}
//底部支付区域:只有按钮跟选中卡走,渐变底和客服文案是静态的
Widget _buildPay({required bool classic}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.black.withValues(alpha: 0), Colors.black],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
12.sizeBoxH,
GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
id: MineChargeVipLogic.kSelection,
builder: (logic) => VipPayButton(logic, classic: classic),
),
12.sizeBoxH,
EasyRichText(
'支付问题反馈,点击联系 在线客服',
patternList: [
EasyRichTextPattern(
targetString: '在线客服',
style: TextStyle(color: Color(0xFFFFD460), fontSize: 12),
recognizer: TapGestureRecognizer()
..onTap = () => pushToCustomService(),
)
],
defaultStyle: TextStyle(color: Color(0xffDCDCDC), fontSize: 12),
),
// 垫上虚拟导航栏高度,避免底部内容被遮挡(edge-to-edge
(12 + screen.paddingBottom).sizeBoxH,
],
),
);
}
}
/// 更多权益:深色圆角面板 + 四列网格,默认展开,可收起为 2 行
class _VipMorePrivilegesPanel extends StatefulWidget {
final List<NewPrivilege> list;
final Widget title;
const _VipMorePrivilegesPanel({required this.list, required this.title});
@override
State<_VipMorePrivilegesPanel> createState() =>
_VipMorePrivilegesPanelState();
}
class _VipMorePrivilegesPanelState extends State<_VipMorePrivilegesPanel> {
static const _collapsedCount = 8; // 收起时展示 2 行 × 4
static const _radius = 24.0;
bool _expanded = true;
/// 描边:上→下 #FFFBE5 30% → 0% → 30%(上下可见,左右中段淡出)
static const _borderGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x4DFFFBE5), Color(0x00FFFBE5), Color(0x4DFFFBE5)],
);
@override
Widget build(BuildContext context) {
final canToggle = widget.list.length > _collapsedCount;
final showCount =
(!_expanded && canToggle) ? _collapsedCount : widget.list.length;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: CustomPaint(
painter: _FigmaGradientBorderPainter(
gradient: _borderGradient,
strokeWidth: 1,
radius: _radius,
inner: true,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_radius),
child: ColoredBox(
color: const Color(0x0DFFFFFF),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 14, 12, 10),
child: Column(
children: [
widget.title,
14.sizeBoxH,
GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: 72 / 110,
),
itemCount: showCount,
itemBuilder: (_, index) =>
VipProductPrivilegeItem(widget.list[index]),
),
if (canToggle) ...[
8.sizeBoxH,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_expanded ? '收起特权' : '展开特权',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
fontSize: 12,
),
),
Icon(
_expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 16,
color: Colors.white.withValues(alpha: 0.55),
),
],
),
),
),
],
],
),
),
),
),
),
);
}
}
/// Figma 线性渐变描边:shader 按节点 bounds 映射(与 Figma stroke fill 一致)
class _FigmaGradientBorderPainter extends CustomPainter {
final Gradient gradient;
final double strokeWidth;
final double radius;
final bool inner;
_FigmaGradientBorderPainter({
required this.gradient,
required this.strokeWidth,
required this.radius,
this.inner = true,
});
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
// Inner:描边中心线向内缩 strokeWidth/2,使整条描边落在边界内侧
final inset = inner ? strokeWidth / 2 : 0.0;
final rrect = RRect.fromRectAndRadius(
rect.deflate(inset),
Radius.circular(math.max(0, radius - inset)),
);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..shader = gradient.createShader(rect);
canvas.drawRRect(rrect, paint);
}
@override
bool shouldRepaint(covariant _FigmaGradientBorderPainter oldDelegate) {
return oldDelegate.gradient != gradient ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.radius != radius ||
oldDelegate.inner != inner;
}
}
@@ -0,0 +1,597 @@
import 'dart:convert';
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../assets_tool/app_colors.dart';
import '../../../assets_tool/images.dart';
import '../../../config/address.dart';
import '../../../hj_model/mine/exchange/dc_model.dart';
import '../../../hj_model/mine/exchange/recharge_type_list_model.dart';
import '../../../hj_utils/api_service/mine_service.dart';
import '../../../hj_utils/pay/pay_manager.dart';
import '../../../hj_utils/widget_util.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/global_store/store.dart';
import '../../../tools_base/loading/loading_helper.dart';
import '../../../tools_base/net/net_manager.dart';
import '../../main_page/provider/msg_provider.dart';
import '../../web_page/h5_page.dart';
import 'pay_order_source.dart';
import 'pay_success_alert.dart';
import 'vip_card_analytics.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
/// 在线支付弹窗:VIP 会员卡购买(vipProductModel) 或 金币充值(coinRcModel) 二选一,
/// 底部弹出,展示支付方式列表 + 支付按钮;下单渠道分代充(dc)/线上跳链/金币三种。
class OnlinePayPage extends StatefulWidget {
final VipProductModel? vipProductModel; // 传此值:购买 VIP 会员卡
final RechargeTypeModel? coinRcModel; // 传此值:充值金币
/// 下单来源/实验/会话等埋点信息(透传 /mine/topay
final PayOrderTrackInfo? orderTrack;
const OnlinePayPage({
super.key,
this.vipProductModel,
this.coinRcModel,
this.orderTrack,
});
@override
State<OnlinePayPage> createState() => _OnlinePayPageState();
}
class _OnlinePayPageState extends State<OnlinePayPage> {
// 当前选中的支付方式下标
int payIndex = 0;
// 支付请求防重入标记
bool isPaying = false;
// 加赠券:isCouponPanel 控制切到选券面板,coupon 为已选券
bool isCouponPanel = false;
CouponModel? coupon;
// 支付方式列表。会员卡与金币档位二选一,getter 每次访问都会重建列表,故每帧只取一次
List<RchgType> get _payTypes =>
widget.vipProductModel?.rchgTypeUI ??
widget.coinRcModel!.rechargeTypeListUI;
/// 支付按钮文案:预售(尾款/升级/预订) → 会员卡(升级/普通) → 金币充值
String get _payText {
final vip = widget.vipProductModel;
if (vip == null) return '¥${widget.coinRcModel?.moneyYuan ?? 0}/立即支付';
if (!vip.isPreSale)
return '¥${vip.discountedPriceUI}/${vip.isUpgrade == true ? '补差价升级' : '立即支付'}';
// 预售:可付尾款优先,其次升级,最后预订
final preSale = PreSaleProvider();
final detail = preSale.preSaleModel?.detailModel;
if (preSale.canPayBalance) return '¥${detail?.balanceAmount ?? 0}支付尾款';
if (vip.isUpgrade == true) return '¥${(vip.advanceAmount ?? 0) ~/ 10}/立即升级';
return '¥${detail?.advanceAmount ?? 0}立即预订';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
decoration: ShapeDecoration(
color: AppColors.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
),
// 选中券类型支付方式时切到选券面板(本项目通常无券数据)
child: isCouponPanel
? ChoseCouponView(
onSelect: (model) => setState(() {
isCouponPanel = false;
coupon = model;
}),
)
: _payPanel(),
);
}
//支付主面板
Widget _payPanel() {
final types = _payTypes;
final curType = types[payIndex]; // 当前选中的支付方式,与列表同源,build 时取
return Column(mainAxisSize: MainAxisSize.min, children: [
const SheetHandleBar(),
18.sizeBoxH,
const Text(
'选择支付方式',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w500, color: Colors.white),
),
18.sizeBoxH,
//支付方式列表
Wrap(children: [
for (var i = 0; i < types.length; i++) _payItem(types[i], i)
]),
//支付小贴士
Padding(
padding: EdgeInsets.only(bottom: 24.h, left: 16, right: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'支付小贴士:',
style: textStyle(
14, Colors.white.withValues(alpha: .9), FontWeight.w500),
),
6.sizeBoxH,
Text(
'''1.因超时支付无法到账,请重新发起。
2.每天发起支付不能超过5次,连续发起且未支付,账号可能被加入黑名单。''',
style: textStyle(
12, Colors.white.withValues(alpha: .45), FontWeight.w400),
),
],
),
),
//支付按钮
GestureDetector(
onTap: () => _onPay(curType),
child: Container(
alignment: Alignment.center,
height: 44,
decoration: ShapeDecoration(
color: AppColors.actionRed,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(3),
),
),
child: Text(
_payText,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 20.sp,
color: Colors.white),
),
),
),
18.sizeBoxH,
//客服入口
Center(
child: EasyRichText(
'支付中如有问题,请咨询 在线客服',
defaultStyle: textStyle(
12, Colors.white.withValues(alpha: .6), FontWeight.w500),
patternList: [
EasyRichTextPattern(
targetString: '在线客服',
style: textStyle(12, Color(0xffFFD460), FontWeight.w500),
recognizer: TapGestureRecognizer()
..onTap = () => pushToCustomService(),
),
],
),
),
]);
}
//单个支付方式
Widget _payItem(RchgType type, int index) {
final icon = type.getPayIcon();
return InkWell(
enableFeedback: false,
onTap: () => setState(() {
//券类型切到选券面板,其余直接切换选中
if (type.type == 'coupon') {
isCouponPanel = true;
} else {
payIndex = index;
}
}),
child: Container(
height: 41,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12),
margin: EdgeInsets.only(bottom: 20.h),
child: Row(
children: [
icon.isEmpty
? const SizedBox(width: 36, height: 36)
: Image.asset(icon, width: 36),
13.sizeBoxW,
Expanded(
child: Text(
"${type.typeName}",
style: textStyle(
16, Colors.white.withValues(alpha: .9), FontWeight.w500),
),
),
Image.asset(
payIndex == index
? 'radio_sel.png'.commonImgPath
: 'mine_withdraw_nor.png'.mineImgPath,
width: 16),
],
),
),
);
}
//按选中的支付方式下单:金币余额 / 代充 / 线上跳链
void _onPay(RchgType type) {
final vip = widget.vipProductModel;
final coinRc = widget.coinRcModel;
if (type.type == "coin") {
_payByCoin();
} else if (type.isOfficial == true) {
// 正常购买:金币 / vip购买
if (coinRc != null) {
_payByDc(false, type, coinRc.daichong, coinRc.money);
} else {
_payByDc(true, type, vip?.daichong, (vip?.discountedPrice ?? 0) * 10);
}
} else {
_payByLink(type);
}
}
///线上跳链支付:创建支付单后跳外部支付页
Future<void> _payByLink(RchgType payType) async {
final vip = widget.vipProductModel; // 非空 = 购买会员卡,空 = 购买金币
final productId = vip != null ? vip.productID! : widget.coinRcModel!.id;
if (isPaying) return;
isPaying = true;
LoadingHelper.showLoading();
// 合并入口透传;仅实验 ACTIVE 时附带卡皮实验信息(copyWith 无法清 null,故重建)
final base = widget.orderTrack ?? const PayOrderTrackInfo();
final orderTrack = PayOrderTrackInfo(
sourcePage: base.sourcePage ?? PaySourcePage.unknown,
sourceRef: base.sourceRef,
videoId: base.videoId,
activityId: base.activityId,
sessionId: base.sessionId,
//短剧付费墙的归因字段,这里重建时漏抄会被服务端 8001 拒单
mediaId: base.mediaId,
contentId: base.contentId,
checkoutContextId: base.checkoutContextId,
experimentId: vipProductManager.isExperimentActive
? (base.experimentId ??
(vip == null
? null
: vip.experimentId ?? vipProductManager.experimentId))
: null,
experimentVariant: vipProductManager.isExperimentActive
? (base.experimentVariant ??
(vip == null ? null : vip.variant ?? vipProductManager.variant))
: null,
);
final urlModel = await MineService.chargeGoldCoin(
payType.type,
productId: productId,
isVip: vip != null,
goldExtraID: coupon?.cId,
finalPayStatus: vip?.isPreSale == true
? PreSaleProvider().preSaleModel?.detailModel?.balancePayment
: null,
orderTrack: orderTrack,
);
LoadingHelper.dismissLoading();
if (urlModel != null) {
if (urlModel.mode == "url") {
await launchUrlToWeb(urlModel.payUrl ?? '');
} else if (urlModel.mode == "sdk") {
showToast("没有找到支付类型为:${payType.type} 的sdk");
Get.back();
isPaying = false;
return;
}
// VIP 已创建支付单:视为产生购买行为,关闭会员卡页不再报无购买关闭
if (vip != null) VipCardAnalyticsSession.markPurchaseOnActiveSessions();
Get.back();
Future.delayed(const Duration(milliseconds: 1500), () {
//支付成功刷新vip到期时间
Get.dialog(
const Center(child: PaySuccessAlert()),
barrierColor: const Color(0x22000000),
barrierDismissible: true,
);
});
}
isPaying = false;
}
///金币余额支付(仅会员卡有该方式)
Future<void> _payByCoin() async {
if (isPaying) return;
isPaying = true;
final vip = widget.vipProductModel;
final track = widget.orderTrack;
final experimentActive = vipProductManager.isExperimentActive;
await PayManager().buyVip(
vip?.productType,
vip?.productID,
vip?.productName,
vip?.discountedPrice,
source: 'online_pay',
jumpWalletOnInsufficient: false, // 当前就在充值页,余额不足不跳转
finalPayStatus: vip?.isPreSale == true
? PreSaleProvider().preSaleModel?.detailModel?.balancePayment
: null,
// 与 /mine/topay 一致:仅实验 ACTIVE 时回传;sessionId 有则带上
experimentId: experimentActive
? (track?.experimentId ??
vip?.experimentId ??
vipProductManager.experimentId)
: null,
experimentVariant: experimentActive
? (track?.experimentVariant ??
vip?.variant ??
vipProductManager.variant)
: null,
sessionId: track?.sessionId,
//短剧付费墙开卡:金币余额支付这条也要带归因,否则只有第三方充值那条统计得到
mediaId: track?.mediaId,
contentId: track?.contentId,
checkoutContextId: track?.checkoutContextId,
onSuccess: (data) async {
VipCardAnalyticsSession.markPurchaseOnActiveSessions();
showToast("购买成功");
globalStore.refreshWallet();
await globalStore.updateUserInfo();
MineMsgProvider().refreshPayPopup(); //会员状态变了,重拉分层与付费引导开关(买完别再弹引导)
Get.back();
},
);
isPaying = false;
}
///代充支付:拼装代充参数后跳 H5 收银台
Future<void> _payByDc(
bool isVip, RchgType payType, DCModel? dcModel, int? money) async {
// daichong 是所有会员卡/金币档位共用的同一实例,下面的改写只为拼给 H5,必须在副本上做:
// 原地改会把 payInfos 截成单条(支付方式列表变短→下标越界)、ordUrl 被重复拼 host
final dc = dcModel!.clone();
final payList = dc.traders![0].payInfos!;
PayInfoModel? payInfo;
for (final model in payList) {
if (model.payMethod == payType.payMethod) {
payInfo = model;
break;
}
}
//设置默认值
if (dc.limit == 0) dc.limit = 500;
final payMoney = money ?? 0;
//超额度走大额通道
if (payInfo!.payType!.contains(2) && payInfo.payType!.contains(3)) {
payInfo.payType = (payMoney / 100) > dc.limit! ? [3] : [2];
}
if (payInfo.payType!.length >= 3) {
payInfo.payType = (payMoney / 100) > dc.limit! ? [1, 3] : [1, 2];
}
dc.traders![0].payInfos = [payInfo];
final host = Address.baseHost!;
dc.ordUrl = host + dc.ordUrl!;
dc.traderUrl = host + dc.traderUrl!;
dc.chargeMoney = payMoney ~/ 100;
final channel = payType.channel!;
dc.channel = channel;
final token = await netManager.getToken();
//bt64
final data = base64Encode(utf8.encode(json.encode(dc)));
if (isVip) VipCardAnalyticsSession.markPurchaseOnActiveSessions();
Get.to(H5Page(title: "代理充值", url: "$channel/?data=$data&token=$token"),
opaque: false);
}
}
/// 加赠券选择面板:拉取用户券列表,选中经 onSelect 回传(传 null 表示返回不选)
class ChoseCouponView extends StatefulWidget {
final Function(CouponModel? coupon) onSelect;
const ChoseCouponView({super.key, required this.onSelect});
@override
State<ChoseCouponView> createState() => _ChoseCouponViewState();
}
class _ChoseCouponViewState extends State<ChoseCouponView> {
bool isLoading = true;
final _dataSource = <CouponModel>[];
int page = 1;
// refreshCtr 由 CustomRefreshView 创建并 dispose,本类只持引用,绝不能再 dispose
RefreshController? refreshCtr;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch());
}
Future<void> _fetch({bool isRefresh = true}) async {
if (isRefresh) page = 1;
final res = await MineService.fetchUserCoupons(1, page: page);
isLoading = false;
if (isRefresh) {
refreshCtr?.refreshCompleted();
_dataSource.clear();
}
_dataSource.length < 20
? refreshCtr?.loadNoData()
: refreshCtr?.loadComplete();
_dataSource.addAll(res);
page += 1;
setState(() {});
}
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 400),
child: Column(
children: [
//顶部拖动条
Container(
height: 41,
alignment: Alignment.center,
child: const SheetHandleBar(color: Colors.black12),
),
//返回 + 标题
Stack(
children: [
GestureDetector(
onTap: () => widget.onSelect(null),
child: Image.asset(
'common_back.png'.commonImgPath,
width: 18,
height: 18,
color: Colors.black.withValues(alpha: .9),
),
),
const Center(
child: Text(
'选择加赠券',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600),
),
)
],
),
18.sizeBoxH,
Expanded(
child: pullYsRefresh(
onRefresh: (_) => _fetch(),
onLoading: (_) => _fetch(isRefresh: false),
child: () {
if (isLoading) return const LoadingCenterWidget();
if (_dataSource.isEmpty) return const CErrorWidget();
return ListView.separated(
separatorBuilder: (_, __) => 12.sizeBoxH,
itemCount: _dataSource.length,
itemBuilder: (_, index) => _couponItem(_dataSource[index]),
);
}(),
onInit: (ctr) => refreshCtr = ctr),
)
],
),
);
}
Widget _couponItem(CouponModel model) {
return Container(
height: 72,
padding: const EdgeInsets.only(left: 24, right: 11),
alignment: Alignment.centerLeft,
width: double.infinity,
decoration: BoxDecoration(
color: Color(0xffff891c).withValues(alpha: .4),
borderRadius: BorderRadius.circular(4)),
child: Row(
children: [
//券面额
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Image.asset(
'coin_icon.webp'.mineImgPath,
width: 16,
height: 16,
),
4.sizeBoxW,
Text(
'${model.price ?? 0}',
style: TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.w500,
height: 33 / 24),
)
],
),
Text(
'充值加送金币',
style: TextStyle(
color: Colors.black.withValues(alpha: .6), fontSize: 12),
)
],
),
23.sizeBoxW,
SizedBox(
width: 1,
height: 45,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: .5),
Colors.black.withValues(alpha: 0)
], begin: Alignment.topCenter, end: Alignment.bottomCenter),
),
),
),
12.sizeBoxW,
//券说明 + 有效期
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'金币加购券',
style: TextStyle(
color: Colors.black,
fontSize: 12,
),
),
4.sizeBoxH,
Text(
'有效期:${model.expireTime?.utcToYMDHM()}',
style: TextStyle(
color: Colors.black.withValues(alpha: .6),
fontSize: 8,
),
)
],
),
),
11.sizeBoxW,
GestureDetector(
onTap: () => widget.onSelect(model),
child: Container(
decoration: BoxDecoration(
color: Color(0xffF68216),
borderRadius: BorderRadius.circular(22)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
child: Text(
'立即使用',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500),
),
),
)
],
),
);
}
}
@@ -0,0 +1,86 @@
/// 创建订单(/mine/topay)的来源页,[value] 与后端约定一致
enum PaySourcePage {
homeUserSegment('HOME_USER_SEGMENT'), //用户分层首页弹窗
homeFloatWindow('HOME_FLOAT_WINDOW'), //首页右下角浮窗(分层浮窗 + 活动浮窗内链)
videoBottomBanner('VIDEO_BOTTOM_BANNER'), //视频播放页下方 banner
videoBottomSheet('VIDEO_BOTTOM_SHEET'), //视频播放页底部弹窗(含播放页金币解锁)
vipCenter('VIP_CENTER'), //会员中心
h5Activity('H5_ACTIVITY'), //H5 活动
dramaPaywall('DRAMA_PAYWALL'), //短剧付费墙(金币充值 / 开短剧卡)
unknown('UNKNOWN'); //入口没指定
final String value;
const PaySourcePage(this.value);
}
/// 下单(/mine/topay)透传的埋点信息:来源 / 实验 / 会话等,均为非必填
class PayOrderTrackInfo {
/// 来源页
final PaySourcePage? sourcePage;
/// 来源关联(如 videoId 文案标识)
final String? sourceRef;
/// 关联视频 ID
final String? videoId;
/// 活动 ID
final String? activityId;
/// 实验 IDVIP 卡皮 A/B
final String? experimentId;
/// 实验分组(对应接口 experimentVariant
final String? experimentVariant;
/// 会话 ID(与 VIP 卡片统计 session 对齐)
final String? sessionId;
/// 短剧:当前剧 ID
final String? mediaId;
/// 短剧:当前分集 ID
final String? contentId;
/// 短剧:本次付费墙上下文,服务端据此把充值/开卡订单归因到这一集
final String? checkoutContextId;
const PayOrderTrackInfo({
this.sourcePage,
this.sourceRef,
this.videoId,
this.activityId,
this.experimentId,
this.experimentVariant,
this.sessionId,
this.mediaId,
this.contentId,
this.checkoutContextId,
});
PayOrderTrackInfo copyWith({
PaySourcePage? sourcePage,
String? sourceRef,
String? videoId,
String? activityId,
String? experimentId,
String? experimentVariant,
String? sessionId,
String? mediaId,
String? contentId,
String? checkoutContextId,
}) {
return PayOrderTrackInfo(
sourcePage: sourcePage ?? this.sourcePage,
sourceRef: sourceRef ?? this.sourceRef,
videoId: videoId ?? this.videoId,
activityId: activityId ?? this.activityId,
experimentId: experimentId ?? this.experimentId,
experimentVariant: experimentVariant ?? this.experimentVariant,
sessionId: sessionId ?? this.sessionId,
mediaId: mediaId ?? this.mediaId,
contentId: contentId ?? this.contentId,
checkoutContextId: checkoutContextId ?? this.checkoutContextId,
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/global_store/store.dart';
class PaySuccessAlert extends StatelessWidget {
const PaySuccessAlert({super.key});
@override
Widget build(BuildContext context) {
return CommonDialog(
child: _buildContent(),
);
}
_buildContent() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"支付确认",
style: TextStyle(
fontSize: 20,
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500,
),
),
12.sizeBoxH,
0.5.line,
12.sizeBoxH,
Text(
'''1.订单支付后,一般会在1-10分钟内到账,如超时未到账,请联系在线客服为您处理。
2.受特殊行业限制,如支付失败可尝试重新发起订单,系统将会随机切换备用的支付通道。
3.本APP有稳定的广告收益,产品稳定安全,请放心充值使用,如遇报毒提示忽略即可。''',
style: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 14,
height: 1.8,
),
),
12.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () => pushToCustomService(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'支付遇到问题',
style: TextStyle(color: Color(0xff999999), fontSize: 14),
),
2.sizeBoxW,
Icon(
Icons.arrow_forward_ios_outlined,
size: 14,
color: Colors.white,
)
],
)),
24.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () {
globalStore.updateUserInfo();
Get.back();
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(3)),
color: AppColors.actionRed,
),
child: Text(
"支付成功",
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
}
@@ -0,0 +1,149 @@
import 'package:flutter/scheduler.dart';
import 'package:hgdj/hj_model/mine/vip_card_analytics_event.dart';
import 'package:hgdj/hj_page/mine/mine_vip/vip_product_manager.dart';
import 'package:hgdj/hj_page/mine/mine_vip/vip_support_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:uuid/uuid.dart';
/// VIP 卡片统计:一次进入会员卡页/购买弹窗对应一个 session,关闭时按是否下单上报。
/// 曝光在 UI 实际展示后(post-frame)上报。
class VipCardAnalyticsSession {
VipCardAnalyticsSession() {
// 进页时快照实验上下文:关闭时 manager 的原始数据可能已被重拉/失效,不能再读 live manager
_experimentActive = vipProductManager.isExperimentActive;
_experimentId = vipProductManager.experimentId;
_variant = vipProductManager.variant;
_active.add(this);
}
static final _uuid = Uuid();
static final Set<VipCardAnalyticsSession> _active = {};
/// 当前访问会话 ID(同一次进入会员卡 UI 内事件共用)
final String sessionId = _uuid.v4();
/// 进页瞬间的实验快照(关闭上报必须用这份,避免中途 force 重拉把 live 态冲掉)
late final bool _experimentActive;
late final String? _experimentId;
late final String? _variant;
bool _pageViewReported = false;
bool _purchased = false;
bool _closed = false;
String? _lastImpressedProductId;
/// 同一帧内 jumpToPage + onPageChanged 去重
String? _pendingImpressionProductId;
static String _nowUtc() =>
DateTimeUtil.format2utc(DateTime.now().toUtc()) ?? '';
/// DISABLED / 无实验 ID:不上报(空 experimentId 会参数错误)
bool get _canReport {
if (!_experimentActive) return false;
final id = _experimentId;
return id != null && id.isNotEmpty;
}
VipCardAnalyticsEvent _build({
required String eventName,
String? productId,
int? price,
}) {
return VipCardAnalyticsEvent(
eventId: _uuid.v4(),
eventName: eventName,
sessionId: sessionId,
occurredAt: _nowUtc(),
experimentId: _experimentId,
variant: _variant,
productId: productId,
price: price,
);
}
Future<void> _send(List<VipCardAnalyticsEvent> events) async {
if (!_canReport || events.isEmpty) return;
try {
await MineService.reportAnalyticsEvents(events);
} catch (e) {
debugLog('VipCardAnalytics', e);
}
}
/// 卡皮页展示:UI 就绪后只报一次
void reportPageViewAfterPaint() {
if (_pageViewReported || _closed || !_canReport) return;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_pageViewReported || _closed || !_canReport) return;
_pageViewReported = true;
_send([_build(eventName: VipCardAnalyticsEventName.pageView)]);
});
}
/// 套餐曝光:选中 / 默认选中各算一次;同 product 连续重复不重复报
void reportProductImpression(VipProductModel? product,
{bool afterPaint = false}) {
final productId = product?.productID;
if (productId == null || productId.isEmpty || _closed || !_canReport)
return;
if (_lastImpressedProductId == productId) return;
void doReport() {
if (_closed || !_canReport) return;
if (_lastImpressedProductId == productId) return;
_lastImpressedProductId = productId;
_pendingImpressionProductId = null;
_send([
_build(
eventName: VipCardAnalyticsEventName.productImpression,
productId: productId,
price: product?.discountedPrice,
),
]);
}
if (afterPaint) {
_pendingImpressionProductId = productId;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_pendingImpressionProductId != productId) return;
doReport();
});
} else {
doReport();
}
}
/// 下单成功:本访问内关闭不再报「无购买关闭」
void markPurchased() => _purchased = true;
/// 活跃会话均标记已购买(支付页成功回调用)
static void markPurchaseOnActiveSessions() {
for (final s in _active) {
s.markPurchased();
}
}
/// 无购买关闭
void reportCloseWithoutPurchaseIfNeeded() {
if (_closed) return;
_closed = true;
_active.remove(this);
if (_purchased) {
debugLog(
'VipCardAnalytics', 'skip CLOSE_WITHOUT_PURCHASE: already purchased');
return;
}
if (!_canReport) {
debugLog(
'VipCardAnalytics',
'skip CLOSE_WITHOUT_PURCHASE: canReport=false active=$_experimentActive id=$_experimentId',
);
return;
}
// 关闭瞬间触发上报;不 await,避免卡住 onClose,但请求走全局 http 不会随页面 dispose 取消
_send([_build(eventName: VipCardAnalyticsEventName.closeWithoutPurchase)]);
}
}
@@ -0,0 +1,883 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../main_page/provider/msg_provider.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
import 'vip_ui_kit.dart';
class VipCardItem extends StatefulWidget {
final Function()? callback;
final bool isSelect;
final VipProductModel model;
const VipCardItem(this.model,
{super.key, this.callback, this.isSelect = false});
@override
State<StatefulWidget> createState() {
return _VipCardItemState();
}
}
class _VipCardItemState extends State<VipCardItem> {
/// 分层倒计时停表后(如已是会员),新人卡仍需本地秒级刷新
final ValueNotifier<int> _localTick = ValueNotifier<int>(0);
Timer? _localTimer;
/// 仅按套餐 badgeType 决定角标,不看名称 / sort / 默认选中 / actionDesc
String get _badgeType => (widget.model.badgeType ?? '').trim().toUpperCase();
String? get _badgeLabel {
switch (_badgeType) {
case 'MOST_POPULAR':
case 'NEW_USER_OFFER':
final text = widget.model.badgeText?.trim() ?? '';
if (text.isNotEmpty) return text;
// NEW_USER_OFFER 无文案时兜底
return _badgeType == 'NEW_USER_OFFER' ? '新人特惠' : null;
default:
// 角标只认 badgeType / badgeText,不回退 actionDesc / desc
return null;
}
}
bool get _isMostPopularBadge => _badgeType == 'MOST_POPULAR';
/// 仅 productType==5(新人卡)展示倒计时
bool get _isNewerOfferCard => widget.model.productType == 5;
/// 当前应展示的倒计时:仅新人卡;优先分层 lastDiscountTime;会员态无分层时用卡 showCountdownTime(小时) 滚动
({String hour, String min, String sec})? get _countdownParts {
if (!_isNewerOfferCard) return null;
final layered = MineMsgProvider().countdownConfig;
if (layered != null) {
return (
hour: layered.discountHour,
min: layered.discountMin,
sec: layered.discountSec
);
}
final hours = widget.model.showCountdownTime ?? 0;
if (hours <= 0) return null;
final period = hours * 3600;
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
var remain = period - (nowSec % period);
if (remain <= 0) remain = period;
final h = (remain ~/ 3600).toString().padLeft(2, '0');
final m = ((remain ~/ 60) % 60).toString().padLeft(2, '0');
final s = (remain % 60).toString().padLeft(2, '0');
return (hour: h, min: m, sec: s);
}
bool get _needLocalTick =>
_isNewerOfferCard &&
MineMsgProvider().countdownConfig == null &&
(widget.model.showCountdownTime ?? 0) > 0;
@override
void initState() {
super.initState();
_syncLocalTimer();
}
@override
void didUpdateWidget(covariant VipCardItem oldWidget) {
super.didUpdateWidget(oldWidget);
_syncLocalTimer();
}
@override
void dispose() {
_localTimer?.cancel();
_localTick.dispose();
super.dispose();
}
void _syncLocalTimer() {
if (_needLocalTick) {
_localTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
_localTick.value++;
});
} else {
_localTimer?.cancel();
_localTimer = null;
}
}
String get _title => (widget.model.newName?.trim().isNotEmpty == true)
? widget.model.newName!.trim()
: (widget.model.productName ?? '');
@override
Widget build(BuildContext context) {
_syncLocalTimer();
// variant=B:组合卡 UIA / DISABLED:接口卡图
if (vipProductManager.isNewVipUi) {
return _buildVariantBCard();
}
return _buildClassicImageCard();
}
/// 旧版:选中/未选中靠接口下发卡面图 + 角标/新人倒计时叠加
Widget _buildClassicImageCard() {
final badgeLabel = _badgeLabel;
return Material(
color: Colors.transparent,
child: InkWell(
enableFeedback: false,
onTap: widget.callback,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Stack(
children: [
Positioned(
left: 0,
top: 9,
bottom: 10,
right: 0,
child: NetworkImageLoader(
imageUrl: widget.isSelect
? (widget.model.realSelectVipImage().isEmpty
? widget.model.realNormalVipImage()
: widget.model.realSelectVipImage())
: widget.model.realNormalVipImage(),
fit: BoxFit.fill,
),
),
if (badgeLabel != null)
Positioned(
right: 0,
top: 0,
child: _buildBadge(badgeLabel),
),
if (_isNewerOfferCard)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
alignment: Alignment.bottomCenter,
child: _buildNewerTimer(),
),
),
],
),
),
),
);
}
/// variant=B148×157 组合卡;选中金渐变+#B2FFF6DC 边;未选中 #211C12+#14FFFFFF 边
Widget _buildVariantBCard() {
final selected = widget.isSelect;
final badgeLabel = _badgeLabel;
const cardW = 148.0;
const cardH = 157.0;
const badgeTop = 8.0;
// VIP/Glow GoldY10+Blur28,底部预留绘制空间,避免被轮播裁切
const glowPadBottom = 24.0;
return Material(
color: Colors.transparent,
child: InkWell(
enableFeedback: false,
onTap: widget.callback,
child: SizedBox(
width: cardW,
height: cardH + badgeTop + glowPadBottom,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: badgeTop,
height: cardH,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
boxShadow: selected
? const [
// VIP/Glow GoldY10 / Blur28 / Spread-6 / #DEAB54 35%
BoxShadow(
color: Color(0x59DEAB54),
offset: Offset(0, 10),
blurRadius: 28,
spreadRadius: -6,
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
// 底色 / 渐变
DecoratedBox(
decoration: BoxDecoration(
color: selected ? null : const Color(0xff211C12),
gradient: selected
? const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffFBF0CD),
Color(0xffE5B75D)
],
)
: null,
),
),
// 内容(含底栏 desc
Column(
children: [
Expanded(child: _buildBCardBody(selected)),
_buildBCardFooter(selected),
],
),
// 边框置顶,避免被底栏遮住;新人卡选中不描边
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: (selected && _isNewerOfferCard)
? null
: Border.all(
color: selected
? const Color(0xB2FFF6DC)
: const Color(0x14FFFFFF),
width: 1,
),
),
),
),
),
],
),
),
),
),
if (badgeLabel != null)
Positioned(
right: 0,
top: 0,
child: _buildBadge(badgeLabel),
),
],
),
),
),
);
}
Widget _buildBCardBody(bool selected) {
final titleColor = selected ? const Color(0xff3B2B0E) : Color(0xffF6EEDC);
final priceColor =
selected ? const Color(0xff3B2B0E) : const Color(0xffF6EEDC);
final originColor =
selected ? const Color(0xff6C644F) : const Color(0xff9A927C);
final showOrigin = widget.model.originalPriceUI > 0 &&
widget.model.originalPriceUI != widget.model.discountedPriceUI;
return Padding(
padding: const EdgeInsets.fromLTRB(10, 22, 10, 8),
child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: titleColor,
fontSize: 15,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
10.sizeBoxH,
EasyRichText(
'¥${widget.model.discountedPriceUI}',
defaultStyle: TextStyle(
color: priceColor,
fontSize: 40,
fontWeight: FontWeight.w700,
height: 1,
),
patternList: [
EasyRichTextPattern(
targetString: '¥',
matchWordBoundaries: false,
matchOption: 'first',
style: TextStyle(
color: priceColor,
fontSize: 17,
fontWeight: FontWeight.w800,
height: 1,
),
),
],
),
if (showOrigin) ...[
4.sizeBoxH,
Text(
'¥${widget.model.originalPriceUI}',
style: TextStyle(
color: originColor,
fontSize: 13,
decoration: TextDecoration.lineThrough,
decorationColor: originColor,
height: 1.2,
),
),
] else ...[
// 无原价时占位短横,对齐设计稿
10.sizeBoxH,
Text(
'-',
style: TextStyle(color: originColor, fontSize: 13, height: 1.2),
),
],
],
),
);
}
Widget _buildBCardFooter(bool selected) {
if (!_isNewerOfferCard) return _buildBDurationFooter(selected);
return ValueListenableBuilder<int>(
valueListenable: _needLocalTick ? _localTick : MineMsgProvider().tick,
builder: (_, __, ___) {
final cd = _countdownParts;
if (cd != null) {
if (selected) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 7),
alignment: Alignment.center,
color: const Color(0xffC14A38),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_timerBox(cd.hour),
_timerColon(),
_timerBox(cd.min),
_timerColon(),
_timerBox(cd.sec),
4.sizeBoxW,
const Text(
'结束',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600),
),
],
),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
alignment: Alignment.center,
color: const Color(0xff16140F),
child: Text(
'${cd.hour}:${cd.min}:${cd.sec}后失效',
style: const TextStyle(
color: Color(0xff8A8A8A),
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
);
}
return _buildBDurationFooter(selected);
},
);
}
/// 无倒计时时的底栏:取套餐 desc
Widget _buildBDurationFooter(bool selected) {
final text = widget.model.desc?.trim() ?? '';
if (text.isEmpty) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
alignment: Alignment.center,
color: selected ? const Color(0xff2B2314) : const Color(0xff1A150C),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: selected ? const Color(0xffFFFFFF) : const Color(0xff9A927C),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildBadge(String label) {
final isMostPopular = _isMostPopularBadge;
final useB = vipProductManager.isNewVipUi;
// B 未选中:统一深底金边/金字;B 选中 / A:优先 uiConfig.badgeStyles
final useApiStyle = useB ? widget.isSelect : true;
Color? apiBg;
Color? apiFg;
if (useApiStyle) {
final style = vipProductManager.badgeStyleFor(_badgeType);
apiBg = _parseHexColor(style?.backgroundColor);
apiFg = _parseHexColor(style?.textColor);
}
final Color bg;
final Color fg;
final Border? border;
if (useB && !widget.isSelect) {
bg = const Color(0xCC1A1A1A);
fg = const Color(0xFFE8C078);
border = Border.all(color: const Color(0xFFE8C078), width: 0.8);
} else {
bg = apiBg ??
(isMostPopular ? const Color(0xCC1A1A1A) : const Color(0xffE1351F));
fg = apiFg ??
(isMostPopular ? const Color(0xFFE8C078) : const Color(0xE5FFFFFF));
border = (apiBg == null && isMostPopular)
? Border.all(color: const Color(0xFFE8C078), width: 0.8)
: null;
}
return Container(
constraints: const BoxConstraints(maxWidth: 111),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: bg,
border: border,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontWeight: FontWeight.w500,
fontSize: 10.0,
),
),
);
}
/// 解析 #RRGGBB / RRGGBB / #AARRGGBB;非法或空返回 null
Color? _parseHexColor(String? raw) {
var hex = (raw ?? '').trim().toUpperCase().replaceAll('#', '');
if (hex.isEmpty) return null;
if (hex.length == 6) hex = 'FF$hex';
if (hex.length != 8) return null;
final value = int.tryParse(hex, radix: 16);
return value == null ? null : Color(value);
}
/// A / DISABLED 旧版新人倒计时:卡底橙色小标签「HH:MM:SS」
Widget _buildNewerTimer() {
return ValueListenableBuilder<int>(
valueListenable: _needLocalTick ? _localTick : MineMsgProvider().tick,
builder: (_, __, ___) {
final cd = _countdownParts;
if (cd == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: const BoxDecoration(
color: Color(0xffF68804),
borderRadius: BorderRadius.all(Radius.circular(4)),
),
child: Text(
'${cd.hour}:${cd.min}:${cd.sec}',
style: const TextStyle(color: Color(0xff000000), fontSize: 10),
),
);
},
);
}
Widget _timerBox(String value) {
return Container(
width: 17,
height: 16,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(2),
border: Border.all(color: Colors.white, width: 1),
),
child: Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
height: 1.0),
),
);
}
Widget _timerColon() => const Padding(
padding: EdgeInsets.symmetric(horizontal: 3),
child: Text(':',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600)),
);
}
class VipProductPrivilegeItem extends StatelessWidget {
final NewPrivilege? model;
final bool isShowOnePrivilege;
/// true:旧版白字圆图标样式(会员中心 A / DISABLED
final bool classic;
const VipProductPrivilegeItem(
this.model, {
super.key,
this.isShowOnePrivilege = false,
this.classic = false,
});
@override
Widget build(BuildContext context) {
if (classic) {
return Column(
children: [
NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 50,
height: 50,
borderRadius: 25,
),
3.sizeBoxH,
Text(
model?.privilegeName ?? '',
maxLines: 1,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: .9)),
),
if (!isShowOnePrivilege) ...[
4.sizeBoxH,
Text(
_descLine(0),
maxLines: 1,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 10,
),
textAlign: TextAlign.center,
),
4.sizeBoxH,
Text(
_descLine(1),
maxLines: 2,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 9,
),
textAlign: TextAlign.center,
),
2.sizeBoxH,
],
],
);
}
return Column(
children: [
NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 44,
height: 44,
borderRadius: 22,
),
6.sizeBoxH,
VipGradientMask(
child: Text(
model?.privilegeName ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500, color: Colors.white),
),
),
if (!isShowOnePrivilege) ...[
4.sizeBoxH,
Text(
_descLine(0),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 10,
),
textAlign: TextAlign.center,
),
2.sizeBoxH,
Text(
_descLine(1),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 9,
),
textAlign: TextAlign.center,
),
],
],
);
}
String _descLine(int index) {
final raw = model?.privilegeDesc ?? '';
final bySpace = raw.split(' ');
if (bySpace.length > 1) {
return index < bySpace.length ? bySpace[index].trim() : '';
}
final byLine = raw.split('\n');
return index < byLine.length ? byLine[index].trim() : '';
}
}
/// 核心权益卡片
/// - classic=false:左图标 + 右标题/描述(会员中心 B / 购买弹窗)
/// - classic=true72×72 金边方卡(会员中心 A / DISABLED
class VipCorePrivilegeCard extends StatelessWidget {
final NewPrivilege? model;
final bool classic;
const VipCorePrivilegeCard(this.model, {super.key, this.classic = false});
static const _radius = 16.0;
static const _fillGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x12FFFFFF), // #FFFFFF 7.06%
Color(0x05FFFFFF), // #FFFFFF 7.06%
],
);
static const _borderGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x29FFFFFF), // #FFFFFF 16.08%
Color(0x0AFFFFFF), // #FFFFFF 3.92%
],
);
@override
Widget build(BuildContext context) {
if (classic) return _buildClassic();
return Container(
width: 168,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_radius),
boxShadow: const [
BoxShadow(
color: Color(0x1AFFFFFF),
offset: Offset(0, 1),
blurRadius: 0,
),
BoxShadow(
color: Color(0x4D000000),
offset: Offset(0, 8),
blurRadius: 20,
spreadRadius: -8,
),
],
),
// 渐变描边用 CustomPaint 画 stroke,避免双层 Container「假边框」透出底层背景
child: CustomPaint(
painter: _GradientBorderPainter(
gradient: _borderGradient,
strokeWidth: 1,
radius: _radius,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_radius),
child: DecoratedBox(
decoration: const BoxDecoration(gradient: _fillGradient),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
_buildIcon(),
10.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
model?.privilegeName ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xffF6EEDC),
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
4.sizeBoxH,
_buildDesc(model?.privilegeDesc ?? ''),
],
),
),
],
),
),
),
),
),
);
}
/// 旧版:正方形金框 + 深棕底图 + 图标/标题/描述
Widget _buildClassic() {
return Container(
width: 72,
height: 72,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('core_privilege_bg.webp'.videoPath),
fit: BoxFit.fill,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xffFFF576), width: 0.5),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 22,
height: 22,
borderRadius: 0),
5.sizeBoxH,
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xffFF9642), Color(0xffFFE7BD), Color(0xffFCCF36)],
stops: [0.066, 0.49, 0.914],
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Text(
model?.privilegeName ?? '',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
3.sizeBoxH,
Text(
model?.privilegeDesc ?? '',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xffB7A463), fontSize: 9),
),
],
),
);
}
Widget _buildIcon() {
return Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0x14FFFFFF),
border: Border.all(color: const Color(0x66DCAD55), width: 0.8),
),
child: NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 26,
height: 26,
borderRadius: 0,
),
);
}
/// 设计稿描述:前半灰、后半金(支持空格分隔;否则前 4 字灰、其余金)
Widget _buildDesc(String raw) {
if (raw.isEmpty) return const SizedBox.shrink();
return Text(
raw,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: Color(0xff9A927C), fontSize: 11, height: 1.2),
);
}
}
/// 1px 内渐变描边(stroke),不占用布局厚度,避免双层 Container 透底
class _GradientBorderPainter extends CustomPainter {
final Gradient gradient;
final double strokeWidth;
final double radius;
_GradientBorderPainter({
required this.gradient,
required this.strokeWidth,
required this.radius,
});
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final inset = strokeWidth / 2;
final rrect = RRect.fromRectAndRadius(
rect.deflate(inset),
Radius.circular(math.max(0, radius - inset)),
);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..shader = gradient.createShader(rect);
canvas.drawRRect(rrect, paint);
}
@override
bool shouldRepaint(covariant _GradientBorderPainter oldDelegate) {
return oldDelegate.gradient != gradient ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.radius != radius;
}
}
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_alert.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'mine_charge_vip_logic.dart';
import 'vip_support_model.dart';
/// 会员支付按钮:会员卡页 MineChargeVipPage 与购买弹窗 BuyVipAlert 共用
/// [classic] true:旧版红底/升级金橙;false:改版金渐变 CTA
class VipPayButton extends StatelessWidget {
final MineChargeVipLogic logic;
final bool classic;
const VipPayButton(this.logic, {super.key, this.classic = false});
static const _ctaGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffFBEDC2),
Color(0xffEFCB84),
Color(0xffD9A346),
],
);
@override
Widget build(BuildContext context) {
final product = logic.currentProductModel;
//预售首付或非升级预售 → 预售按钮(透传会员中心 sessionId / 实验字段)
if (product?.isPreSale == true && product?.isUpgrade == false) {
return PreSaleActivityButton(onTap: () async {
await PreSaleProvider()
.startPay(product!, orderTrack: logic.buildOrderTrack());
logic.update();
});
}
if (classic) return _buildClassic(product);
return GestureDetector(
onTap: logic.onInitiatePayAction,
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
boxShadow: const [
BoxShadow(
color: Color(0x66DEAB54),
offset: Offset(0, 10),
blurRadius: 30,
spreadRadius: -6,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Stack(
alignment: Alignment.center,
children: [
const DecoratedBox(
decoration: BoxDecoration(gradient: _ctaGradient),
child: SizedBox.expand(),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 3,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0x8CFFFFFF),
const Color(0x00FFFFFF),
],
),
),
),
),
),
Text(
_payText,
style: const TextStyle(
color: Color(0xff3D2914),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
/// 旧版:升级卡金橙渐变 / 普通卡红底
Widget _buildClassic(VipProductModel? product) {
final isUpgrade = product?.isUpgrade ?? false;
return GestureDetector(
onTap: logic.onInitiatePayAction,
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
gradient: isUpgrade
? const LinearGradient(
colors: [Color(0xFFFFAF50), Color(0xFFE75100)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)
: null,
color: isUpgrade ? null : AppColors.actionRed,
),
child: Text(
_payText,
style: const TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
),
),
);
}
String get _payText {
final product = logic.currentProductModel;
if (product == null) return '';
if (product.isPreSale) {
if (product.isUpgrade ?? false) {
return '${(product.advanceAmount ?? 0) ~/ 10}元/立即升级';
}
if (PreSaleProvider().canPayBalance) {
return '¥${PreSaleProvider().preSaleModel?.detailModel?.balanceAmount ?? 0}/支付尾款';
}
return '¥${PreSaleProvider().preSaleModel?.detailModel?.advanceAmount ?? 0}/立即预订';
}
if (product.isUpgrade ?? false) {
return '¥${product.discountedPriceUI}/补差价升级';
}
return '¥${product.discountedPriceUI}/立即支付';
}
}
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../hj_page/main_page/provider/msg_provider.dart';
import '../../../hj_page/pre_sale/pre_sale_provider.dart';
import 'online_pay_page.dart';
import 'pay_order_source.dart';
import 'vip_card_analytics.dart';
import 'vip_support_model.dart';
/// 会员卡 / 短剧卡列表统一数据源(全站唯一):请求 / 缓存 / 组装 / 选卡兜底 / 按卡ID直接拉支付。
/// 会员中心页、购买弹窗、分层横幅等「需要卡列表」的地方用 Consumer<VipProductManager> 消费。
/// 页面级状态(当前选中卡 / 轮播控制器 / 支付触发)仍留在各自 Logic,避免多入口串味。
class VipProductManager with ChangeNotifier {
static final VipProductManager _instance = VipProductManager._();
factory VipProductManager() => _instance;
VipProductManager._();
/// 按卡 id 取卡,会员卡和短剧卡共用。id 空/匹配不上都给 null,让调用方用 `??` 往下一级兜。
/// 空串必须当「未指定」:分层/实验/短剧默认卡没配时下发的都是空串,
/// 不拦会跟 productID 为空的脏数据撞上
VipProductModel? _pick(List<VipProductModel> list, String? id) =>
(id ?? '').isEmpty
? null
: list.firstWhereOrNull((e) => e.productID == id);
// ===== 会员卡数据 =====
/// 原始接口数据(含代充/预售卡组/升级卡等 / 实验态)
VipSupportModel? _raw;
/// 分层变化等场景标记需重拉;不清 _raw,避免已挂载会员页丢失实验态(B 皮闪回 A)
bool _needsReload = false;
/// 组装后的会员卡列表(常规卡 + 符合条件的预售卡,按 sort 排序)——全站共享一份
final vipCards = <VipProductModel>[];
/// 加载态
bool isLoading = true;
/// 预售卡分组(会员中心页据此判断是否显示预售入口)
VipProductListModel? presaleGroup;
// ===== A/B 实验 =====
/// 实验是否生效(ACTIVE);DISABLED 时不挂实验字段、不上报 analytics、topay 不传实验参数
bool get isExperimentActive => _raw?.isExperimentActive == true;
/// 会员中心 UI:仅 ACTIVE 且 variant=B 用改版;A / DISABLED / 无实验时 variant 恒为 null,自然走旧版
bool get isNewVipUi => (variant ?? '').trim().toUpperCase() == 'B';
/// 实验字段(仅 ACTIVE 时有值,下单时原样回传)
String? get experimentId => isExperimentActive ? _raw?.experimentId : null;
String? get variant => isExperimentActive ? _raw?.variant : null;
/// 实验指定的默认卡 id。和短剧卡的 [_dramaCardId] 是两码事,别混
String? get _abCardId => isExperimentActive ? _raw?.defaultProductId : null;
/// 皮肤配置:ACTIVE 时才消费;DISABLED / 无实验不走接口皮肤
VipUiConfig? get _uiConfig => isExperimentActive ? _raw?.uiConfig : null;
/// 会员中心背景图:接口给了就用网络图,否则 null(走本地默认)
String? get vipBgImage {
final url = (_uiConfig?.backgroundImage ?? '').trim();
return url.isEmpty ? null : url;
}
/// 角标配色:按套餐 badgeType 匹配皮肤配置
VipBadgeStyle? badgeStyleFor(String? badgeType) =>
_uiConfig?.styleFor(badgeType);
// ===== 会员卡:请求 / 组装 =====
/// 请求会员卡列表。[force] 为 false 且缓存有效时,直接用缓存重组,不发请求。
Future<void> loadVipCards({bool force = false}) async {
if (_raw != null && !force && !_needsReload) {
_rebuild();
return;
}
isLoading = true;
notifyListeners();
final result = await MineService.getVipProduct();
// 容错:请求失败或返回空数据时,保留上次成功的数据,只复位加载态,不覆盖 _raw/vipCards
if (result == null || (result.list?.isEmpty ?? true)) {
isLoading = false;
notifyListeners();
return;
}
_raw = result;
_needsReload = false;
_rebuild();
}
/// 缓存失效:标记下次 load 强制重拉;保留 _raw/vipCards,避免购卡后已挂载页实验皮闪回 A。
/// (分层状态变化时调用)
void invalidate() {
_needsReload = true;
}
/// 切换账号后:清掉上个用户的数据并按新用户权限重新拉取。
/// 卡列表(尤其预售卡)由后端按用户权限/购买状态返回,切号必须重拉,否则残留上个账号的卡。
Future<void> reloadForUser() async {
_raw = null;
_needsReload = true;
vipCards.clear();
await loadVipCards(force: true);
}
/// 用 _raw 组装 vipCards(会员卡组 + 符合条件的预售卡)
void _rebuild() {
isLoading = false;
presaleGroup = _raw?.list?.firstWhereOrNull((e) => e.position == '预售卡');
vipCards
..clear()
..addAll(_assemble(_raw));
notifyListeners();
}
/// 纯组装:会员卡组 + 符合条件的预售卡。无副作用,供 _rebuild 与 payByVipCard 共用。
/// 卡序统一按 sort 升序(含卡皮 ACTIVE
List<VipProductModel> _assemble(VipSupportModel? raw) {
final cardGroup = raw?.list?.firstWhereOrNull((e) => e.position == '会员卡');
final presale = raw?.list?.firstWhereOrNull((e) => e.position == '预售卡');
final list = <VipProductModel>[...(cardGroup?.list ?? [])];
// 优先添加预售卡(预售活动中且未付全款)
if ((presale?.list ?? []).isNotEmpty &&
PreSaleProvider().advanceStatus?.activityStatus == true &&
!PreSaleProvider().isPayAll) {
list.add(presale!.list!.first..isPreSale = true);
}
list.sort((a, b) => (a.sort ?? 0).compareTo(b.sort ?? 0));
return list;
}
// ===== 会员卡:选卡 / 支付 =====
/// 默认选中卡:外部指定卡ID > 分层卡 > 实验默认卡 > 升级差价卡 > 保留当前 > 第一张。
/// 选中态归页面 Logic,这里只按 vipCards 计算,[vipID]/[current] 由调用方透传。
VipProductModel? defaultVipCard({String? vipID, VipProductModel? current}) {
if (vipCards.isEmpty) return null;
return _pick(vipCards, vipID) ??
_pick(vipCards, MineMsgProvider().payTier?.config?.vipCard) ??
_pick(vipCards, _abCardId) ??
(globalStore.meInfo?.isUpgrade == true
? _cheapestUpgrade(vipCards)
: null) ??
_pick(vipCards, current?.productID) ?? // 二次刷新时保留当前选中
vipCards.first;
}
/// 按卡 ID 直接拉起支付弹窗(跳过会员中心列表页)。无数据时先请求一次。
/// 选卡:传入 [cardId] > 分层卡 > 实验默认卡 > 升级差价卡 > 第一张
/// [reportAnalytics] 是否上报 VIP 卡皮事件(PAGE_VIEW / 曝光 / CLOSE);视频底部分层 banner 等入口传 false
Future<void> payByVipCard(
String? cardId, {
PayOrderTrackInfo? orderTrack,
bool reportAnalytics = true,
}) async {
var raw = _raw;
if (raw == null || _needsReload) {
LoadingHelper.showLoading();
try {
raw = await MineService.getVipProduct();
} finally {
LoadingHelper.dismissLoading(); //无论成功/异常都关 loading,避免请求抛异常时卡死
}
// 缓存供后续复用,但不触碰 isLoading/vipCards/notify,避免打扰已挂载的会员卡列表 UI
if (raw != null && (raw.list?.isNotEmpty ?? false)) {
_raw = raw;
_needsReload = false;
}
}
// 用当前预售状态现算列表(不依赖共享 vipCards,也不受其重拉影响)
final list = _assemble(raw);
if (list.isEmpty) {
showToast("未获取到会员卡~");
return;
}
final card = _pick(list, cardId) ??
_pick(list, MineMsgProvider().payTier?.config?.vipCard) ??
_pick(list, _abCardId) ??
(globalStore.meInfo?.isUpgrade == true
? _cheapestUpgrade(list)
: null) ??
list.first;
if (card.rchgTypeUI.isEmpty) {
showToast("未配置支付方式,请联系客服");
return;
}
// 直拉支付弹窗没有 MineChargeVipLogic:需要卡皮埋点时自建 session,关弹窗报 CLOSE
final analytics = reportAnalytics ? VipCardAnalyticsSession() : null;
final base = orderTrack ?? const PayOrderTrackInfo();
final track = PayOrderTrackInfo(
sourcePage: base.sourcePage ?? PaySourcePage.unknown,
sourceRef: base.sourceRef,
videoId: base.videoId,
activityId: base.activityId,
sessionId: base.sessionId ?? analytics?.sessionId,
//短剧付费墙的归因字段,重建时漏抄就丢了(同 OnlinePayPage._payByLink)
mediaId: base.mediaId,
contentId: base.contentId,
checkoutContextId: base.checkoutContextId,
// DISABLED:正常下单但不传实验字段
experimentId: isExperimentActive
? (base.experimentId ?? card.experimentId ?? experimentId)
: null,
experimentVariant: isExperimentActive
? (base.experimentVariant ?? card.variant ?? variant)
: null,
);
analytics?.reportPageViewAfterPaint();
analytics?.reportProductImpression(card, afterPaint: true);
try {
await Get.bottomSheet(
OnlinePayPage(vipProductModel: card, orderTrack: track),
isScrollControlled: true,
);
} finally {
analytics?.reportCloseWithoutPurchaseIfNeeded();
}
}
/// 多张可升级卡挑一张:待付价低优先,同价预售卡优先;无可升级卡返回 null
VipProductModel? _cheapestUpgrade(List<VipProductModel> list) {
final upgrades = list.where((e) => e.isUpgrade == true).toList();
if (upgrades.isEmpty) return null;
upgrades.sort((a, b) {
final cmp = _upgradePrice(a).compareTo(_upgradePrice(b));
if (cmp != 0) return cmp;
return a.isPreSale ? -1 : 1; //同价预售卡优先
});
return upgrades.first;
}
/// 升级比价用价:预售卡用实际待付(已付首款=尾款 balanceAmount / 未付=定金 advanceAmount),普通卡用 discountedPrice(对齐 mrhs)
int _upgradePrice(VipProductModel card) {
if (card.isPreSale) {
return PreSaleProvider().isPayFirst
? (card.balanceAmount ?? 0)
: (card.advanceAmount ?? 0);
}
return card.discountedPrice ?? 0;
}
}
/// 全站唯一实例(对齐 presaleProvider / globalStore 的顶层单例引用)
final vipProductManager = VipProductManager();
@@ -0,0 +1,778 @@
//会员支持model
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import '../../../hj_model/mine/exchange/dc_model.dart';
/// /vip/product 实验状态:ACTIVE 生效,DISABLED 不可用(回原套餐,无实验字段)
abstract class VipExperimentStatus {
static const active = 'ACTIVE';
static const disabled = 'DISABLED';
}
/// uiConfig.badgeStyles 单项:按 badgeType 匹配角标底色/字色
class VipBadgeStyle {
String? badgeType;
String? backgroundColor;
String? textColor;
VipBadgeStyle({this.badgeType, this.backgroundColor, this.textColor});
VipBadgeStyle.fromJson(Map<String, dynamic>? json) {
badgeType = json?['badgeType']?.toString();
backgroundColor = json?['backgroundColor']?.toString();
textColor = json?['textColor']?.toString();
}
Map<String, dynamic> toJson() => {
'badgeType': badgeType,
'backgroundColor': backgroundColor,
'textColor': textColor,
};
}
/// /vip/product.data.uiConfig:皮肤背景 + 角标样式表
class VipUiConfig {
String? backgroundImage;
List<VipBadgeStyle>? badgeStyles;
VipUiConfig({this.backgroundImage, this.badgeStyles});
VipUiConfig.fromJson(Map<String, dynamic>? json) {
backgroundImage = json?['backgroundImage']?.toString();
final raw = json?['badgeStyles'];
if (raw is List) {
badgeStyles = raw
.whereType<Map>()
.map((e) => VipBadgeStyle.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
}
Map<String, dynamic> toJson() => {
'backgroundImage': backgroundImage,
'badgeStyles': badgeStyles?.map((e) => e.toJson()).toList(),
};
/// 按 badgeType 查找样式(大小写不敏感)
VipBadgeStyle? styleFor(String? badgeType) {
final key = (badgeType ?? '').trim().toUpperCase();
if (key.isEmpty) return null;
for (final s in badgeStyles ?? const <VipBadgeStyle>[]) {
if ((s.badgeType ?? '').trim().toUpperCase() == key) return s;
}
return null;
}
}
class VipSupportModel {
DCModel? daichong;
List<IntegralList>? integralList; //积分兑换列表
bool? isNewUser;
List<VipProductListModel>? list;
List<VipProductModel>? upgradeableVipCardList;
/// A/B 实验状态:ACTIVE / DISABLED
String? experimentStatus;
/// A/B 实验:实验 ID(下单时原样回传;DISABLED 时后端不返回)
String? experimentId;
/// A/B 实验:分组 A / B(下单时原样回传)
String? variant;
/// A/B 实验:默认选中卡 ID
String? defaultProductId;
/// 皮肤 key,如 vip-card-skin-b(视觉皮肤标识,UI 分支仍以 variant 为准)
String? skinKey;
/// 皮肤 UI 配置(背景图 / 角标样式表)
VipUiConfig? uiConfig;
/// 实验是否生效(仅 ACTIVE 才挂实验字段 / 上报埋点 / 下单回传实验信息)
bool get isExperimentActive => experimentStatus == VipExperimentStatus.active;
VipSupportModel({
this.daichong,
this.integralList,
this.isNewUser,
this.list,
this.upgradeableVipCardList,
this.experimentStatus,
this.experimentId,
this.variant,
this.defaultProductId,
this.skinKey,
this.uiConfig,
});
VipSupportModel.fromJson(Map<String, dynamic>? json) {
daichong =
json?['daichong'] != null ? DCModel.fromJson(json?['daichong']) : null;
if (json?['integralList'] != null) {
integralList = <IntegralList>[];
json?['integralList'].forEach((v) {
integralList!.add(IntegralList.fromJson(v));
});
}
isNewUser = json?['isNewUser'];
experimentStatus = json?['experimentStatus']?.toString();
experimentId = json?['experimentId']?.toString();
variant = json?['variant']?.toString();
defaultProductId = json?['defaultProductId']?.toString();
skinKey = json?['skinKey']?.toString();
uiConfig = json?['uiConfig'] != null
? VipUiConfig.fromJson(json?['uiConfig'])
: null;
/// 升级卡
upgradeableVipCardList = (json?['upgradeableVipCardList'] as List?)
?.map((e) => VipProductModel.fromJson(e['product'] ?? {}))
.toList();
if (json?['list'] != null) {
list = <VipProductListModel>[];
json?['list'].forEach((v) {
VipProductListModel vipModel =
VipProductListModel.fromJson(v, daichong);
list!.add(vipModel);
});
}
// 仅 ACTIVE 时把实验上下文挂到卡上;DISABLED 时后端不带实验字段,也不 stamp
if (isExperimentActive) {
_stampAbExperiment();
}
}
void _stampAbExperiment() {
void stamp(VipProductModel p) {
p.experimentId = experimentId;
p.variant = variant;
}
for (final group in list ?? <VipProductListModel>[]) {
for (final p in group.list ?? <VipProductModel>[]) {
stamp(p);
}
}
for (final p in upgradeableVipCardList ?? <VipProductModel>[]) {
stamp(p);
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
if (daichong != null) {
data['daichong'] = daichong!.toJson();
}
if (integralList != null) {
data['integralList'] = integralList!.map((v) => v.toJson()).toList();
}
data['isNewUser'] = isNewUser;
data['experimentStatus'] = experimentStatus;
data['experimentId'] = experimentId;
data['variant'] = variant;
data['defaultProductId'] = defaultProductId;
data['skinKey'] = skinKey;
if (uiConfig != null) data['uiConfig'] = uiConfig!.toJson();
if (list != null) {
data['list'] = list!.map((v) => v.toJson()).toList();
}
return data;
}
}
class IntegralList {
String? desc;
int? duration;
String? id;
String? img;
String? name;
int? price;
int? type;
String? bgImg;
IntegralList(
{this.desc,
this.duration,
this.id,
this.img,
this.name,
this.price,
this.type});
IntegralList.fromJson(Map<String, dynamic> json) {
desc = json['desc'];
duration = json['duration'];
id = json['id'];
img = json['img'];
name = json['name'];
price = json['price'];
bgImg = json['bgImg'];
type = json['type'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['desc'] = desc;
data['duration'] = duration;
data['id'] = id;
data['img'] = img;
data['name'] = name;
data['price'] = price;
data['type'] = type;
return data;
}
}
class VipProductListModel {
List<VipProductModel>? list;
String? position;
String? positionID;
int? showType;
VipProductListModel(
{this.list, this.position, this.positionID, this.showType});
VipProductListModel.fromJson(Map<String, dynamic> json, DCModel? daichong) {
if (json['list'] != null) {
list = <VipProductModel>[];
json['list'].forEach((v) {
VipProductModel pModel = VipProductModel.fromJson(v);
pModel.daichong = daichong;
list!.add(pModel);
});
}
position = json['position'];
positionID = json['positionID'];
showType = json['showType'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
if (list != null) {
data['list'] = list!.map((v) => v.toJson()).toList();
}
data['position'] = position;
data['positionID'] = positionID;
data['showType'] = showType;
return data;
}
}
class VipProductModel {
String? actionDesc;
/// 角标类型,如 MOST_POPULAR
String? badgeType;
/// 角标文案,如「最受欢迎」
String? badgeText;
int? aiUndressCount;
String? alias;
String? bgImg;
int? chanSplitMod;
int? chatPrice;
String? createdAt;
String? desc;
int? discountedPrice;
int? discountedPriceAnd;
int? discountedPriceIos;
int? downloadCount;
int? duration;
int? everyDayGiveCoin;
String? exclusiveOffer;
int? giveCoin;
int? giveFruitCoin;
int? goldVideoCouponCount;
int? goldVideoCouponNum;
int? goldVideoFreeDay;
bool? isAmountPay; // true 支持金币支付
String? newBgImg;
String? newName;
List<NewPrivilege>? newPrivilege;
int? originalPrice;
int? payVidDiscount;
String? position;
List<int>? privilege;
String? privilegeDesc;
String? productID;
String? productName;
int? productType; //21- 预售卡
List<RchgType>? rchgType;
int? serviceTime;
int? showCountdownTime;
int? signDays;
int? sort;
bool? status;
int? timesAWeek;
int? type; //会员卡类型 1、会员卡 2、礼包卡
bool? unitPriceDisplay;
String? updatedAt;
int? videoDiscount;
String? vipCardDesc;
int? vipLevel;
String? tag;
DCModel? daichong; // 从上级数据结构手动赋值过来
/// VIP 卡皮 A/B:由 /vip/product 响应挂到卡上,下单原样回传
String? experimentId;
String? variant;
//是否VIP升级
bool? isUpgrade;
//当前VIP卡名称
String? currentVipName;
//当前VIP卡价格
String? currentVipPrice;
// 原价购买价格
int? purchasePrice;
// 预付升级价格
int? advanceAmount;
// 预售尾款
int? balanceAmount;
int get discountedPriceUI {
return (discountedPrice ?? 0) ~/ 10;
} // 现价 单位角(金币)
int? prepaidPrice;
int get prepaidPriceUI {
return (prepaidPrice ?? 0) ~/ 10;
}
int get originalPriceUI {
return (originalPrice ?? 0) ~/ 10;
} // 现价 单位角(金币)
bool isPreSale = false;
// 预售新增字段
String? endBgImg;
String? endBgSelectImg;
List<RchgType> get rchgTypeUI {
List<RchgType> payList = [];
List<String> payNameArr = [
"支付宝(人工充值)",
"微信(人工充值)",
"银联(人工充值)",
"信用卡(人工充值)",
"花呗(人工充值)",
"云闪付(人工充值)",
"QQ錢包(人工充值)",
"京东支付(人工充值)"
];
for (RchgType rechargeTypeBean in (rchgType ?? [])) {
if (rechargeTypeBean.type == 'daichong') {
if (daichong?.traders?.isNotEmpty == true) {
PayForModel dcPayModel = daichong!.traders![0];
if (dcPayModel.payInfos?.isNotEmpty == true) {
for (PayInfoModel payInfoModel in dcPayModel.payInfos!) {
var payType = RchgType();
payType.isOfficial = true;
payType.channel = rechargeTypeBean.channel;
payType.incrAmount = rechargeTypeBean.incrAmount;
payType.incTax = rechargeTypeBean.incTax;
payType.payMethod = payInfoModel.payMethod;
if (payInfoModel.payMethod == 101) {
payType.type = 'alipy';
payType.typeName = payNameArr[0];
} else if (payInfoModel.payMethod == 102) {
payType.type = 'wechat';
payType.typeName = payNameArr[1];
} else if (payInfoModel.payMethod == 103) {
payType.type = 'union';
payType.typeName = payNameArr[2];
} else if (payInfoModel.payMethod == 104) {
payType.type = 'credit';
payType.typeName = payNameArr[3];
} else if (payInfoModel.payMethod == 105) {
payType.type = 'huabei';
payType.typeName = payNameArr[4];
} else if (payInfoModel.payMethod == 106) {
payType.type = 'yunSanPay';
payType.typeName = payNameArr[5];
} else if (payInfoModel.payMethod == 107) {
payType.type = 'qqWallet';
payType.typeName = payNameArr[6];
} else if (payInfoModel.payMethod == 108) {
payType.type = 'jindongPay';
payType.typeName = payNameArr[7];
}
payList.add(payType);
}
}
}
} else {
payList.add(rechargeTypeBean);
}
}
if (isAmountPay == true) {
RchgType coinType = RchgType();
coinType.type = "coin";
coinType.typeName = "金币";
payList.add(coinType);
}
return payList;
}
VipProductModel({
this.actionDesc,
this.badgeType,
this.badgeText,
this.aiUndressCount,
this.alias,
this.bgImg,
this.chanSplitMod,
this.chatPrice,
this.createdAt,
this.desc,
this.discountedPrice,
this.discountedPriceAnd,
this.discountedPriceIos,
this.downloadCount,
this.duration,
this.everyDayGiveCoin,
this.exclusiveOffer,
this.giveCoin,
this.giveFruitCoin,
this.goldVideoCouponCount,
this.goldVideoCouponNum,
this.goldVideoFreeDay,
this.isAmountPay,
this.newBgImg,
this.newName,
this.newPrivilege,
this.originalPrice,
this.payVidDiscount,
this.position,
this.privilege,
this.privilegeDesc,
this.productID,
this.productName,
this.productType,
this.rchgType,
this.serviceTime,
this.showCountdownTime,
this.signDays,
this.sort,
this.status,
this.timesAWeek,
this.type,
this.unitPriceDisplay,
this.updatedAt,
this.videoDiscount,
this.vipCardDesc,
this.vipLevel,
this.isPreSale = false,
this.tag,
this.isUpgrade,
this.currentVipName,
this.currentVipPrice,
this.purchasePrice,
this.advanceAmount,
});
VipProductModel.fromJson(Map<String, dynamic> json) {
prepaidPrice = json['prepaidPrice'];
endBgImg = json['endBgImg'];
endBgSelectImg = json['endBgSelectImg'];
actionDesc = json['actionDesc'];
badgeType = json['badgeType'];
badgeText = json['badgeText'];
aiUndressCount = json['aiUndressCount'];
alias = json['alias'];
bgImg = json['bgImg'];
chanSplitMod = json['chanSplitMod'];
chatPrice = json['chatPrice'];
createdAt = json['createdAt'];
desc = json['desc'];
discountedPrice = json['discountedPrice'];
discountedPriceAnd = json['discountedPriceAnd'];
discountedPriceIos = json['discountedPriceIos'];
downloadCount = json['downloadCount'];
duration = json['duration'];
everyDayGiveCoin = json['everyDayGiveCoin'];
exclusiveOffer = json['exclusiveOffer'];
giveCoin = json['giveCoin'];
giveFruitCoin = json['giveFruitCoin'];
goldVideoCouponCount = json['goldVideoCouponCount'];
goldVideoCouponNum = json['goldVideoCouponNum'];
goldVideoFreeDay = json['goldVideoFreeDay'];
isAmountPay = json['isAmountPay'];
newBgImg = json['newBgImg'];
newName = json['newName'];
tag = json['tag'];
if (json['newPrivilege'] != null) {
newPrivilege = <NewPrivilege>[];
json['newPrivilege'].forEach((v) {
newPrivilege!.add(NewPrivilege.fromJson(v));
});
}
originalPrice = json['originalPrice'];
payVidDiscount = json['payVidDiscount'];
position = json['position'];
if (json['privilege'] != null) {
privilege = (json['privilege'] as List?)?.map((v) {
return int.tryParse(v.toString()) ?? 0;
}).toList();
}
privilegeDesc = json['privilegeDesc'];
productID = json['productID'];
productName = json['productName'];
productType = json['productType'];
if (json['rchgType'] != null) {
rchgType = <RchgType>[];
json['rchgType'].forEach((v) {
rchgType!.add(RchgType.fromJson(v));
});
}
serviceTime = json['serviceTime'];
showCountdownTime = json['showCountdownTime'];
signDays = json['signDays'];
sort = json['sort'];
status = json['status'];
timesAWeek = json['timesAWeek'];
type = json['type'];
unitPriceDisplay = json['unitPriceDisplay'];
updatedAt = json['updatedAt'];
videoDiscount = json['videoDiscount'];
vipCardDesc = json['vipCardDesc'];
vipLevel = json['vipLevel'];
isPreSale = false;
isUpgrade = json['isUpgrade'];
currentVipName = json['currentVipName'];
currentVipPrice = "${json['currentVipPrice']}";
purchasePrice = json['purchasePrice'];
advanceAmount = json['advanceAmount'];
balanceAmount = json['balanceAmount'];
}
//是否有折扣
bool hasDiscout() {
return discountedPrice != originalPrice;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['actionDesc'] = actionDesc;
data['badgeType'] = badgeType;
data['badgeText'] = badgeText;
data['aiUndressCount'] = aiUndressCount;
data['alias'] = alias;
data['bgImg'] = bgImg;
data['chanSplitMod'] = chanSplitMod;
data['chatPrice'] = chatPrice;
data['createdAt'] = createdAt;
data['desc'] = desc;
data['discountedPrice'] = discountedPrice;
data['discountedPriceAnd'] = discountedPriceAnd;
data['discountedPriceIos'] = discountedPriceIos;
data['downloadCount'] = downloadCount;
data['duration'] = duration;
data['everyDayGiveCoin'] = everyDayGiveCoin;
data['exclusiveOffer'] = exclusiveOffer;
data['giveCoin'] = giveCoin;
data['giveFruitCoin'] = giveFruitCoin;
data['goldVideoCouponCount'] = goldVideoCouponCount;
data['goldVideoCouponNum'] = goldVideoCouponNum;
data['goldVideoFreeDay'] = goldVideoFreeDay;
data['isAmountPay'] = isAmountPay;
data['newBgImg'] = newBgImg;
data['newName'] = newName;
if (newPrivilege != null) {
data['newPrivilege'] = newPrivilege!.map((v) => v.toJson()).toList();
}
data['originalPrice'] = originalPrice;
data['payVidDiscount'] = payVidDiscount;
data['position'] = position;
if (privilege != null) {
data['privilege'] = privilege!.map((v) => v).toList();
}
data['privilegeDesc'] = privilegeDesc;
data['productID'] = productID;
data['productName'] = productName;
data['productType'] = productType;
if (rchgType != null) {
data['rchgType'] = rchgType!.map((v) => v.toJson()).toList();
}
data['serviceTime'] = serviceTime;
data['showCountdownTime'] = showCountdownTime;
data['signDays'] = signDays;
data['sort'] = sort;
data['status'] = status;
data['timesAWeek'] = timesAWeek;
data['type'] = type;
data['unitPriceDisplay'] = unitPriceDisplay;
data['updatedAt'] = updatedAt;
data['videoDiscount'] = videoDiscount;
data['vipCardDesc'] = vipCardDesc;
data['vipLevel'] = vipLevel;
data['isUpgrade'] = isUpgrade;
data['currentVipName'] = currentVipName;
data['currentVipPrice'] = currentVipPrice;
data['purchasePrice'] = purchasePrice;
data['advanceAmount'] = advanceAmount;
data['balanceAmount'] = balanceAmount;
return data;
}
}
extension PreSale on VipProductModel {
String realNormalVipImage() {
if (!isPreSale) return bgImg ?? '';
if (PreSaleProvider().canPayBalance) return endBgImg ?? '';
return bgImg ?? '';
}
String realSelectVipImage() {
if (!isPreSale) return newBgImg ?? '';
if (PreSaleProvider().canPayBalance) return endBgSelectImg ?? '';
return newBgImg ?? '';
}
}
class NewPrivilege {
String? id;
String? img;
String? privilegeDesc;
String? privilegeName;
int? privilege;
String? uncheckedImg;
bool? isCore; //是否核心权益(true 进「我的核心权益」横向卡片,false 进「更多权益」网格)
NewPrivilege({this.img, this.privilegeDesc, this.privilegeName});
NewPrivilege.fromJson(Map<String, dynamic> json) {
id = json['id'];
img = json['img'];
privilegeDesc = json['privilegeDesc'];
privilegeName = json['privilegeName'];
privilege = json['privilege'];
uncheckedImg = json['uncheckedImg'];
isCore = json['isCore'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['img'] = img;
data['privilegeDesc'] = privilegeDesc;
data['privilegeName'] = privilegeName;
data['uncheckedImg'] = uncheckedImg;
data['privilege'] = privilege;
data['isCore'] = isCore;
return data;
}
}
class RchgType {
String? channel; //渠道类型 鲨鱼 金鱼
int? incTax; //按比率增加额外优惠额 0-1之间 ,如果 incrAmount 与 incrTax 同时存在 以 incrAmount 为准
int? incrAmount; //增加的优惠额度
String? type; //充值方式 //coin 金币方式
String? typeName; //支付宝,微信,银联
//daichong 业务字段
bool? isOfficial = false; // 官方推荐
int? payMethod;
RchgType(
{this.channel, this.incTax, this.incrAmount, this.type, this.typeName});
RchgType.fromJson(Map<String, dynamic> json) {
channel = json['channel'];
incTax = json['incTax'];
incrAmount = json['incrAmount'];
type = json['type'];
typeName = json['typeName'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['channel'] = channel;
data['incTax'] = incTax;
data['incrAmount'] = incrAmount;
data['type'] = type;
data['typeName'] = typeName;
return data;
}
String getPayIcon() {
if (isOfficial == true) {
// 代充
return "pay_icon103.png".mineImgPath;
} else if (type == 'alipay') {
return "ic_alipay.png".mineImgPath;
} else if (type == 'union') {
return "pay_icon103.png".mineImgPath;
} else if (type == 'wechat') {
return "ic_wechat.png".mineImgPath; //AssetsSvg.SVG_PAY_ICON102;
} else if (type == 'coin') {
//金币
return "ic_coin.webp".mineImgPath; //AssetsSvg.SVG_PAY_ICON102;
} else if (type == 'usdt') {
return "ic_usdt.png".mineImgPath;
} else {
return "";
// return "ic_coupon.png".mineImgPath;
}
}
}
/// 加赠券
class CouponModel {
String? cId;
int? count;
String? createTime;
String? expireTime;
String? id;
String? name;
int? price;
int? type;
bool? used;
int? value;
CouponModel(
{this.cId,
this.count,
this.createTime,
this.expireTime,
this.id,
this.name,
this.price,
this.type,
this.used,
this.value});
CouponModel.fromJson(Map<String, dynamic> json) {
cId = json['cId'];
count = json['count'];
createTime = json['createTime'];
expireTime = json['expireTime'];
id = json['id'];
name = json['name'];
price = json['price'];
type = json['type'];
used = json['used'];
value = json['value'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['cId'] = this.cId;
data['count'] = this.count;
data['createTime'] = this.createTime;
data['expireTime'] = this.expireTime;
data['id'] = this.id;
data['name'] = this.name;
data['price'] = this.price;
data['type'] = this.type;
data['used'] = this.used;
data['value'] = this.value;
return data;
}
}
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
/// VIP 金色标题渐变 #F5E7BC → #DCAD55
/// 会员中心 / 购买弹窗 / 权益标题等金色文案共用,调色只改此处
const LinearGradient kVipTitleGradient = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xffF5E7BC), Color(0xffDCAD55)],
);
/// 金渐变着色包装:给文字 / 线条 / 菱形套上 [kVipTitleGradient]
class VipGradientMask extends StatelessWidget {
final Widget child;
const VipGradientMask({super.key, required this.child});
@override
Widget build(BuildContext context) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => kVipTitleGradient.createShader(bounds),
child: child,
);
}
}
/// B 组「核心权益」标题图(会员中心 / 购买弹窗共用)
class VipCoreSectionTitleImage extends StatelessWidget {
const VipCoreSectionTitleImage({super.key});
@override
Widget build(BuildContext context) {
return Image.asset(
'vip_special_title.webp'.mineImgPath,
width: double.infinity,
fit: BoxFit.fitWidth,
);
}
}
/// B 组「更多权益」标题图(会员中心 / 购买弹窗共用)
class VipMoreSectionTitleImage extends StatelessWidget {
const VipMoreSectionTitleImage({super.key});
@override
Widget build(BuildContext context) {
return Image.asset(
'vip_more_title.webp'.mineImgPath,
width: double.infinity,
fit: BoxFit.fitWidth,
);
}
}
/// 区块标题:两侧「金渐变横线 + 菱形」+ 居中金渐变文字(会员中心 B / 购买弹窗共用)
class VipSectionTitle extends StatelessWidget {
final String text;
/// true:旧版橙色标题(会员中心 A / DISABLED
final bool classic;
const VipSectionTitle(this.text, {super.key, this.classic = false});
static const _classicOrange = Color(0xffF68804);
@override
Widget build(BuildContext context) {
if (classic) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_classicDeco(lineFirst: true),
8.sizeBoxW,
Text(text,
style: const TextStyle(
color: _classicOrange,
fontSize: 18,
fontWeight: FontWeight.w600)),
8.sizeBoxW,
_classicDeco(lineFirst: false),
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildDeco(lineFirst: true),
8.sizeBoxW,
VipGradientMask(
child: Text(
text,
style: const TextStyle(
color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
),
),
8.sizeBoxW,
_buildDeco(lineFirst: false),
],
);
}
Widget _classicDeco({required bool lineFirst}) {
final line = Container(width: 40, height: 0.5, color: _classicOrange);
final diamond = Transform.rotate(
angle: 0.7853981633974483,
child: Container(width: 5, height: 5, color: _classicOrange),
);
return Row(
mainAxisSize: MainAxisSize.min,
children: lineFirst ? [line, diamond] : [diamond, line],
);
}
// 标题两侧装饰:金渐变细横线 + 菱形(lineFirst=true 横线在外侧,false 镜像)
Widget _buildDeco({required bool lineFirst}) {
const line = VipGradientMask(
child: SizedBox(
width: 40, height: 0.5, child: ColoredBox(color: Colors.white)),
);
return Row(
mainAxisSize: MainAxisSize.min,
children: lineFirst ? [line, _diamond] : [_diamond, line],
);
}
// 金渐变小菱形装饰(45° 旋转的小方块)
Widget get _diamond => Transform.rotate(
angle: 0.7853981633974483,
child: const VipGradientMask(
child: SizedBox(
width: 5, height: 5, child: ColoredBox(color: Colors.white)),
),
);
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/mine/exchange/recharge_type_list_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
// 金币充值档位卡片
class CoinItem extends StatelessWidget {
final RechargeTypeModel model;
final bool isSelected; // 是否选中
final VoidCallback? onTap;
const CoinItem(this.model, {super.key, this.onTap, this.isSelected = false});
@override
Widget build(BuildContext context) {
final couponDesc = model.couponDesc;
return GestureDetector(
onTap: onTap,
child: Stack(
children: [
Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8),
border: isSelected
? Border.all(width: 1, color: const Color(0xffF9C142))
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('coin_icon.webp'.mineImgPath, width: 36),
4.sizeBoxH,
Text(
"${model.amount}金币",
style: const TextStyle(color: Colors.white, fontSize: 14),
),
4.sizeBoxH,
Text(
'¥${model.moneyYuan}',
style: const TextStyle(
color: Color(0xffF9C142), fontWeight: FontWeight.w500),
),
4.sizeBoxH,
],
),
),
// 左上角优惠角标
if (couponDesc?.isNotEmpty == true)
Positioned(
top: 0,
left: 0,
child: Container(
height: 20,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
bottomRight: Radius.circular(12)),
color: Color(0xffFFD460),
),
child: Text(
couponDesc!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xff292929),
fontWeight: FontWeight.w500,
height: 1,
fontSize: 10.0,
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/hj_utils/widget_util.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:provider/provider.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import '../../make_money/mine_withdrawal_record_page.dart';
import '../mine_charge_coin_logic.dart';
import '../pay_order_source.dart';
import 'coin_item.dart';
/// 金币支付底部弹窗(按设计稿:余额卡片 + 档位网格 + 立即支付)
class CoinPayBottomSheet extends StatelessWidget {
/// 下单来源,默认播放页底部弹窗——本弹窗只在播放页(购买弹窗/解锁蒙层)用
final PaySourcePage sourcePage;
/// 下单埋点上下文(短剧付费墙充金币要带剧/集/付费墙上下文)
final PayOrderTrackInfo? orderTrack;
const CoinPayBottomSheet(
{super.key,
this.sourcePage = PaySourcePage.videoBottomSheet,
this.orderTrack});
/// 弹窗展示中,防连点叠多个 bottomSheet
static bool _isShowing = false;
static Future<T?> show<T>({
PaySourcePage sourcePage = PaySourcePage.videoBottomSheet,
PayOrderTrackInfo? orderTrack,
}) async {
if (_isShowing) return null;
_isShowing = true;
try {
return await Get.bottomSheet<T>(
CoinPayBottomSheet(sourcePage: sourcePage, orderTrack: orderTrack),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
} finally {
_isShowing = false;
}
}
@override
Widget build(BuildContext context) {
return GetBuilder<MineChargeCoinLogic>(
init: MineChargeCoinLogic(sourcePage: sourcePage, orderTrack: orderTrack),
global: false,
builder: (logic) {
// Material 填深色底:圆角抗锯齿对着 #040018,避免透明底导致左右上角异色
return Material(
color: const Color(0xff040018),
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
clipBehavior: Clip.antiAlias,
child: Container(
constraints: BoxConstraints(maxHeight: Get.height * 0.85),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xff040018), Color(0xff060606)],
),
),
child: Stack(
children: [
// 顶部径向黄光:#FACC15 16% → 0%
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.topCenter,
radius: 1.2,
colors: [
Color(0x29FACC15), // #FACC15 @ 16%
Color(0x00FACC15), // #FACC15 @ 0%
],
),
),
),
),
SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
12.sizeBoxH,
const SheetHandleBar(),
16.sizeBoxH,
const Text(
'金币支付',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
16.sizeBoxH,
Flexible(child: _buildBody(logic)),
_buildBottomBar(logic),
],
),
),
],
),
),
);
},
);
}
Widget _buildBody(MineChargeCoinLogic logic) {
if (logic.isInitLoading) {
return const SizedBox(height: 220, child: LoadingCenterWidget());
}
if (logic.model?.list?.isEmpty != false) {
return SizedBox(
height: 220,
child: CErrorWidget(retryOnTap: () => logic.loadData()),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_buildWallet(),
16.sizeBoxH,
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 111 / 138,
),
itemCount: logic.model!.list!.length,
itemBuilder: (context, index) {
final model = logic.model!.list![index];
final isSelected = logic.selectedCoin?.id == model.id;
return CoinItem(
model,
isSelected: isSelected,
onTap: () => logic.onSelectCoin(index),
);
},
),
18.sizeBoxH,
],
),
);
}
/// 我的金币余额卡片
Widget _buildWallet() {
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'我的金币余额',
style: TextStyle(
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
8.sizeBoxH,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('coin_icon.webp'.mineImgPath, width: 36),
4.sizeBoxW,
Consumer<GlobalStore>(
builder: (_, store, __) {
final wallet = store.wallet;
final total = (wallet?.amount ?? 0) + (wallet?.income ?? 0);
return Text(
'$total',
style: const TextStyle(
color: Color(0xffFFD460),
fontSize: 32,
fontWeight: FontWeight.w600,
),
);
},
),
const Spacer(),
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(RecordsPage(RecordType.bill), opaque: false),
child: Container(
alignment: Alignment.center,
height: 30,
width: 90,
decoration: BoxDecoration(
color: const Color(0xffFFD460),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'余额明细',
style: TextStyle(color: Color(0xff3D3D3D), fontSize: 14),
),
),
),
],
),
],
),
);
}
/// 立即支付 + 在线客服
Widget _buildBottomBar(MineChargeCoinLogic logic) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => logic.onGotoPay(),
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Text(
'¥${logic.selectedCoin?.moneyYuan ?? 0}/立即支付',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
12.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('支付问题反馈,点击联系 ',
style: textStyle(12, const Color(0xffBFBFC1), FontWeight.w400)),
GestureDetector(
onTap: pushToCustomService,
child: Text('在线客服',
style:
textStyle(12, const Color(0xffFFD460), FontWeight.w400)),
),
],
),
16.sizeBoxH,
],
);
}
}