Files
2026-09-15 15:44:13 +07:00

437 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(',');
}
}