Initial commit: 91dj android client dev_1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-08 11:37:25 +07:00
co-authored by Claude Opus 5.5
commit 97dd6a739a
1262 changed files with 119813 additions and 0 deletions
+337
View File
@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import 'package:jydj/hj_utils/screen.dart';
import 'package:jydj/tools_base/widget/card_swiper/src/swiper.dart';
import 'package:jydj/tools_base/widget/image_browser_page.dart';
import 'package:jydj/tools_base/widget/net_image_widget.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../track_event_manager/track_manager.dart';
import 'ads_item.dart';
/// 广告轮播的指示器位置
enum IndicatorAlign {
bottomRight, //右下角(默认)
bottomCenter, //底部居中,小号主题黄点(视频广告 cell 用)
underCenter, //轮播下方居中(Column 布局,轮播固定高 256)
}
/// 广告轮播:多张时自动轮播 + 指示器
class AdsBanner extends StatefulWidget {
final List<AdsInfoModel>? models; //广告数据
final double? width;
final double? height;
final int autoPlayMs; //自动轮播间隔(毫秒)
final double borderRadius; //图片圆角
final ValueChanged<int>? onTap; //点击某页,传 index
final ValueChanged<int>? onIndexChanged; //切换页,传 index
// ===== 指示器 =====
final IndicatorAlign indicatorAlign;
final IndicatorStyle indicatorStyle;
final Color? color; //未选中色,不传按位置/样式取默认
final Color? selectColor; //选中色,不传按位置/样式取默认
final AdExposureModel? exposureModel; //曝光埋点模型
const AdsBanner(
this.models, {
super.key,
this.width,
this.height,
this.autoPlayMs = 5000,
this.borderRadius = 12,
this.onTap,
this.onIndexChanged,
this.indicatorAlign = IndicatorAlign.bottomRight,
this.indicatorStyle = IndicatorStyle.bar,
this.color,
this.selectColor,
this.exposureModel,
});
@override
State<AdsBanner> createState() => _AdsBannerState();
}
class _AdsBannerState extends State<AdsBanner> {
//当前页随轮播变化,用 ValueNotifier 局部刷新指示器,避免每次都 setState 重建整棵树(含 Swiper)
final _index = ValueNotifier(0);
int get _count => widget.models?.length ?? 0;
@override
void initState() {
super.initState();
//adType 兜底设一次(不放 itemBuilder 里,避免每次构建都 mutate)
widget.exposureModel?.adType ??= AdType.bigBanner;
}
@override
void dispose() {
_index.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
//指示器放轮播下方:轮播固定高 256(唯一调用方不传 height,靠它撑开 Swiper)
if (widget.indicatorAlign == IndicatorAlign.underCenter) {
return SizedBox(
width: widget.width,
height: widget.height,
child: Column(
children: [
SizedBox(height: 256, child: _swiper()),
12.sizeBoxH,
if (_count > 1)
Center(child: _indicator(space: 4, dotSize: 4, selectWidth: 8, color: widget.color, selectColor: widget.selectColor)),
],
),
);
}
return SizedBox(
width: widget.width,
height: widget.height,
child: Stack(
clipBehavior: Clip.none,
children: [
_swiper(),
//指示器:只有一张时不出
if (_count > 1)
widget.indicatorAlign == IndicatorAlign.bottomCenter
? Positioned(
bottom: 10,
left: 0,
right: 0,
child: Center(
child: _indicator(
space: 2,
dotSize: 2,
selectWidth: 5,
//视频广告 cell:主题黄(未选中半透明,选中实心)
color: widget.color ?? const Color(0xffFFD900).withValues(alpha: 0.4),
selectColor: widget.selectColor ?? const Color(0xffFFD900),
),
),
)
: Positioned(
bottom: 7,
right: 7,
child: _indicator(space: 4, dotSize: 4, selectWidth: 8, color: widget.color, selectColor: widget.selectColor),
),
],
),
);
}
//轮播主体;两种指示器布局共用
Widget _swiper() {
return Swiper(
autoplay: _count > 1,
autoplayDelay: widget.autoPlayMs,
loop: _count > 1,
itemBuilder: (_, index) => AdsItem(
adInfo: widget.models![index],
showType: AdShowType.img,
borderRadius: widget.borderRadius,
exposureModel: widget.exposureModel,
),
onTap: widget.onTap,
onIndexChanged: (index) {
_index.value = index;
widget.onIndexChanged?.call(index);
},
itemCount: _count,
);
}
//指示器随当前页局部刷新
Widget _indicator({
required double space,
required double dotSize,
required double selectWidth,
Color? color,
Color? selectColor,
}) {
return ValueListenableBuilder<int>(
valueListenable: _index,
builder: (_, index, __) => DotIndicator(
count: _count,
index: index,
space: space,
dotSize: dotSize,
selectWidth: selectWidth,
style: widget.indicatorStyle,
color: color,
selectColor: selectColor,
),
);
}
}
/// 指示器样式
enum IndicatorStyle {
bar, //选中项拉成长条,默认红色系
dot, //全是圆点,默认白色系
}
/// 分页指示器:选中项宽度与颜色平滑过渡(类似 Android 系统页面指示器)
class DotIndicator extends StatelessWidget {
final int count; //圆点总数
final int index; //当前选中页
final double space; //每个点左侧的间距
final double dotSize; //圆点直径
final double selectWidth; //选中长条宽度,仅 bar 样式生效
final IndicatorStyle style;
final Color? color; //未选中色,不传按样式取默认
final Color? selectColor; //选中色,不传按样式取默认
const DotIndicator({
super.key,
required this.count,
required this.index,
this.space = 5,
this.dotSize = 6,
this.selectWidth = 12,
this.style = IndicatorStyle.bar,
this.color,
this.selectColor,
});
@override
Widget build(BuildContext context) {
final isBar = style == IndicatorStyle.bar;
final activeColor = selectColor ?? (isBar ? const Color(0xffE1351F) : Colors.white);
final inactiveColor = color ?? (isBar ? const Color(0xffE1351F).withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.5));
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < count; i++)
Padding(
padding: EdgeInsets.only(left: space),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
width: i == index && isBar ? selectWidth : dotSize,
height: dotSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dotSize / 2),
color: i == index ? activeColor : inactiveColor,
),
),
),
],
);
}
}
/// 图片轮播:左右滑动浏览,点图看大图,多张时右下角出指示器
class ImageSwiper extends StatefulWidget {
final List<String>? imgs;
final double aspectRatio;
const ImageSwiper({super.key, this.imgs, required this.aspectRatio});
@override
State<ImageSwiper> createState() => _ImageSwiperState();
}
class _ImageSwiperState extends State<ImageSwiper> {
//当前页用 ValueNotifier 局部刷新指示器,避免每次滑动都 setState 重建 Swiper
final _index = ValueNotifier(0);
int get _count => widget.imgs?.length ?? 0;
@override
void dispose() {
_index.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: widget.aspectRatio,
child: Stack(
alignment: Alignment.center,
children: [
Swiper(
autoplay: _count > 1,
loop: _count > 1,
itemBuilder: (_, index) => InkWell(
enableFeedback: false,
onTap: () => ImageBrowserPage.open(widget.imgs ?? [], index: index),
child: NetworkImageLoader(imageUrl: widget.imgs![index]),
),
onIndexChanged: (index) => _index.value = index,
itemCount: _count,
),
if (_count > 1)
Positioned(
bottom: 10,
right: 12,
child: ValueListenableBuilder<int>(
valueListenable: _index,
builder: (_, index, __) => DotIndicator(count: _count, index: index, space: 6),
),
),
],
),
);
}
}
/// AI 图生视频 banner:每页左原图、右生成图,多张时自动轮播
class AiBanner extends StatefulWidget {
final List<AdsInfoModel>? models;
const AiBanner({super.key, this.models});
@override
State<AiBanner> createState() => _AiBannerState();
}
class _AiBannerState extends State<AiBanner> {
//当前页用 ValueNotifier 局部刷新指示器,避免滑动重建 Swiper
final _index = ValueNotifier(0);
int get _count => widget.models?.length ?? 0;
@override
void dispose() {
_index.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Swiper(
autoplay: _count > 1,
loop: _count > 1,
autoplayDelay: 5000,
itemBuilder: (_, index) {
final item = widget.models?[index];
//左:原图 右:AI 生成图
return Row(children: [
Expanded(child: NetworkImageLoader(imageUrl: item?.cover ?? '', borderRadius: 0)),
Expanded(child: NetworkImageLoader(imageUrl: item?.newUrl ?? '', borderRadius: 0)),
]);
},
onIndexChanged: (index) => _index.value = index,
itemCount: _count,
),
if (_count > 1)
Positioned(
bottom: 10,
right: 10,
child: ValueListenableBuilder<int>(
valueListenable: _index,
builder: (_, index, __) => DotIndicator(count: _count, index: index),
),
),
],
);
}
}
+177
View File
@@ -0,0 +1,177 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_infinite_marquee/flutter_infinite_marquee.dart';
import 'package:jydj/hj_utils/screen.dart';
import 'package:jydj/tools_base/ad_manager.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../track_event_manager/track_manager.dart';
import 'ads_banner.dart';
import 'ads_item.dart';
/// 广告位展示样式,由广告的 coverImgSize 映射而来(见 AdsInfoModel.adStyle)
enum AdStyle {
normal, //两排共 10 个(固定样式时用)
banner, //大 banner 轮播
twoAutoScroll, //双排:上排固定,下排自动滚动
oneScrollBig, //单排自动滚动的大图
}
/// 广告位通用容器:按 [AdStyle] 切换九宫格 / banner / 双排滚动 / 大图滚动
class AdsGridView extends StatelessWidget {
final int position; //广告位标识,ads 为空时按此取本地缓存广告
final List<AdsInfoModel>? ads; //外部直接传入的广告,优先于 position
final bool autoStyle; //true:按第一个广告的 coverImgSize 选样式;false:固定两排九宫格
final EdgeInsetsGeometry? padding;
final AdExposureModel? exposureModel; //曝光埋点模型
const AdsGridView(
this.position, {
super.key,
this.ads,
this.autoStyle = false,
this.padding,
this.exposureModel,
});
@override
Widget build(BuildContext context) {
//一次 build 只取一次,自动滚动的 itemBuilder 复用同一份,避免反复全表 filter
final list = ads ?? AdManager().adsByType(position);
//无数据或 ab 测未命中广告,直接占位不渲染
if (list.isEmpty || !AdManager().showAbTestAd) return const SizedBox();
final style = autoStyle ? list.first.adStyle : AdStyle.normal;
return switch (style) {
AdStyle.normal => _normal(list),
AdStyle.banner => _banner(list),
AdStyle.twoAutoScroll => _twoAutoScroll(list),
AdStyle.oneScrollBig => _oneScrollBig(list),
};
}
//统一构造广告项;每个 item 用独立的曝光模型副本,避免共享同一实例导致曝光 adType 被覆盖串号
Widget _item(AdsInfoModel model, {double? aspectRatio}) {
final em = exposureModel;
return AdsItem(
adInfo: model,
aspectRatio: aspectRatio,
exposureModel: em == null ? null : AdExposureModel(pageType: em.pageType, adType: model.adType, tabIndex: em.tabIndex),
);
}
//两排共 10 个
Widget _normal(List<AdsInfoModel> list) {
return GridView.builder(
padding: padding,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: 60 / 82,
),
itemCount: min(10, list.length),
itemBuilder: (_, index) => _item(list[index]),
);
}
//大 banner:单张轮播,最多 20 张
Widget _banner(List<AdsInfoModel> list) {
return Container(
padding: padding,
alignment: Alignment.center,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AspectRatio(
aspectRatio: 720 / 150,
child: AdsBanner(
list.length > 20 ? list.take(20).toList() : list,
autoPlayMs: 2000,
exposureModel: exposureModel,
),
),
),
);
}
//双排:不足 11 个全放九宫格;否则上排固定 5 个,其余在下排自动滚动
Widget _twoAutoScroll(List<AdsInfoModel> list) {
if (list.length < 11) {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: 60 / 82,
),
itemCount: list.length,
itemBuilder: (_, index) => _item(list[index]),
);
}
final head = list.take(5).toList();
final foot = list.sublist(5);
return LayoutBuilder(
builder: (_, constraints) {
//下排一格和上排九宫格一样宽:4 个列间距共 40 + 左右边距各 16
final itemWidth = ((constraints.maxWidth - 40 - 16 * 2) / 5);
return Column(
children: [
GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: 60 / 82,
),
itemCount: head.length,
itemBuilder: (_, index) => _item(head[index]),
),
12.sizeBoxH,
SizedBox(
height: itemWidth / (60 / 82), //宽高比和上排一致
child: InfiniteMarquee(
frequency: const Duration(milliseconds: 20),
itemBuilder: (_, index) => Container(
width: itemWidth,
margin: const EdgeInsets.only(right: 7),
child: _item(foot[index % foot.length]),
),
),
),
12.sizeBoxH,
],
);
},
);
}
//单排自动滚动的大图,最多 20 个;一屏 3.2 个,露出下一张引导滑动
Widget _oneScrollBig(List<AdsInfoModel> list) {
return LayoutBuilder(
builder: (_, constraints) {
final data = list.length > 20 ? list.take(20).toList() : list;
final itemWidth = ((constraints.maxWidth - 7 * 2 - 16 * 2) / 3.2);
return Container(
margin: padding,
height: itemWidth / (104 / 176),
child: InfiniteMarquee(
frequency: const Duration(milliseconds: 20),
itemBuilder: (_, index) => Container(
width: itemWidth,
margin: const EdgeInsets.only(right: 7),
child: _item(data[index % data.length], aspectRatio: 104 / 152),
),
),
);
},
);
}
}
+191
View File
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jydj/assets_tool/images.dart';
import 'package:jydj/hj_utils/api_service/common_service.dart';
import 'package:jydj/hj_utils/screen.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../routers/jump_router.dart';
import '../../track_event_manager/track_manager.dart';
import '../widget/net_image_widget.dart';
enum AdShowType {
img, //纯图片
hor, //水平:左图标题 + 右下载按钮
vImgText, //图文:上图下文
splash, //启动全屏图
}
/// 广告项:按 [AdShowType] 出不同样式(不传是上图下标题的九宫格项);进入可视区上报曝光,点击按链接跳转并上报
class AdsItem extends StatefulWidget {
final AdsInfoModel adInfo;
final AdShowType? showType;
final double? aspectRatio;
final double borderRadius; //纯图样式的圆角
final int clickType; //点击上报类型 0:应用 1:广告
final AdExposureModel? exposureModel;
final Widget? child; //自定义子视图(IM 广告用)
final VoidCallback? onTap; //点击附加回调(IM 广告已读上报用)
const AdsItem({
super.key,
required this.adInfo,
this.showType,
this.aspectRatio,
this.borderRadius = 12,
this.clickType = 1,
this.exposureModel,
this.child,
this.onTap,
});
@override
State<AdsItem> createState() => _AdsItemState();
}
class _AdsItemState extends State<AdsItem> with AdExposureMixin {
AdsInfoModel get adInfo => widget.adInfo;
AdExposureModel? get exposureModel => widget.exposureModel;
//⚠️ 曝光去重 key 必须稳定
Key get _visibilityKey => ValueKey(
'ad_${adInfo.id ?? adInfo.href ?? ''}_${exposureModel?.pageType?.name ?? ''}_${exposureModel?.tabIndex}',
);
@override
Widget build(BuildContext context) {
return VisibilityDetector(
key: _visibilityKey,
onVisibilityChanged: (info) {
if (info.visibleFraction > 0.1) _expose();
},
child: InkWell(
enableFeedback: false,
onTap: _onTap,
child: widget.child ??
switch (widget.showType) {
AdShowType.img => _image(),
AdShowType.hor => _hor(),
AdShowType.vImgText => _imageText(),
AdShowType.splash => _splash(),
null => _normal(),
},
),
);
}
//进入可视区上报曝光
void _expose() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || exposureModel?.pageType == null) return;
adExposure(adInfo: adInfo, exposureModel: exposureModel);
});
}
void _onTap() {
pushToPageByLink(
adInfo.href,
id: adInfo.id,
arguments: {
'pageType': exposureModel?.pageType ?? TrackEventPageType.acgHome,
'adInfo': adInfo,
'payload': {'ad_type': exposureModel?.adType?.value ?? ''},
},
);
CommonService.adsClick(widget.clickType);
widget.onTap?.call();
}
//启动全屏图:封面铺满 + 兜底背景图
Widget _splash() {
return NetworkImageLoader(
imageUrl: adInfo.cover ?? '',
width: Get.width,
height: Get.height,
fit: BoxFit.cover,
placeHolderWidget: Image.asset('ic_splash_bg.webp'.launchPath, fit: BoxFit.cover, width: Get.width, height: Get.height),
);
}
//默认样式:上图下标题(九宫格项)
Widget _normal() {
return Column(
children: [
AspectRatio(
aspectRatio: widget.aspectRatio ?? 1,
child: NetworkImageLoader(imageUrl: adInfo.cover ?? '', borderRadius: 12),
),
const Spacer(),
Text(
adInfo.title?.trim() ?? '',
maxLines: 1,
style: const TextStyle(color: Color(0xffFAFAFA), fontSize: 11),
),
const Spacer(),
],
);
}
//图文样式:上图下文
Widget _imageText() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 72 / 20,
child: NetworkImageLoader(
borderRadius: 4,
imageUrl: adInfo.cover ?? '',
width: double.infinity,
height: double.infinity,
fit: BoxFit.fill,
),
),
10.sizeBoxH,
Text(
adInfo.title ?? '',
style: const TextStyle(color: Color(0xffdddfdf), fontWeight: FontWeight.w300, fontSize: 12),
),
],
);
}
//纯图样式:可选宽高比
Widget _image() {
final image = NetworkImageLoader(
imageUrl: adInfo.cover ?? '',
borderRadius: widget.borderRadius,
fit: BoxFit.fill,
);
final ratio = widget.aspectRatio;
return ratio == null ? image : AspectRatio(aspectRatio: ratio, child: image);
}
//水平样式:左图 + 标题,右「立即下载」按钮
Widget _hor() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
//图片自己切 10 的圆角
NetworkImageLoader(imageUrl: adInfo.cover ?? '', borderRadius: 10, width: 50, height: 50),
10.sizeBoxW,
Text(
adInfo.title ?? '',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w300, fontSize: 12),
),
],
),
Container(
width: 72,
height: 28,
decoration: BoxDecoration(color: const Color(0xff3476FF), borderRadius: BorderRadius.circular(14)),
child: const Center(child: Text('立即下载', style: TextStyle(color: Colors.white, fontSize: 12))),
),
],
);
}
}