初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
import 'dart:ui';
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/routers/jump_router.dart';
import '../hj_utils/screen.dart';
import '../tools_base/widget/net_image_widget.dart';
/// 暗网板块未开通会员时的全屏毛玻璃遮罩:配了运营图走整图样式,否则走默认文案样式
class AwPermissionAlert extends StatelessWidget {
const AwPermissionAlert({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.black.withValues(alpha: .4),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Config.darkWebImg?.isNotEmpty == true
? _imageView()
: _normalView(),
),
);
}
//运营配置的整图样式,点哪都去开通
Widget _imageView() {
return GestureDetector(
onTap: () => pushToWalletPage(vipId: Config.darkWebVipId),
//用 DecoratedBox 不用 ColoredBox:后者 hitTest 是 opaque,会让图片没加载出来时整屏都可点,和原行为不一致
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: .7)),
child: NetworkImageLoader(
imageUrl: Config.darkWebImg,
fit: BoxFit.cover,
borderRadius: 0,
),
),
);
}
//默认文案样式
Widget _normalView() {
final vipName = Config.darkWebVipName ?? '';
return Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => pushToWalletPage(tabPosition: 0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('aw_warning.webp'.homePath, height: 96),
28.sizeBoxH,
const Text(
'系统警告\n'
'【暗网】含有百万全球禁播封杀资源,全球\n'
'上流社会权贵的私密丑闻!\n\n'
'萝莉岛事件,N号房,暴力重口,巴以/俄\n'
'乌/中东战争,血腥缅北,呦呦破处,变态\n'
'恐怖,暗网交易,等稀缺资源!',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
18.sizeBoxH,
//会员卡名标红
EasyRichText(
'马上开通\n$vipName\n进入世界的黑暗面!',
textAlign: TextAlign.center,
defaultStyle: const TextStyle(
color: Colors.white, fontSize: 14, height: 1.5),
patternList: [
if (vipName.isNotEmpty)
EasyRichTextPattern(
targetString: vipName,
matchWordBoundaries: false, // 纯中文无 \b 词边界,关掉才能匹配上
hasSpecialCharacters:
true, // 卡名是服务端下发的,带 ()+ 等正则符会构造出非法正则崩掉
style: const TextStyle(color: Color(0xffC40F0B)),
),
],
),
18.sizeBoxH,
GestureDetector(
onTap: () => pushToWalletPage(vipId: Config.darkWebVipId),
child: Image.asset('aw_button.webp'.homePath, height: 62),
),
30.sizeBoxH,
Text(
'此板块为黑客破解内容,真实稀缺资源,\n'
'可能会引起极度不适,请谨慎进入!\n',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: .6), fontSize: 12),
),
],
),
),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
import '../../routers/jump_router.dart';
/// 显示vipdialog isJump 是否在dialog 内部处理跳转事件
Future<int?> showVipLevelDialog(
String content, {
String buttonTitle = '立即开通',
String? desc,
Function? vipEvent,
}) {
return Get.dialog(
barrierColor: Colors.black.withValues(alpha: .4),
barrierDismissible: true,
Container(
alignment: Alignment.center,
child: CommonDialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
"溫馨提示",
style: TextStyle(
fontSize: 20,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w500,
),
),
24.sizeBoxH,
Container(
padding: EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
child: Text(
content,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16, color: Color(0x73FFFFFF), height: 1.5),
),
),
if (desc != null && desc.isNotEmpty)
Container(
padding: EdgeInsets.only(top: 16),
child: Text(
desc,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Color(0x73FFFFFF)),
),
),
24.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () {
if (vipEvent != null) {
vipEvent.call();
} else {
pushToWalletPage(tabPosition: 0);
}
},
child: Container(
alignment: Alignment.center,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: Color(0xffF68804),
),
child: Text(
buttonTitle,
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
),
);
}
+108
View File
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../hj_utils/screen.dart';
import '../../tools_base/banner/ads_banner_widget.dart';
import '../../tools_base/banner/ads_item.dart';
class AppCenterDialog extends StatefulWidget {
final List<AdsInfoModel>? appList;
final List<AdsInfoModel>? adsList;
final VoidCallback? callback;
const AppCenterDialog({super.key, this.appList, this.callback, this.adsList});
@override
State<AppCenterDialog> createState() => _AppCenterDialogState();
}
class _AppCenterDialogState extends State<AppCenterDialog> {
bool _showClose = false;
List<AdsInfoModel> get _appList => widget.appList ?? [];
/// 网格高度上限 = 4 行 + 底部留白,超出滚动。数字跟下面 gridDelegate 对应,改一处要同步。
/// 别用「首帧 shrinkWrap 量高度、次帧再改成固定高」那套——两帧布局不同,弹窗又居中,打开瞬间会跳
double get _maxGridHeight {
final itemWidth = (Get.width - 32 * 2 - 12 * 3) / 4;
return itemWidth * 93 / 68 * 4 + 16 * 3 + 25;
}
@override
void initState() {
super.initState();
// 1 秒后再显示关闭按钮,防误触
Future.delayed(const Duration(milliseconds: 1000), () {
if (mounted) setState(() => _showClose = true);
});
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black.withValues(alpha: 0.6),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.adsList?.isNotEmpty == true)
Container(
margin: const EdgeInsets.only(bottom: 24),
child: AdsBannerWidget(
widget.adsList,
width: 329,
height: 88,
autoPlayMs: 5000,
),
),
//Flexible 兜住矮屏:内容再多也不会顶破屏幕
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: _maxGridHeight),
child: GridView.builder(
padding: const EdgeInsets.only(bottom: 25),
shrinkWrap: true, //不足 4 行时按内容收,不留空
physics: const ClampingScrollPhysics(),
itemCount: _appList.length,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
childAspectRatio: 68 / 93,
),
itemBuilder: (context, index) {
return AdsItem(
adInfo: _appList[index],
);
},
),
),
),
32.sizeBoxH,
//一直挂在树上只切显隐:等 _showClose 才 build 的话,asset 解码前图高是 0,
//会先塌一下再弹回来,居中布局跟着抖
Opacity(
opacity: _showClose ? 1 : 0,
child: IgnorePointer(
ignoring: !_showClose,
child: GestureDetector(
onTap: () => Get.back(),
child: Image.asset("close_button.png".commonImgPath,
width: 32, height: 32),
),
),
),
],
),
),
),
);
}
}
+145
View File
@@ -0,0 +1,145 @@
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/app_colors.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
class LineErrorDialog extends StatelessWidget {
static Future show({Function? callback}) {
return Get.dialog(
LineErrorDialog(callback: callback),
barrierDismissible: false,
);
}
final Function? callback;
const LineErrorDialog({super.key, this.callback});
String get url => '91porn01.cc';
@override
Widget build(BuildContext context) {
return CommonDialog(
//线路异常必须让用户处理,Get.dialog 的 barrierDismissible 到不了这层
barrierDismissible: false,
canTapClose: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'系统提示',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
12.sizeBoxH,
0.5.line,
12.sizeBoxH,
Row(
children: [
Text(
"APP线路检测失败",
style: TextStyle(
color: Color(0xffF52C56),
fontSize: 12,
),
),
],
),
12.sizeBoxH,
Container(
alignment: Alignment.centerLeft,
child: const Text(
"解决方案:",
style: TextStyle(
color: Color(0xff656565),
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
12.sizeBoxH,
EasyRichText(
'''1.请检测您的网络是否正常,手机是否开了VPN或10分钟后再尝试访问。
2.前往官网地址 $url下载最新版
3.前往Telegram${Config.appName}官方交流群
''',
defaultStyle:
TextStyle(fontSize: 12, color: Color(0xff656565), height: 1.8),
patternList: [
EasyRichTextPattern(
targetString: url,
style: TextStyle(color: AppColors.primaryHighColor),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlToWeb('https://$url');
},
),
],
),
18.sizeBoxH,
SizedBox(
height: 38,
child: Row(
children: [
GestureDetector(
onTap: () async {
Get.back();
callback?.call();
},
child: Container(
width: 96,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: Colors.white.withValues(alpha: .1),
),
child: Text(
"切换路线",
style: TextStyle(
color: Color(0xff989898),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
12.sizeBoxW,
Expanded(
child: GestureDetector(
onTap: () {
launchUrlToWeb('https://t.me/hougong9527');
},
child: Container(
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed,
),
child: const Text(
"前往群聊",
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
)
],
),
)
],
),
);
}
}
+91
View File
@@ -0,0 +1,91 @@
// ignore_for_file: prefer_const_constructors, use_key_in_widget_constructors, use_full_hex_values_for_flutter_colors, sized_box_for_whitespace, unnecessary_import
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/splash/ads_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../tools_base/banner/ads_item.dart';
/// 系统公告--图片公告
class NoticeImageDialog extends StatelessWidget {
final AdsInfoModel? model;
NoticeImageDialog({this.model});
@override
Widget build(BuildContext context) {
return Center(
child: Material(
color: Colors.transparent,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AdsItem(
adInfo: model!,
showType: AdShowType.img,
aspectRatio: 6 / 8,
),
20.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () => Get.back(result: false),
child: Image.asset("close_button.png".commonImgPath, width: 32),
)
],
),
),
),
);
}
}
/// 系统公告--上下双排图片公告
class SystemImagesDialog extends StatelessWidget {
final List<AdsInfoModel>? ads;
SystemImagesDialog({this.ads});
@override
Widget build(BuildContext context) {
return Center(
child: Material(
color: Colors.transparent,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListView.separated(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return AspectRatio(
aspectRatio: 300 / 169,
child: AdsItem(
adInfo: ads![index],
showType: AdShowType.img,
aspectRatio: 6 / 8,
),
);
},
separatorBuilder: (context, index) => 6.sizeBoxH,
itemCount: ads?.length ?? 0,
),
20.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () => Get.back(result: false),
child: Image.asset("close_button.png".commonImgPath, width: 32),
)
],
),
),
),
);
}
}
+85
View File
@@ -0,0 +1,85 @@
// ignore_for_file: prefer_const_constructors, use_key_in_widget_constructors
import 'package:easy_rich_text/easy_rich_text.dart';
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 '../../hj_model/splash/ads_model.dart';
/// 系统公告--文字公告
class NoticeTextDialog extends StatelessWidget {
const NoticeTextDialog({
this.content,
this.addr,
this.wordBean,
});
final AnnounceInfoBean? wordBean;
final String? content;
final String? addr;
@override
Widget build(BuildContext context) {
return CommonDialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
wordBean?.title ?? "官方公告",
style: TextStyle(
fontSize: 20,
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w500,
),
),
12.sizeBoxH,
0.5.line,
12.sizeBoxH,
SizedBox(
height: 220,
child: SingleChildScrollView(
child: EasyRichText(
content ?? "",
defaultStyle: TextStyle(
color: Color(0xff656565), fontSize: 12, height: 25 / 12),
patternList: [
EasyRichTextPattern(
targetString:
'(http(s)?:\\/\\/.)?(www\\.)?[-a-zA-Z0-9:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)|https:\/\/t\\.me\/[0-9A-Za-z]{2,256}',
urlType: 'web',
style: TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
],
),
),
),
12.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () => Get.back(),
child: Container(
alignment: Alignment.center,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed),
child: Text(
"我知道了",
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
),
],
),
);
}
}
+260
View File
@@ -0,0 +1,260 @@
// ignore_for_file: depend_on_referenced_packages
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/install_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../../hj_model/splash/domain_source_model.dart';
import '../../hj_page/mine/identity/mine_identity_page.dart';
import '../../routers/jump_router.dart';
import '../../tools_base/net/load_apk/dio_cli.dart';
import '../../tools_base/net/load_apk/dio_slice_downloader.dart';
import '../../track_event_manager/device_service.dart';
//升级版本对话框
class UpdateDialog extends StatefulWidget {
static Future show(CheckVersionInfo phoneBean) async {
return await Get.dialog(
UpdateDialog(updateInfo: phoneBean),
barrierDismissible: false,
);
}
final CheckVersionInfo updateInfo;
const UpdateDialog({super.key, required this.updateInfo});
@override
State<UpdateDialog> createState() => _UpdateDialog();
}
class _UpdateDialog extends State<UpdateDialog> {
bool isDownloading = false; // 下载中:既防重入,也决定进度条露不露
var progress = .0;
String apkPath = "";
/// 点「立即升级」:iOS 跳商店,Android 下载 apk,下完再点就是安装
void _onUpdate() async {
//iOS 没有下载安装 apk 那一套,直接跳 App Store;进度条也就不用起(isDownloading 不置位)
if (Platform.isIOS) {
final iosUrl = widget.updateInfo.iosUrl ?? '';
iosUrl.isEmpty ? showToast("暂无更新地址,请稍后再试") : await launchUrlToWeb(iosUrl);
return;
}
if (progress == 1.0) {
await InstallUtil.installApk(apkPath);
return;
}
if (isDownloading) {
return;
}
//开始下载
isDownloading = true;
String devId = DeviceInfoService.deviceId;
Clipboard.setData(ClipboardData(text: "***$devId^^^"));
_downloadApk(widget.updateInfo.url ?? "");
setState(() {});
}
@override
Widget build(BuildContext context) {
return CommonDialog(
//强更场景要挡住点空白关闭,Get.dialog 的 barrierDismissible 到不了这层
barrierDismissible: false,
canTapClose: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
'系统升级',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
12.sizeBoxH,
0.5.line,
12.sizeBoxH,
Row(
children: [
Text(
"更新版本 v ${widget.updateInfo.verName}",
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 12,
),
),
],
),
12.sizeBoxH,
SizedBox(
height: 175,
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Row(
children: [
//Row 不给约束,Text 会按自然宽度排版撑出去,必须 Expanded 收回可用宽度才会换行
Expanded(
child: Text(
widget.updateInfo.description
?.replaceAll("|", "\n") ??
"",
style: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 12,
height: 1.6,
),
),
),
],
),
),
),
],
),
18.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () => Get.to(() => const MineAccountIdentityPage()),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"保存账号凭证",
style: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 14,
),
),
3.sizeBoxW,
Icon(
Icons.arrow_forward_ios_outlined,
size: 14,
color: Color(0xffDCDCDC),
)
],
),
),
18.sizeBoxH,
if (isDownloading) ...[
Container(
padding: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
backgroundColor: const Color(0xffBEBEBE),
valueColor: AlwaysStoppedAnimation(AppColors.actionRed),
),
),
12.sizeBoxH,
Text(
"${((progress * 100).toInt())}%",
style: TextStyle(
fontSize: 16,
color: Colors.white.withValues(alpha: .55),
),
),
],
),
),
] else
_buildButtonMenu(),
],
),
);
}
Widget _buildButtonMenu() {
if (widget.updateInfo.forcedUpdate == true) {
return _buildUpdateBtn();
} else {
return Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: Colors.white.withValues(alpha: .1),
),
child: Text(
"暂不升级",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xff989898),
),
),
),
),
),
const SizedBox(width: 12),
Expanded(child: _buildUpdateBtn()),
],
);
}
}
Widget _buildUpdateBtn() {
return GestureDetector(
onTap: _onUpdate,
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: const Text(
"立即升级",
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
);
}
///下载 apk(仅 AndroidiOS 走商店)
Future<void> _downloadApk(String url) async {
final saveDirectory =
path.join((await getExternalStorageDirectory())?.path ?? "", "apk");
DioSliceRetryDownloader(DioCli(), url, null, saveDirectory, "paofu.apk",
onReceiveProgress: (count, total) {
progress = (count / total);
setState(() {});
}, onRetry: (int retryCount) {
showToast("下载发生错误正在自动重试中...($retryCount)");
}).download().then((String savePath) async {
apkPath = savePath;
await InstallUtil.installApk(apkPath);
}).catchError((e) {
showToast("下载发生错误,点击重新进行下载.");
setState(() => isDownloading = false);
});
}
}
+510
View File
@@ -0,0 +1,510 @@
// ignore_for_file: use_build_context_synchronously
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/hj_utils/screen.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:provider/provider.dart';
import '../../hj_model/splash/domain_source_model.dart';
import '../../hj_page/main_page/provider/msg_provider.dart';
import '../../hj_page/mine/mine_vip/mine_charge_vip_logic.dart';
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
import '../../hj_page/mine/mine_vip/vip_card_item.dart';
import '../../hj_page/mine/mine_vip/vip_pay_button.dart';
import '../../hj_page/mine/mine_vip/vip_product_manager.dart';
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
import '../../hj_page/mine/mine_vip/vip_ui_kit.dart';
import '../../routers/jump_router.dart';
import '../../tools_base/loading/loading_center_widget.dart';
import 'buy_vip_alert_logic.dart';
///购买视频对话框
class BuyVipAlert extends StatefulWidget {
final String? vipId; //外部指定卡ID(可选),不传走分层卡/升级卡兜底
final String? videoId; //在看的那条内容 id,下单时透传给服务端做归因
final PayOrderTrackInfo?
orderTrack; //整份下单上下文,短剧付费墙要带 mediaId/contentId/checkoutContextId
const BuyVipAlert({super.key, this.vipId, this.videoId, this.orderTrack});
static Future show(
{String? vipId, String? videoId, PayOrderTrackInfo? orderTrack}) {
return Get.bottomSheet(
BuyVipAlert(vipId: vipId, videoId: videoId, orderTrack: orderTrack),
isScrollControlled: true,
);
}
@override
State<BuyVipAlert> createState() => _BuyVipAlertState();
}
// 可叠弹(试看结束/超时长等多处触发),用 per-实例唯一 tag 隔离 controller
class _BuyVipAlertState extends State<BuyVipAlert> with UniqueTagMixin {
late final logic = BuyVipAlertLogic(
vId: widget.vipId,
videoId: widget.videoId,
orderTrack: widget.orderTrack);
static const _sheetRadius = 26.0;
/// 顶部可见边:上→下 #66EBD097 → #15EBD097 → #00EBD097(顶边最实,圆角向下渐隐)
static const _borderGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66EBD097),
Color(0x15EBD097),
Color(0x00EBD097),
],
);
/// 只订阅「选中卡变化」的局部刷新:标题/价格/卡片选中态/权益/支付按钮用它,
/// 滑卡时不再整个弹窗重建(与会员中心页共用 [MineChargeVipLogic.kSelection]
Widget _selectionBuilder(Widget Function(BuyVipAlertLogic logic) builder) {
return GetBuilder<BuyVipAlertLogic>(
tag: uniqueTag,
id: MineChargeVipLogic.kSelection,
builder: builder,
);
}
@override
Widget build(BuildContext context) {
return GetBuilder<BuyVipAlertLogic>(
tag: uniqueTag,
init: logic,
builder: (_) => Material(
color: Colors.transparent,
child: SizedBox(
height: screen.screenHeight * 0.8,
width: screen.screenWidth,
child: Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(_sheetRadius),
topRight: Radius.circular(_sheetRadius),
),
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xff2B2010),
Color(0xff1A140B),
Color(0xff100C07),
],
),
),
child: Column(
children: [
16.sizeBoxH,
const SheetHandleBar(color: Color(0x22FFFFFF)),
16.sizeBoxH,
// 标题 + 分层倒计时(对齐设计稿:新人特惠/卡名 + 红块 HH:MM:SS
Row(
mainAxisSize: MainAxisSize.min,
children: [
_selectionBuilder(
(_) => Text(
_title,
style: const TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
_buildCountdown(),
],
),
14.sizeBoxH,
_selectionBuilder(
(_) => _buildPriceInfo()), //原价 / 限时特价 / 优惠
16.sizeBoxH,
Expanded(
child: _buildContent(),
)
],
),
),
),
// 只描顶部 + 左右上圆角;竖向渐变让顶边最亮、沿圆角向下淡出
Positioned(
top: 0,
left: 0,
right: 0,
height: _sheetRadius + 2,
child: IgnorePointer(
child: CustomPaint(
painter: _BuyVipTopBorderPainter(
gradient: _borderGradient,
strokeWidth: 1,
topRadius: _sheetRadius,
),
),
),
),
],
),
),
),
);
}
Widget _buildContent() {
return Consumer<VipProductManager>(
builder: (_, mgr, __) {
// 与会员中心一致:仅 ACTIVE+B 用改版;A / DISABLED 用 classic
final classic = !mgr.isNewVipUi;
if (mgr.isLoading) return LoadingCenterWidget();
if (mgr.vipCards.isEmpty) return CErrorWidget();
return Stack(
children: [
Column(
children: [
// 会员卡实体
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 6),
child: CarouselSlider.builder(
carouselController: logic.pageCtr,
itemCount: mgr.vipCards.length + 2,
itemBuilder: (_, index, __) => _selectionBuilder(
(l) => l.instanceChildItem(index)),
options: CarouselOptions(
height: logic.cardHeight,
viewportFraction: logic.itemRatio,
initialPage: 0,
enableInfiniteScroll: false,
reverse: false,
autoPlay: false,
enlargeCenterPage: false,
padEnds: false,
clipBehavior: Clip.none,
onPageChanged: (index, __) =>
logic.onPageChanged(index),
scrollDirection: Axis.horizontal,
),
),
),
// B:卡皮与核心权益标题间距略收;A 保持原 14
(classic ? 14 : 4).sizeBoxH,
_buildPrivilegeSections(classic: classic),
100.sizeBoxH,
],
),
),
),
],
),
Positioned(
bottom: screen.paddingBottom + 12,
left: 12,
right: 12,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_selectionBuilder((l) => VipPayButton(l, classic: classic)),
12.sizeBoxH,
EasyRichText(
'支付问题反馈,点击联系 在线客服',
patternList: [
EasyRichTextPattern(
targetString: '在线客服',
style: const TextStyle(
color: Color(0xFFFFD460), fontSize: 12),
recognizer: TapGestureRecognizer()
..onTap = () => pushToCustomService(),
)
],
defaultStyle:
const TextStyle(color: Color(0xffDCDCDC), fontSize: 12),
),
],
),
)
],
);
},
);
}
/// 弹窗标题:新人分层固定"新人特惠",其他分层跟随当前选中卡名
String get _title {
if (MineMsgProvider().payTier?.status == PayTier.newUnpay) return '新人特惠';
return logic.currentProductModel?.productName ?? '会员特惠';
}
/// 价格行:原价(标题+价格删除线) / 限时特价 / 优惠
Widget _buildPriceInfo() {
final p = logic.currentProductModel;
if (p == null) return const SizedBox();
final original = p.originalPriceUI;
final discounted = p.discountedPriceUI;
const muted = TextStyle(
color: Color(0x8cFFFFFF),
fontSize: 14,
decoration: TextDecoration.lineThrough,
decorationColor: Color(0x8cFFFFFF),
);
const normal = TextStyle(
color: Color(0x8cFFFFFF), fontSize: 14, fontWeight: FontWeight.w500);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_priceItem("原价", "¥$original", muted, titleStyle: muted),
24.sizeBoxW,
_priceItem("限时特价", "¥$discounted", normal),
24.sizeBoxW,
_priceItem(
"优惠", "¥${(original - discounted).clamp(0, original)}", normal),
],
);
}
Widget _priceItem(String title, String value, TextStyle valueStyle,
{TextStyle? titleStyle}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(title,
style: titleStyle ??
const TextStyle(color: Color(0x99FFFFFF), fontSize: 14)),
4.sizeBoxW,
Text(value, style: valueStyle),
],
);
}
/// 分层倒计时:按 config.lastDiscountTime,有则显示(监听 tick 每秒刷新),过期自动收起
Widget _buildCountdown() {
return ValueListenableBuilder<int>(
valueListenable: MineMsgProvider().tick,
builder: (_, __, ___) {
final c = MineMsgProvider().payTier?.config;
if (c == null || !c.hasDiscountCountdown)
return const SizedBox.shrink();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
12.sizeBoxW,
_timeItem(c.discountHour),
_colon(),
_timeItem(c.discountMin),
_colon(),
_timeItem(c.discountSec),
],
);
},
);
}
Widget _timeItem(String value) {
return Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xffE1351F),
borderRadius: BorderRadius.circular(3)),
child: Text(value,
style: const TextStyle(
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)),
);
}
Widget _colon() => const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child:
Text(":", style: TextStyle(color: Color(0xffDD001B), fontSize: 20)),
);
/// 权益区:与会员中心同实验分支 —— classic=A/DISABLED,改版=ACTIVE+B
Widget _buildPrivilegeSections({required bool classic}) {
return _selectionBuilder((l) {
final privileges = l.currentProductModel?.newPrivilege ?? [];
final coreList = privileges.where((p) => p.isCore == true).toList();
final moreList = privileges.where((p) => p.isCore != true).toList();
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: (_, i) => VipCorePrivilegeCard(list[i]),
),
),
16.sizeBoxH,
],
);
}
// 核心权益:一行金边方卡横向滑动 —— A / DISABLED
Widget _buildClassicCorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const VipSectionTitle('我的核心权益', classic: true),
16.sizeBoxH,
SizedBox(
//设计稿一行放得下 4 张多一点,第 5 张露半张提示可以划
height: 72,
child: ListView.separated(
clipBehavior: Clip.none, // 金边有外发光,裁了会缺一圈
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: list.length,
separatorBuilder: (_, __) => 8.sizeBoxW,
itemBuilder: (_, i) => SizedBox(
width: 72, child: VipCorePrivilegeCard(list[i], classic: true)),
),
),
16.sizeBoxH,
],
);
}
// 更多权益:深色圆角 + 四列网格 —— B 改版
Widget _buildMorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: const Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const VipMoreSectionTitleImage(),
12.sizeBoxH,
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 6),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 0,
crossAxisSpacing: 14,
childAspectRatio: 66 / 110,
),
itemCount: list.length,
itemBuilder: (_, i) => VipProductPrivilegeItem(list[i]),
),
],
),
);
}
// 会员特权:直接四列网格 —— 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: (_, i) =>
VipProductPrivilegeItem(list[i], classic: true),
),
],
);
}
}
/// 仅绘制底部弹窗顶部可见边界(上边 + 左右上圆角)
class _BuyVipTopBorderPainter extends CustomPainter {
final Gradient gradient;
final double strokeWidth;
final double topRadius;
_BuyVipTopBorderPainter({
required this.gradient,
required this.strokeWidth,
required this.topRadius,
});
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final r = topRadius;
final half = strokeWidth / 2;
// Flutter arcTo0=右、正角度顺时针。左上圆心(r,r) 由 π 扫到 3π/2,右上圆心(w-r,r) 由 3π/2 扫到 2π
final path = Path()
..moveTo(half, r)
..arcTo(
Rect.fromCircle(center: Offset(r, r), radius: r - half),
math.pi,
math.pi / 2,
false,
)
..lineTo(w - r, half)
..arcTo(
Rect.fromCircle(center: Offset(w - r, r), radius: r - half),
math.pi * 1.5,
math.pi / 2,
false,
);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..isAntiAlias = true
..strokeJoin = StrokeJoin.round
..shader = gradient.createShader(Rect.fromLTWH(0, 0, w, r + 2));
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _BuyVipTopBorderPainter oldDelegate) {
return oldDelegate.gradient != gradient ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.topRadius != topRadius;
}
}
+15
View File
@@ -0,0 +1,15 @@
import '../../hj_page/mine/mine_vip/mine_charge_vip_logic.dart';
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
class BuyVipAlertLogic extends MineChargeVipLogic {
//外部指定卡ID(可选);不传则走分层卡/升级卡兜底,对齐 mrhs
//视频场景购买弹窗 → VIDEO_BOTTOM_SHEET
BuyVipAlertLogic({String? vId, String? videoId, PayOrderTrackInfo? orderTrack})
: super(vipID: vId, sourcePage: PaySourcePage.videoBottomSheet, videoId: videoId, orderTrack: orderTrack);
@override
bool get preferFreshData => false; //弹窗优先用 manager 已加载的缓存,避免重复请求
@override
bool get enableEntryGuidePopup => false; //购买弹窗本身就是付费引导,不再叠「进会员页」倒计时弹窗
}
+189
View File
@@ -0,0 +1,189 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:provider/provider.dart';
import '../../assets_tool/app_colors.dart';
import '../../hj_model/acg/comic_chapters_model.dart';
import '../../hj_model/cartoon_media_info.dart';
import '../../hj_utils/pay/pay_manager.dart';
import '../../routers/jump_router.dart';
import '../../tools_base/global_store/store.dart';
///购买漫画/小说弹窗
class ComicBuyAlert extends StatefulWidget {
//// 返回值: 1 购买单集, 2 购买全集,null 啥都没购买
static Future<int?> show({
ComicChapterInfo? curModel, // 单集数据
CartoonMediaInfo? mediaInfo, // 全集数据
}) async {
return await Get.bottomSheet(ComicBuyAlert(
mediaInfo: mediaInfo,
curModel: curModel,
));
}
final ComicChapterInfo? curModel; // 单集数据
final CartoonMediaInfo? mediaInfo; // 全集数据
const ComicBuyAlert({
super.key,
this.curModel,
this.mediaInfo,
});
@override
State<ComicBuyAlert> createState() => _ComicBuyAlertState();
}
class _ComicBuyAlertState extends State<ComicBuyAlert> {
bool _isBuying = false;
//整套价格(产品当前只开整套购买)
int get _price => widget.mediaInfo?.price ?? 0;
@override
void initState() {
super.initState();
//刷完钱包会 notifyListeners,下面的 Consumer 自动重建,不用再 setState
globalStore.refreshWallet();
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Consumer<GlobalStore>(
builder: (_, store, __) {
final enough = (store.wallet?.amount ?? 0) >= _price;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
decoration: const BoxDecoration(
color: AppColors.primaryColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetHandleBar(),
18.sizeBoxH,
const Text(
'购买作品',
style: TextStyle(
color: Color(0xE5ffffff),
fontSize: 20,
fontWeight: FontWeight.w500),
),
36.sizeBoxH,
//作品解锁价
Row(
children: [
const Text('作品解锁',
style:
TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
const Spacer(),
GestureDetector(
onTap: () => pushToWalletPage(tabPosition: 1),
child: Text(
'$_price 金币',
style: const TextStyle(
color: Color(0xffFF9000),
fontSize: 14,
fontWeight: FontWeight.w500),
),
),
],
),
18.sizeBoxH,
Divider(
height: 0.5, color: Colors.white.withValues(alpha: .05)),
18.sizeBoxH,
//我的金币余额 + 充值入口
Row(
children: [
const Text('我的金币',
style:
TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
const Spacer(),
Text(
'${store.wallet?.amount?.toDouble().fixed(2) ?? 0}',
style: const TextStyle(
color: Color(0xE5ffffff),
fontSize: 14,
fontWeight: FontWeight.w500),
),
12.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => pushToWalletPage(tabPosition: 1),
child: const Text(
'充值',
style: TextStyle(
color: Color(0xffA4634D),
fontSize: 14,
fontWeight: FontWeight.w400,
decoration: TextDecoration.underline,
decorationColor: Color(0xffA4634D),
),
),
),
],
),
18.sizeBoxH,
//支付按钮:余额不足直接去充值
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => enough
? _buyProduct(true)
: pushToWalletPage(tabPosition: 1),
child: Container(
width: double.infinity,
height: 47,
decoration: BoxDecoration(
color: const Color(0xffF68804),
borderRadius: BorderRadius.circular(3)),
alignment: Alignment.center,
child: Text(
enough ? '$_price金币/立即支付' : '余额不足,前往充值',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
),
],
),
);
},
),
);
}
///购买作品:[isAll] true 整本(19)false 单集(25)
Future<void> _buyProduct(bool isAll) async {
if (_isBuying) return; //纯防重入,build 不依赖它,改了不用 setState
_isBuying = true;
final productType = isAll ? ProductType.mediaAll : ProductType.mediaChapter;
final productID = isAll
? (widget.mediaInfo?.id ?? widget.curModel?.mediaId)
: widget.curModel?.id;
await PayManager().buy(
productID,
productType,
source: 'comic_buy',
onSuccess: (data) {
globalStore.refreshWallet();
showToast("购买成功");
Get.back(result: isAll ? 2 : 1);
},
);
_isBuying = false;
}
}
+202
View File
@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/config/config.dart';
import 'package:hgdj/hj_model/user/user_info_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../config/address.dart';
import '../../hj_model/video_model.dart';
import '../../hj_utils/image_util.dart';
import '../../tools_base/global_store/store.dart';
///分享二维码对话框
class ShareMediaDialog extends StatefulWidget {
final VideoModel? videoModel;
final bool needDecrypt; //封面是否需要解密(直播封面不加密,传 false)
const ShareMediaDialog({
super.key,
this.videoModel,
this.needDecrypt = true,
});
@override
State<ShareMediaDialog> createState() => _ShareMediaDialogState();
}
class _ShareMediaDialogState extends State<ShareMediaDialog> {
final _shotKey = GlobalKey(); //保存图片时截取的范围
UserInfoModel? get _me => globalStore.meInfo;
//推广链接:弹窗打开时取一次即可(二维码/复制都用它)
late final String _shareUrl = _me?.promoteURL ?? "";
/// 复制链接
void _onCopyLink() {
if (_shareUrl.isEmpty) {
showToast("链接生成错误");
return;
}
Clipboard.setData(ClipboardData(text: _shareUrl));
showToast("复制成功,快去分享吧");
Get.back();
}
/// 保存图片(必须等截图完成再关弹窗,否则 _shotKey 对应的 widget 已销毁)
Future<void> _onSaveImg() async {
final ok = await ImageUtil.saveWidgetToAlbum(_shotKey);
showToast(ok ? "保存成功,快去分享吧" : "保存失败,请重试");
if (mounted) Get.back(); // 保存期间弹窗可能已被关掉(连点/点了复制),别多 pop 一层
}
@override
Widget build(BuildContext context) {
return CommonDialog(
padding: EdgeInsets.zero,
child: RepaintBoundary(
key: _shotKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 23, vertical: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//app 图标 + 名称 + 标语
Row(
children: [
Image.asset('ic_launcher.webp'.commonImgPath,
width: 48.w, height: 48.w),
10.w.sizeBoxW,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
Config.appName,
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500,
fontSize: 14),
),
4.sizeBoxH,
Text("亚洲最火成人网站",
style: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 10)),
],
),
],
),
18.sizeBoxH,
//视频封面
Padding(
padding: const EdgeInsets.symmetric(horizontal: 27),
child: NetworkImageLoader(
width: double.infinity,
imageUrl: widget.videoModel?.cover ?? "",
fit: BoxFit.fitHeight,
encrypt: widget.needDecrypt,
),
),
18.sizeBoxH,
//二维码 + 邀请码 + 官网地址
SizedBox(
height: 94,
child: Row(
children: [
Container(
width: 94,
height: 94,
padding: const EdgeInsets.all(2),
color: Colors.white,
child: QrImageView(
data: _shareUrl, padding: EdgeInsets.zero),
),
16.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"邀请码${_me?.promotionCode ?? ""}",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: .9)),
),
20.sizeBoxH,
Text(
'官方网址:${Address.groundUrl ?? ""}',
maxLines: 2,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: .55)),
),
],
),
),
],
),
),
24.sizeBoxH,
//底部两个按钮
Row(
children: [
Expanded(
child: _button(
"复制链接",
bgColor: Colors.white.withValues(alpha: .1),
textColor: const Color(0xff989898),
onTap: _onCopyLink,
),
),
16.sizeBoxW,
Expanded(
child: _button(
"保存图片",
bgColor: AppColors.actionRed,
textColor: Colors.white,
onTap: _onSaveImg,
),
),
],
),
],
),
),
),
);
}
Widget _button(String text,
{required Color bgColor,
required Color textColor,
required VoidCallback onTap}) {
return InkWell(
enableFeedback: false,
onTap: onTap,
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: bgColor,
),
child: Text(
text,
style: TextStyle(
fontSize: 16, color: textColor, fontWeight: FontWeight.w500),
),
),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart';
import 'package:hgdj/hj_page/mine/widgets/gradient_text.dart';
import 'package:hgdj/hj_utils/pay/pay_manager.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:provider/provider.dart';
///金币购买作品面板
class VideoBuyBottomSheet extends StatefulWidget {
final VideoModel video;
const VideoBuyBottomSheet({super.key, required this.video});
@override
State<VideoBuyBottomSheet> createState() => _VideoBuyBottomSheetState();
}
class _VideoBuyBottomSheetState extends State<VideoBuyBottomSheet> {
VideoModel get _video => widget.video;
bool get _isCartoon => _video.videoType == 1;
bool get _isSeedLink => _video.newsType == 'SEED_LINK';
//图集和黄游金币解锁,没有vip折扣
bool get _noDiscount => _video.newsType == "PIC" || _isSeedLink;
//实际售价:无折扣品类按原价,其余按折后价
String get _priceText =>
'${(_noDiscount ? _video.originCoins : _video.coins) ?? 0} 金币';
@override
void initState() {
super.initState();
globalStore.updateUserInfo();
globalStore.refreshWallet();
}
@override
Widget build(BuildContext context) {
return Consumer<GlobalStore>(
builder: (_, store, __) {
final enough = (store.wallet?.amount ?? 0) >= (_video.realCoins ?? 0);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
decoration: const BoxDecoration(
color: AppColors.primaryColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetHandleBar(),
18.sizeBoxH,
const Text(
'购买作品',
style: TextStyle(
color: Color(0xE5ffffff),
fontSize: 20,
fontWeight: FontWeight.w500),
),
18.sizeBoxH,
//作品解锁价
Row(
children: [
const Text('作品解锁',
style: TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
const Spacer(),
GestureDetector(
onTap: () => CoinPayBottomSheet.show(),
child: Text(
_priceText,
style: const TextStyle(
color: Color(0xffFF9000),
fontSize: 14,
fontWeight: FontWeight.w500),
),
),
],
),
18.sizeBoxH,
//图集和黄游金币解锁,没有vip折扣,不展示原价和折扣说明
if (!_noDiscount) ..._discountRows(store),
Divider(height: 0.5, color: Colors.white.withValues(alpha: .05)),
18.sizeBoxH,
//我的金币余额 + 充值入口
Row(
children: [
const Text('我的金币',
style: TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
const Spacer(),
Text(
'${store.wallet?.amount?.toDouble().fixed(2) ?? 0}',
style: const TextStyle(
color: Color(0xE5ffffff),
fontSize: 14,
fontWeight: FontWeight.w500),
),
12.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => CoinPayBottomSheet.show(),
child: const Text(
'充值',
style: TextStyle(
color: Color(0xffA4634D),
fontSize: 14,
fontWeight: FontWeight.w400,
decoration: TextDecoration.underline,
decorationColor: Color(0xffA4634D),
),
),
),
],
),
18.sizeBoxH,
//底部两个按钮:立即支付 / 开通会员
Row(
children: [
Expanded(
child: _button(
enough ? '$_priceText/立即支付' : '余额不足,前往充值',
textColor: const Color(0xff7B0000),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xfff8f3ae), Color(0xffddac44)],
),
borderRadius: BorderRadius.circular(3),
),
onTap: () => _onPay(enough),
),
),
12.sizeBoxW,
Expanded(
child: _button(
'开通会员免费看',
textColor: Colors.white,
decoration: BoxDecoration(
color: const Color(0xfff68804),
borderRadius: BorderRadius.circular(3),
),
onTap: () => pushToWalletPage(),
),
),
],
),
],
),
);
},
);
}
//原价 + 折扣说明(有 vip 折扣的品类才展示)
List<Widget> _discountRows(GlobalStore store) {
final isNormalUser = store.meInfo == null || store.meInfo?.vipLevel == 0;
final isVip = store.isVIP;
final String discountText;
if ((_video.coins ?? 0) < (_video.originCoins ?? 0) && isVip) {
discountText = '您当前享受${store.meInfo?.payVidDiscount}折优惠';
} else if (isVip) {
discountText = '您当前不享受免费观看';
} else {
discountText = '您当前不享受折扣优惠';
}
return [
Row(
children: [
const Text('作品原价',
style: TextStyle(color: Color(0xE5FFFFFF), fontSize: 14)),
const Spacer(),
Text(
'${_video.originCoins ?? 0} 金币',
style: TextStyle(
color: const Color(0x8CFFFFFF),
fontSize: 14,
decoration: isNormalUser ? null : TextDecoration.lineThrough,
decorationColor: const Color(0x8CFFFFFF),
),
),
],
),
18.sizeBoxH,
Row(
children: [
GradientText(
discountText,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
gradient: const LinearGradient(
colors: [Color(0xffAC7E6E), Color(0xffA4634D)]),
),
const Spacer(),
GestureDetector(
onTap: () => pushToWalletPage(),
child: GradientText(
isVip ? '升级会员免费看' : '购买VIP免费看',
gradient: const LinearGradient(
colors: [Color(0xffAC7E6E), Color(0xffA4634D)]),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: Color(0xffA4634D),
),
),
),
],
),
18.sizeBoxH,
];
}
Widget _button(String text,
{required Color textColor,
required BoxDecoration decoration,
required VoidCallback onTap}) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: Container(
height: 44,
alignment: Alignment.center,
decoration: decoration,
child: Text(
text,
style: TextStyle(
color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
),
),
);
}
void _onPay(bool enough) async {
if (!enough) {
await CoinPayBottomSheet.show();
return;
}
//黄游(SEED_LINK)和普通视频都按单片算,动漫按整部算
final type =
(!_isSeedLink && _isCartoon) ? ProductType.mediaAll : ProductType.media;
await PayManager().buy(
_video.id,
type,
source: 'video_buy',
goldVideoCouponNum: 0,
jumpWalletOnInsufficient: false,
onSuccess: (data) {
_video.vidStatus?.hasPaid = true;
Get.back(result: true);
},
onFailure: (data) {
if (data?.code == 8000) {
CoinPayBottomSheet.show();
}
},
);
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import '../../config/address.dart';
import '../../hj_model/splash/domain_source_model.dart';
import '../../tools_base/global_store/store.dart';
import '../mine/vip_level_dialog.dart';
class VideoLineMenuAlert extends StatelessWidget {
/// 返回 true 表示切换了线路,调用方据此重新起播
static Future<bool> show() async {
return await Get.bottomSheet<bool>(VideoLineMenuAlert()) ?? false;
}
VideoLineMenuAlert({super.key});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Container(
width: screen.screenWidth,
padding: EdgeInsets.only(bottom: screen.paddingBottom),
decoration: BoxDecoration(
color: Color(0xff0F0F0F),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
18.sizeBoxH,
const SheetHandleBar(),
4.sizeBoxH,
...Address.cdnAddressLists.map(_lineItem),
],
),
),
);
}
Widget _lineItem(Domain domain) {
final isSelected = domain.url == Address.cdnAddress;
return InkWell(
enableFeedback: false,
onTap: () {
if (!globalStore.isVIP) {
showVipLevelDialog("该线路仅限会员使用\n\n"
"开通会员立享 极速观影体验");
} else if (isSelected) {
Get.back();
} else {
Address.cdnAddress = domain.url;
Get.back(result: true); // 带回切换结果,由调用方重新起播
}
},
child: Container(
height: 56,
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Center(
child: Text(
domain.desc ?? "",
style: TextStyle(
fontSize: 14,
color: isSelected
? Color(0xfff68804)
: Colors.white.withValues(alpha: 0.45),
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
),
),
),
),
Container(
color: Colors.white.withValues(alpha: 0.05),
height: 1,
),
],
),
),
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
/// 动漫/图集的 VIP 门槛提示弹窗:只有文案和一个开通按钮
class VipACGDialog extends StatelessWidget {
const VipACGDialog({super.key});
@override
Widget build(BuildContext context) {
return CommonDialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"温馨提示",
style: TextStyle(
fontSize: 20,
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500),
),
24.sizeBoxH,
Text(
"本内容需要开通VIP会员\n开通会员 即可解锁查看完整版",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white.withValues(alpha: .45),
height: 2),
),
24.sizeBoxH,
//开通按钮。margin 留在 InkWell 外,否则左右各 12 的空白会被算进点击热区
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
enableFeedback: false,
onTap: () => pushToWalletPage(),
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3)),
child: const Text(
"立即开通",
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500),
),
),
),
),
],
),
);
}
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
import '../../hj_page/mine/widgets/gradient_text.dart';
import 'guide_common.dart';
import 'guide_config.dart';
import 'guide_manager.dart';
/// 吸底型付费引导弹窗(通用壳):横条 + 标题 + [badge] + 主图 + 副文案 + 开通按钮,
/// 首页/播放页各自传 title/subtitle/buttonText 组装
class GuideBottomSheet extends StatelessWidget {
final VipProductModel card;
final Widget title; // 各场景自定义
final Widget? badge; // 标题下方的小标签(免费次数场景用),不传不占位
final Widget subtitle; // 主图下方文案
final String buttonText;
final PaySourcePage sourcePage;
final String cover; // 场景下发的主图,相对路径由 ImageCacheManager 拼域名
const GuideBottomSheet({
super.key,
required this.card,
required this.title,
this.badge,
required this.subtitle,
required this.buttonText,
required this.sourcePage,
required this.cover,
});
/// [scene] 决定主图/卡/下单来源,调用方须先用开关判过能弹
/// 返回 true = 点了开通并已跳会员页(调用方据此别恢复播放)
static Future<bool> show({
required GuideScene scene,
required Widget title,
required Widget subtitle,
Widget? badge,
String? buttonText,
}) async {
final card = await loadGuideCard(scene);
if (card == null) return false; // 配置有误不弹
final goVip = await Get.bottomSheet<bool>(
GuideBottomSheet(
card: card,
title: title,
badge: badge,
subtitle: subtitle,
buttonText: buttonText ?? '立即开通 · 仅需¥${card.discountedPriceUI}',
sourcePage: guideSourcePage(scene),
cover: GuideManager().configOf(scene)?.cover ?? '', //已校验非空
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
return goVip ?? false;
}
/// 播放页引导(长短视频共用文案):文案优先用下发的,没配走默认。
/// [buttonText] 短视频传无价文案,长视频走默认带价文案
static Future<bool> showVideo({String? buttonText}) {
final config = GuideManager().configOf(GuideScene.videoPreviewEnd);
return show(
scene: GuideScene.videoPreviewEnd,
title: Text(
config?.title?.isNotEmpty == true
? config!.title!
: '该视频为VIP内容\n只能观看试看内容哦~',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white, fontSize: 17, fontWeight: FontWeight.w600),
),
subtitle: GradientText(
config?.description?.isNotEmpty == true
? config!.description!
: '想要查看完整内容,需要开通会员',
gradient:
const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]),
style: const TextStyle(
color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
),
buttonText: buttonText,
);
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(16, 18, 16, 18 + screen.paddingBottom),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xff0F0F0F), Colors.black]),
border: Border(
top: BorderSide(
color: Color(0x4DE4BB6D))), // rgba(228,187,109,0.3)
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetHandleBar(),
18.sizeBoxH,
title,
if (badge != null) ...[18.sizeBoxH, badge!],
18.sizeBoxH,
// 主图(场景下发)
SizedBox(
width: 270,
height: 122,
child: NetworkImageLoader(
imageUrl: cover, fit: BoxFit.fill, borderRadius: 8),
),
18.sizeBoxH,
subtitle,
18.sizeBoxH,
// 开通按钮
GestureDetector(
onTap: () => goVipDetail(card, sourcePage: sourcePage),
child: Container(
width: double.infinity,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xffFFB03F), Color(0xffF2680C)]),
borderRadius: BorderRadius.circular(26),
boxShadow: [
BoxShadow(
color: const Color(0x73F2680C),
blurRadius: 18,
offset: const Offset(0, 6))
], // rgba(242,104,12,0.45)
),
child: Text(buttonText,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700)),
),
),
],
),
),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import '../../hj_page/main_page/provider/msg_provider.dart';
import '../../hj_page/mine/mine_vip/mine_charge_vip_page.dart';
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
import '../../hj_page/mine/mine_vip/vip_product_manager.dart';
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
import '../../tools_base/debug_log.dart';
import 'guide_config.dart';
import 'guide_manager.dart';
/// 付费引导弹窗的共用逻辑:取卡 / 场景来源 / 跳会员页
/// 弹窗前置校验都收在这里,返回 null 即不弹:productId、cover 缺任一或卡不在列表里
/// 都算配置有误,一律不弹、不做兜底回落。
/// 顺带备好卡数据(有缓存走缓存),播放页可提前调一次,别暂停后才拉数据白停几百毫秒
Future<VipProductModel?> loadGuideCard(GuideScene scene) async {
final config = GuideManager().configOf(scene);
if (config == null) return null; // 开关关着
if (config.productId?.isNotEmpty != true ||
config.cover?.isNotEmpty != true) {
debugLog('paymentGuide', '${scene.value} 缺 productId 或 cover,配置有误不弹');
return null;
}
if (vipProductManager.vipCards.isEmpty)
await vipProductManager.loadVipCards();
final card = vipProductManager.vipCards
.firstWhereOrNull((e) => e.productID == config.productId);
if (card == null)
debugLog(
'paymentGuide', '${scene.value} 配置的会员卡不在列表里,不弹: ${config.productId}');
return card;
}
/// 分层门槛:非 VIP + 未付费的 under7/over7 两档。只剩上新横幅在用,其余场景走开关
bool isUnpayGuideUser() {
if (globalStore.isVIP) return false;
final status = MineMsgProvider().payTier?.status;
return status == PayTier.under7DayUnpay || status == PayTier.over7DayUnpay;
}
/// 场景 → 下单来源页(透传 /mine/topay 的 sourcePage)
PaySourcePage guideSourcePage(GuideScene scene) => switch (scene) {
GuideScene.homeNewUser ||
GuideScene.homeOldUser ||
GuideScene.homeNewUserFreeTrial =>
PaySourcePage.homeUserSegment,
GuideScene.vipCenter => PaySourcePage.vipCenter,
_ => PaySourcePage.videoBottomSheet, //播放页起播引导 / 返回挽留
};
/// 开通 → 关弹窗 + 进会员页并选中该卡(落地页 VIP_CENTER 开着还会再弹一次,不去重)
void goVipDetail(VipProductModel card, {required PaySourcePage sourcePage}) {
Get.back(result: true); // 带回"去开通了",调用方据此不恢复播放
Get.to(
() => MineChargeVipPage(vipID: card.productID, sourcePage: sourcePage));
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:get/get.dart';
/// 付费引导场景(ping 下发 paymentGuide.scenes 的 key
enum GuideScene {
homeNewUser('HOME_NEW_USER'), //首页新用户引导(吸底)
homeOldUser('HOME_OLD_USER'), //首页老用户引导(吸底)
//新用户免费次数将尽引导(GuideFreeTrialSheet)。key 是 HOME_ 开头,但实际触发在播放页:
//newUnpay 用免费次数看视频且停留满5分钟
homeNewUserFreeTrial('HOME_NEW_USER_FREE_TRIAL'),
//文档叫「试看结束引导」,实际是需付费视频起播3秒的前置引导(真试看结束是 BuyVipAlert 硬拦截,不受开关管)
videoPreviewEnd('VIDEO_PREVIEW_END'),
videoBack('VIDEO_BACK'), //退出视频挽留
vipCenter('VIP_CENTER'), //会员中心引导
//会员内容上新:前端不读这个开关,横幅按分层 + 独立接口判断(见 GuidePushBanner),此项只为枚举完整
vipContentUpdate('VIP_CONTENT_UPDATE');
final String value;
const GuideScene(this.value);
/// 后端 key → 枚举,不认识的返回 null
static GuideScene? from(String? value) => values.firstWhereOrNull((e) => e.value == value);
}
/// 付费引导配置(/ping/domain 的 paymentGuide 字段)
/// 场景弹不弹全看这里的开关,触发时不再另发查询请求
class GuideConfig {
bool enabled = false; //全局开关,false 时所有场景都不展示
final scenes = <GuideScene, GuideSceneConfig>{};
GuideConfig.fromJson(Map<String, dynamic>? json) {
json ??= {};
enabled = json['enabled'] ?? false;
final map = json['scenes'];
if (map is! Map) return;
map.forEach((key, value) {
final scene = GuideScene.from('$key'); //前端不认识的场景直接丢
if (scene != null && value is Map) {
scenes[scene] = GuideSceneConfig.fromJson(Map<String, dynamic>.from(value));
}
});
}
}
/// 单场景配置:enabled && show 都为 true 才弹,触发几次弹几次,前端不改这两个值。
/// 后端另下发的 configId / style / durationSeconds 前端用不上,没解析
class GuideSceneConfig {
bool enabled = false;
bool show = false;
String? title;
String? description;
String? cover; //弹窗主图(设计稿那张「限时永久卡」),不是会员卡图
String? productId; //会员商品 id:按它取卡,跳会员页也选中它
GuideSceneConfig.fromJson(Map<String, dynamic>? json) {
json ??= {};
enabled = json['enabled'] ?? false;
show = json['show'] ?? false;
title = json['title'];
description = json['description'];
cover = json['cover'];
productId = json['productId'];
}
}
@@ -0,0 +1,267 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../assets_tool/images.dart';
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
import '../../hj_page/mine/widgets/gradient_text.dart';
import 'guide_common.dart';
import 'guide_config.dart';
import 'guide_manager.dart';
/// 优惠倒计时:时长前端写死,起点由 app 启动时 reset 一次,回前台不重置
class PromoCountdown {
static const Duration _duration = Duration(hours: 2);
static DateTime? _deadline;
static DateTime get _end => _deadline ??= DateTime.now().add(_duration);
/// 剩余时长,归零停在 0
static Duration get remain {
final d = _end.difference(DateTime.now());
return d.isNegative ? Duration.zero : d;
}
/// app 启动时调一次(见 main),进程存活期间自然倒数
static void reset() => _deadline = DateTime.now().add(_duration);
}
/// 优惠倒计时弹窗(居中):主图骑顶 + 优惠文案 + 倒计时 + 开通按钮。
/// 长视频返回挽留 / 进会员页时触发
class GuideCountdownDialog extends StatelessWidget {
final VipProductModel card;
final ValueChanged<VipProductModel>?
onConfirm; // 默认跳会员页选卡;已在会员页的场景自己处理(关弹窗+就地选中该卡)
final PaySourcePage sourcePage;
final String cover; // 场景下发的主图,相对路径由 ImageCacheManager 拼域名
const GuideCountdownDialog({
super.key,
required this.card,
required this.sourcePage,
required this.cover,
this.onConfirm,
});
/// [scene] 决定主图/卡/下单来源,调用方须先用开关判过能弹
/// 返回 true = 点了「立即开通」(点X/点蒙层/没弹成都是 false),返回挽留场景据此决定要不要放行返回
static Future<bool> show(
{required GuideScene scene,
ValueChanged<VipProductModel>? onConfirm}) async {
final card = await loadGuideCard(scene);
if (card == null) return false; // 配置有误不弹
final confirmed = await Get.dialog<bool>(
GuideCountdownDialog(
card: card,
sourcePage: guideSourcePage(scene),
cover: GuideManager().configOf(scene)?.cover ?? '', //已校验非空
onConfirm: onConfirm,
),
);
return confirmed ?? false;
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 311,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
// 主体(顶部留 61 给主图骑顶溢出)
Container(
margin: const EdgeInsets.only(top: 61),
padding: const EdgeInsets.fromLTRB(24, 80, 24, 24),
decoration: BoxDecoration(
color: const Color(0xff0F0F0F),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 现在下单获得[优惠]福利:「优惠」金渐变、更大号
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
const Text('现在下单获得',
style: TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.w800)),
2.sizeBoxW,
const GradientText(
'优惠',
gradient: LinearGradient(colors: [
Color(0xffFFE3A0),
Color(0xffF0A93C)
]),
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w800),
),
2.sizeBoxW,
const Text('福利',
style: TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.w800)),
],
),
15.sizeBoxH,
const _CountdownRow(),
15.sizeBoxH,
GestureDetector(
onTap: () {
if (onConfirm != null) {
onConfirm!(card);
} else {
goVipDetail(card, sourcePage: sourcePage);
}
},
child: Container(
width: 269,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [
Color(0xffFFB03F),
Color(0xffF2680C)
]),
borderRadius: BorderRadius.circular(26),
boxShadow: [
BoxShadow(
color: const Color(0x73F2680C),
blurRadius: 18,
offset: const Offset(0, 6))
], // rgba(242,104,12,0.45)
),
child: Text(
'立即开通 · 仅需¥${card.discountedPriceUI}',
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700),
),
),
),
],
),
),
// 主图骑在顶部
Positioned(
top: 0,
child: SizedBox(
width: 270,
height: 122,
child: NetworkImageLoader(
imageUrl: cover, fit: BoxFit.fill, borderRadius: 8),
),
),
],
),
),
20.sizeBoxH,
GestureDetector(
onTap: () => Get.back(),
child: Image.asset("close_button.png".commonImgPath, width: 32),
)
],
),
),
);
}
}
/// 距优惠结束 HH:MM:SS:每秒只重建这一行,归零后停表
class _CountdownRow extends StatefulWidget {
const _CountdownRow();
@override
State<_CountdownRow> createState() => _CountdownRowState();
}
class _CountdownRowState extends State<_CountdownRow> {
final _remain = ValueNotifier<Duration>(PromoCountdown.remain);
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
final r = PromoCountdown.remain;
if (r == Duration.zero) t.cancel(); //归零停表(含弹窗打开时就已过期)
if (r.inSeconds == _remain.value.inSeconds) return; //秒数没变不通知
_remain.value = r;
});
}
@override
void dispose() {
_timer?.cancel();
_remain.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('距优惠结束',
style: TextStyle(
color: Color(0xffB8AE9C),
fontSize: 12,
fontWeight: FontWeight.w500)),
8.sizeBoxW,
ValueListenableBuilder<Duration>(
valueListenable: _remain,
builder: (_, r, __) => Row(
mainAxisSize: MainAxisSize.min,
children: [
_timeCell(r.inHours),
_colon,
_timeCell(r.inMinutes % 60),
_colon,
_timeCell(r.inSeconds % 60),
],
),
),
],
);
}
// 时/分/秒块
Widget _timeCell(int v) => Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xffE23B2E),
borderRadius: BorderRadius.circular(6)),
child: Text(
v.toString().padLeft(2, '0'),
style: const TextStyle(
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700),
),
);
Widget get _colon => const Padding(
padding: EdgeInsets.symmetric(horizontal: 3),
child: Text(':',
style: TextStyle(
color: Color(0xffE85B4F),
fontSize: 14,
fontWeight: FontWeight.w700)),
);
}
@@ -0,0 +1,63 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/material.dart';
import '../../hj_page/mine/widgets/gradient_text.dart';
import '../../hj_utils/free_play_manager.dart';
import 'guide_bottom_sheet.dart';
import 'guide_config.dart';
import 'guide_manager.dart';
/// 新用户免费次数将尽的付费引导(吸底,复用 [GuideBottomSheet] 通用壳,多一条剩余次数红标)。
/// 触发:newUnpay 用免费次数看视频、次数将尽且停留满5分钟;卡和主图都取 HOME_NEW_USER_FREE_TRIAL 场景配置
class GuideFreeTrialSheet {
GuideFreeTrialSheet._();
static const scene = GuideScene.homeNewUserFreeTrial;
/// 剩余免费次数。+1 是为了和右下角「免费视频剩余N次」角标同口径——都含正在看的这一次
/// (remain 在 _initPlayer 起手就被 useFreePlay 扣过一次了,不 +1 会比角标少 1)
static int get remainCount => (FreePlayManager().remain?.watchCount ?? 0) + 1;
/// 次数将尽:剩 2 次及以内才值得打扰
static bool get isCountLow => remainCount <= 2;
/// 返回 true = 点了开通并已跳会员页
static Future<bool> show() {
final remain = remainCount;
final total = FreePlayManager().remain?.total;
final description = GuideManager().configOf(scene)?.description; //副文案走下发,没配用默认
return GuideBottomSheet.show(
scene: scene,
title: EasyRichText(
'免费观看次数 即将用尽!',
textAlign: TextAlign.center,
defaultStyle: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w500),
patternList: [
EasyRichTextPattern(
targetString: '即将用尽!',
matchWordBoundaries: false, // 纯中文无 \b 词边界,关掉才能匹配上
style: const TextStyle(color: Color(0xffE94335), fontSize: 17, fontWeight: FontWeight.w500),
),
],
),
// 剩余免费次数红标
badge: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
decoration: BoxDecoration(
color: const Color(0x1AE94335), // rgba(233,67,53,0.1)
border: Border.all(color: const Color(0x33E7472A)), // rgba(231,71,42,0.2)
borderRadius: BorderRadius.circular(20),
),
child: Text(
total != null ? '剩余免费次数 $remain/$total次' : '剩余免费次数 $remain次',
style: const TextStyle(color: Color(0xffE94335), fontSize: 15),
),
),
subtitle: GradientText(
description?.isNotEmpty == true ? description! : '开通会员即可 畅看 "VIP全部视频"',
gradient: const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]),
style: const TextStyle(color: Colors.white, fontSize: 19, fontWeight: FontWeight.w500),
),
);
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import 'package:hgdj/hj_page/home/activity_float/home_activity_float.dart';
import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/fly_to_overlay.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
/// 支付分层首页弹窗:展示后端 homePage 图,点击跳会员页并选中 config.vipCard。
/// 倒计时读 config.lastDiscountTime,秒级刷新由 MineMsgProvider().tick 驱动,过期自动停
class GuideHomeDialog extends StatefulWidget {
final PayTierConfig config;
const GuideHomeDialog({super.key, required this.config});
@override
State<GuideHomeDialog> createState() => _GuideHomeDialogState();
}
class _GuideHomeDialogState extends State<GuideHomeDialog> {
final _posterKey = GlobalKey(); // 关闭动画的起飞位置
/// 关闭中:① 防连点,pop 过渡期弹窗还在树上,再点会把首页也 pop 掉
/// ② 藏掉原海报,否则它随弹窗淡出、和飞行副本重影
bool _closing = false;
PayTierConfig get config => widget.config;
/// 关闭:海报缩小飞进右下角浮窗,告诉用户同款入口收在哪
void _close() {
if (_closing) return;
_closing = true;
final target = _floatRect();
if (target != null) {
FlyToOverlay.play(
context: context,
sourceKey: _posterKey,
target: target,
// 副本只放海报,倒计时胶囊不跟着飞(缩到 60 宽也看不清)
// 占位给透明:没命中内存缓存时别飞一个灰块过去
child: NetworkImageLoader(
imageUrl: config.homePage ?? "",
fit: BoxFit.fill,
borderRadius: 0,
placeHolderWidget: const SizedBox(),
),
);
}
setState(() {}); // 原海报立刻消失,只留副本在飞
Get.back();
}
/// 落点:容器右下角一块浮窗大小的区域(浮窗都 bottomRight 对齐)。
/// 认容器不认轮播子项——子项会轮到屏幕外,容器位置才稳定。
/// 浮窗没展示时容器不存在,返回 null → 直接关不飞
Rect? _floatRect() {
final box = homeFloatKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return null;
final origin = box.localToGlobal(Offset.zero);
const w = 60.0, h = 66.0; // 与 _LayeredFloatView 的尺寸一致
return Rect.fromLTWH(
origin.dx + box.size.width - w, origin.dy + box.size.height - h, w, h);
}
@override
Widget build(BuildContext context) {
// 点蒙层、返回键都走 _close,保证任何关闭方式都有飞入动画
// (canPop:false 只拦 maybePop_close 里的 Get.back() 是强制 pop,不会被自己拦住)
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _close();
},
child: Center(
// 关闭中内容交给飞行副本。必须同时 IgnorePointer
// Opacity(0) 照样吃手势,退出动画那百来毫秒点到海报会再 pop 一次并跳会员页
child: IgnorePointer(
ignoring: _closing,
child: Opacity(
opacity: _closing ? 0 : 1,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: GestureDetector(
// 只回传"去开通",跳会员页交给调用方(_showPayDialog)——它要 await 到从会员页
// 返回才继续弹窗链,否则后面的首页吸底引导会弹在会员页上
onTap: () {
if (_closing) return; // 防连点:pop 过渡期弹窗还在树上,再点会把首页也 pop 掉
_closing = true;
Get.back(result: true);
},
child: AspectRatio(
aspectRatio: 3 / 4,
child: Stack(
key: _posterKey,
fit: StackFit.expand,
children: [
NetworkImageLoader(
imageUrl: config.homePage ?? "",
fit: BoxFit.fill,
borderRadius: 0,
placeHolderWidget:
Container(color: const Color(0x00000000)),
),
// 倒计时胶囊:海报右上角,听 tick 每秒刷新,过期自动收起
Positioned(
top: 0,
right: 0,
child: ValueListenableBuilder<int>(
valueListenable: MineMsgProvider().tick,
builder: (_, __, ___) =>
config.hasDiscountCountdown
? _buildCountdownPill()
: const SizedBox.shrink(),
),
),
],
),
),
),
),
20.sizeBoxH,
GestureDetector(
onTap: _close,
child: Image.asset("close_button.png".commonImgPath,
width: 32),
)
],
),
),
),
),
),
);
}
/// 倒计时胶囊:红渐变 + 奶白边,HH:MM:SS
Widget _buildCountdownPill() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xffE03017), Color(0xffF27952)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xffFFFAEE), width: 0.5),
),
child: Text(
'${config.discountHour}:${config.discountMin}:${config.discountSec}',
style: const TextStyle(
color: Color(0xffFFF0D1),
fontSize: 20,
fontWeight: FontWeight.w600),
),
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import '../../hj_page/main_page/provider/msg_provider.dart';
import 'guide_config.dart';
/// 付费引导开关(全站唯一数据源)
///
/// 配置随 /ping/domain 及 /ping/domain/refresh?keys=paymentGuide 下发后缓存在内存,
/// 首页/播放页/会员中心触发时直接读,不再临时请求接口。
///
/// 弹不弹只认后端下发的 enabled && show:本地不去重、不改这两个值、也不上报曝光,
/// 触发几次弹几次,要收敛得由后端把 show 改成 false;没下发配置则一律不弹。
/// (VIP_CONTENT_UPDATE 不归这里管,它走独立接口 + 曝光回执,见 GuidePushBanner)
class GuideManager {
static final GuideManager _instance = GuideManager._();
factory GuideManager() => _instance;
GuideManager._();
GuideConfig? _config;
/// ping / ping refresh 下发时写入。字段缺失(后端未下发)时保留上一次配置,不清空
void update(dynamic json) {
if (json is! Map) return;
_config = GuideConfig.fromJson(Map<String, dynamic>.from(json));
}
/// 场景配置:全局开关关掉、场景没配或没启用都返回 null
GuideSceneConfig? configOf(GuideScene scene) {
final all = _config;
if (all == null || !all.enabled) return null;
final config = all.scenes[scene];
return config?.enabled == true ? config : null;
}
/// 能不能弹:全局开关 + 场景启用([configOf])之外,还要后端本次放行
bool canShow(GuideScene scene) => configOf(scene)?.show == true;
/// 首页新/老用户二选一:**按本地分层选**,newUnpay 走新用户场景,其余走老用户;
/// 选中的那个不可展示就不弹,不回退到另一个——分层不匹配的文案给错人比不弹更糟。
/// (以前只按 show 挑,后端两个都下发 true 时老用户会命中新人场景)
///
/// ⚠️ 分层为 null 时必须退回按 show 挑,不能当成老用户:payTier 由 MainLogic.onInit 的
/// refreshPayPopup 异步拉,而本 getter 在首帧后的弹窗链里就被读了——首页没别的弹窗时
/// 请求还没回来,硬判成老用户会让新用户永远错过新人引导,且 _adShown 保证不会再有第二次
GuideScene? get homeScene {
final tier = MineMsgProvider().payTier?.status;
if (tier == null)
return [GuideScene.homeNewUser, GuideScene.homeOldUser]
.firstWhereOrNull(canShow);
final scene = tier == PayTier.newUnpay
? GuideScene.homeNewUser
: GuideScene.homeOldUser;
return canShow(scene) ? scene : null;
}
}
+436
View File
@@ -0,0 +1,436 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../../assets_tool/images.dart';
import '../../hj_page/home/home_cell_style/video_simple_cell.dart';
import '../../hj_page/main_page/main_page.dart';
import '../../hj_page/mine/widgets/gradient_text.dart';
import '../../hj_utils/api_service/common_service.dart';
import '../../hj_utils/light_model.dart';
import '../../hj_utils/screen.dart';
import '../../hj_utils/store_keys.dart';
import '../../routers/jump_router.dart';
import '../../tools_base/debug_log.dart';
import '../../tools_base/global_store/store.dart';
import 'guide_common.dart';
import 'guide_push_model.dart';
import 'timed_popup_manager.dart';
/// VIP「特享内容」顶部推送横幅(下滑入,上滑收起)
///
/// 用 app 满 [_delay] 后,每次回到 MainPage 拉一次接口,show=true 才弹。
/// [start] 的定时任务兜住「到点时一直停在 MainPage、没有 pop 事件」这种边界,
/// 其余靠 [onBackToMainPage]。调度走 [TimedPopupManager],与其它引导互斥
class GuidePushBanner extends StatelessWidget {
final GuidePushModel model;
const GuidePushBanner({super.key, required this.model});
static const _scene = 'VIP_CONTENT_UPDATE'; //接口场景标识
static const _delay = Duration(minutes: 3); //进 app 多久后才开始推
//进 app 的时刻,MainPage 每次创建都重置。null = start() 还没跑
static DateTime? _enterAt;
static bool _showing = false; //显示中:自身关闭引发的 didPopNext 不能又弹一次
static bool _fetching = false; //拉取中:快速进出页面会连续触发,防并发重复请求
//够不够时长。算墙上时间,切后台那段也算(要的是用满3分钟,不是前台累积)
static bool get _overDelay => _enterAt != null && DateTime.now().difference(_enterAt!) >= _delay;
/// 只对 under7/over7 未付费、在 MainPage 栈顶、自己没在显示时弹
static bool get _canShow => !_showing && isUnpayGuideUser() && Get.currentRoute == MainPage.routeName;
/// MainPage 创建时调一次
static void start() {
_enterAt = DateTime.now();
TimedPopupManager().schedule(
type: TimedPopupType.guidePushBanner,
delay: _delay,
//计时无条件起(见 schedule 注释),条件到点才判,和 onBackToMainPage 走同一套
canShow: () => _canShow,
onShow: _fetchThenShow,
);
}
/// MainPage 销毁时调:定时任务不留着空跑
static void stop() => TimedPopupManager().cancel(TimedPopupType.guidePushBanner);
/// 回到一级页面:满 [_delay] 后每次都拉一次接口
static Future<void> onBackToMainPage() {
if (!_overDelay) return Future.value();
return TimedPopupManager().trigger(canShow: () => _canShow, onShow: _fetchThenShow);
}
/// 拉配置 → show=true 且本地没展示过这批才弹。调用方已在互斥区内,不再嵌套 trigger
static Future<void> _fetchThenShow() async {
if (_fetching || !_canShow) return;
_fetching = true;
try {
final model = await CommonService.fetchPaymentGuide(_scene);
if (model?.show != true || model!.videos.isEmpty) return;
// 回执没落库时服务端会重复下发同一批:挡掉别重复弹,顺便补报那次没成功的曝光
// (只挡不补的话服务端永远认为没展示过,就卡死在「本地挡着、服务端一直发」)
final record = await _readRecord();
if (record != null && record.isSameAs(model)) {
if (!record.reported) await _report(record);
return;
}
if (!_canShow) return; //拉取期间可能已经跳去别的页面
_showing = true;
final newRecord = ShownRecord.from(model, const Uuid().v4());
await _saveRecord(newRecord); //先落本地再弹,并发的后一次才挡得住
final closed = _showBanner(model); //generalDialog 一调用就已入栈 = 展示成功
_report(newRecord); //不 await:上报不挡展示
//等关闭,期间 _showing 保持 true:横幅自己是 MainPage 上的一个 route
//关闭会触发 didPopNext,不挡住就立刻又弹一次
await closed;
} catch (e) {
debugLog('guidePushBanner', e.toString());
} finally {
_showing = false;
_fetching = false;
}
}
/// 上报已展示。失败不改 reported,留到下次拉到同一批时重报(幂等,必须用同一个 requestId)
static Future<void> _report(ShownRecord record) async {
try {
final success = await CommonService.reportPaymentGuideImpression(
configId: record.configId,
scene: _scene,
contentVersion: record.version,
requestId: record.requestId,
);
if (!success) return;
record.reported = true;
await _saveRecord(record);
} catch (e) {
debugLog('guidePushBanner', '曝光上报失败,留待下次重报: $e');
}
}
/// 记录 key 带 uid,换号互不影响。只留最近一条,更早的版本服务端不会再下发
static String get _recordKey => '${StoreKeys.VIP_PUSH_SHOWN_VERSION_PREFIX}${globalStore.meInfo?.uid ?? 0}';
static Future<ShownRecord?> _readRecord() async {
final raw = await lightKV.getString(_recordKey);
if (raw == null || raw.isEmpty) return null;
try {
return ShownRecord.fromJson(jsonDecode(raw));
} catch (_) {
return null; //旧格式/脏数据,当没记录处理
}
}
static Future<void> _saveRecord(ShownRecord record) => lightKV.setString(_recordKey, jsonEncode(record.toJson()));
static Future<void> _showBanner(GuidePushModel model) {
return Get.generalDialog(
barrierDismissible: false, //点遮罩不关,只认上滑和横幅里的按钮
barrierLabel: 'GuidePushBanner',
barrierColor: Colors.black.withValues(alpha: .5),
transitionDuration: const Duration(milliseconds: 320),
pageBuilder: (_, __, ___) => GuidePushBanner(model: model),
transitionBuilder: (_, animation, __, child) => SlideTransition(
position: Tween(begin: const Offset(0, -1), end: Offset.zero).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
),
),
child: child,
),
);
}
static const _maxCount = 4; //最多展示几个封面
List<GuidePushVideo> get videos => model.videos.take(_maxCount).toList();
// ===== 尺寸按比例算,不写死高度,任何屏宽都不溢出 =====
static const _cellRatio = 76 / 55; //单卡整体比例(设计稿 76×55,含底部标题)
static const _maxContentWidth = 500.0; //超宽屏限宽居中,不让封面无限拉大
double get _contentWidth => Get.width < _maxContentWidth ? Get.width : _maxContentWidth;
//以下几个常量要和 _buildVideoCard 的实际布局一致,改一处就同步改
static const _cardMargin = 6.0; //卡片左右外边距
static const _cardPadding = 6.0; //卡片内左右边距
static const _itemSpacing = 4.0; //封面之间的间距
static const _headerSpace = 36.0; //卡片内顶部留给标签/标题的高度
double get _itemWidth => (_contentWidth - _cardMargin * 2 - _cardPadding * 2 - _itemSpacing * (_maxCount - 1)) / _maxCount;
double get _itemHeight => _itemWidth / _cellRatio;
/// 卡片高 = 顶部让位 + 单卡高 + 底部留白
double get _cardHeight => _headerSpace + _itemHeight + 16;
@override
Widget build(BuildContext context) {
//Material 只包横幅本体,铺满全屏没必要还白吃一层点击
return Align(
alignment: Alignment.topCenter,
child: Material(
color: Colors.transparent,
child: _SwipeUpToDismiss(
child: Container(
width: double.infinity,
padding: EdgeInsets.only(top: Get.mediaQuery.padding.top),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)),
border: const Border(bottom: BorderSide(color: Color(0xffB71E03))),
),
child: Center(
heightFactor: 1, //不加会撑满全屏高度,横幅变成一整块黑屏
child: SizedBox(
width: _contentWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//标签/标题压在卡片上部,故同层叠
Stack(children: [_buildVideoCard(), _buildHeader()]),
19.sizeBoxH,
_buildVipButton(),
21.sizeBoxH,
],
),
),
),
),
),
),
);
}
/// 视频卡片:标签/标题压在上半部,视频排下半部。
/// GridView 固定 4 列,不足 4 条时靠左排,封面不会被拉大撑破卡片
Widget _buildVideoCard() {
return Padding(
padding: const EdgeInsets.only(left: _cardMargin, right: _cardMargin, top: 19),
child: Container(
height: _cardHeight,
padding: const EdgeInsets.only(left: _cardPadding, right: _cardPadding, top: _headerSpace),
decoration: BoxDecoration(
color: const Color(0xffB71E03).withValues(alpha: .1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xffB71E03)),
),
child: GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _maxCount,
crossAxisSpacing: _itemSpacing,
childAspectRatio: _cellRatio,
),
itemCount: videos.length,
itemBuilder: (_, index) => _buildVideoItem(videos[index]),
),
),
);
}
/// 复用首页视频 cell:不传 imgAspectRatio,封面撑满格子剩余高度,比例随格子走(裁切显示)
Widget _buildVideoItem(GuidePushVideo video) {
return VideoSimpleCell(
videoModel: video.toVideoModel(), //freeArea=true 顺带去掉右上角 VIP 角标
textLines: 1,
isShowBottom: false,
titleFontSize: 8,
coverFontSize: 7,
onTap: () {
Get.back();
//只传 id:转出来的 model 带 freeArea=true 会被播放页当成免费区,
//且 playTime<300 会被判成短视频跳到抖音页
pushToVideoPage(videoId: video.id);
},
);
}
/// 贴纸 + 标题底部对齐一行,标题走接口下发的 description,没配用默认文案
Widget _buildHeader() {
return Positioned(
left: 25,
right: 27,
top: 4,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Image.asset('update_video.webp'.videoPath, width: 113),
//用 Expanded 不用 SpacerRow 给非 flex 子节点的是无界约束,scaleDown 缩不动会溢出
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: GradientText(
model.description?.isNotEmpty == true ? model.description! : 'VIP会员“特享内容”更新上架啦~',
gradient: const LinearGradient(
colors: [Color(0xffFFE8BE), Color(0xffE6B764)],
),
style: const TextStyle(fontSize: 13),
),
),
),
],
),
);
}
Widget _buildVipButton() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 39),
child: GestureDetector(
onTap: () {
Get.back();
final action = model.action; //VIP_PRODUCT 带商品 id 进会员页,没配走默认
pushToWalletPage(vipId: action?.type == 'VIP_PRODUCT' ? action?.value : null);
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: const LinearGradient(colors: [Color(0xffFFB03F), Color(0xffF2680C)]),
boxShadow: [
BoxShadow(
color: const Color(0xffF2680C).withValues(alpha: .45),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: const Text(
'开通会员 · 查看劲爆视频',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
/// 上滑关闭:跟手位移 + 下拉阻尼 + 松手回弹,滑得够远或甩得够快直接 pop。
/// 不用 Dismissible:它要等自己那套滑出动画跑完才回调,横幅都滑没了遮罩还杵在那
class _SwipeUpToDismiss extends StatefulWidget {
final Widget child;
const _SwipeUpToDismiss({required this.child});
@override
State<_SwipeUpToDismiss> createState() => _SwipeUpToDismissState();
}
class _SwipeUpToDismissState extends State<_SwipeUpToDismiss> with SingleTickerProviderStateMixin {
static const _dismissRatio = 0.2; //上滑超过自身高度 20% 松手即关
static const _flingVelocity = 700.0; //甩得够快就关,不管滑了多远
static const _downDamping = 0.18; //往下拖的阻尼:横幅本来就贴着顶,只留一点点余量
late final AnimationController _springCtr = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
);
late final CurvedAnimation _spring = CurvedAnimation(parent: _springCtr, curve: Curves.easeOutCubic);
double _dragY = 0; //手指累计位移,负 = 往上
//回弹就是把当前位移按 _spring 收回 0
double get _translateY => (_dragY < 0 ? _dragY : _dragY * _downDamping) * (1 - _spring.value);
@override
void dispose() {
_spring.dispose();
_springCtr.dispose();
super.dispose();
}
//回弹途中又按下:接住当前位置继续跟手,别手指按着它还自顾自往回收
void _onDragStart(DragStartDetails _) {
_dragY = _translateY;
_springCtr.value = 0;
}
void _onDragUpdate(DragUpdateDetails details) => setState(() => _dragY += details.delta.dy);
void _onDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0; //上滑为负
if (-_dragY > (context.size?.height ?? Get.height) * _dismissRatio || velocity < -_flingVelocity) {
//位移不复位就 pop,路由的反向动画从当前位置接着走,横幅和遮罩一块收走
Get.back();
} else {
_springCtr.forward(from: 0);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: AnimatedBuilder(
animation: _spring,
builder: (_, child) => Transform.translate(offset: Offset(0, _translateY), child: child),
child: widget.child,
),
);
}
}
/// 本地展示记录:弹过哪批内容 + 曝光有没有报成功,存 lightKV(重启不丢)
class ShownRecord {
final String version; //contentVersion
final String configId;
final String requestId; //幂等 id,重报要用同一个
final List<String> videoIds;
bool reported;
ShownRecord({
required this.version,
required this.configId,
required this.requestId,
required this.videoIds,
this.reported = false,
});
factory ShownRecord.from(GuidePushModel model, String requestId) => ShownRecord(
version: model.contentVersion ?? '',
configId: model.configId ?? '',
requestId: requestId,
videoIds: model.videos.map((e) => e.id ?? '').toList(),
);
factory ShownRecord.fromJson(Map<String, dynamic> json) => ShownRecord(
version: json['version'] ?? '',
configId: json['configId'] ?? '',
requestId: json['requestId'] ?? '',
videoIds: (json['videoIds'] as List?)?.map((e) => '$e').toList() ?? [],
reported: json['reported'] ?? false,
);
Map<String, dynamic> toJson() => {
'version': version,
'configId': configId,
'requestId': requestId,
'videoIds': videoIds,
'reported': reported,
};
/// 同一批:版本号一致,或版本号变了但视频还是那几条(前后端不同步)
bool isSameAs(GuidePushModel model) {
if (version.isNotEmpty && version == model.contentVersion) return true;
final ids = model.videos.map((e) => e.id ?? '').toList();
return ids.isNotEmpty && ids.join(',') == videoIds.join(',');
}
}
+73
View File
@@ -0,0 +1,73 @@
import '../../hj_model/video_model.dart';
/// 付费引导弹窗配置(场景 VIP_CONTENT_UPDATEVIP 内容上新顶部推送横幅)
/// 只解析前端用得到的字段;title/segment/style/cover/durationSeconds/videoIds 暂未使用
class GuidePushModel {
bool? show; // false = 本次不展示(已展示过 / 无可用配置 / 无符合条件内容)
String? configId;
String? contentVersion; // 内容版本,上报回执时原样带回;也是本地去重的依据
String? description; // 横幅标题文案
String? productId;
GuidePushAction? action; // 按钮跳转配置
List<GuidePushVideo> videos = []; // 最新 VIP 内容,最多 4 条
GuidePushModel.fromJson(Map<String, dynamic>? json) {
json ??= {};
show = json['show'];
configId = json['configId'];
contentVersion = json['contentVersion'];
description = json['description'];
productId = json['productId'];
if (json['action'] is Map) {
action = GuidePushAction.fromJson(json['action']);
}
if (json['videos'] is List) {
videos = (json['videos'] as List).map((e) => GuidePushVideo.fromJson(e)).toList();
}
}
}
/// 弹窗按钮动作:type=VIP_PRODUCT 时 value 为会员商品 id
class GuidePushAction {
String? type;
String? value;
GuidePushAction.fromJson(Map<String, dynamic>? json) {
json ??= {};
type = json['type'];
value = json['value'];
}
}
class GuidePushVideo {
String? id;
String? title;
String? cover;
String? coverThumb; // 缩略图,列表展示优先用它
int? playTime; // 时长(秒)
int? playCount; // 播放量
GuidePushVideo.fromJson(Map<String, dynamic>? json) {
json ??= {};
id = json['id'];
title = json['title'];
cover = json['cover'];
coverThumb = json['coverThumb'];
playTime = json['playTime'];
playCount = json['playCount'];
}
/// 列表用封面:缩略图为空时回落大图
String get showCover => coverThumb?.isNotEmpty == true ? coverThumb! : cover ?? '';
/// 转成 VideoModel,复用项目现成的视频 cell。
/// freeArea=true 是为了让 cell 不画右上角 VIP 角标(见 VideoSimpleCell._buildLevelIcon)
/// ⚠️ 因此这个对象只能喂给 cell 渲染,不能传给播放页——那边会当成免费区、放过付费拦截
VideoModel toVideoModel() => VideoModel()
..id = id
..title = title
..cover = showCover
..playTime = playTime
..playCount = playCount
..freeArea = true;
}
@@ -0,0 +1,65 @@
import 'dart:async';
/// 定时弹窗的任务标识:只服务 [TimedPopupManager.schedule] / [TimedPopupManager.cancel] 的去重与取消。
/// 立即触发的场景(首页吸底、返回挽留、会员页倒计时等)走 trigger,不占枚举值。
enum TimedPopupType {
freeWatchPay, // 新用户(newUnpay)免费次数观看满5分钟付费引导
videoPayAhead, // 需付费视频起播3秒的前置提示(VIDEO_PREVIEW_END 开关控,一次进页面只弹一次)
guidePushBanner, // 进 app 满3分钟的 VIP「特享内容」顶部推送横幅
}
/// 引导弹窗统一调度管理器(全站唯一)
///
/// 所有付费引导弹窗都经这里触发,统一「多久/什么条件/弹什么」,并保证同一时刻只弹一个(互斥,防叠弹)。
/// - [schedule] 定时触发:停留 N 时长后弹(如试看/停留计时)。
/// - [trigger] 立即触发:事件驱动即弹(如进页面/点返回)。
/// 具体触发条件与弹窗动作由调用方传入(canShow/onShow),本类只负责调度、去重、互斥、生命周期。
class TimedPopupManager {
static final TimedPopupManager _instance = TimedPopupManager._();
factory TimedPopupManager() => _instance;
TimedPopupManager._();
/// 进行中的计时任务(按类型去重,同类型再次 schedule 会重启计时)
final Map<TimedPopupType, Timer> _timers = {};
/// 互斥:同一时刻只显示一个引导弹窗,避免多场景叠弹
bool _showing = false;
/// 定时触发:停留 [delay] 后满足条件弹。同 [type] 重复调用会取消上一个、重新计时。
///
/// ⚠️ 计时无条件起,[canShow] 只在到点时判一次。别在这里加"起步先判一次不满足就不计时"的优化:
/// 这些条件(分层、免费次数、登录态)本来就是等着这 N 分钟里变的,起步挡掉就再也起不来了
/// ——切号后分层/次数要等接口回来才对,起步判死会让弹窗永久不弹。
void schedule({
required TimedPopupType type,
required Duration delay,
required bool Function() canShow,
required Future<void> Function() onShow,
}) {
cancel(type);
_timers[type] = Timer(delay, () {
_timers.remove(type);
trigger(canShow: canShow, onShow: onShow); // 到点走统一出口;被互斥挡下则本次作废
});
}
/// 立即触发:事件驱动(进页面/返回等)满足条件即弹,也是 [schedule] 到点后的统一出口。
/// 返回的 Future 在弹窗关闭时完成,调用方可 await 以串起后续逻辑(如试看结束链路)。
/// ⚠️ [onShow] 的 Future 必须最终完成,否则互斥标记不复位、全站引导弹窗会静默失效。
Future<void> trigger({
required bool Function() canShow,
required Future<void> Function() onShow,
}) async {
if (_showing) return; // 已有引导弹窗在显示
if (!canShow()) return; // 弹前二次校验(定时场景期间状态可能变)
_showing = true;
try {
await onShow(); // 弹窗 show 的 Future 在关闭时 resolve
} finally {
_showing = false;
}
}
/// 取消指定类型的计时任务(页面退出时调用)
void cancel(TimedPopupType type) => _timers.remove(type)?.cancel();
}