初始化
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user