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 onBackToMainPage() { if (!_overDelay) return Future.value(); return TimedPopupManager().trigger(canShow: () => _canShow, onShow: _fetchThenShow); } /// 拉配置 → show=true 且本地没展示过这批才弹。调用方已在互斥区内,不再嵌套 trigger static Future _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 _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 _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 _saveRecord(ShownRecord record) => lightKV.setString(_recordKey, jsonEncode(record.toJson())); static Future _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 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 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 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 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(','); } }