初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+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(',');
}
}