初始化

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,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../hj_utils/widget_util.dart';
final limitTimeProvider = LimitTimeProvider();
//限时活动
class LimitTimeProvider extends ChangeNotifier {
static final LimitTimeProvider _instance = LimitTimeProvider._();
LimitTimeProvider._();
factory LimitTimeProvider() => _instance;
Timer? _timer;
BannerJump? bannerJump; //限时Banner活动
/// 活动倒计时,供 ValueListenableBuilder 局部刷新(不走 notifyListeners
final deadline = ValueNotifier<String>('');
/// 距截止时间的剩余秒数
int get endSeconds =>
bannerJump?.additionalProp?.endAt?.remainingSeconds() ?? -1;
//是否展示活动 后台返回bannerjump && 结束时间未到
bool get canShow => bannerJump != null && endSeconds > 0;
//开启限时活动倒计时
void refresh() {
if (canShow && _timer == null) _startTimer();
notifyListeners();
}
// 定时器
void _startTimer() {
_timer?.cancel(); // 防重入:先清掉已有定时器,杜绝多重泄漏
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
// 每秒只更新 deadline,交给 ValueListenableBuilder 局部刷新,不触发全局 notifyListeners
deadline.value = bannerJump?.additionalProp?.endAt?.countdownText() ?? '';
if (endSeconds < 0) {
timer.cancel();
_timer = null;
notifyListeners(); // 到期时通知一次,隐藏限时活动入口(Consumer 依赖 canShow
}
});
}
}
/// 限时活动横幅:整条点击跳活动页,右侧叠倒计时
class LimitTimeBanner extends StatelessWidget {
const LimitTimeBanner({super.key});
@override
Widget build(BuildContext context) {
final prop = limitTimeProvider.bannerJump?.additionalProp;
return InkWell(
enableFeedback: false,
onTap: () => pushToPageByLink(prop?.url),
child: SizedBox(
height: 46,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: NetworkImageLoader(
imageUrl: prop?.banner ?? '',
borderRadius: 0,
fit: BoxFit.fill,
),
),
Positioned(
right: 12,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(99),
color: Colors.black.withValues(alpha: 0.2)),
child: ValueListenableBuilder<String>(
valueListenable: limitTimeProvider.deadline,
builder: (context, value, child) => Text(
'$value ',
style: textStyle(12, Colors.white, FontWeight.w400),
),
),
),
)
],
),
),
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_page.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import 'pre_sale_provider.dart';
//支付按钮
class PreSaleActivityButton extends StatelessWidget {
final VoidCallback onTap;
const PreSaleActivityButton({super.key, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: onTap,
child: NetworkImageLoader(
fit: BoxFit.fill,
imageUrl: presaleProvider.payBtnBg,
).paddingSymmetric(horizontal: 6),
);
}
}
/// 首页预售弹窗:整张背景图就是弹窗内容,点图进预售页。
/// 预定期 / 尾款期只差一张图,由 [PreSaleProvider.popBg] 按期次给,别再拆成两个类
class PreSaleAlert extends StatelessWidget {
const PreSaleAlert({super.key});
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: const Offset(0, -30),
child: GestureDetector(
onTap: () {
Get.back();
Get.to(() => const PreSalePage());
},
child: Center(
child: Consumer<PreSaleProvider>(
builder: (_, provider, __) => NetworkImageLoader(
imageUrl: provider.popBg,
),
).paddingSymmetric(horizontal: 30),
),
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import 'pre_sale_page.dart';
import 'pre_sale_provider.dart';
// 首页 - 会员中心入口
class PreSaleEntry extends StatefulWidget {
final ScrollController? scrollController;
const PreSaleEntry({super.key, this.scrollController});
@override
State<PreSaleEntry> createState() => _PreSaleEntryState();
}
class _PreSaleEntryState extends State<PreSaleEntry>
with SingleTickerProviderStateMixin {
double rightPos = 6;
late final _controller =
AnimationController(vsync: this, duration: Duration(milliseconds: 900));
late Animation<double> _rotateAni;
late Animation<double> _scaleAni;
@override
void initState() {
_scaleAni = Tween<double>(begin: 1, end: 1.02).animate(
CurvedAnimation(parent: _controller, curve: Interval(0, 0.8)),
);
_rotateAni = Tween<double>(begin: 0, end: 2 * pi).animate(
CurvedAnimation(parent: _controller, curve: Interval(0.8, 1)),
);
widget.scrollController?.addListener(() {
double offset = widget.scrollController?.offset ?? 0;
if (offset > (24 + 151 + 36 + 110 + 256)) {
if (rightPos == 6) {
rightPos = -65;
if (mounted) {
setState(() {});
}
}
} else {
if (rightPos != 6) {
rightPos = 6;
if (mounted) {
setState(() {});
}
}
}
});
if (PreSaleProvider().canShowEnter) {
_controller.repeat(reverse: true);
}
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _openPreSale() {
// 不传来源:活动页内下单一律记 H5_ACTIVITY,不按入口区分
Get.to(() => const PreSalePage());
}
@override
Widget build(BuildContext context) {
return Consumer<PreSaleProvider>(
builder: (ctx, provider, child) {
return provider.canShowEnter
? GestureDetector(
onTap: _openPreSale,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => Transform(
///构建Matrix4
transform:
Matrix4.rotationZ(sin(0.2 * sin(_rotateAni.value))),
alignment: Alignment.center,
child: NetworkImageLoader(
imageUrl: provider.enterBg,
width: 65 * _scaleAni.value,
),
),
),
)
: SizedBox.shrink();
},
);
}
}
//我的 - 活动页入口
class PreSaleVipEntry extends StatefulWidget {
const PreSaleVipEntry({super.key});
@override
State<PreSaleVipEntry> createState() => _PreSaleVipEntryState();
}
class _PreSaleVipEntryState extends State<PreSaleVipEntry> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Get.to(() => const PreSalePage()),
child: Consumer<PreSaleProvider>(
builder: (ctx, provider, child) {
return provider.canShowEnter
? Stack(
children: [
NetworkImageLoader(
imageUrl: provider.payBanner,
width: double.infinity,
fit: BoxFit.fill,
height: 76,
),
if (provider.canPayBalance)
Positioned(
bottom: 11,
left: 84,
child: ValueListenableBuilder(
valueListenable: provider.deadline,
builder: (BuildContext context, String value,
Widget? child) {
return Text(
'剩余 $value',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700),
);
},
),
)
],
)
: SizedBox.shrink();
},
),
);
}
}
//活动抽奖
class LuckyDrawButton extends StatelessWidget {
final EdgeInsetsGeometry? margin;
const LuckyDrawButton({super.key, this.margin});
@override
Widget build(BuildContext context) {
if (presaleProvider.luckyDrawH5?.isNotEmpty != true)
return const SizedBox();
final icon = presaleProvider.luckyDrawIcon;
return Container(
margin: margin,
child: InkWell(
enableFeedback: false,
onTap: presaleProvider.openLuckyDraw,
child: icon?.isNotEmpty == true
? NetworkImageLoader(imageUrl: icon!, width: 60, height: 60)
: Image.asset('lucky_lottery.webp'.commonImgPath, width: 60),
),
);
}
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:hgdj/hj_utils/text_util.dart';
class PreSaleModel {
bool? hasNext;
List<PreSaleVideoModel>? list;
PreSaleDetailModel? detailModel;
PreSaleModel({this.hasNext, this.list, this.detailModel});
PreSaleModel.fromJson(Map<String, dynamic> map) {
hasNext = map['hasNext'] ?? false;
list = (map['list'] as List?)
?.map((o) => PreSaleVideoModel.fromJson(o))
.toList();
detailModel = PreSaleDetailModel.fromJson(map['activityDetail']);
}
Map toJson() => {
"hasNext": hasNext,
"list": list,
"PreSaleDetailModel": PreSaleDetailModel,
};
}
class PreSaleVideoModel {
PreSaleVideoModel({
this.id,
this.vidId,
this.vidTitle,
this.cover,
this.sourceURL,
this.seriesCover,
this.coins,
this.detail,
});
PreSaleVideoModel.fromJson(Map<String, dynamic> json) {
id = json['id'];
vidId = json['vidId'];
vidTitle = json['vidTitle'];
sourceURL = json['sourceURL'];
cover = json['cover'];
seriesCover = parseStringList(json['seriesCover']);
coins = json['coins'];
detail = json['detail'];
}
String? id;
String? vidId;
String? vidTitle;
String? sourceURL;
String? cover;
List<String>? seriesCover;
num? coins;
dynamic detail;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['id'] = id;
map['vidId'] = vidId;
map['vidTitle'] = vidTitle;
map['sourceURL'] = sourceURL;
map['cover'] = cover;
map['seriesCover'] = seriesCover;
map['coins'] = coins;
map['detail'] = detail;
return map;
}
}
class PreSaleDetailModel {
String? activityTime; //活动时间
bool? balancePayment; //是否有尾款支付
String? endTime; // 支付尾款结束时间
String? startTime; // 支付尾款开始时间
num? totalCount; //会员卡总数
num? status; //预售订单状态
num? balanceAmount; //尾款金额
num? advanceAmount; //预付金额
PreSaleDetailModel({
this.activityTime,
this.balancePayment,
this.endTime,
this.startTime,
this.totalCount,
this.status,
this.balanceAmount,
this.advanceAmount,
});
PreSaleDetailModel.fromJson(dynamic json) {
activityTime = json['activityTime'];
balancePayment = json['balancePayment'];
endTime = json['endTime'];
startTime = json['startTime'];
totalCount = json['totalCount'];
status = json['status'];
balanceAmount = json['balanceAmount'];
advanceAmount = json['advanceAmount'];
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['activityTime'] = activityTime;
map['balancePayment'] = balancePayment;
map['endTime'] = endTime;
map['startTime'] = startTime;
map['totalCount'] = totalCount;
map['status'] = status;
map['balanceAmount'] = balanceAmount;
map['advanceAmount'] = advanceAmount;
return map;
}
}
//预售页面资源
class AdvancePage {
String? banner; //个人中心 立即预定
String? bannerWait; //个人中心 已预定,去支付尾款
String? bannerProc; //个人中心 已预定,去支付尾款
String? buttonBackground; //支付按钮 立即预定
String? buttonProcBackground; //支付按钮 已预定,去支付尾款
String? buttonWaitBackground; //支付按钮 已预定,未到支付时间
String? enterBgProc; //入口按钮背景(付尾款)
String? enterBgWait; //入口按钮背景(未付款)
String? pageBackground; //大背景
String? popBgProc; //弹窗背景(付尾款)
String? popBgWait; //弹窗背景(未付款)
String? pageVidBackground; //金币视频区的背景图
AdvancePage.fromJson(dynamic json) {
banner = json['banner'];
bannerWait = json['bannerWait'];
bannerProc = json['bannerProc'];
buttonBackground = json['buttonBackground'];
buttonProcBackground = json['buttonProcBackground'];
buttonWaitBackground = json['buttonWaitBackground'];
enterBgProc = json['enterBgProc'];
enterBgWait = json['enterBgWait'];
pageBackground = json['pageBackground'];
popBgProc = json['popBgProc'];
popBgWait = json['popBgWait'];
pageVidBackground = json['pageVidBackground'];
}
}
+344
View File
@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../../hj_utils/api_service/mine_service.dart';
import '../../hj_utils/screen.dart';
import '../../routers/jump_router.dart';
import '../../tools_base/loading/loading_center_widget.dart';
import '../mine/mine_vip/pay_order_source.dart';
import '../mine/mine_vip/vip_support_model.dart';
import 'pre_sale_alert.dart';
import 'pre_sale_model.dart';
import 'pre_sale_provider.dart';
class PreSalePage extends StatefulWidget {
const PreSalePage({super.key});
@override
State<PreSalePage> createState() => _PreSalePageState();
}
class _PreSalePageState extends State<PreSalePage> {
VipSupportModel? pageData;
bool isLoading = true;
VipProductListModel? selectProductM;
VipProductModel? selectVipItem;
/// 仅供 /mine/topay 透传,不走卡皮埋点事件
late final String _sessionId = const Uuid().v4();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
presaleProvider.refreshConfig();
MineService.getVipProduct().then((value) {
pageData = value;
isLoading = false;
// 找到「预售卡」对应的商品组并选中第一项
selectProductM =
pageData?.list?.firstWhereOrNull((e) => e.position == '预售卡');
selectVipItem = selectProductM?.list?.firstOrNull;
if (mounted) setState(() {});
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
body: _buildBody(),
);
}
Widget _buildBody() {
if (isLoading) return LoadingCenterWidget();
if (pageData == null) return CErrorWidget();
return Consumer<PreSaleProvider>(
builder: (_, __, ___) => Stack(
children: [
SingleChildScrollView(child: _buildBgWithVideos()),
_buildPayButton(),
Positioned(
left: 16,
top: screen.paddingTop + 20,
child: InkWell(
enableFeedback: false,
onTap: () => Get.back(),
child: const Icon(
Icons.arrow_back_ios_sharp,
size: 18,
color: Colors.white,
),
),
),
],
),
);
}
/// 整页背景图 + 视频网格
Widget _buildBgWithVideos() {
return Stack(
children: [
NetworkImageLoader(
imageUrl: presaleProvider.advancePage?.pageBackground ?? '',
fit: BoxFit.fill,
width: double.infinity,
borderRadius: 0,
height: 1970,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 20),
child: Column(
children: [
const SizedBox(height: 1116),
_buildVideoBlock(),
],
),
),
],
);
}
/// 视频区背景 + 网格浮层
Widget _buildVideoBlock() {
return Stack(
children: [
NetworkImageLoader(
imageUrl: presaleProvider.advancePage?.pageVidBackground ?? ' ',
width: double.infinity,
height: 750,
fit: BoxFit.fill,
),
Positioned(
top: 55,
left: 0,
right: 0,
bottom: 18,
child: _buildVideoGrid(),
),
],
);
}
Widget _buildVideoGrid() {
final list = presaleProvider.preSaleModel?.list ?? const [];
return GridView.builder(
padding: const EdgeInsets.only(left: 20, right: 20, top: 50),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: list.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, //横向数量
crossAxisSpacing: 5, //间距
mainAxisSpacing: 12, //行距
childAspectRatio: 165 / 152,
),
itemBuilder: (_, index) => _buildVideoItem(list[index]),
);
}
/// 底部支付按钮:首付状态或非升级款 → 用预售活动按钮;其余 → 自定义升级按钮
Widget _buildPayButton() {
final useActivityBtn =
presaleProvider.isPayFirst || selectVipItem?.isUpgrade == false;
return Positioned(
bottom: 37,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 44),
height: 46,
child: useActivityBtn
? PreSaleActivityButton(onTap: _onPay)
: _buildUpgradeButton(),
),
);
}
Widget _buildUpgradeButton() {
return GestureDetector(
onTap: _onPay,
child: Container(
height: 46,
margin: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(46),
gradient: const LinearGradient(
colors: [Color(0xFF343434), Color(0xFF0E0E0E)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: Border.all(color: const Color(0xFFF8D38F), width: 1),
),
child: Text(
payText,
style: const TextStyle(
color: Color(0xFFFFF0D3),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
);
}
String get payText {
// 预售的支付金额
if (presaleProvider.canPayBalance) {
return '¥${presaleProvider.preSaleModel?.detailModel?.balanceAmount ?? 0}支付尾款';
}
if (selectVipItem?.isUpgrade ?? false) {
return '¥${(selectVipItem?.advanceAmount ?? 0) ~/ 10}/立即升级';
}
return '¥${presaleProvider.preSaleModel?.detailModel?.advanceAmount ?? 0}立即预订';
}
void _onPay() {
if (selectProductM == null && selectVipItem == null) {
return;
}
final active = pageData?.isExperimentActive == true;
presaleProvider.startPay(
selectVipItem!..isPreSale = true,
orderTrack: PayOrderTrackInfo(
// 支付发生在活动页,来源就记活动页,与从哪个入口(浮窗/弹窗/播放页/我的)进来无关
sourcePage: PaySourcePage.h5Activity,
sessionId: _sessionId,
experimentId: active
? (selectVipItem?.experimentId ?? pageData?.experimentId)
: null,
experimentVariant:
active ? (selectVipItem?.variant ?? pageData?.variant) : null,
),
);
}
Widget _buildVideoItem(PreSaleVideoModel? videoModel) {
return InkWell(
enableFeedback: false,
onTap: () => pushToVideoPage(videoId: videoModel?.vidId),
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 10,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
),
Positioned.fill(
child: Column(
children: [
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 3.5),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color:
const Color(0xffD43824).withValues(alpha: .2),
blurRadius: 2),
],
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: NetworkImageLoader(
imageUrl: videoModel?.cover ?? '',
width: null,
fit: BoxFit.cover,
),
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 15, vertical: 3),
child: Text(
"${videoModel?.vidTitle}",
style: const TextStyle(
color: Color(0xffDA0101),
fontWeight: FontWeight.w500,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
Container(
height: 25,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color:
const Color(0xffD43824).withValues(alpha: .2),
blurRadius: 2),
],
gradient: const LinearGradient(
colors: [Color(0xffFFF7E9), Color(0xffF9DC9A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12.5),
),
padding: const EdgeInsets.all(2),
child: Row(
children: [
const SizedBox(width: 10),
const Text(
'免费',
style: TextStyle(
color: Color(0xffDA0101),
fontSize: 14,
fontWeight: FontWeight.w600),
),
const SizedBox(width: 10),
Expanded(
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xffFFF3D9), Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(12.5),
),
alignment: Alignment.center,
child: Text(
"原价 ${videoModel?.coins}",
style: const TextStyle(
color: Color(0xff8B8B8B),
fontSize: 12.0,
decoration: TextDecoration.lineThrough,
),
),
),
),
],
),
),
const SizedBox(height: 6.8),
],
),
),
],
),
),
);
}
}
+270
View File
@@ -0,0 +1,270 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/config/address.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_page/web_page/h5_page.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/tools_base/net/net_manager.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../hj_model/splash/domain_source_model.dart';
import '../../hj_model/video_model.dart';
import '../../tools_base/debug_log.dart';
import '../mine/mine_vip/online_pay_page.dart';
import '../mine/mine_vip/pay_order_source.dart';
import '../mine/mine_vip/vip_support_model.dart';
import 'pre_sale_alert.dart';
import 'pre_sale_model.dart';
import 'pre_sale_service.dart';
extension DateStringExt on String? {
/// 解析为本地时间。null / 空串 / 零值时间(0001-01-01) / 非法格式一律返回 null
DateTime? get _local {
final s = this;
if (s == null || s.isEmpty || s.contains('0001-01-01')) return null;
return DateTime.tryParse(s)?.toLocal();
}
/// 已过秒数:now − 该时间。> 0 表示该时间已过,< 0 表示还没到
int elapsedSeconds() {
final date = _local;
if (date == null) return -1;
return DateTime.now().difference(date).inSeconds;
}
/// 剩余秒数:该时间 − now。> 0 表示还没到该时间,< 0 表示已过
int remainingSeconds() {
final date = _local;
if (date == null) return -1;
return date.difference(DateTime.now()).inSeconds;
}
/// 倒计时文案:"X天 HH:MM:SS"(已过期按 0 处理)
String countdownText() {
final date = _local;
if (date == null) return '';
final secs = date.difference(DateTime.now()).inSeconds;
final left = secs < 0 ? 0 : secs; //已过期按 0 处理
return "${left ~/ 86400}${DateTimeUtil.formatHMS(left % 86400, alwaysHour: true)}";
}
}
final presaleProvider = PreSaleProvider();
class PreSaleProvider extends ChangeNotifier {
static final PreSaleProvider _instance = PreSaleProvider._();
PreSaleProvider._();
factory PreSaleProvider() => _instance;
// ===== 数据 =====
PreSaleModel? preSaleModel; // 预售活动
AdvanceStatus? advanceStatus; // 预售状态
AdvancePage? advancePage; // 预售页面资源
String? luckyDrawH5; // 限时活动抽奖h5
String? luckyDrawIcon; // 限时活动icon配置
// ===== 尾款倒计时 =====
Timer? _timer;
/// 付尾款截止时间倒计时,供 ValueListenableBuilder 局部刷新(不走 notifyListeners
final deadline = ValueNotifier<String>('');
// ===== 状态 =====
bool get isOpen => advanceStatus?.activityStatus == true; //活动是否开启
bool get isPayAll => preSaleModel?.detailModel?.status == 4; //是否支付完首尾款
bool get isPayFirst =>
preSaleModel?.detailModel?.status == 2 ||
preSaleModel?.detailModel?.status == 3; //支付首款未付尾款
bool get balancePayment =>
preSaleModel?.detailModel?.balancePayment ?? false; //是否有尾款支付
bool get hasLimit =>
advanceStatus?.privilegeLimit?.hasLimit ?? false; //是否有权益限制
Remain? get remain => advanceStatus?.privilegeLimit?.remain; //剩余权限次数
String? get startTimeMD => preSaleModel?.detailModel?.startTime?.utcToCnMD();
/// 开始时间已过秒数。> 0 表示已到达开始时间
int get _startSeconds =>
preSaleModel?.detailModel?.startTime?.elapsedSeconds() ?? -1;
/// 距截止时间的剩余秒数
int get endSeconds =>
preSaleModel?.detailModel?.endTime?.remainingSeconds() ?? -1;
/// 是否可以支付尾款
bool get canPayBalance => isPayFirst && _startSeconds > 0;
/// 是否展示预售入口:活动开启 & 未付完首尾款 & 未过截止时间
bool get canShowEnter => isOpen && !isPayAll && endSeconds >= 0;
/// 金币视频免费观看权益次数(-1 表示没有权益)
int get coinVideoFreeCount {
if (!hasLimit) return -1;
if (isOpen && !isPayAll) {
//有首款权益,判断当前免费权益金币视频数量
if ((remain?.todayCoinVideoCount ?? -1) >= 0) {
// ⚠️注意业务层判断: 大等于0 是有权益的, (如只有一次,即count =1 ,业务层先减权益数即count = 0,再做其他的逻辑, 是先减)
return remain!.todayCoinVideoCount!;
}
}
return -1;
}
// ===== 资源图 =====
/// 支付按钮背景图
String get payBtnBg {
if (!balancePayment) return advancePage?.buttonBackground ?? '';
if (_startSeconds > 0) return advancePage?.buttonProcBackground ?? '';
return advancePage?.buttonWaitBackground ?? '';
}
/// 预售小图标入口背景图
String get enterBg => balancePayment
? advancePage?.enterBgProc ?? ''
: advancePage?.enterBgWait ?? '';
/// 首页弹窗背景图(整张图就是弹窗内容)
String get popBg => balancePayment
? advancePage?.popBgProc ?? ''
: advancePage?.popBgWait ?? '';
/// 预售 banner
String get payBanner {
if (!balancePayment) return advancePage?.banner ?? ''; //开启预定
return canPayBalance
? advancePage?.bannerProc ?? ''
: advancePage?.bannerWait ?? ''; //开启支付尾款
}
// ===== 数据刷新 =====
/// 刷新预售全部数据(配置 + 状态,并行)
Future<void> refreshAll() async {
await Future.wait([refreshConfig(), refreshStatus()]);
}
/// 刷新预售配置(活动信息 + 视频)
Future<void> refreshConfig() async {
preSaleModel = await PreSaleService.fetchConfig();
// 进入尾款支付期且未结束时,开启全局倒计时定时器(仅一次)
if (canPayBalance && endSeconds > 0 && _timer == null) {
_startTimer();
}
notifyListeners();
}
/// 刷新预售状态(权益/开关等)
Future<void> refreshStatus() async {
final res = await PreSaleService.fetchStatus();
if (res != null) {
advanceStatus = res;
notifyListeners();
}
}
// ===== 业务动作 =====
//预售弹窗逻辑
Future<void> showAlert() async {
if (advanceStatus?.activityPopUp != true) return;
if (preSaleModel == null) return;
final status = preSaleModel?.detailModel?.status ?? -1;
if (status == -1 || status == 4) return; //未知或者已经完成
//预定期直接弹;尾款期只在能付尾款时弹。两期的图由 popBg 自己分流
if (!balancePayment || canPayBalance)
await Get.dialog(const PreSaleAlert());
}
// 拉起支付
/// [orderTrack] 透传下单埋点(会员中心传入 sessionId / 实验字段;预售独立页可不传)
Future<void> startPay(VipProductModel model,
{PayOrderTrackInfo? orderTrack}) async {
if (isPayFirst && _startSeconds <= 0) {
showToast("还未到尾款支付时间,无法支付~");
return;
}
if (endSeconds <= 0) {
showToast("已超过预定支付时间,无法支付~");
return;
}
final base = orderTrack ?? const PayOrderTrackInfo();
await Get.bottomSheet(
OnlinePayPage(
vipProductModel: model..isPreSale = true,
orderTrack: PayOrderTrackInfo(
sourcePage: base.sourcePage ?? PaySourcePage.vipCenter,
sourceRef: base.sourceRef,
videoId: base.videoId,
activityId: base.activityId,
sessionId: base.sessionId,
//短剧付费墙的归因字段,重建时漏抄就丢了(同 OnlinePayPage._payByLink)
mediaId: base.mediaId,
contentId: base.contentId,
checkoutContextId: base.checkoutContextId,
experimentId: base.experimentId,
experimentVariant: base.experimentVariant,
),
),
isScrollControlled: true,
);
await refreshConfig();
}
/// 消费一次「金币视频免费观看」权益
Future<void> useCoinPrivilege(VideoModel? video) async {
if (video?.videoType == 1) return; // 动漫视频不享受金币权益
if (video?.freeArea == true) return;
if (video?.isCoinVideo() != true ||
(video?.coins ?? 0) <= 0 ||
video?.vidStatus?.hasPaid == true) return;
if (!isOpen || isPayAll || !hasLimit) return; //没有首款权益
final count = remain?.todayCoinVideoCount ?? 0;
remain?.reduceVideoCoinCount(); //有无次数都先本地减,取数要在减之前
if (count <= 0) return; //已无次数,不必再调接口
final resp =
await PreSaleService.consumePrivilege(type: PrivilegeType.coinVideo);
if (resp.isSuccess) {
debugLog("权益次数消费成功: ${remain?.todayCoinVideoCount}");
}
}
/// 今日下载次数 -1
void reduceDownload() {
remain?.todayDownloadCount = (remain?.todayDownloadCount ?? 0) - 1;
notifyListeners();
}
//跳转h5抽奖
Future<void> openLuckyDraw() async {
if (luckyDrawH5?.isNotEmpty != true) return;
final token = await netManager.getToken();
final url = "$luckyDrawH5&token=$token&appUrl=${Address.baseHost}";
Get.to(
H5Page(url: url, showTitle: false, showClose: true),
opaque: false,
);
}
// ===== 私有 =====
/// 全局尾款支付倒计时定时器
void _startTimer() {
_timer?.cancel(); // 防重入:先清掉已有定时器,杜绝多重泄漏
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
// 每秒只更新 deadline,交给 ValueListenableBuilder 局部刷新,不触发全局 notifyListeners
deadline.value =
preSaleModel?.detailModel?.endTime?.countdownText() ?? '';
if (endSeconds < 0) {
timer.cancel();
_timer = null;
notifyListeners(); // 到期时通知一次,隐藏预售入口(Consumer 依赖 endSeconds
}
});
}
}
@@ -0,0 +1,45 @@
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
import 'package:hgdj/tools_base/net/http_manager.dart';
import 'pre_sale_model.dart';
/// 预售卡预付权益类型(枚举名即后端约定值,直接取 name 上传)
enum PrivilegeType {
coinVideo, // 金币视频
luckDraw, // 抽奖
aiUndress, // AI脱衣
download, // 下载
}
class PreSaleService {
//金币视频列表+活动详情
static Future<PreSaleModel?> fetchConfig() async {
final res = await httpManager.fetchResponseByGET(
'/video_gold_coin/list',
param: {'pageNumber': 1, 'pageSize': 8},
jsonTransformation: (json) => PreSaleModel.fromJson(json),
);
return res.data;
}
//获取预售状态
static Future<AdvanceStatus?> fetchStatus() async {
final res = await httpManager.fetchResponseByGET(
'/product/advanceStatus',
jsonTransformation: (json) => AdvanceStatus.fromJson(json),
);
return res.data;
}
//消耗预售卡预付权益
static Future<BaseRespBean> consumePrivilege({
required PrivilegeType type,
int count = 1,
}) {
return httpManager.fetchResponseByPOST(
'/mine/privilege/consume',
param: {'count': count, 'privilegeType': type.name},
);
}
}