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