初始化
This commit is contained in:
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 不用 Spacer:Row 给非 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(',');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import '../../hj_model/video_model.dart';
|
||||
|
||||
/// 付费引导弹窗配置(场景 VIP_CONTENT_UPDATE:VIP 内容上新顶部推送横幅)
|
||||
/// 只解析前端用得到的字段;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();
|
||||
}
|
||||
Reference in New Issue
Block a user