初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
import 'dart:convert';
import 'package:hgdj/config/address.dart';
import 'package:hgdj/hj_model/home/module_detail_model.dart';
import 'package:hgdj/hj_model/splash/ads_model.dart';
import 'package:hgdj/hj_model/user/user_info_model.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/light_model.dart';
import 'package:hgdj/hj_utils/store_keys.dart';
import 'package:hgdj/hj_utils/text_util.dart';
import 'package:path/path.dart' as path;
/// 广告总管:广告数据(内存+本地缓存)、免广告策略、列表插广告
class AdManager {
static final AdManager _instance = AdManager._();
AdManager._();
factory AdManager() => _instance;
List<AdsInfoModel>? _adsList; // 广告内存缓存,splash 启动早期由 preload 预热
List<AnnounceInfoBean> announceList = []; // 文字/图片公告,随 /ping/domain 下发
bool _adFreeAlways = false; // 首次进 app 免广告
DateTime? _adFreeUntil; // 限时免广告的到期时间,null 为不限时免广告
/// 预热广告内存缓存:内存命中直接返回,否则从磁盘读
Future<void> preload() async {
if (_adsList?.isNotEmpty == true) return;
_adsList = _decodeAds(await lightKV.getString(StoreKeys.ADS_LIST));
}
/// 按 [position] 同步取广告,走内存缓存
List<AdsInfoModel> adsByType(int? position) =>
_filterByPosition(_adsList, position);
/// 按 [position] 异步取上次展示的广告,直接读磁盘(不走/不写内存缓存,splash 启动早期用)
Future<List<AdsInfoModel>> lastAdsByType(int? position) async {
if (position == null) return const [];
return _filterByPosition(
_decodeAds(await lightKV.getString(StoreKeys.ADS_LIST)), position);
}
/// 保存服务端下发的广告:cover 补全成完整地址后刷内存、写磁盘
Future<bool?> saveAds(List<AdsInfoModel>? ads) async {
for (final model in ads ?? const <AdsInfoModel>[]) {
final cover = model.cover;
// 完整链接只取 path 拼当前图片域名,相对路径直接拼
final subPath = cover?.startsWith('http') == true
? Uri.tryParse(cover!)?.path
: cover;
model.cover = path.join(Address.baseImagePath ?? '', subPath);
}
_adsList = ads;
try {
final jsonStr = json.encode(ads);
if (TextUtil.isEmpty(jsonStr)) return false;
return lightKV.setString(StoreKeys.ADS_LIST, jsonStr);
} catch (_) {
return false;
}
}
List<AdsInfoModel>? _decodeAds(String? jsonStr) {
if (TextUtil.isEmpty(jsonStr)) return null;
try {
return (jsonDecode(jsonStr!) as List?)
?.map((o) => AdsInfoModel.fromMap(o))
.toList();
} catch (_) {
return null;
}
}
List<AdsInfoModel> _filterByPosition(List<AdsInfoModel>? all, int? position) {
if (position == null || all == null || all.isEmpty) return const [];
return all.where((it) => it.position == position).toList();
}
/// 是否展示 ab 测广告,实时判定,限时免广告到点自动恢复展示
bool get showAbTestAd {
if (_adFreeAlways) return false;
return _adFreeUntil == null || DateTime.now().isAfter(_adFreeUntil!);
}
/// 按用户的广告 AB 类型刷新免广告策略
/// adverAbTestShowType: -1-首次进 app 免广告 0-所有广告都展示 其他N-注册后 N 分钟内免广告
/// 只能在登录成功后调用:AB 类型仅登录接口下发,拿 /mine/info 的结果刷新会把策略清空
Future<void> refresh(UserInfoModel? userInfo) async {
final type = userInfo?.adverAbTestShowType ?? 0;
_adFreeAlways = false;
_adFreeUntil = null; // 0 及其余取值:所有广告都展示
if (type == -1) {
// 首次免广告只给第一次进 app:用掉就记本地,第二次启动起照常展示
final used = await lightKV.getBool(StoreKeys.AD_FREE_FIRST_USED) ?? false;
_adFreeAlways = !used;
if (!used) await lightKV.setBool(StoreKeys.AD_FREE_FIRST_USED, true);
} else if (type > 0) {
// 注册时刻起算 type 分钟:注册时间是固定的绝对时刻,重进 app 既不会续期也不会丢剩余时长
// createdAt 是 UTC 串,缺时区标记时补 Z 再解析,否则会被当成本地时间差出时区偏移
final raw = userInfo?.createdAt ?? '';
final registeredAt = DateTime.tryParse(
raw.endsWith('Z') || raw.contains('+') ? raw : '${raw}Z')
?.toLocal();
_adFreeUntil = registeredAt?.add(Duration(minutes: type));
}
}
/// 专题列表插入广告:每隔 [adGap] 条普通内容插一条广告位
/// [modelArr] 原始列表(会被原地修改);[advList] 广告数据;[adGap] 间隔条数
void insertSectionAds(List<AllSection> modelArr, List<AdsInfoModel> advList,
{int adGap = 5}) {
if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return;
int countGap = 0; // 距上一条广告已累计的普通内容数
modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告,避免重复插入
for (int i = 0; i < modelArr.length; i++) {
AllSection model = modelArr[i];
if (countGap >= adGap) {
// 已够间隔,在当前位置插一条广告(已是广告位则跳过)
countGap = 0;
if (!model.isAdsArr()) {
AllSection adModel = AllSection();
adModel.adsInfoArr = advList;
modelArr.insert(i, adModel);
}
} else {
// 未够间隔:遇广告位重置计数,否则累加
countGap = model.isAdsArr() ? 0 : countGap + 1;
}
// 遍历到末尾且刚好满间隔,补一条广告收尾
if (countGap == adGap && i == modelArr.length - 1) {
AllSection adModel = AllSection();
adModel.adsInfoArr = advList;
modelArr.add(adModel);
break;
}
}
}
/// 视频流插入「单条随机广告」:每隔 [adGap] 条插一条,广告按 adIndex 轮播取用
void insertRandomAds(List<VideoModel> modelArr, List<AdsInfoModel> advList,
{int adGap = 5}) {
if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return;
int countGap = 0; // 距上一条广告已累计的普通内容数
int adIndex = 0; // 当前取到第几条广告,与 advList 取模实现轮播
modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告
for (int i = 0; i < modelArr.length; i++) {
VideoModel model = modelArr[i];
if (countGap >= adGap) {
// 够间隔,插一条随机广告(已是随机广告则跳过)
countGap = 0;
if (!model.isRandomAd()) {
VideoModel adModel = VideoModel();
adModel.randomAdsInfo = advList[adIndex % advList.length];
modelArr.insert(i, adModel);
adIndex++;
}
} else {
countGap = model.isAdsArr() ? 0 : countGap + 1;
}
// 末尾刚好满间隔,补一条收尾
if (countGap == adGap && i == modelArr.length - 1) {
VideoModel adModel = VideoModel();
adModel.randomAdsInfo = advList[adIndex % advList.length];
modelArr.add(adModel);
adIndex++;
break;
}
}
}
/// 视频流插入「整组广告」:每隔 [adGap] 条插一条,广告位携带整个 advList(与 [insertRandomAds] 的单条随机不同)
void insertGroupAds(List<VideoModel> modelArr, List<AdsInfoModel> advList,
{int adGap = 5}) {
if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return;
int countGap = 0; // 距上一条广告已累计的普通内容数
modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告
for (int i = 0; i < modelArr.length; i++) {
VideoModel model = modelArr[i];
if (countGap >= adGap) {
// 够间隔,插一条携带整组广告的广告位
countGap = 0;
if (!model.isRandomAd()) {
VideoModel adModel = VideoModel();
adModel.adsInfoArr = advList;
modelArr.insert(i, adModel);
}
} else {
countGap = model.isAdsArr() ? 0 : countGap + 1;
}
// 末尾刚好满间隔,补一条收尾
if (countGap == adGap && i == modelArr.length - 1) {
VideoModel adModel = VideoModel();
adModel.adsInfoArr = advList;
modelArr.add(adModel);
break;
}
}
}
}
@@ -0,0 +1,364 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/card_swiper/src/swiper.dart';
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../hj_model/splash/ads_model.dart';
import 'ads_item.dart';
/// 广告轮播
class AdsBannerWidget extends StatefulWidget {
final List<AdsInfoModel>? models; // 广告数据列表
final double? width; // 整体宽度
final double? height; // 整体高度
final ValueChanged<int>? onItemClick; // 点击某页回调(传 index
final ValueChanged<int>? onIndexChanged; // 切换页回调(传 index
final int? autoPlayMs; // 自动轮播间隔(毫秒),默认 5000
final bool isIndicatorBottomCenter; // 指示器置于底部居中
final bool isIndicatorUnderCenter; // 指示器置于轮播下方居中(Column 布局)
final Color? color; // 指示器未选中色
final Color? selectColor; // 指示器选中色
final bool isCircle; // 透传给 CIndicator.isBarStyletrue=长条红色风格)
final double borderRadius; // 图片圆角,默认 12
AdsBannerWidget(
this.models, {
super.key,
this.width,
this.height,
this.autoPlayMs = 5000,
this.onItemClick,
this.onIndexChanged,
this.isIndicatorBottomCenter = false,
this.isIndicatorUnderCenter = false,
this.color,
this.selectColor,
this.isCircle = true,
this.borderRadius = 12,
});
@override
State<AdsBannerWidget> createState() => _AdsBannerWidgetState();
}
class _AdsBannerWidgetState extends State<AdsBannerWidget> {
// 当前页随轮播变化,用 ValueNotifier 局部刷新指示器,避免每次都 setState 重建整棵树(含 Swiper)
final ValueNotifier<int> _selectIndex = ValueNotifier(0);
int get _count => widget.models?.length ?? 0;
@override
void dispose() {
_selectIndex.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.width,
height: widget.height,
child: widget.isIndicatorUnderCenter
? Column(
children: [
// 该分支高度固定 256(唯一调用方不传 height,靠此撑开 Swiper
SizedBox(height: 256, child: _buildSwiper()),
12.sizeBoxH,
if (_count > 1)
Container(
alignment: Alignment.center,
child:
_buildIndicator(space: 4, dotSize: 4, selectWidth: 8),
),
],
)
: Stack(
clipBehavior: Clip.none,
children: [
_buildSwiper(),
if (widget.isIndicatorBottomCenter)
Positioned(
bottom: 10,
left: 0,
right: 0,
child: _count > 1
? Container(
alignment: Alignment.center,
child: _buildIndicator(
space: 2,
dotSize: 2,
selectWidth: 5,
// 视频广告 cell:主题黄(未选中半透明,选中实心)
color: widget.color ??
const Color(0xffFFD900)
.withValues(alpha: 0.4),
selectColor:
widget.selectColor ?? const Color(0xffFFD900),
),
)
: const SizedBox.shrink(),
)
else
Positioned(
bottom: 7,
right: 7,
child: _count > 1
? _buildIndicator(space: 4, dotSize: 4, selectWidth: 8)
: const SizedBox.shrink(),
),
],
),
);
}
/// 轮播主体;两种指示器布局共用
Widget _buildSwiper() {
return Swiper(
autoplay: _count > 1,
autoplayDelay: widget.autoPlayMs ?? 5000,
loop: _count > 1,
itemBuilder: (context, index) => AdsItem(
adInfo: widget.models![index],
showType: AdShowType.img,
borderRadius: widget.borderRadius,
),
onTap: widget.onItemClick,
onIndexChanged: (index) {
_selectIndex.value = index;
widget.onIndexChanged?.call(index);
},
itemCount: _count,
);
}
/// 指示器随当前页局部刷新
Widget _buildIndicator({
required double space,
required double dotSize,
required double selectWidth,
Color? color,
Color? selectColor,
}) {
return ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (_, index, __) => CIndicator(
itemCount: _count,
selectIndex: index,
space: space,
dotSize: dotSize,
selectWidth: selectWidth,
color: color ?? widget.color,
selectColor: selectColor ?? widget.selectColor,
isBarStyle: widget.isCircle,
),
);
}
}
/// 分页指示器
class CIndicator extends StatelessWidget {
const CIndicator({
super.key,
this.itemCount,
this.selectIndex,
this.space = 5.0,
this.isBarStyle = true,
this.color,
this.selectColor,
this.dotSize = 6,
this.selectWidth = 12,
});
final int? itemCount; // 圆点总数
final int? selectIndex; // 当前选中页
final double? space; // 圆点间距
final bool isBarStyle; // true=选中态长条+红色系;false=圆点+白色系
final Color? color; // 未选中色(覆盖默认)
final Color? selectColor; // 选中色(覆盖默认)
final double dotSize; // 圆点直径
final double selectWidth; // 选中态(长条)宽度,isBarStyle 时生效
Widget _buildDot(int index) {
final isSelected = selectIndex == index;
final activeColor =
selectColor ?? (isBarStyle ? const Color(0xffE1351F) : Colors.white);
final inactiveColor = color ??
(isBarStyle
? const Color(0xffE1351F).withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.5));
// 选中态长条、未选中态圆点;宽度与颜色平滑过渡(类似 Android 系统页面指示器)
return Padding(
padding: EdgeInsets.only(left: space ?? 0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
width: isSelected && isBarStyle ? selectWidth : dotSize,
height: dotSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dotSize / 2),
color: isSelected ? activeColor : inactiveColor,
),
),
);
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [for (int i = 0; i < (itemCount ?? 0); i++) _buildDot(i)],
);
}
}
/// 图集:左右滑动浏览,可选右上角页码 + 底部圆点指示器
class ImageCollectionWidget extends StatefulWidget {
final List<String>? imgs;
final double aspectRatio;
final bool showCIndicator; // 是否显示底部圆点指示器
final bool showIndex; // 是否显示右上角页码
ImageCollectionWidget({
super.key,
this.imgs,
this.aspectRatio = 375 / 467,
this.showCIndicator = true,
this.showIndex = true,
});
@override
State<ImageCollectionWidget> createState() => _ImageCollectionWidgetState();
}
class _ImageCollectionWidgetState extends State<ImageCollectionWidget> {
// 当前页用 ValueNotifier 局部刷新页码/指示器,避免每次滑动都 setState 重建 Swiper
final ValueNotifier<int> _selectIndex = ValueNotifier(0);
int get _count => widget.imgs?.length ?? 0;
@override
void dispose() {
_selectIndex.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: (context, index) {
final cover = widget.imgs![index];
return InkWell(
enableFeedback: false,
onTap: () =>
ImageBrowserPage.open(widget.imgs ?? [], index: index),
child: NetworkImageLoader(imageUrl: cover),
);
},
onIndexChanged: (index) => _selectIndex.value = index,
itemCount: _count,
),
// 右上角页码 1/N
if (widget.showIndex && _count > 1)
Positioned(
top: 24,
right: 16,
child: ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (_, index, __) => Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Colors.black.withValues(alpha: .6),
),
child: Text('${index + 1}/$_count',
style:
const TextStyle(fontSize: 14, color: Colors.white)),
),
),
),
// 底部圆点指示器
if (widget.showCIndicator && _count > 1)
Positioned(
bottom: 10,
right: 12,
child: ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (_, index, __) =>
CIndicator(itemCount: _count, selectIndex: index, space: 6),
),
),
],
),
);
}
}
/// AI 图生视频 banner:每页左原图、右生成图,自动轮播
class AIBannerWidget extends StatefulWidget {
final int index; // 初始选中页
final List<AdsInfoModel>? models;
const AIBannerWidget({super.key, this.index = 0, this.models});
@override
State<AIBannerWidget> createState() => _AIBannerWidgetState();
}
class _AIBannerWidgetState extends State<AIBannerWidget> {
// 当前页用 ValueNotifier 局部刷新指示器,避免滑动重建 Swiper
late final ValueNotifier<int> _selectIndex = ValueNotifier(widget.index);
int get _count => widget.models?.length ?? 0;
@override
void dispose() {
_selectIndex.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(children: [
Swiper(
autoplay: _count > 1,
loop: _count > 1,
autoplayDelay: 5000,
itemBuilder: (context, 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) => _selectIndex.value = index,
itemCount: _count,
),
Positioned(
bottom: 10,
right: 10,
child: _count > 1
? ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (_, index, __) =>
CIndicator(itemCount: _count, selectIndex: index),
)
: const SizedBox.shrink(),
),
]);
}
}
@@ -0,0 +1,303 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_infinite_marquee/flutter_infinite_marquee.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/ad_manager.dart';
import '../../hj_model/splash/ads_model.dart';
import 'ads_banner_widget.dart';
import 'ads_item.dart';
enum AdStyle {
normal, //默认为10宫格
banner, //大banner
oneScroll, //单排左右手动滚动
oneAutoScroll, //单排左右自动滚动
twoAutoScroll, //双排展示,上排固定,下排自动滚动
oneScrollBig, //一排滚动,大样式
}
/// 广告位通用容器:按 [AdStyle] 切换九宫格/banner/单双排滚动等展示形态
class AdsGridViewWidget extends StatefulWidget {
final int position; // 广告位标识,adsArr 为空时按此拉本地缓存广告
final AdStyle? style; // 写死的展示样式(accordingAdsType=false 时生效)
final double? aspectRatio; // banner 样式的宽高比
final EdgeInsetsGeometry? padding;
final List<AdsInfoModel>? adsArr; // 外部直接传入的广告数据,优先于 position
//是否根据后台广告coverImgSize判断,
//false,写死类型不根据广告返回字段判断,比如直播广告,左右手动滑动等
//yes,根据position数组的第一个广告类型判断
final bool accordingAdsType;
AdsGridViewWidget(
this.position, {
super.key,
this.style = AdStyle.normal,
this.padding,
this.adsArr,
this.aspectRatio,
this.accordingAdsType = false,
});
@override
State<AdsGridViewWidget> createState() => _AdsGridViewWidgetState();
}
class _AdsGridViewWidgetState extends State<AdsGridViewWidget> {
// 优先用外部传入的 adsArr,没有则按 position 取本地缓存广告
// 缓存一次,避免一次 build / 自动滚动 itemBuilder 反复全表 filter
List<AdsInfoModel>? _adsCache;
List<AdsInfoModel> get adsList =>
_adsCache ??= (widget.adsArr ?? AdManager().adsByType(widget.position));
bool get accordingAdsType => widget.accordingAdsType;
EdgeInsetsGeometry? get padding => widget.padding;
double? get aspectRatio => widget.aspectRatio;
final double itemScale = 60 / 82; // 广告项宽高比(普通样式通用)
final double horCount = 5; // 横向一屏显示个数(普通样式通用)
@override
Widget build(BuildContext context) {
_adsCache = null; // 每次 build 重新取一次,build 内(含滚动 itemBuilder)复用
// 无数据或 ab 测未命中广告,直接占位不渲染
if (adsList.isEmpty || !AdManager().showAbTestAd) return const SizedBox();
// accordingAdsType=true 时按后台返回的广告类型决定样式,否则用写死的 style
final adStyle = accordingAdsType
? (adsList.firstOrNull?.adStyle ?? AdStyle.twoAutoScroll)
: widget.style;
switch (adStyle) {
case AdStyle.normal:
return _buildNormalStyle();
case AdStyle.banner:
return _buildBannerStyle();
case AdStyle.oneScroll:
return _buildOneScrollStyle();
case AdStyle.oneAutoScroll:
return _buildOneAutoScrollStyle();
case AdStyle.twoAutoScroll:
return _buildTwoAutoScrollStyle();
case AdStyle.oneScrollBig:
return _buildOneScrollBigStyle();
default:
return Container();
}
}
// 统一构造广告项
Widget _adsItem(AdsInfoModel model, {double? aspectRatio}) {
return AdsItem(adInfo: model, aspectRatio: aspectRatio);
}
//两排共10个
Widget _buildNormalStyle() {
return GridView.builder(
padding: padding,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: itemScale,
),
itemCount: min(10, adsList.length),
itemBuilder: (context, index) => _adsItem(adsList[index]),
);
}
// 大 banner 样式:单张轮播
Widget _buildBannerStyle() {
return Container(
padding: padding,
alignment: Alignment.center,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AspectRatio(
aspectRatio: aspectRatio ?? 720 / 150,
child: AdsBannerWidget(
adsList.length > 20 ? adsList.take(20).toList() : adsList,
autoPlayMs: 2000,
),
),
),
);
}
//单排手动滚动
Widget _buildOneScrollStyle() {
return Container(
margin: padding,
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final width = (constraints.maxWidth - (horCount - 1) * 10) / horCount;
final height = width / itemScale;
return SizedBox(
height: height,
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: adsList.length,
scrollDirection: Axis.horizontal,
separatorBuilder: (context, index) => 10.sizeBoxW,
itemBuilder: (context, index) => SizedBox(
width: width,
child: _adsItem(adsList[index]),
),
),
);
},
),
);
}
//单排自动滚动
Widget _buildOneAutoScrollStyle() {
if (adsList.length <= 5) {
return Container(
padding: padding,
margin: const EdgeInsets.symmetric(horizontal: 12),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final width =
(constraints.maxWidth - (horCount - 1) * 10) / horCount;
final height = width / itemScale;
return SizedBox(
height: height,
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: adsList.length,
scrollDirection: Axis.horizontal,
separatorBuilder: (context, index) => 10.sizeBoxW,
itemBuilder: (context, index) => SizedBox(
width: width,
child: _adsItem(adsList[index]),
),
),
);
},
),
);
} else {
return Container(
padding: padding,
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
double itemGap = 12;
final itemWidth =
(constraints.maxWidth - (horCount - 1) * itemGap - 32) /
horCount;
final height = itemWidth / itemScale;
return SizedBox(
height: height,
child: InfiniteMarquee(
frequency: const Duration(milliseconds: 20),
itemBuilder: (BuildContext context, int index) {
AdsInfoModel model = adsList[index % adsList.length];
return Container(
width: itemWidth,
margin: const EdgeInsets.only(right: 7),
child: _adsItem(model),
);
},
),
);
},
));
}
}
//双排展示,上排固定,下排自动滚动
Widget _buildTwoAutoScrollStyle() {
double hPadding = 16;
if (adsList.length < 11) {
return GridView.builder(
padding: EdgeInsets.fromLTRB(hPadding, 12, hPadding, 12),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: 60 / 82,
),
itemCount: adsList.length,
itemBuilder: (context, index) => _adsItem(adsList[index]),
);
} else {
final headList = adsList.take(5).toList(); //头数组
final footList = adsList.sublist(5); //尾部数组
//拆分数组
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth = ((constraints.maxWidth - 40 - hPadding * 2) / 5);
return Column(
children: [
GridView.builder(
padding: EdgeInsets.fromLTRB(hPadding, 12, hPadding, 0),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: 60 / 82,
),
itemCount: headList.length,
itemBuilder: (context, index) => _adsItem(headList[index]),
),
12.sizeBoxH,
SizedBox(
height: itemWidth / itemScale,
child: InfiniteMarquee(
frequency: const Duration(milliseconds: 20),
itemBuilder: (BuildContext context, int index) {
AdsInfoModel model = footList[index % footList.length];
return Container(
width: itemWidth,
margin: const EdgeInsets.only(right: 7),
child: _adsItem(model),
);
},
),
),
12.sizeBoxH,
],
);
},
);
}
}
//自动滚动的大图广告,最多显示20个
Widget _buildOneScrollBigStyle() {
// 大图样式:宽高比和一屏个数(3.2 个露出下一张引导滑动)
const double itemScale = 104 / 176;
const double horCount = 3.2;
double itemMargin = 7;
double hPadding = 16;
return LayoutBuilder(
builder: (context, constraints) {
final dataSource =
adsList.length > 20 ? adsList.take(20).toList() : adsList;
final itemWidth =
((constraints.maxWidth - itemMargin * 2 - hPadding * 2) / horCount);
return Container(
margin: padding,
height: itemWidth / itemScale,
child: InfiniteMarquee(
frequency: const Duration(milliseconds: 20),
itemBuilder: (BuildContext context, int index) {
AdsInfoModel model = dataSource[index % dataSource.length];
return Container(
width: itemWidth,
margin: EdgeInsets.only(right: itemMargin),
child: _adsItem(model, aspectRatio: 104 / 152),
);
},
),
);
},
);
}
}
+188
View File
@@ -0,0 +1,188 @@
//广告item
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../routers/jump_router.dart';
import '../widget/net_image_widget.dart';
enum AdShowType {
img, // 纯图片
hor, // 水平:左图标题 + 右下载按钮
vImgText, // 图文:上图下文
splash, // 启动全屏图
}
//所有广告判断
class AdsItem extends StatefulWidget {
final AdsInfoModel adInfo;
final double? aspectRatio;
final AdShowType? showType;
final double borderRadius;
final int clickType; //0:应用 1:广告
final Widget? child; //自定义子视图(IM 广告用)
final VoidCallback? onTap; //点击附加回调(IM 广告已读上报用)
const AdsItem({
super.key,
required this.adInfo,
this.aspectRatio,
this.showType,
this.borderRadius = 12,
this.clickType = 1,
this.child,
this.onTap,
});
@override
State<AdsItem> createState() => _AdsItemState();
}
class _AdsItemState extends State<AdsItem> {
AdsInfoModel get adInfo => widget.adInfo;
void _onTap() {
pushToPageByLink(adInfo.href);
CommonService.adsClick(widget.clickType);
widget.onTap?.call();
}
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: _onTap,
child: widget.child ??
switch (widget.showType) {
AdShowType.img => _buildImageStyle(),
AdShowType.hor => _buildHorStyle(),
AdShowType.vImgText => _buildImageTextStyle(),
AdShowType.splash => _buildSplash(),
_ => _buildNormalStyle(),
},
);
}
// 启动全屏图:封面铺满 + 兜底背景图
Widget _buildSplash() {
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 _buildNormalStyle() {
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 _buildImageTextStyle() {
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.0,
),
),
],
);
}
// 纯图样式:可选宽高比
Widget _buildImageStyle() {
final image = NetworkImageLoader(
imageUrl: adInfo.cover ?? '',
borderRadius: widget.borderRadius,
fit: BoxFit.fill,
);
if (widget.aspectRatio == null) return image;
return AspectRatio(aspectRatio: widget.aspectRatio!, child: image);
}
// 水平样式:左图+标题,右「立即下载」按钮
Widget _buildHorStyle() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: 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.0,
),
),
],
),
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),
),
),
),
],
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:get/get.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'debug_log.dart';
//列表基类逻辑,包含分页加载、数据状态处理等
class ListBaseLogic<T> extends GetxController with GetTickerProviderStateMixin {
int currentPage = 1; //当前页码
RefreshController? refreshCtr; //刷新控制器(由 pullYsRefresh 的 onInit 注入,组件负责释放)
List<T>? dataList; //数据源
bool hasMore = true; //是否还有更多
bool _isFetching = false; //请求进行中标志,防止并发重复请求
//是否在加载
bool get isLoading => dataList == null;
//无数据
bool get isEmptyData => dataList?.isEmpty ?? true;
//加载方法,isRefresh=true 下拉刷新 / false 上拉加载更多
//fetch 接收页码参数,返回 (列表数据, 是否还有更多),调用方不用自己算页码
Future<void> fetchData({
bool isRefresh = true,
required Future<(List<T>?, bool)> Function(int page) fetch,
}) async {
//0.防重入:上一次请求未结束时忽略新请求,避免并发导致重复页/数据错乱
if (_isFetching) return;
_isFetching = true;
//1.计算请求页码:刷新从1开始,加载更多取当前+1
final reqPage = isRefresh ? 1 : currentPage + 1;
try {
//2.发起网络请求,拿到列表和接口返回的 hasNext
final (list, hasNext) = await fetch(reqPage);
//3.成功回调
_fetchFinish(list, hasNext: hasNext, isRefresh: isRefresh, reqPage: reqPage);
} catch (e) {
//4.异常统一走失败分支
debugLog(e);
_fetchFail(isRefresh: isRefresh);
} finally {
//5.无论成功失败都释放标志
_isFetching = false;
}
}
void _fetchFinish(
List<T>? dataSource, {
required bool hasNext,
required bool isRefresh,
required int reqPage,
}) {
//1.空响应视为失败
if (dataSource == null) {
_fetchFail(isRefresh: isRefresh);
return;
}
//2.下拉刷新:清空旧数据 + 重置footer状态
if (isRefresh) {
dataList = [];
refreshCtr?.refreshCompleted();
refreshCtr?.resetNoData();
}
//3.追加新数据
dataList ??= [];
dataList!.addAll(dataSource);
//4.更新页码与是否还有更多(直接采用接口 hasNext,不靠数量推断)
currentPage = reqPage;
hasMore = hasNext;
//5.通知footer加载完成/无更多
hasMore ? refreshCtr?.loadComplete() : refreshCtr?.loadNoData();
//6.刷新UI
update();
}
void _fetchFail({required bool isRefresh}) {
//1.失败保留旧数据(首次则初始化为空,避免永远loading)
dataList ??= [];
//2.刷新/加载都正常结束,不显示失败态(保留旧数据,与原列表行为一致)
isRefresh ? refreshCtr?.refreshCompleted() : refreshCtr?.loadComplete();
//3.刷新UI
update();
}
}
+151
View File
@@ -0,0 +1,151 @@
// ignore_for_file: unnecessary_null_comparison, unused_element
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
show DefaultCacheManager;
import 'package:hgdj/tools_base/image/image_data_handle/image_cache_disk.dart';
import 'package:hgdj/tools_base/image/image_data_handle/image_manager.dart';
import 'package:hgdj/tools_base/video_download/video_download_manager.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../../hj_utils/file_util.dart';
import '../../hj_utils/video_cache_manager.dart';
import 'image_cache_manager.dart';
///加载缓存 统计缓存大小
Future<String> loadCache() async {
double total = 0;
Directory videoCacheDir =
Directory(await VideoCacheManager().getFilePath() ?? '');
if (await videoCacheDir.exists()) {
total += await _getTotalSizeOfFilesInDir(videoCacheDir);
}
Directory videoLoadedDir =
Directory(await VideoDownloadManager.instance.cacheDir());
if (await videoLoadedDir.exists()) {
total += await _getTotalSizeOfFilesInDir(videoLoadedDir);
}
// 图片磁盘缓存(ImageCacheDisk)
Directory imageDiskCacheDir = Directory(await ImageCacheDisk.findSavePath());
if (await imageDiskCacheDir.exists()) {
total += await _getTotalSizeOfFilesInDir(imageDiskCacheDir);
}
// CachedNetworkImage 缓存(ImageCacheManager)
Directory imageCacheDir = Directory(await ImageCacheManager().getFilePath());
if (await imageCacheDir.exists()) {
total += await _getTotalSizeOfFilesInDir(imageCacheDir);
}
// NetworkImageLoader 在 encrypt:false 时(直播封面)走 DefaultCacheManager,目录单独统计
Directory defaultImageCacheDir = Directory(await _defaultImageCacheDirPath());
if (await defaultImageCacheDir.exists()) {
total += await _getTotalSizeOfFilesInDir(defaultImageCacheDir);
}
return FileUtil.byteFmt(total.toInt());
}
/// DefaultCacheManager 的磁盘目录:临时目录下的 'libCachedImageData'(flutter_cache_manager 默认 key)
Future<String> _defaultImageCacheDirPath() async {
final dir = await getTemporaryDirectory();
return path.join(dir.path, 'libCachedImageData');
}
Future<double> _getTotalSizeOfFilesInDir(final FileSystemEntity file) async {
try {
if (file is File) {
return (await file.length()).toDouble();
}
if (file is Directory && await file.exists()) {
double total = 0;
// recursive 已一次性列出所有后代文件,只累加 File 即可;不再对子目录二次递归,
// 避免同一文件被重复计入导致缓存大小翻倍。异步 list 替代 listSync,避免大目录阻塞 UI。
await for (final child
in file.list(recursive: true, followLinks: false)) {
if (child is File) {
total += (await child.length()).toDouble();
}
}
return total;
}
} catch (e) {
return 0;
}
return 0;
}
// 手动清理缓存
Future handleClearAllCache() async {
await VideoDownloadManager.instance.emptyCache();
await ImageCacheDisk.emptyCache();
await VideoCacheManager().emptyCache();
await ImageCacheManager().emptyCache();
await DefaultCacheManager().emptyCache(); // 直播封面(encrypt:false)走的默认缓存
}
/// 清除缓存如果必要
/// [force] 强至清除
Future clearCacheIfNeed({bool force = false, bool isHandle = false}) async {
if (force) {
await VideoCacheManager().emptyCache();
// await VideoSubCacheManager().emptyCache();
// CachedVideoStore().clean();
await ImageCacheManager().emptyCache();
if (isHandle) {
//await VideoDownloadManager.instance.emptyCache();
// await ImageCacheDisk.emptyCache();
}
debugPrint(
" ------------------ VideoCacheManager ImageCacheManager clear completely");
} else {
double total = 0;
Directory videoCacheDir =
Directory(await VideoCacheManager().getFilePath() ?? '');
if (await videoCacheDir.exists()) {
total += await _getTotalSizeOfFilesInDir(videoCacheDir);
}
//ts流最大2M,应该是>1000个ts流
if (total > 500 * MB_SIZE) {
await VideoCacheManager().emptyCache();
}
total = 0;
Directory imageCacheDir =
Directory(await ImageCacheManager().getFilePath());
if (await imageCacheDir.exists()) {
total += await _getTotalSizeOfFilesInDir(imageCacheDir);
}
//按照100Kb一张图片的化,估计是5000张网络图片
if (total > 500 * MB_SIZE) {
await ImageCacheManager().emptyCache();
}
}
}
clearAllCache(bool isHandle) async {
//await clearCacheIfNeed(force: true,isHandle: isHandle);
ImageManager.instance.clearBySize(maxSize: 1000);
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
debugPrint(
" ----------- PaintingBinding.instance.imageCache.clearLiveImages()");
}
///递归方式删除目录
Future<Null> delDir(FileSystemEntity file) async {
try {
if (file is Directory) {
final List<FileSystemEntity> children = file.listSync();
for (final FileSystemEntity child in children) {
await delDir(child);
}
}
await file.delete();
} catch (e) {}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:dio/dio.dart';
import '../../hj_utils/text_util.dart';
/// 取消token管理
class CancelTokenManager {
static CancelTokenManager? _instance;
//取消token单例
factory CancelTokenManager() {
_instance ??= CancelTokenManager._();
return _instance!;
}
CancelTokenManager._();
/// 取消token列表
final List<CancelTokenWarp> _cancelTokens = [];
/// 创建cancleToken如果需要
/// [url]
CancelToken createToken(String url, [String uniquekey='']) {
var ctw = CancelTokenWarp(url, CancelToken())..uniqueKey = uniquekey;
_cancelTokens.add(ctw);
return ctw.token;
}
/// 获取cancleToken如果需要
/// [url]
CancelToken? getToken(String url, [String uniquekey = '']) {
// indexWhere 找不到返回 -1,避免 firstWhere 无 orElse 时抛 StateError
final idx = _cancelTokens.indexWhere((it) =>
TextUtil.isEmpty(uniquekey) ? it.url == url : (it.url == url && it.uniqueKey == uniquekey));
return idx < 0 ? null : _cancelTokens[idx].token;
}
List<CancelTokenWarp> get peekList => _cancelTokens;
/// 删除
/// [url]
CancelToken? remove(String url, [String uniquekey = '']) {
// indexWhere 找不到返回 -1,避免 firstWhere 无 orElse 时抛 StateError
final idx = _cancelTokens.indexWhere((it) =>
TextUtil.isEmpty(uniquekey) ? it.url == url : (it.url == url && it.uniqueKey == uniquekey));
if (idx < 0) return null;
return _cancelTokens.removeAt(idx).token;
}
/// 正在请求的长度
int get length => _cancelTokens.length;
}
class CancelTokenWarp {
String url;
CancelToken token;
String? uniqueKey;
CancelTokenWarp(this.url, this.token);
}
+71
View File
@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:http/http.dart' hide Response;
import '../../extension/extensions.dart';
import '../../hj_utils/file_util.dart';
import '../net/load_apk/dio_cli.dart';
import 'cancel_token_manager.dart';
/// dio 文件下载器(视频流缓存的 FileService
class DioFileService extends FileService {
/// 真正请求的 dio
final _dio = createDio();
@override
Future<FileServiceResponse> get(String url,
{Map<String, String>? headers = const {}}) async {
// cancelToken 用文件名注册、下载结束后按同一 key 移除
//(原来 createToken 用 name 而 remove 用 url,key 不一致:既移不掉导致泄漏,又会 firstWhere 抛 StateError
final name = FileUtil.getName(url);
final token = CancelTokenManager().createToken(name);
// 每次请求用独立 Options:本类随 VideoCacheManager 单例,共享一份 Options 会被并发请求互相覆盖 headers
final options = Options(
method: 'GET',
sendTimeout: const Duration(milliseconds: 5000),
receiveTimeout: const Duration(milliseconds: 10000),
headers: {...?headers, 'cache-control': 'max-age=31104000'},
contentType: ContentType.binary.toString(),
responseType: ResponseType.stream,
);
Response<ResponseBody>? resp;
try {
debugLog("DioFileService get: $url");
resp = await _dio.get<ResponseBody>(url,
cancelToken: token, options: options);
} catch (e) {
debugLog("DioFileService get error: $url -> $e");
} finally {
CancelTokenManager().remove(name);
}
// 只认 200/206;其余(含请求异常 resp==null)直接抛:避免返回空流挂起、坏响应被缓存
final statusCode = resp?.statusCode ?? HttpStatus.badRequest;
if (resp?.data == null || (statusCode != 200 && statusCode != 206)) {
throw HttpException("video chunk fetch failed ($statusCode): $url");
}
final respHeaders = <String, String>{};
resp!.headers.forEach((key, values) {
if (values.notEmpty()) respHeaders[key] = values.first;
});
// Content-Length 可能缺失/非数字,安全解析(原 contentLengthS[0] 在空串时会 RangeError);缺失传 null 表示长度未知
final contentLength =
int.tryParse(respHeaders[Headers.contentLengthHeader] ?? '');
debugLog(
"DioFileService resp($statusCode): $url contentLength:$contentLength");
return HttpGetResponse(StreamedResponse(
resp.data!.stream,
statusCode,
headers: respHeaders,
contentLength: contentLength,
));
}
}
@@ -0,0 +1,104 @@
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
/// 浏览历史本地存储基类(sqflite 持久化)
/// 子类需声明 [dbName] 和 [tableName],每种业务类型独占一个 db 文件
abstract class BaseHistoryRecordStore {
/// 子类提供:db 文件名(如 video_history.db
String get dbName;
/// 子类提供:表名(如 video_history
String get tableName;
/// 同一类型保留最大条数(超过后插入时自动删最旧)
static const int maxRows = 1000;
Database? _db;
Future<Database> _openDB() async {
if (_db != null) return _db!;
final dir = await getDatabasesPath();
final path = p.join(dir, dbName);
_db = await openDatabase(
path,
version: 1,
onCreate: (db, v) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $tableName(
model_id TEXT PRIMARY KEY,
create_time INTEGER NOT NULL,
model_data TEXT NOT NULL
);
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_${tableName}_time ON $tableName(create_time DESC)',
);
},
);
return _db!;
}
/// 保存记录(model_id 主键冲突时 REPLACE 实现去重,自动刷新 create_time = "插到最前"
Future<bool> save({
required String modelId,
required String modelDataJson,
}) async {
if (modelId.isEmpty) return false;
final db = await _openDB();
await db.insert(
tableName,
{
'model_id': modelId,
'create_time': DateTime.now().millisecondsSinceEpoch,
'model_data': modelDataJson,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _trimOverflow(db);
return true;
}
/// 分页查询(按创建时间倒序),返回 model_data JSON 字符串列表
Future<List<String>> fetch({
int page = 1,
int pageSize = maxRows,
}) async {
final db = await _openDB();
final offset = ((page - 1) < 0 ? 0 : (page - 1)) * pageSize;
final rows = await db.query(
tableName,
columns: ['model_data'],
orderBy: 'create_time DESC',
limit: pageSize,
offset: offset,
);
return rows.map((r) => r['model_data'] as String).toList();
}
/// 删除单条
Future<bool> remove(String modelId) async {
if (modelId.isEmpty) return false;
final db = await _openDB();
await db.delete(tableName, where: 'model_id = ?', whereArgs: [modelId]);
return true;
}
/// 清空当前类型的所有记录
Future<void> clean() async {
final db = await _openDB();
await db.delete(tableName);
}
/// 超过 maxRows 时删除最旧的若干条
Future<void> _trimOverflow(Database db) async {
final cntRow = await db.rawQuery('SELECT COUNT(*) AS cnt FROM $tableName');
final cnt = Sqflite.firstIntValue(cntRow) ?? 0;
if (cnt <= maxRows) return;
final excess = cnt - maxRows;
await db.execute(
'DELETE FROM $tableName WHERE rowid IN ('
'SELECT rowid FROM $tableName ORDER BY create_time ASC LIMIT ?)',
[excess],
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 卡通浏览历史(MediaStyle.Cartoon
class CartoonHistoryStore extends BaseHistoryRecordStore {
static CartoonHistoryStore? _instance;
factory CartoonHistoryStore() => _instance ??= CartoonHistoryStore._();
CartoonHistoryStore._();
@override
String get dbName => 'cartoon_history.db';
@override
String get tableName => 'cartoon_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 漫画浏览历史(MediaStyle.Comics
class ComicsHistoryStore extends BaseHistoryRecordStore {
static ComicsHistoryStore? _instance;
factory ComicsHistoryStore() => _instance ??= ComicsHistoryStore._();
ComicsHistoryStore._();
@override
String get dbName => 'comics_history.db';
@override
String get tableName => 'comics_history';
}
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 社区帖子浏览历史(MediaStyle.Community
class CommunityHistoryStore extends BaseHistoryRecordStore {
static CommunityHistoryStore? _instance;
factory CommunityHistoryStore() => _instance ??= CommunityHistoryStore._();
CommunityHistoryStore._();
@override
String get dbName => 'community_history.db';
@override
String get tableName => 'community_history';
}
+157
View File
@@ -0,0 +1,157 @@
import 'dart:convert';
import 'package:hgdj/hj_model/drama/drama_models.dart';
import 'package:hgdj/hj_model/drama_media_info.dart';
import 'package:hgdj/tools_base/cache/history/base_history_record_store.dart';
import 'package:hgdj/tools_base/debug_log.dart';
/// 短剧观看记录:纯本地,不依赖后端。一部剧一行(看到哪一集、第几秒 + 剧信息快照),
/// 最多留 [BaseHistoryRecordStore.maxRows] 条,超了先淘汰最久没看的那部。
/// 它同时是「续播位置」和「历史记录」两件事的数据源——每次写入都会刷新 create_time
/// 所以 [history] 按 create_time 倒序拿到的就是最近观看列表,不用再建第二张表
class DramaResumeStore extends BaseHistoryRecordStore {
static final DramaResumeStore _instance = DramaResumeStore._();
factory DramaResumeStore() => _instance;
DramaResumeStore._();
@override
String get dbName => 'drama_resume.db';
@override
String get tableName => 'drama_resume';
//读盘一次,之后 of() 都走内存(它是同步的,定位续播集时没法等 IO)
Map<String, DramaResume>? _cache;
//并发挡板:Feed 和二级页会同时调 load(),没这层的话后到的直接返回,
//拿着还没填好的表去定位,续播位置就白丢了
Future<void>? _loading;
/// 全表读进内存。进短剧频道/二级页前调一次即可,重复调只读一次
Future<void> load() => _loading ??= _load();
Future<void> _load() async {
final disk = <String, DramaResume>{};
try {
//反着插:Map 保留插入顺序,最旧的排在队头,超量从队头砍,队尾就是最近看的
for (final item in (await _readAllDesc()).reversed) {
disk[item.mediaId] = item;
}
} catch (e) {
debugLog('短剧续播记录读取失败', e.toString()); // 坏数据直接当没有,别把页面带崩
}
//读盘期间已经看起来的那几条比盘上的新:后放,既覆盖旧值又排到队尾
final live = _cache;
_cache = disk;
live?.forEach(_put);
}
/// 写内存表:先删再插把它挪到队尾(=最近观看),超 [BaseHistoryRecordStore.maxRows] 从队头砍。
/// 淘汰口径要和 db 的 create_time 对齐,否则内存里会留着盘上已经没有的剧
void _put(String mediaId, DramaResume item) {
final cache = _cache ??= {};
cache
..remove(mediaId)
..[mediaId] = item;
while (cache.length > BaseHistoryRecordStore.maxRows) {
cache.remove(cache.keys.first);
}
}
/// 这部剧上次看到哪:没看过返回 null
DramaResume? of(String? mediaId) => mediaId == null ? null : _cache?[mediaId];
/// 读全表并按「最后观看时间」倒序。老数据没有 watchedAt(全是 0),同值时退回 db 给的
/// create_time 顺序,别让升级前的那批互相乱掉——List.sort 不保证稳定,所以拿下标当次序键
Future<List<DramaResume>> _readAllDesc() async {
final rows = await fetch(); // db 那层给的就是 create_time 倒序
final indexed = <(int, DramaResume)>[];
for (var i = 0; i < rows.length; i++) {
final item = DramaResume.fromJson(jsonDecode(rows[i]));
if (item.mediaId.isNotEmpty) indexed.add((i, item));
}
indexed.sort((a, b) {
final byTime = b.$2.watchedAt.compareTo(a.$2.watchedAt);
return byTime != 0 ? byTime : a.$1.compareTo(b.$1);
});
return indexed.map((e) => e.$2).toList();
}
/// 观看历史:按最后观看时间倒序分页,不走 db 分页——排序键是记录里的 watchedAt,
/// 本地表统共上限 1000 条,整表读出来再切页就够,也免得把内存表里的剧信息快照交出去被改
Future<List<DramaResume>> history({int page = 1, int pageSize = 20}) async {
try {
final all = (await _readAllDesc()).where((e) => e.drama != null).toList();
final start = (page < 1 ? 0 : page - 1) * pageSize;
if (start >= all.length) return [];
final end = start + pageSize;
return all.sublist(start, end > all.length ? all.length : end);
} catch (e) {
debugLog('短剧观看记录读取失败', e.toString());
return [];
}
}
/// 删一条(历史记录页编辑态用)。内存表要一起删,否则 [of] 还会把它当续播位置返回
Future<void> erase(String? mediaId) async {
if (mediaId == null || mediaId.isEmpty) return;
_cache?.remove(mediaId);
await remove(mediaId);
}
/// 刷剧模式划到这部剧:每次都把观看时间刷成现在(历史记录按它倒序,最后刷到的排最前),
/// 但进度一律不动——已有记录就原样沿用二级页记下的集数+秒数,没有才新建一条 0 秒的
Future<void> markWatched({
required String? mediaId,
required String? contentId,
DramaMediaInfo? drama,
}) async {
if (mediaId == null || mediaId.isEmpty) return;
await load(); // 盘还没读完就取,会把已有进度当成没有,反手写一条 0 秒的盖掉
final last = _cache?[mediaId];
record(
mediaId: mediaId,
//空 contentId 会被 record 挡掉,那样这次刷新就白记了,退回当前这一集
contentId:
last?.contentId.isNotEmpty == true ? last!.contentId : contentId,
seconds: last?.progressSeconds ?? 0,
drama: drama ?? last?.drama,
);
}
/// 记一次进度。[seconds] 传 0 表示从头看(本集刚播完时用)。
/// [drama] 是列表展示用的剧信息快照,不传就沿用这部剧上一条记录里的,别把它写没了
void record({
required String? mediaId,
required String? contentId,
required int seconds,
DramaMediaInfo? drama,
}) {
//空串和 null 一样要挡:contentId 为空的记录谁都匹配不上,
//却会把这部剧上一条好记录顶掉,还会让 _resumeIndex 白翻完整部剧的分页
if (mediaId == null ||
mediaId.isEmpty ||
contentId == null ||
contentId.isEmpty) return;
final item = DramaResume(
mediaId: mediaId,
contentId: contentId,
progressSeconds: seconds < 0 ? 0 : seconds,
drama: drama ?? _cache?[mediaId]?.drama,
watchedAt: DateTime.now().millisecondsSinceEpoch,
);
_put(mediaId, item);
//不等落盘:主键冲突走 REPLACE,顺带把 create_time 刷成本次时间,超量时最久没看的先淘汰。
//必须接住异常,否则 db 出错会变成没人管的 async error
save(modelId: mediaId, modelDataJson: jsonEncode(item.toJson()))
.catchError((e) {
debugLog('短剧续播记录写入失败', e.toString());
return false;
});
}
}
/// 全站唯一实例
final dramaResume = DramaResumeStore();
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 黄游浏览历史(MediaStyle.Game
class GameHistoryStore extends BaseHistoryRecordStore {
static GameHistoryStore? _instance;
factory GameHistoryStore() => _instance ??= GameHistoryStore._();
GameHistoryStore._();
@override
String get dbName => 'game_history.db';
@override
String get tableName => 'game_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 小说浏览历史(MediaStyle.Novel
class NovelHistoryStore extends BaseHistoryRecordStore {
static NovelHistoryStore? _instance;
factory NovelHistoryStore() => _instance ??= NovelHistoryStore._();
NovelHistoryStore._();
@override
String get dbName => 'novel_history.db';
@override
String get tableName => 'novel_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 图集浏览历史(MediaStyle.Pic
class PicHistoryStore extends BaseHistoryRecordStore {
static PicHistoryStore? _instance;
factory PicHistoryStore() => _instance ??= PicHistoryStore._();
PicHistoryStore._();
@override
String get dbName => 'pic_history.db';
@override
String get tableName => 'pic_history';
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
/// 搜索历史本地存储(sqflite 持久化)
/// keyword 作为主键去重:重复搜索 REPLACE 刷新时间,即「提到最前」
class SearchHistoryStore {
static SearchHistoryStore? _instance;
factory SearchHistoryStore() => _instance ??= SearchHistoryStore._();
SearchHistoryStore._();
static const String _dbName = 'search_history.db';
static const String _tableName = 'search_history';
/// 最多保留条数,超过后插入时自动删最旧
static const int maxRows = 10;
Database? _db;
Future<Database> _openDB() async {
if (_db != null) return _db!;
final dir = await getDatabasesPath();
final path = p.join(dir, _dbName);
_db = await openDatabase(
path,
version: 1,
onCreate: (db, v) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $_tableName(
keyword TEXT PRIMARY KEY,
create_time INTEGER NOT NULL
);
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_${_tableName}_time ON $_tableName(create_time DESC)',
);
},
);
return _db!;
}
/// 新增一条搜索词(主键冲突 REPLACE 实现去重并刷新时间 = 提到最前)
Future<void> save(String keyword) async {
if (keyword.isEmpty) return;
final db = await _openDB();
await db.insert(
_tableName,
{'keyword': keyword, 'create_time': DateTime.now().millisecondsSinceEpoch},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _trimOverflow(db);
}
/// 查询全部搜索词(按时间倒序)
Future<List<String>> fetchAll() async {
final db = await _openDB();
final rows = await db.query(
_tableName,
columns: ['keyword'],
orderBy: 'create_time DESC',
);
return rows.map((r) => r['keyword'] as String).toList();
}
/// 清空全部搜索历史
Future<void> clean() async {
final db = await _openDB();
await db.delete(_tableName);
}
/// 超过 [maxRows] 时删除最旧的若干条
Future<void> _trimOverflow(Database db) async {
final cntRow = await db.rawQuery('SELECT COUNT(*) AS cnt FROM $_tableName');
final cnt = Sqflite.firstIntValue(cntRow) ?? 0;
if (cnt <= maxRows) return;
await db.execute(
'DELETE FROM $_tableName WHERE rowid IN ('
'SELECT rowid FROM $_tableName ORDER BY create_time ASC LIMIT ?)',
[cnt - maxRows],
);
}
}
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 短视频浏览历史(MediaStyle.ShortVideo
class ShortVideoHistoryStore extends BaseHistoryRecordStore {
static ShortVideoHistoryStore? _instance;
factory ShortVideoHistoryStore() => _instance ??= ShortVideoHistoryStore._();
ShortVideoHistoryStore._();
@override
String get dbName => 'short_video_history.db';
@override
String get tableName => 'short_video_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 视频浏览历史(MediaStyle.Video
class VideoHistoryStore extends BaseHistoryRecordStore {
static VideoHistoryStore? _instance;
factory VideoHistoryStore() => _instance ??= VideoHistoryStore._();
VideoHistoryStore._();
@override
String get dbName => 'video_history.db';
@override
String get tableName => 'video_history';
}
+119
View File
@@ -0,0 +1,119 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:http/http.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../../config/address.dart';
import '../../extension/extensions.dart';
import '../debug_log.dart';
import '../image/image_data_handle/image_crypto.dart';
import '../net/load_apk/dio_cli.dart';
class ImageCacheManager extends CacheManager {
static const image_key = "customCache";
// 工厂模式
factory ImageCacheManager() => _getInstance();
static ImageCacheManager get instance => _getInstance();
static ImageCacheManager? _instance;
static ImageCacheManager _getInstance() {
_instance ??= ImageCacheManager._internal();
return _instance!;
}
ImageCacheManager._internal()
: super(Config(image_key, maxNrOfCacheObjects: 400, stalePeriod: const Duration(days: 7), fileService: CustomFileRespons()));
Future<String> getFilePath() async {
var directory = await getTemporaryDirectory();
return path.join(directory.path, image_key);
}
}
class CustomFileRespons extends HttpFileService {
DioCli client = DioCli(
options: BaseOptions(
connectTimeout: const Duration(milliseconds: 30000),
receiveTimeout: const Duration(milliseconds: 30000),
sendTimeout: const Duration(milliseconds: 30000),
validateStatus: (int? status) {
if (status != null) return status < 600;
return false;
}));
@override
Future<FileServiceResponse> get(String url, {Map<String, String>? headers = const {}}) async {
if (!url.startsWith("http") && !url.startsWith("https")) {
url = path.join(Address.baseImagePath ?? '', url);
}
if (kDebugMode) {
debugLog("image_request", "get()...begin...ulr:$url...");
}
headers?['cache-control'] = 'max-age=31104000';
final resp = await client.getBytes(url, headers: headers);
final statusCode = resp.data?.statusCode ?? HttpStatus.badRequest;
// 失败 / 非 2xx:直接抛。避免错误响应(空内容、CDN 错误页)被落盘缓存成坏图,
// 让 CachedNetworkImage 走 errorWidget;下次能重新请求,而不是一直命中坏缓存。
if (resp.err != null || statusCode < 200 || statusCode >= 300) {
if (kDebugMode) {
debugLog("image_request", "get()...failed...$url...code:($statusCode): ${resp.err}");
}
throw HttpException("image fetch failed ($statusCode): ${resp.err}", uri: Uri.tryParse(url));
}
// 解密(Dio 不同版本 bytes 返回 List<int> 或 Uint8List)。解密失败 / 空字节同样抛出,不缓存坏图。
Uint8List bytes;
try {
final raw = resp.data?.data;
final decrypted = raw == null ? null : ImageCrypto.decryptImage(raw is Uint8List ? raw : Uint8List.fromList(raw));
if (decrypted == null || decrypted.isEmpty) {
throw const FormatException("empty image bytes");
}
//部分 CDN 返回的加密 JPEG 缺末尾 EOI(FF D9)Skia 严格 decoder 会拒;补 EOI 让标准 decoder 也能解
bytes = _repairJpegEoiIfNeeded(decrypted);
} catch (e) {
if (kDebugMode) debugLog("ImageCacheManager", "decrypt failed: $url -> $e");
throw HttpException("image decrypt failed: $e", uri: Uri.tryParse(url));
}
final respHeaders = <String, String>{};
resp.data?.headers.forEach((key, arrayValue) {
if (arrayValue.notEmpty()) respHeaders[key] = arrayValue[0];
});
if (kDebugMode) {
debugLog("image_request", "get()...success...$url...code:($statusCode): contentLength:${bytes.length}");
}
return HttpGetResponse(StreamedResponse(
Stream.value(bytes),
statusCode,
contentLength: bytes.length, // 补 EOI 后用真实长度
headers: respHeaders,
));
}
}
//判断是 jpeg 头但无 FF D9 结尾时补 EOI;其他格式 / 已完整 jpeg 原样返回
Uint8List _repairJpegEoiIfNeeded(Uint8List bytes) {
if (bytes.length < 4) return bytes;
final isJpeg = bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
if (!isJpeg) return bytes;
final hasEoi = bytes[bytes.length - 2] == 0xFF && bytes[bytes.length - 1] == 0xD9;
if (hasEoi) return bytes;
final fixed = Uint8List(bytes.length + 2)
..setRange(0, bytes.length, bytes)
..[bytes.length] = 0xFF
..[bytes.length + 1] = 0xD9;
return fixed;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
// TEMP: 诊断 release-only 问题用,临时让 debugLog 在 release 也输出。
// 用完务必改回 false,否则线上日志会被业务 print 淹没。
const _kForceDebugLogInRelease = false;
void debugLog(Object? message, [Object? message2]) {
if (!kDebugMode && !_kForceDebugLogInRelease) return;
_printChunked(message);
if (message2 != null) _printChunked(message2);
}
/// 长文本完整输出:
/// 1) 用 debugPrint 替代 print —— dart:core print 高频时会被 Flutter/logcat 节流丢段,
/// debugPrint 节流但排队不丢,能保证完整;
/// 2) 按 ~800 字符分段 —— 避开 Android logcat 单条(~1KB)截断。
void _printChunked(Object? message) {
final str = message?.toString() ?? 'null';
const chunkSize = 800;
if (str.length <= chunkSize) {
debugPrint(str);
return;
}
for (var i = 0; i < str.length; i += chunkSize) {
final end = (i + chunkSize < str.length) ? i + chunkSize : str.length;
debugPrint(str.substring(i, end));
}
}
@@ -0,0 +1,45 @@
import 'dart:async';
import 'package:event_bus/event_bus.dart';
typedef EventCallback<T> = void Function(T event);
class EventBusUtil {
//保存单例
static final EventBusUtil _instance = EventBusUtil._internal();
//工厂构造函数
factory EventBusUtil() => _instance;
//初始化eventBus
late EventBus _eventBus;
EventBusUtil._internal() {
// 初始化
_eventBus = EventBus();
}
/// 订阅stream列表
// List<StreamSubscription> subscriptionList;
/// 开启eventbus订阅 并
StreamSubscription on<T>(EventCallback<T> callback) {
StreamSubscription stream = _eventBus.on<T>().listen((event) {
callback(event);
});
// subscriptionList.add(stream);
return stream;
}
/// 发送消息
void emit(event) {
_eventBus.fire(event);
}
/// 移除steam
void off(StreamSubscription steam) {
steam.cancel();
}
}
var eventBus = EventBusUtil._instance;
+48
View File
@@ -0,0 +1,48 @@
/// 暂停所有视频播放(耳机/蓝牙断开、来电中断时通知长视频/直播/简单播放器暂停,防止外放泄露隐私)
class PauseVideoEvent {}
//刷新动漫详情是否显示顶部
class CollectEvent {
bool collect;
CollectEvent({this.collect = false});
}
class CollectStatusModel {
String? id; // 点赞,收藏id值
String? type;
int? uid; // 用户关注 uid
bool? isCollected; // 这个有值,是收藏状态的处理
bool? isLiked; // 这个有值,是点赞状态的处理
bool? isFollowed; // 这个有值,是关注状态的处理
/// 点赞数变化量:点赞 +1,取消点赞 -1
int? likeCountDelta;
CollectStatusModel({
this.id,
this.uid,
this.isCollected,
this.type,
this.isLiked,
this.isFollowed,
this.likeCountDelta,
});
}
class ReLoginEvent {}
class YinseinnerModel {
String? routeName;
String? id;
String? type;
YinseinnerModel({this.routeName, this.id, this.type});
}
class TabIndexChanged {
int? index;
TabIndexChanged({this.index = 0});
}
//acg目录修改
class ACGMenuChanged {
int? index;
ACGMenuChanged({this.index = 0});
}
@@ -0,0 +1,348 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import '../../config/address.dart';
import '../../hj_utils/file_util.dart';
import '../loading/loading_alert_widget.dart';
import '../net/http_resp_interceptor.dart';
import '../net/net_manager.dart';
import 'upload_result_model.dart';
/// 文件上传工具:图片(压缩后整传)、视频(分片传)
class FileUploadTool {
// ==================== 配置 ====================
/// 图片压缩目标体积上限 200KB(尽量压到此值内,触及质量下限则止)
static const int maxImageSize = 200 * 1024;
/// 压缩长边上限(保清晰)
static const int maxImageEdge = 1920;
/// 压缩质量下限(再小也不低于此,避免糊)
static const int minCompressQuality = 60;
/// 视频单片最大重试次数(首传失败后再试 N 次,pos+id 固定,重传幂等)
static const int maxChunkRetry = 2;
// ==================== 图片上传 ====================
/// 上传单张图片(读本地文件 → 压缩 → 上传),本地文件读不到时返回 null
Future<ImageUploadResultModel?> uploadImage(String path,
{Function(int, int)? callback}) async {
final ext = FileUtil.getNameSuffix(path);
final fileName =
'${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.$ext';
Uint8List fileData;
try {
// 相册图被删/路径失效时 readAsBytes 会抛,统一转成 null 失败,别让异常穿到调用方
fileData = await File(path).readAsBytes();
} catch (e) {
debugLog("uploadImage()...read error:$e");
return null;
}
return uploadImageData(fileData, fileName: fileName, callback: callback);
}
/// 上传图片字节数据(压缩后 POST),成功返回结果模型,失败返回 null
/// [callback] (已传字节, 总字节)
Future<ImageUploadResultModel?> uploadImageData(
Uint8List imageData, {
String? fileName,
Function(int, int)? callback,
}) async {
final compressedData =
await _compressImage(imageData, maxSize: maxImageSize);
final name = fileName ??
'${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.jpg';
debugLog("开始上传----", name);
final formData = FormData.fromMap({
'upload': MultipartFile.fromBytes(compressedData, filename: name),
});
final options = await _buildOptions();
try {
final resp = await createDio().post(
"${Address.baseApiPath}${Address.uploadImg}",
options: options,
data: formData,
onSendProgress: (count, total) => callback?.call(count, total),
);
if (resp.statusCode == 200) {
await HttpRespInterceptor.handleResponse(resp);
return ImageUploadResultModel.fromMap(resp.data);
}
return ImageUploadResultModel();
} catch (e) {
debugLog("uploadImageData()...error:$e");
return null;
}
}
/// 批量上传图片(有界并发 + 保序),任一失败立即中断并返回 null
/// [maxConcurrent] 最大并发数,默认 5[onProgress] 整体进度 0.0~1.0(单调递增)
Future<List<ImageUploadResultModel>?> uploadImageList(
List<String> paths, {
int maxConcurrent = 5,
void Function(double progress)? onProgress,
}) async {
if (paths.isEmpty) return [];
final results = List<ImageUploadResultModel?>.filled(paths.length, null);
final fractions = List<double>.filled(paths.length, 0.0); // 每张的完成度 0~1
bool failed = false;
void reportProgress() {
if (onProgress == null) return;
final sum = fractions.fold<double>(0, (a, b) => a + b);
onProgress(sum / paths.length);
}
// 单 isolate 无真并行,index 领取不会有竞态
int nextIndex = 0;
Future<void> worker() async {
while (!failed) {
final i = nextIndex;
if (i >= paths.length) return;
nextIndex++;
final model = await uploadImage(paths[i], callback: (sent, total) {
fractions[i] = total > 0 ? sent / total : 0;
reportProgress();
});
if (model?.coverImg?.isNotEmpty == true) {
results[i] = model;
fractions[i] = 1;
reportProgress();
} else {
failed = true; // 任一失败:不再领取新任务
return;
}
}
}
final workerCount = min(maxConcurrent, paths.length);
await Future.wait(List.generate(workerCount, (_) => worker()));
if (failed) return null;
return results.map((e) => e!).toList();
}
/// 批量上传图片,内部自管「上传阶段」的 loading(show → 实时进度 → 结束 cancel)。
/// 上传成功回调 [onSuccess](图片 url 列表),失败或结果为空回调 [onFailure]。
Future<void> uploadImagesWithProgress(
List<String> paths, {
String title = "正在上传图片",
required Function(List<String> urls) onSuccess,
Function()? onFailure,
}) async {
List<ImageUploadResultModel>? results;
LoadingAlertWidget.show(title: "$title...");
try {
results = await uploadImageList(paths, onProgress: (progress) {
LoadingAlertWidget.showExchangeTitle(
"$title${(progress * 100).toStringAsFixed(1)}%");
});
} catch (e) {
debugLog(
"uploadImagesWithProgress()...error:$e"); // 兜底:异常也要把 loading 关掉,别卡死转圈
} finally {
LoadingAlertWidget.cancel();
}
final urls = results?.map((e) => e.coverImg ?? "").toList() ?? [];
if (urls.isEmpty) {
onFailure?.call();
} else {
onSuccess(urls);
}
}
// ==================== 视频上传 ====================
/// 上传视频(分片 POST,有界并发 + 单片失败重试 + 全部成功后回填 md5),失败(文件缺失 / 重试用尽)返回 null
/// [maxConcurrent] 最大并发片数,默认 5[onProgress] 整体进度 0.0~1.0
Future<VideoUploadResultModel?> uploadVideo(
String localPath, {
int maxConcurrent = 5,
void Function(double progress)? onProgress,
}) async {
if (!FileUtil.isFileExist(localPath)) {
return null;
}
try {
final videoFile = File(localPath);
final fileLen = FileUtil.getFileSize(localPath);
final fileBytes = await videoFile.readAsBytes();
final fileId = md5.convert(fileBytes).toString();
final patchSize = FileUtil.getPatchSize(fileLen);
final patchCount = FileUtil.getPatchCount(fileLen);
debugLog("分段大小:$patchSize 视频被分成:$patchCount 个片段");
final options = await _buildOptions(
contentType: "application/json",
receiveTimeout: const Duration(seconds: 60),
responseType: ResponseType.json,
);
final fractions = List<double>.filled(patchCount, 0.0); // 每片完成度 0~1
VideoUploadResultModel? finalResult; // 收尾片(带非空 videoUri)的响应
bool failed = false;
void reportProgress() {
if (onProgress == null) return;
final sum = fractions.fold<double>(0, (a, b) => a + b);
onProgress(sum / patchCount);
}
// 单 isolate 无真并行,index 领取不会有竞态
int nextIndex = 0;
Future<void> worker() async {
while (!failed) {
final index = nextIndex;
if (index >= patchCount) return;
nextIndex++;
// 每片取 patchSize,最后一片取剩余字节;直接切内存中的 fileBytes(零拷贝视图),避免重读磁盘/开文件句柄
final start = index * patchSize;
final end = min(start + patchSize, fileBytes.length);
// 正常 start 必 < fileBytes.length;越界(文件被截短)时取空块,不抛 RangeError
final blockData = start < end
? Uint8List.sublistView(fileBytes, start, end)
: Uint8List(0);
final postData = {
'data': base64.encode(blockData),
'pos': index + 1,
'totalPos': patchCount,
'id': fileId,
};
// 单片重试:pos+id 固定,重传幂等;弱网偶发丢片不再整段作废。指数退避 0.5s/1s
Object? lastError;
for (int attempt = 0; attempt <= maxChunkRetry; attempt++) {
if (failed) return; // 其它 worker 已判定失败,无谓再传
if (attempt > 0) {
await Future.delayed(
Duration(milliseconds: 500 * (1 << (attempt - 1))));
debugLog("uploadVideo() 第${index + 1}片 第$attempt 次重试");
}
try {
final resp = await createDio().post(
Address.baseApiPath! + Address.uploadVideo,
options: options,
data: postData,
onSendProgress: (sent, total) {
fractions[index] = total > 0 ? sent / total : 0;
reportProgress();
},
);
await HttpRespInterceptor.handleResponse(resp);
fractions[index] = 1;
reportProgress();
// 并发下到达顺序不定,只认带 videoUri 的响应为收尾结果
final r = VideoUploadResultModel.fromMap(resp.data);
if (r.videoUri?.isNotEmpty == true) {
finalResult = r;
}
lastError = null;
break; // 本片成功,跳出重试
} catch (e) {
lastError = e;
fractions[index] = 0; // 回退本片进度,避免重试期间整体进度虚高
reportProgress();
debugLog("uploadVideo() 第${index + 1}片 error(第$attempt 次):$e");
}
}
if (lastError != null) {
failed = true; // 重试用尽仍失败:不再领取新片
return;
}
}
}
final workerCount = min(maxConcurrent, patchCount);
await Future.wait(List.generate(workerCount, (_) => worker()));
if (failed) return null;
finalResult?.md5 = fileId;
return finalResult;
} catch (e) {
debugLog("uploadVideo()...error:$e");
return null;
}
}
/// 上传视频,内部自管「上传阶段」的 loading(show → 实时进度 → 结束 cancel)。
/// 上传成功回调 [onSuccess](结果模型),失败(文件缺失/异常)回调 [onFailure]。
/// 注意:回调触发前 loading 已 cancel,后续若还需 loading 请在回调内自行 show。
Future<void> uploadVideoWithProgress(
String localPath, {
String title = "正在上传视频",
required Function(VideoUploadResultModel result) onSuccess,
Function()? onFailure,
}) async {
LoadingAlertWidget.show(title: "$title...");
final result = await uploadVideo(localPath, onProgress: (progress) {
LoadingAlertWidget.showExchangeTitle(
"$title${(progress * 100).toStringAsFixed(1)}%");
});
LoadingAlertWidget.cancel();
if (result != null) {
onSuccess(result);
} else {
onFailure?.call();
}
}
// ==================== 内部工具 ====================
/// 构造 POST 请求配置(默认图片上传用;视频上传覆写 contentType / 超时 / responseType
Future<Options> _buildOptions({
String contentType = "*/*", // 暂时让服务器全部接受
Duration receiveTimeout = const Duration(seconds: 30),
ResponseType? responseType,
}) async {
return Options(
method: "POST",
sendTimeout: const Duration(seconds: 30),
receiveTimeout: receiveTimeout,
contentType: contentType,
responseType: responseType,
headers: {
'User-Agent': await netManager.userAgent(),
'Authorization': await netManager.getToken(),
},
);
}
/// 压缩图片:长边限到 maxImageEdge 保清晰,从高质量起逐档降(每次都从原图重压,避免二次劣化),
/// 直到体积 ≤ maxSize 或触及质量下限 minCompressQuality(保清晰优先,不无脑压糊)。统一转 JPEG。
Future<Uint8List> _compressImage(Uint8List bytes,
{required int maxSize}) async {
Future<Uint8List> compress(int quality) =>
FlutterImageCompress.compressWithList(
bytes,
minWidth: maxImageEdge,
minHeight: maxImageEdge,
quality: quality,
format: CompressFormat.jpeg,
);
int quality = 90;
var result = await compress(quality);
while (result.length > maxSize && quality > minCompressQuality) {
quality -= 15; // 90 → 75 → 60
result = await compress(quality);
}
debugLog(
"图片压缩 ${bytes.length ~/ 1024}KB → ${result.length ~/ 1024}KB (q$quality)");
return result;
}
}
@@ -0,0 +1,23 @@
/// 图片上传结果
class ImageUploadResultModel {
String? coverImg; // 图片远程地址
static ImageUploadResultModel fromMap(Map<String, dynamic>? map) {
map ??= {};
return ImageUploadResultModel()..coverImg = map['coverImg'];
}
}
/// 视频上传结果
class VideoUploadResultModel {
String? id; // 视频资源 id
String? videoUri; // 视频远程地址
String? md5; // 文件 md5(分片全部上传成功后回填)
static VideoUploadResultModel fromMap(Map<String, dynamic>? map) {
map ??= {};
return VideoUploadResultModel()
..id = map['id']
..videoUri = map['videoUri'];
}
}
+258
View File
@@ -0,0 +1,258 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/hj_utils/light_model.dart';
import 'package:hgdj/tools_base/ad_manager.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../config/config.dart';
import '../../hj_model/user/user_info_model.dart';
import '../../hj_model/user/wallet_model.dart';
import '../../hj_model/video_model.dart';
import '../../hj_utils/store_keys.dart';
import '../../hj_utils/text_util.dart';
import '../../track_event_manager/device_service.dart';
import '../net/net_manager.dart';
/// 全局用户状态(登录信息、钱包、锁屏密码),ChangeNotifier 单例
final globalStore = GlobalStore();
class GlobalStore extends ChangeNotifier {
static final GlobalStore _instance = GlobalStore._();
GlobalStore._();
factory GlobalStore() => _instance;
// VIP 等级(对应服务端 vipLevel
static const int vipNormal = 0; // 普通用户
static const int vipBasic = 1; // 普通会员
static const int vipSuper = 2; // 超级会员
static const int vipDarkWeb = 3; // 暗网会员
static const int vipSwap = 4; // 换妻会员
final password = <int>[]; // 锁屏密码
bool isUnlocked = false; // 本次是否已通过锁屏校验
UserInfoModel? meInfo; // 个人信息
WalletModel? wallet; // 钱包信息
/// 短剧权益从无到有的那一刻。后台加卡不会推给客户端,只有重新拉到用户信息才知道,
/// 还挂着付费墙/正在放试看的短剧盯着它重问一次服务端
final dramaCardGained = ValueNotifier(0);
/// 启动时从本地读取锁屏密码
Future<void> init() async {
final nativePass = await lightKV.getString(StoreKeys.PASSWORD_LOCK);
if (nativePass == null) return;
try {
password.addAll(List<int>.from(json.decode(nativePass)));
} catch (e) {
debugLog('锁屏密码解析失败', e);
}
}
/// 设置锁屏密码
Future<bool?> setLockPassword(List<int> input) async {
final res =
await lightKV.setString(StoreKeys.PASSWORD_LOCK, json.encode(input));
if (res == true) {
showToast('设置密码成功');
//落盘写的是 input(覆盖),内存也得覆盖;addAll 会把旧密码留在前面,与落盘的对不上
password
..clear()
..addAll(input);
notifyListeners();
return true;
}
return res;
}
/// 关闭锁屏密码(需校验原密码)
Future<bool?> closeLockPassword(List<int> input) async {
if (input.join('') != password.join('')) {
showToast('密码验证失败,请重新输入~');
return false;
}
final res =
await lightKV.setString(StoreKeys.PASSWORD_LOCK, json.encode([]));
if (res == true) {
showToast('取消锁屏密码成功');
password.clear();
notifyListeners();
}
return res;
}
/// 校验锁屏密码
Future<bool?> checkLockPassword(List<int> input) async {
if (input.join('') != password.join('')) {
showToast('密码验证失败,请重新输入~');
return false;
}
isUnlocked = true;
return true;
}
/// 会员等级,没登录 / 没下发都按普通用户算
int get _level => meInfo?.vipLevel ?? vipNormal;
/// 是否为当前用户
bool isMe(int? uid) => uid != null && (meInfo?.uid ?? 0) == uid;
/// 是否充值 VIP(不包括推广)
bool get isRechargeVIP => (meInfo?.isVip ?? false) && _level > vipNormal;
/// VIP(包括推广)
bool get isVIP => meInfo?.isVip ?? false;
/// 有没有短剧卡:/mine/info 的 dramaExpire 晚于当前时间就是有
bool get hasDramaCard => DateTimeUtil.isExpireDate(meInfo?.dramaExpire);
/// 暗网会员及以上
bool get isAWVIP => _level >= vipDarkWeb;
/// 恰好是超级会员
bool get isSuperVip => _level == vipSuper;
/// 超级会员及以上
bool get isSuperUp => _level >= vipSuper;
/// 换妻会员及以上
bool get isVIPTopLevel => _level >= vipSwap;
/// ab测试:按 showType 原地剔除当前用户看不到的视频
/// showType 0-所有的人都可以看 1-奇数可看 2-偶数可看
void filterShowType(List<VideoModel> list) {
final hideType = (meInfo?.uid ?? 0) % 2 == 0 ? 1 : 2; //偶数 uid 看不到「奇数可看」的
list.removeWhere((e) => e.showType == hideType);
}
/// 会员状态文案:会员显示到期时间,非会员显示下载次数引导;[hasVipName] 是否拼接会员名称
String vipTip({bool hasVipName = true}) {
final vipName = meInfo?.vipName ?? '';
if (_level > vipNormal) {
return '${(hasVipName && vipName.isNotEmpty) ? '$vipName ' : ''} ${DateTimeUtil.utcTurnYear(meInfo?.vipExpireDate, char: '-')}';
}
return '开通会员免费看大片 剩余可下载次数${wallet?.downloadCount ?? 0}';
}
/// 刷新钱包信息
Future<WalletModel?> refreshWallet({bool refresh = true}) async {
wallet = await MineService.fetchWalletData();
if (refresh) notifyListeners();
return wallet;
}
/// 二维码登录
/// [refresh] 是否刷新关联 globalStore 状态的页面,==false 则仅发起网络请求
Future<UserInfoModel?> loginByQr(String qr,
{String paste = "", bool refresh = true}) async {
//1.存下当前 token,扫码失败要还原
final token = await netManager.getToken();
//2.设置 token 为空,否则获取的二维码也是老账号
netManager.setToken('');
//3.获取二维码账号信息,可能有其他项目的二维码,导致返回为空
final userInfo = await MineService.devLogin(
"",
qr,
DeviceInfoService.devType,
Platform.operatingSystem,
Config.innerVersion,
DeviceInfoService.buildID,
"",
paste,
);
return _onLogin(userInfo, token, refresh);
}
/// 设备登录
/// [refresh] 刷新所有 user 全局状态
Future<UserInfoModel?> loginByDevice(String deviceId,
{String paste = "", bool refresh = true}) async {
final userInfo = await MineService.devLogin(
deviceId,
"",
DeviceInfoService.devType,
Platform.operatingSystem,
Config.innerVersion,
DeviceInfoService.buildID,
DeviceInfoService.getDevToken(deviceId),
paste,
);
return _onLogin(userInfo, null, refresh);
}
/// 手机号登录
/// [refresh] 刷新所有 user 全局状态
Future<UserInfoModel?> loginByMobile(String mobile, String code,
{String paste = "", bool refresh = true}) async {
final userInfo = await MineService.mobileLogin(
mobile,
code,
DeviceInfoService.deviceId,
DeviceInfoService.devType,
Platform.operatingSystem,
Config.innerVersion,
DeviceInfoService.buildID,
paste,
);
showToast(userInfo != null ? '登录成功' : '登录失败');
if (TextUtil.isNotEmpty(userInfo?.token)) {
netManager.setToken(userInfo?.token);
}
if (userInfo != null && refresh) {
_setMe(userInfo);
await AdManager().refresh(userInfo); //换号后重算免广告策略
notifyListeners();
}
return userInfo;
}
/// 写入用户信息,顺便盯住短剧权益的这一下从无到有
void _setMe(UserInfoModel userInfo) {
final had = hasDramaCard;
meInfo = userInfo;
if (!had && hasDramaCard) dramaCardGained.value++;
}
/// 处理登录结果:写 token、按需刷新;失败时还原原 token
Future<UserInfoModel?> _onLogin(
UserInfoModel? userInfo, String? oldToken, bool refresh) async {
if (userInfo != null) {
if (TextUtil.isNotEmpty(userInfo.token)) {
await netManager.setToken(userInfo.token);
}
if (refresh) {
_setMe(userInfo);
await AdManager().refresh(userInfo); //设备登录/扫码换号后重算免广告策略
notifyListeners();
}
} else if (oldToken != null) {
//登录失败,还原 token
netManager.setToken(oldToken);
}
return userInfo;
}
/// 更新并获取用户信息;[map] 为空则仅拉取用户信息
/// [refresh] 刷新所有 user 全局状态(会触发 Consumer 包裹的 widget 重建)
Future<UserInfoModel?> updateUserInfo(
{Map<String, dynamic>? map, bool refresh = true}) async {
// 传了 map 才是更新,更新成功后再拉取最新信息
final isUpdate = (map?.keys.length ?? 0) > 0;
if (isUpdate) {
final ok = await MineService.updateUserInfo(map!);
if (!ok) return null;
}
final userInfo = await MineService.getUserInfo();
if (refresh && userInfo != null) {
_setMe(userInfo);
notifyListeners();
}
// 与原实现一致:更新分支返回新拉取的 userInfo,纯获取分支返回 meInfo
return isUpdate ? userInfo : meInfo;
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'dart:async';
import 'package:audio_session/audio_session.dart';
import 'package:hgdj/hj_page/short_video/view/video_player_base_logic.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
/// 监听音频环境变化,自动暂停所有视频播放,防止音频通过手机外放泄露隐私:
/// 1. 耳机 / 蓝牙断开(音频切回外放)
/// 2. 来电、微信通话等其他 App 抢占音频(中断)
class HeadphoneMonitor {
HeadphoneMonitor._();
static final HeadphoneMonitor instance = HeadphoneMonitor._();
/// 断开后会导致音频切回外放的「私密」输出设备类型(移除时需暂停)
static const _privateOutputTypes = {
AudioDeviceType.bluetoothA2dp,
AudioDeviceType.bluetoothSco,
AudioDeviceType.wiredHeadset,
AudioDeviceType.wiredHeadphones,
AudioDeviceType.usbAudio,
};
bool _started = false;
StreamSubscription? _noisySub;
StreamSubscription? _interruptSub;
StreamSubscription? _deviceSub;
Future<void> start() async {
if (_started) return;
_started = true;
try {
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
// 耳机拔出 / 蓝牙断开(音频切回外放)
_noisySub = session.becomingNoisyEventStream.listen((_) {
debugLog('耳机断开 → 自动暂停所有播放');
pauseAll();
});
// 兜底:部分机型(华为/荣耀等)蓝牙断开不发 becomingNoisy 广播,改用更底层的设备移除回调兜底
_deviceSub = session.devicesChangedEventStream.listen((event) {
final unplugged = event.devicesRemoved
.any((d) => d.isOutput && _privateOutputTypes.contains(d.type));
if (unplugged) {
debugLog('音频输出设备移除(蓝牙/耳机) → 自动暂停所有播放');
pauseAll();
}
});
// 来电 / 微信通话等抢占音频(中断)→ 暂停;duck(短促提示音降音量)不处理
_interruptSub = session.interruptionEventStream.listen((event) {
if (event.begin && event.type != AudioInterruptionType.duck) {
debugLog('音频被打断(来电/通话等) → 自动暂停所有播放');
pauseAll();
}
});
} catch (e) {
_started = false;
debugLog('HeadphoneMonitor.start error: $e');
}
}
/// 暂停所有正在播放的视频(长视频/直播/简单播放器/短视频列表)
void pauseAll() {
// 长视频详情页 / 直播 / 简单播放器(统一走 eventBus)
eventBus.emit(PauseVideoEvent());
// 短视频列表页
VideoPlayerBaseLogic.pauseAll();
}
void dispose() {
_noisySub?.cancel();
_noisySub = null;
_interruptSub?.cancel();
_interruptSub = null;
_deviceSub?.cancel();
_deviceSub = null;
_started = false;
}
}
@@ -0,0 +1,76 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import '../../debug_log.dart';
class ImageCacheDisk {
static Future<Uint8List?> get(String path) async {
var pathUrl = Uri.tryParse(path);
String fileName = "";
if (pathUrl?.path != null) {
fileName = pathUrl?.path.replaceAll("/", "") ?? "";
}
if (fileName.isNotEmpty) {
String filePath = "${await findSavePath()}/$fileName";
var imageFile = File(filePath);
if (imageFile.existsSync()) {
return imageFile.readAsBytes();
}
}
return null;
}
static void save(String path, List<int> fileData) async {
try {
if (fileData.length < 2048) {
return;
}
var pathUrl = Uri.tryParse(path);
String fileName = "";
if (pathUrl?.path != null) {
fileName = pathUrl?.path.replaceAll("/", "") ?? "";
}
if (fileName.isNotEmpty) {
String filePath = "${await findSavePath()}/$fileName";
var imageFile = File(filePath);
if (imageFile.existsSync()) {
imageFile.deleteSync();
}
imageFile.writeAsBytes(fileData, mode: FileMode.write);
}
} catch (e) {
debugLog("图片存储失败");
debugLog(e);
}
}
static Future<String> findSavePath() async {
final directory = Platform.isAndroid ? await getTemporaryDirectory() : await getTemporaryDirectory();
String saveDir = '${directory.path}/cacheImage';
Directory root = Directory(saveDir);
if (!root.existsSync()) {
debugLog(saveDir);
await root.create();
}
return saveDir;
}
static Future emptyCache() async {
try {
String saveDir = await findSavePath();
Directory root = Directory(saveDir);
if (root.existsSync()) {
await root.delete(recursive: true);
//showToast( "磁盘图片缓存清理成功");
// showToast( "磁盘图片缓存清理成功");
}
} catch (e) {
debugLog(e);
}
}
}
@@ -0,0 +1,157 @@
// ignore_for_file: unrelated_type_equality_checks, constant_identifier_names
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import '../../debug_log.dart';
import 'image_cache_disk.dart';
class ImageCrypto {
static Future<Uint8List?> loadAndDecrypt(String path) async {
BaseOptions options = BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 60),
responseType: ResponseType.bytes,
);
var url = path;
Response response;
try {
Uint8List? imageBytes = await ImageCacheDisk.get(url);
if (imageBytes == null) {
response = await Dio(options).get(url);
imageBytes = Uint8List.fromList(response.data);
ImageCacheDisk.save(url, response.data);
}
var decodeData = decryptImage(imageBytes);
if (!(url.contains('.gif') || url.contains('.GIF'))) {
decodeData = await compressList(decodeData);
}
return decodeData;
} on DioException catch (error) {
debugLog("图片加载失败:$error");
if (error.type == DioException.receiveTimeout) {
try {
response = await Dio(options).get(url);
var imageBytes = Uint8List.fromList(response.data);
ImageCacheDisk.save(url, response.data);
var decodeData = decryptImage(imageBytes);
return decodeData;
} catch (e) {
return null;
}
}
} catch (e) {
// debugLog("dio image error url: $url -> $e");
return null;
}
return null;
}
static final List<Uint8List> _featuresList = [
Uint8List.fromList([0xff, 0xd8, 0xff]), //jpg,jpeg
Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), //png
Uint8List.fromList([0x47, 0x49, 0x46]), //gif
];
/// 加密魔数头
static const encryptMagicNumber = [0x88, 0xA8, 0x30, 0xCB, 0x10, 0x76];
/// 加密密钥
static const ENCRYPT_KEY = 0xA3;
static const int _encryptedLen = 100; // //加密图片的数据长度
static final Uint8List _decryptKey = Uint8List.fromList('2019ysapp7527'.codeUnits); //加密key
static Uint8List? decryptImage(Uint8List? imgBytes) {
if (imgBytes?.isNotEmpty != true) {
return imgBytes;
}
var isAll = false;
for (var i = 0; i < encryptMagicNumber.length; i++) {
if (encryptMagicNumber[i] != imgBytes![i]) {
continue;
}
isAll = true;
}
// if (isAll) {
// imgBytes = xorBaseAllLength(imgBytes!);
// } else {
if (_isEncryptedImage(imgBytes!)) {
imgBytes = xorBaseLength(imgBytes, _decryptKey, _encryptedLen);
}
// }
return imgBytes;
}
static bool _isEncryptedImage(Uint8List imgBytes) {
bool isDecrypted = false;
int featuresLen = _featuresList.length;
for (int i = 0; i < featuresLen; i++) {
isDecrypted = false;
for (int j = 0; j < _featuresList[i].length; j++) {
if (_featuresList[i][j] != imgBytes[j]) {
isDecrypted = true;
break;
}
}
if (isDecrypted) {
continue;
} else {
break;
}
}
return isDecrypted;
}
static Uint8List xorBaseAllLength(Uint8List src) {
var index = -1;
int maxInt = double.maxFinite.toInt();
var dest = Uint8List.fromList(
src
.map((it) {
index++;
if (index < encryptMagicNumber.length && it == encryptMagicNumber[index]) {
return maxInt;
}
return it ^ ENCRYPT_KEY;
})
.where((element) => element != maxInt)
.toList(),
);
return dest;
}
static Uint8List xorBaseLength(Uint8List src, Uint8List key, int length) {
int srcLen = src.length;
int keyLen = key.length;
if (length > srcLen || length <= 0) {
length = srcLen;
}
for (var i = 0; i < length; i += keyLen) {
for (var j = 0; j < keyLen && i + j < length; j++) {
src[i + j] ^= key[j];
}
}
return src;
}
static Uint8List xor(Uint8List src, Uint8List key) {
return xorBaseLength(src, key, src.length);
}
static Future<Uint8List?> compressList(Uint8List? list) async {
if (list?.isNotEmpty == true) {
final result = await FlutterImageCompress.compressWithList(
list!,
quality: 92,
format: CompressFormat.webp,
);
//debugPrint("压缩前大小-----${list.length}");
//debugPrint("压缩后大小-----${result.length}");
return result;
}
return list;
}
}
@@ -0,0 +1,153 @@
import 'dart:typed_data';
import '../../debug_log.dart';
import 'image_crypto.dart';
typedef ImageCallback = void Function(String, Uint8List?);
class ImageManager {
// 工厂模式
factory ImageManager() => _getInstance();
static ImageManager get instance => _getInstance();
static ImageManager? _instance;
static int _taskCount = 0;
static final List<String> _taskQueue = [];
static final _taskCallbackMap = <String, List<ImageCallback>>{};
static final List<String> _taskNetOpQueue = [];
static int get maxTaskCount => 10;
int maxCacheSize = 3000;
ImageManager._internal();
static ImageManager _getInstance() {
_instance ??= ImageManager._internal();
return _instance!;
}
// 正在加载中的图片队列
//final Map<String, dynamic> _pendingImages = {};
// 缓存队列
final Map<String, Uint8List?> _cache = {};
final List<String> _cacheKey = [];
// 缓存数量上限(1000)
// final int _maximumSize = _kDefaultSize;
// 缓存容量上限 (100 MB)
// 清除所有缓存
void clearBySize({required int maxSize}) {
if (_cacheKey.length > maxSize) {
List<String> removeKey = [];
for (int i = 0; i < _cacheKey.length - maxSize; i++) {
removeKey.add(_cacheKey[i]);
}
for (int i = 0; i < removeKey.length; i++) {
_cacheKey.remove(removeKey[i]);
_cache.remove(removeKey[i]);
}
}
}
// 清除指定key对应的图片缓存
bool evict(String key) {
if (_cache[key] != null) {
_cache.remove(key);
return true;
}
return false;
}
Future<Uint8List?> loadImage(String? url) async {
if (url?.isNotEmpty != true) {
return null;
}
var imageBase = _cache[url];
if (imageBase != null) {
return imageBase;
}
imageBase = await ImageCrypto.loadAndDecrypt(url!);
if (imageBase != null && imageBase.length > 2048) {
_cache[url] = imageBase;
_cacheKey.add(url);
clearBySize(maxSize: maxCacheSize);
}
return imageBase;
}
void loadImageInQueue(String url, {ImageCallback? callback}) async {
if (url.isEmpty) {
if (callback != null) {
callback(url, null);
}
return;
}
Uint8List? imageBase = _cache[url];
if (imageBase != null) {
if (callback != null) {
callback(url, imageBase);
}
} else {
if (_taskCount > maxTaskCount) {
_taskQueue.remove(url);
_taskQueue.add(url);
if (callback != null) {
if (_taskCallbackMap[url] == null) {
_taskCallbackMap[url] = [callback];
} else {
_taskCallbackMap[url]?.remove(callback);
_taskCallbackMap[url]?.add(callback);
}
}
return;
}
if (_taskNetOpQueue.contains(url)) {
if (_taskCallbackMap[url] == null) {
_taskCallbackMap[url] == [callback];
} else {
if (callback != null) {
_taskCallbackMap[url]?.remove(callback);
_taskCallbackMap[url]?.add(callback);
}
}
return;
}
// 开始任务
_taskCount++;
_taskNetOpQueue.add(url);
try {
imageBase = await ImageCrypto.loadAndDecrypt(url);
if (imageBase != null) {
_cache[url] = imageBase;
_cacheKey.add(url);
clearBySize(maxSize: maxCacheSize);
}
} catch (e) {
debugLog(e);
}
if (callback != null) {
callback(url, imageBase);
}
_taskCallbackMap[url]?.forEach((element) {
element(url, imageBase);
});
_taskQueue.remove(url);
_taskCallbackMap.remove(url);
_taskNetOpQueue.remove(url);
_taskCount--;
for (int i = _taskCount; i <= maxTaskCount; i++) {
if (_taskQueue.isNotEmpty) {
String taskUrl = _taskQueue.first;
_taskQueue.remove(taskUrl);
try {
ImageManager.instance.loadImageInQueue(taskUrl);
} catch (e) {
debugLog(e);
}
}
}
}
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
//TabBar 自定义指示器:底部一小条,支持纯色/渐变、自定义圆角与离底距离
class CustomIndicator extends Decoration {
final double width; //指示器宽度
final double height; //指示器高度
final Color color; //纯色时的颜色
final bool isGradient; //是否渐变
final List<Color> gradientColors; //渐变色,isGradient 为 true 才生效
final double offsetY; //在贴底的基础上再往上移多少
final BorderRadius? borderRadius; //圆角,不传默认 1
const CustomIndicator({
this.width = 16.0,
this.height = 3.0,
this.color = const Color(0xffF68804),
this.gradientColors = const [Color(0x00F68804), Color(0xffF68804)],
this.isGradient = false,
this.offsetY = 0,
this.borderRadius,
});
@override
BoxPainter createBoxPainter([VoidCallback? onChanged]) => _IndicatorPainter(this, onChanged);
//必须实现值相等:TabBar 用 indicator != oldWidget.indicator 决定要不要重建 painter
//(tabs.dart didUpdateWidget / _IndicatorPainter.shouldRepaint)。调用方都是在 build 里内联
//new 出来的,只按引用比较的话每次 rebuild 都判不等 → 白重建画笔、白重绘一次
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CustomIndicator &&
other.width == width &&
other.height == height &&
other.color == color &&
other.isGradient == isGradient &&
other.offsetY == offsetY &&
other.borderRadius == borderRadius &&
listEquals(other.gradientColors, gradientColors);
}
@override
int get hashCode => Object.hash(width, height, color, isGradient, offsetY, borderRadius, Object.hashAll(gradientColors));
}
class _IndicatorPainter extends BoxPainter {
final CustomIndicator deco;
_IndicatorPainter(this.deco, super.onChanged);
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
assert(configuration.size != null);
final size = configuration.size!;
//横向居中,纵向贴底再上移 offsetY
final topLeft = Offset(
offset.dx + (size.width - deco.width) / 2,
size.height - deco.height - deco.offsetY,
);
final rect = topLeft & Size(deco.width, deco.height);
final paint = Paint();
if (deco.isGradient) {
paint.shader = LinearGradient(colors: deco.gradientColors).createShader(rect);
} else {
paint.color = deco.color;
}
final borderRadius = deco.borderRadius ?? const BorderRadius.all(Radius.circular(1));
canvas.drawRRect(borderRadius.toRRect(rect), paint);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LoadingAlertWidget extends StatefulWidget {
final String? title;
final bool canCancel;
const LoadingAlertWidget({super.key, this.title, this.canCancel = false});
static GlobalKey<_LoadingAlertWidgetState>? _globalKey;
static int showCount = 0;
static show({String? title, bool canCancel = false}) {
_globalKey = GlobalKey<_LoadingAlertWidgetState>();
showCount++;
Get.dialog(
LoadingAlertWidget(key: _globalKey, title: title, canCancel: canCancel),
barrierColor: Colors.transparent,
);
}
static showExchangeTitle(String title) {
_globalKey?.currentState?._flushTitle(title);
}
static cancel() {
_globalKey = null;
if (showCount > 0) {
Get.back();
showCount--;
}
}
@override
State<StatefulWidget> createState() {
return _LoadingAlertWidgetState();
}
}
class _LoadingAlertWidgetState extends State<LoadingAlertWidget> {
late String title;
@override
void initState() {
super.initState();
title = widget.title ?? "加载中...";
}
void _flushTitle(String text) {
title = text;
setState(() {});
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (widget.canCancel == true) {
LoadingAlertWidget._globalKey = null;
return true;
}
return false;
},
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
color: Colors.black45,
height: 100,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(
backgroundColor: Colors.grey[500],
valueColor: const AlwaysStoppedAnimation(Colors.white70),
strokeWidth: 2,
),
),
const SizedBox(height: 12),
Text(
title,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.normal,
decoration: TextDecoration.none,
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart';
int logicMultiCount = 0;
class LoadingWidget extends StatelessWidget {
final double? width;
final double? height;
final double size;
const LoadingWidget(
{super.key, this.width = 40, this.height = 20, this.size = 40});
@override
Widget build(BuildContext context) {
return LoadingAnimationWidget.horizontalRotatingDots(
color: Colors.white, size: size);
}
}
class LoadingCenterWidget extends StatelessWidget {
final double? width;
final double? height;
static int showCount = 0;
const LoadingCenterWidget({super.key, this.width = 40, this.height = 20});
static show() async {
showCount++;
await Get.dialog(const LoadingCenterWidget());
showCount--;
}
static cancel() {
if (showCount > 0) {
Get.back();
}
}
@override
Widget build(BuildContext context) {
return Center(
child: LoadingWidget(
width: width,
height: height,
),
);
}
}
class CErrorWidget extends StatefulWidget {
final String? errorMsg;
final String? errorMsg2;
final VoidCallback? retryOnTap;
const CErrorWidget(
{super.key, this.errorMsg = "什么也没有...", this.errorMsg2, this.retryOnTap});
@override
State<CErrorWidget> createState() => _CErrorWidgetState();
}
class _CErrorWidgetState extends State<CErrorWidget> {
bool _isRetrying = false;
Future<void> _handleRetry() async {
if (_isRetrying || widget.retryOnTap == null) return;
setState(() => _isRetrying = true);
try {
final result = (widget.retryOnTap as dynamic Function())();
if (result is Future) {
await result;
}
} finally {
if (mounted) setState(() => _isRetrying = false);
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, cons) {
if (cons.maxHeight < 146) return SizedBox.shrink();
if (_isRetrying) {
return const Center(child: LoadingWidget());
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image.asset('ic_nodata.webp'.commonImgPath, height: 120),
4.sizeBoxH,
Text(
widget.errorMsg ?? '',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6), fontSize: 12),
),
if (widget.retryOnTap != null) ...[
4.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: _handleRetry,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Text(
"点击重试",
style: TextStyle(
color: AppColors.actionRed,
fontSize: 12,
),
),
),
),
]
],
),
);
});
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import '../../hj_utils/widget_util.dart';
class LoadingHelper {
static void showLoading({bool dismissiable = false, String msg = ''}) {
Get.dialog(
Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const LoadingCenterWidget(),
if (msg.isNotEmpty) ...[
4.sizeBoxH,
Text(
msg,
style:
textStyle(16, AppColors.mainTextColor33, FontWeight.w500),
),
]
],
),
),
),
barrierColor: Colors.transparent,
barrierDismissible: dismissiable,
);
}
static void dismissLoading() => Get.back();
}
+75
View File
@@ -0,0 +1,75 @@
import 'dart:convert';
import 'package:hgdj/hj_model/home/plate_model.dart';
import 'package:hgdj/hj_utils/light_model.dart';
import 'package:hgdj/tools_base/debug_log.dart';
/// 模块亚 tab 排序:合并本地自定义顺序与后端最新数据(后端为内容源,本地只定顺序)
class ModuleSortManager {
static final ModuleSortManager _instance = ModuleSortManager._();
ModuleSortManager._();
factory ModuleSortManager() => _instance;
/// 合并本地排序与后端模块,按用户自定义顺序返回
/// [serverTabs] 后端(含固定注入的"最新")模块 [key] 本地存储 key(home/暗网各一份,沿用原 key 不丢存量)
Future<List<ModuleData>> mergeLocalSortWithServer({
required List<ModuleData> serverTabs,
required String key,
}) async {
// 1.读本地并解析(读失败也不抛,localTabs 留空 → 下面直接退化成 serverTabs,首页不白板)
List<String> jsonList = const <String>[];
try {
jsonList = await lightKV.getStringList(key) ?? const <String>[];
} catch (e) {
debugLog(e);
}
final localTabs = <ModuleData>[];
for (final s in jsonList) {
try {
localTabs.add(ModuleData.fromJson(json.decode(s)));
} catch (e) {
debugLog(e);
}
}
// 2.后端装载成 {id: 最新 model}
final serverMap = <String, ModuleData>{};
for (final t in serverTabs) {
final id = t.id;
if (id != null) serverMap[id] = t;
}
// 3.先按本地顺序,只保留后端仍存在的(取后端最新对象),Set 去重
final result = <ModuleData>[];
final mergedIds = <String>{};
for (final local in localTabs) {
final id = local.id;
if (id == null) continue;
final server = serverMap[id];
if (server != null && mergedIds.add(id)) {
result.add(server);
}
}
// 4.补后端新增模块(按后端顺序追加)
for (final t in serverTabs) {
final id = t.id;
if (id == null) continue;
if (mergedIds.add(id)) result.add(t);
}
// 5.回写本地
saveLocalData(result, key);
return result;
}
/// 持久化排序(写失败只记日志,不抛,避免 fire-and-forget 时未捕获)
Future saveLocalData(List<ModuleData> dataArr, String key) async {
try {
final jsonList = dataArr.map((e) => json.encode(e.toJson())).toList();
await lightKV.setStringList(key, jsonList);
} catch (e) {
debugLog(e);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:encrypt/encrypt.dart';
/// 新版本解密(项目自定义协议:12 字节 nonce + sha256 派生 key/iv + AES-CBC
String aesDecryptEx(String cipher, String key) {
// final t1 = DateTime.now();
const nonceLen = 12;
final cipherBytes = base64Decode(cipher);
final nonce = cipherBytes.sublist(0, nonceLen);
final largeShaRaw = [...utf8.encode(key), ...nonce];
final largeShaRawMid = largeShaRaw.length ~/ 2;
final msgKeyLarge = sha256.convert(largeShaRaw).bytes;
final msgKey = msgKeyLarge.sublist(8, 24);
final shaRawA = [...msgKey, ...largeShaRaw.sublist(0, largeShaRawMid)];
final sha256a = sha256.convert(shaRawA).bytes;
final shaRawB = [...largeShaRaw.sublist(largeShaRawMid), ...msgKey];
final sha256b = sha256.convert(shaRawB).bytes;
final aesKey = [...sha256a.sublist(0, 8), ...sha256b.sublist(8, 24), ...sha256a.sublist(24)];
final aesIV = [...sha256b.sublist(0, 4), ...sha256a.sublist(12, 20), ...sha256b.sublist(28)];
final encrypter = Encrypter(AES(Key(Uint8List.fromList(aesKey)), mode: AESMode.cbc));
final decrypted = encrypter.decryptBytes(Encrypted(cipherBytes.sublist(nonceLen)), iv: IV(Uint8List.fromList(aesIV)));
final text = const Utf8Decoder().convert(decrypted);
return text;
}
+12
View File
@@ -0,0 +1,12 @@
/// future 扔出的异常
class ApiException implements Exception {
int? code = -200;
dynamic message;
ApiException([this.code = -200, this.message]);
@override
String toString() {
if (message == null) return "ApiException:code:$code";
return "ApiException:code:$code message:$message";
}
}
+60
View File
@@ -0,0 +1,60 @@
/// 基础网络请求结构
/// 本来是业务层包装的一层东西,但是这个项目好像没用,用状态码代替了业务码
class BaseRespBean<T> {
int? code;
T? data;
// 打印的data
dynamic printData;
/// 后台提示
String? tip;
String? action;
/// 是否加密
bool? hash;
/// 一般是code不为200的后端错误信息
String? msg;
/// 服务器时间,一切vip时间计算以服务器时间为准
String? time;
// String get avalibleMsg => TextUtil.isNotEmpty(tip) ? tip : msg;
String get toast {
if (tip?.isNotEmpty ?? false) return tip!;
return msg ?? ''; // tip 为空时回退到 msg,避免错误信息弹不出来
}
BaseRespBean(this.code, {this.data, this.msg, this.tip, this.hash = false, this.time, this.printData});
BaseRespBean.fromJson(Map<String, dynamic>? json) {
json ??= {};
code = json['code'];
data = json['data'];
tip = json['tip'];
action = json['action'];
msg = json['msg'];
time = json['time'];
hash = json['hash'];
}
@override
String toString() {
return toJson().toString();
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
data['code'] = code;
data['data'] = printData;
data['tip'] = tip;
data['action'] = action;
data['msg'] = msg;
data['time'] = time;
data['hash'] = hash;
return data;
}
bool get isSuccess => code == 200;
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:dio/dio.dart';
class CurlUtil {
static String generateCurl(RequestOptions options) {
const String curl = 'curl -X ';
final String method = options.method;
String url = options.baseUrl + options.path;
String query = '';
if (options.data != null && options.data is String) {
query = options.data;
} else {
late Map<String, dynamic> map;
if (options.queryParameters.isNotEmpty) {
map = options.queryParameters;
} else if (options.data is Map) {
map = options.data;
} else if (options.data is String) {
} else if (options.data is FormData) {
map = {};
map.addEntries((options.data as FormData).fields);
} else {
map = {};
}
query = Transformer.urlEncodeMap(map);
}
String curlUrl;
if (method.toLowerCase() == 'get') {
if (query.isNotEmpty) {
url += (url.contains('?') ? '&' : '?') + query;
}
String header = '';
options.headers.forEach((key, value) {
if (key != 'content-length') {
header += ' -H ' '\"$key:$value\" ';
}
});
curlUrl = '$curl$method $header \"$url\"';
} else {
String header = '';
options.headers.forEach((key, value) {
if (key != 'content-length') {
header += ' -H ' '\"$key:$value\" ';
}
});
final param = json.encode(options.data).replaceAll('"', '\\"');
header += " -d \"$param\"";
curlUrl = '$curl$method $header \"$url\"';
}
return curlUrl;
}
}
+308
View File
@@ -0,0 +1,308 @@
import 'dart:convert';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/text_util.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
import 'package:hgdj/tools_base/net/http_response_interceptor.dart';
import 'package:hgdj/tools_base/net/net_code.dart';
import 'package:hgdj/tools_base/net/net_manager.dart';
import 'package:uuid/uuid.dart';
import '../../track_event_manager/device_service.dart';
import '../../track_event_manager/track_session.dart';
final httpManager = _HttpManagerImp();
enum HttpMethod {
get('GET'),
post('POST'),
delete('DELETE');
final String method;
const HttpMethod(this.method);
}
abstract class HttpManger {
final dio = Dio();
String _baseUrl = '';
String get baseUrl => _baseUrl;
/// 服务器时间校准
int _diffTimeInSeconds = 0;
DateTime? _serverTime;
initDefault() {
// 选线发生在 resetBaseUrl 之前,这里必须先给 dio.options 配 connectTimeout
// 否则线路握手挂起时永不超时,导致启动页"选线中..."一直转(iOS release 高发)
dio.options.connectTimeout = const Duration(seconds: 15);
dio.options.sendTimeout = const Duration(seconds: 15);
dio.options.receiveTimeout = const Duration(seconds: 15);
_addDioIns();
}
init(String baseUrl) {
resetBaseUrl(baseUrl);
}
// 同步服务器时间
setServerTime(String? serverTimeS) {
if (TextUtil.isNotEmpty(serverTimeS)) {
_serverTime = DateTime.parse(serverTimeS!);
_diffTimeInSeconds = DateTime.now().difference(_serverTime!).inSeconds;
debugLog(
"============>server diff from local in seconds:$_diffTimeInSeconds");
}
}
/// get
Future<BaseRespBean> fetchResponseByGET(
String url, {
Map<String, dynamic>? param,
Options? options,
Function(Map<String, dynamic> json)? jsonTransformation,
});
/// post
Future<BaseRespBean> fetchResponseByPOST(
String url, {
Map<String, dynamic>? param,
Options? options,
Function(Map<String, dynamic> json)? jsonTransformation,
});
/// post
Future<BaseRespBean> fetchResponseByDELETE(
String url, {
Map<String, dynamic>? param,
Options? options,
Function(Map<String, dynamic> json)? jsonTransformation,
});
Future<BaseRespBean> _requestByUrl(
String url, {
Map<String, dynamic>? data,
Map<String, dynamic>? queryParameters,
required Options options,
CancelToken? cancelToken,
Function(Map<String, dynamic> json)? jsonTransformation,
}) async {
// 给默认值兜底:若 response 非 null 但 data 不是 Map/BaseRespBeannull/String/List),
// 下面 resultData 不会被赋值,late 变量访问会抛 LateInitializationError
BaseRespBean resultData =
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
Response? response;
try {
response = await dio.request(url,
queryParameters: queryParameters,
options: options,
data: data,
cancelToken: cancelToken);
} on DioException catch (e) {
debugLog('DioException $e');
if (e.type == DioExceptionType.cancel) {
resultData = BaseRespBean(Code.LOCAL_CANCEL_REQUEST,
msg: '请求已经取消~,请重试', data: null);
} else if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout) {
resultData =
BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
}
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) {
//没有网络
resultData = BaseRespBean(Code.LOCAL_NO_NETWORK,
msg: '暂无网络,请检查网络设置', data: null);
} else {
resultData =
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常,请重新试试~', data: null);
}
} on SocketException catch (e) {
resultData =
BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
debugLog('SocketException $e');
} on HttpException catch (e) {
resultData =
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
debugLog('HttpException $e');
} on FormatException catch (e) {
debugLog('FormatException $e');
resultData =
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
} catch (e) {
resultData =
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
debugLog(e);
}
if (response == null) {
return resultData;
} else if (response.data is BaseRespBean) {
resultData = response.data;
} else if (response.data is Map<String, dynamic>) {
resultData = BaseRespBean.fromJson(response.data);
}
if (resultData.isSuccess) {
if (jsonTransformation != null) {
final data_ = resultData.data;
if (data_ is Map) {
try {
resultData.data =
jsonTransformation.call(Map<String, dynamic>.from(data_));
} catch (e) {
print('jsonTransformation error $e');
resultData.data = null;
}
}
}
} else if (resultData.data is String) {
// 非成功响应 data 仍是未解密的密文串;service 层若 `return result.data`(返回类型是 Model?)
// 会把 String 强转模型,抛 'String is not a subtype of FutureOr<Model?>'。统一置空,降级返回 null。
resultData.data = null;
}
return resultData;
}
// dio 添加拦截
_addDioIns() {
dio.interceptors.add(HttpResponseInterceptor());
}
// 重制地址
resetBaseUrl(String baseUrl) {
_baseUrl = baseUrl;
final options = BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
validateStatus: (int? status) => (status ?? 600) < 600,
baseUrl: baseUrl,
);
options.headers[HttpHeaders.acceptEncodingHeader] = "*";
dio.options = options;
var adapter = DefaultHttpClientAdapter();
adapter.onHttpClientCreate = (client) {
client.badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
return client;
};
dio.httpClientAdapter = adapter;
}
// 获取统一的请求头
Future<Options> generateRequestOption(String apiUrl,
{Options? options, required HttpMethod method}) async {
//调用方可以自带 options(比如 X-Request-ID 这种单接口的头),公共头往上加,method 一律以本次请求为准
options ??= Options();
options.method = method.method;
options.headers ??= {};
final token = await netManager.getToken();
if (token.isNotEmpty == true) {
options.headers?["Authorization"] = token;
}
if (options.method == "GET") {
//options.headers["Content-Type"] = "application/x-www-form-urlencoded";
} else if (options.method == "POST") {
options.headers?["Content-Type"] = "application/json;charset=UTF-8";
}
options.headers?["User-Agent"] = await netManager.userAgent();
options.headers?["api_version"] = "1.0.0";
options.headers?["device"] = Platform.operatingSystem;
Uri? baseUri = Uri.tryParse(baseUrl);
Uri targetUri = Uri(
scheme: baseUri?.scheme,
host: baseUri?.host,
port: baseUri?.port,
path: baseUri!.path + apiUrl);
options.headers?["x-api-key"] = await _sign(targetUri.path);
options.headers?["sid"] = TrackSessionManager().currentSid;
options.headers?["DeviceModel"] = DeviceInfoService.model;
return options;
}
/// 签名
Future<String> _sign(String path) async {
Map<String, dynamic> signObj = {};
final timeDate = DateTime.now().add(Duration(seconds: -_diffTimeInSeconds));
int timestamp = timeDate.toUtc().millisecondsSinceEpoch ~/ 1000;
signObj['nonce'] = const Uuid().v4();
signObj['path'] = path;
signObj['timestamp'] = timestamp.toString();
signObj['token'] = await netManager.getToken();
signObj['userAgent'] = await netManager.userAgent();
var key = utf8.encode(Config.signKey);
var bytes = utf8.encode(jsonEncode(signObj).toString());
var sha1Encrypt = Hmac(sha1, key);
var digest = sha1Encrypt.convert(bytes);
return 'timestamp=$timestamp;sign=${digest.toString()};nonce=${signObj['nonce']}';
}
}
class _HttpManagerImp extends HttpManger {
@override
Future<BaseRespBean> fetchResponseByDELETE(String url,
{Map<String, dynamic>? param,
Options? options,
Function(Map<String, dynamic> json)? jsonTransformation}) async {
final reqOptions = await generateRequestOption(url,
options: options, method: HttpMethod.delete);
return await _requestByUrl(url,
options: reqOptions,
data: param,
jsonTransformation: jsonTransformation);
}
@override
Future<BaseRespBean> fetchResponseByGET(
String url, {
Map<String, dynamic>? param,
Options? options,
Function(Map<String, dynamic> json)? jsonTransformation,
}) async {
final reqOptions = await generateRequestOption(url,
options: options, method: HttpMethod.get);
return await _requestByUrl(url,
options: reqOptions,
queryParameters: param,
jsonTransformation: jsonTransformation);
}
@override
Future<BaseRespBean> fetchResponseByPOST(
String url, {
Map<String, dynamic>? param,
Options? options,
Function(Map<String, dynamic> json)? jsonTransformation,
}) async {
final reqOptions = await generateRequestOption(url,
options: options, method: HttpMethod.post);
return await _requestByUrl(url,
options: reqOptions,
data: param,
jsonTransformation: jsonTransformation);
}
Future<BaseRespBean> fetchDetectLineResponse(String url,
{Options? options, CancelToken? cancelToken}) async {
options ??= Options(
method: HttpMethod.get.method,
sendTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {},
);
final result =
await _requestByUrl(url, options: options, cancelToken: cancelToken);
return result;
}
}
+107
View File
@@ -0,0 +1,107 @@
// ignore_for_file: constant_identifier_names
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../config/config.dart';
import '../../hj_utils/text_util.dart';
import 'aes_util.dart';
import 'api_exception.dart';
import 'base_resp_bean.dart';
import 'net_code.dart';
/// 响应处理器:仅保留 [handleResponse] 静态方法,供 file_upload 等场景复用
class HttpRespInterceptor {
static const String TAG = "HttpRespInterceptor";
static Future<dynamic> handleResponse(Response response) async {
if (response.statusCode != 200) {
return Future.error(
ApiException(response.statusCode, "statusCode is not 200"));
}
BaseRespBean? baseResp;
if (response.data is Map) {
baseResp = BaseRespBean.fromJson(response.data);
} else if (response.data is String) {
baseResp = BaseRespBean.fromJson(json.decode(response.data));
} else {
return Future.error(
ApiException(Code.PARSE_DATE_ERROR, Lang.PARSE_DATE_ERROR));
}
int? code = baseResp.code;
//业务层判断
if (code == Code.SUCCESS) {
dynamic data = baseResp.data;
if (baseResp.hash ?? false) {
var decryptData = aesDecryptEx(data, Config.encryptKey);
data = json.decode(decryptData);
}
baseResp.data = data;
} else if (code == Code.FORCE_UPDATE_VERSION) {
//需要更新
baseResp.msg = "您的版本需要更新了";
await handleVer(baseResp.data);
} else if (code == Code.ACCOUNT_INVISIBLE) {
//账户被封禁了
baseResp.msg = "您的账号已被封禁了";
dynamic data = baseResp.data;
if (baseResp.hash ?? false) {
var decryptData = aesDecryptEx(data, Config.encryptKey);
data = json.decode(decryptData);
}
baseResp.data = data;
} else if (code == Code.TOKEN_ABNORMAL) {
//token异常
baseResp.msg = "token异常";
} else if (code == Code.VERIFY_CODE_REPEAT) {
//验证码频繁异常
baseResp.msg = "获取验证码过于频繁";
} else {
// unknow code
baseResp.data = null;
}
/// 展示提示
if (code != Code.SUCCESS) {
if (TextUtil.isEmpty(baseResp.msg) &&
TextUtil.isEmpty(baseResp.tip) &&
response.statusCode != 200) {
showToast("服务器错误");
} else {
if (!TextUtil.isEmpty(baseResp.tip)) {
showToast(baseResp.tip ?? "");
} else {
showToast(baseResp.msg ?? '');
}
}
}
debugLog(
'path:${response.requestOptions.baseUrl}${response.requestOptions.path}');
debugLog('param: ${response.requestOptions.queryParameters}');
debugLog('data: ${response.requestOptions.data}');
debugLog('head: ${response.requestOptions.headers}');
debugLog('resp:${response.data}');
if (code == Code.SUCCESS) {
response.data = baseResp.data;
} else {
if (baseResp.code != null) {
response.statusCode = baseResp.code;
if (baseResp.tip?.isNotEmpty == true) {
response.statusMessage = baseResp.tip;
} else if (baseResp.msg?.isNotEmpty == true) {
response.statusMessage = baseResp.msg;
}
}
}
}
}
///存储更新信息
handleVer(Map<String, dynamic> map) async {
List<dynamic> list = [];
list.add(map["data"]);
}
@@ -0,0 +1,117 @@
import 'dart:convert';
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hgdj/config/address.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/light_model.dart';
import 'package:hgdj/hj_utils/store_keys.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/net/aes_util.dart';
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
import 'package:hgdj/tools_base/net/curl_util.dart';
import 'package:hgdj/tools_base/net/http_manager.dart';
import 'package:hgdj/tools_base/net/net_code.dart';
import 'package:hgdj/tools_base/toast.dart';
/// 统一处理网络响应:解析 BaseRespBean、按业务码分发、密文解密、错误提示
class HttpResponseInterceptor extends InterceptorsWrapper {
static const String tag = "HttpRespInterceptor";
HttpResponseInterceptor();
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// curl/响应体仅 debug 下生成:generateCurl 会 json.encode 整个请求体、
// '$data' 会序列化整个响应,无条件求值在 release 也跑、纯属浪费
if (kDebugMode) {
_logCurl(response.requestOptions);
debugLog('==========response==========\n${response.data}');
}
handleResponse(response);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (kDebugMode) {
_logCurl(err.requestOptions);
}
handler.next(err);
}
// curl 单行动辄上千字符,走 debugPrint 会被 logcat(~1KB) 截断或被 debugLog 分段;
// developer.log 经 VM service 直达 IDE 调试控制台,不走 logcat,整行完整显示
void _logCurl(RequestOptions options) {
developer.log('$tag curl ====== ${CurlUtil.generateCurl(options)}',
name: 'curl');
}
void handleResponse(Response response) {
BaseRespBean baseResp;
if (response.statusCode != 200) {
baseResp = BaseRespBean(response.statusCode, msg: response.statusMessage);
} else {
if (response.data is Map) {
if (Address.aliCcdApi.contains(response.realUri.path)) {
baseResp = BaseRespBean(200, data: response.data);
} else {
baseResp = BaseRespBean.fromJson(response.data);
}
} else if (response.data is String) {
try {
baseResp = BaseRespBean.fromJson(json.decode(response.data));
} catch (e) {
baseResp =
BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
}
} else if (response.data is BaseRespBean) {
baseResp = response.data;
} else {
baseResp =
BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
}
// 同步下服务器时间
httpManager.setServerTime(baseResp.time);
final code = baseResp.code;
if (code == Code.SUCCESS) {
final data = _decryptIfHashed(baseResp);
baseResp.printData = data;
baseResp.data = data;
} else if (code == Code.FORCE_UPDATE_VERSION) {
//需要更新
baseResp.msg = "您的版本需要更新了";
} else if (code == Code.ACCOUNT_INVISIBLE) {
//账户被封禁了
baseResp.data = _decryptIfHashed(baseResp);
//1000 也被短剧下载授权复用成「没有短剧权益」(靠 data.reason 区分)
//那种情况顶成封禁文案会吓到正常用户,留服务端自己的 msg
final data = baseResp.data;
if (data is! Map || data['reason'] == null) baseResp.msg = "您的账号已被封禁了";
} else if (code == Code.TOKEN_ABNORMAL) {
//token异常:清理 token,后续重新登录
baseResp.msg = "token异常";
lightKV.setString(StoreKeys.NET_TOKEN, '');
} else if (code == Code.VERIFY_CODE_REPEAT) {
//验证码频繁异常
baseResp.msg = "获取验证码过于频繁";
}
}
if (!baseResp.isSuccess) showToast(baseResp.toast);
response.data = baseResp;
}
/// hash 标记的密文统一解密;失败降级为 null——CDN 偶发损坏/截断密文会让
/// aesDecryptEx 抛 ArgumentError(corrupted pad block)/RangeError,裸调会冒成
/// DioException[unknown] 并连带丢失业务状态(如封禁提示),这里兜底降级
dynamic _decryptIfHashed(BaseRespBean baseResp) {
if (baseResp.hash != true) return baseResp.data;
try {
return json.decode(aesDecryptEx(baseResp.data ?? '', Config.encryptKey));
} catch (e) {
debugLog('解密/解析失败,数据置空', e.toString());
return null;
}
}
}
+108
View File
@@ -0,0 +1,108 @@
// ignore_for_file: deprecated_member_use
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import '../../../hj_utils/text_util.dart';
import 'e_data.dart';
final _defaultOptions = BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
// dio 原生会加上 request header:accept-encoding gzip,导致部分请求失败
// headers: {HttpHeaders.acceptEncodingHeader: "*"},
validateStatus: (int? status) => (status ?? 600) < 600,
);
/// 创建一个支持http/http2和兼容tls证书错误的dio层
/// cur => http not http2后台暂时不支持
/// [mainThread] 默认httpclient在主线程中
Dio createDio({BaseOptions? options, bool mainThread = true}) {
options ??= _defaultOptions;
options.headers[HttpHeaders.acceptEncodingHeader] = "*";
var dio = Dio(options);
var adapter = DefaultHttpClientAdapter();
// var adapter = DefaultHttpClientAdapter();
adapter.onHttpClientCreate = (client) {
client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
return client;
};
dio.httpClientAdapter = adapter;
return dio;
}
/// 获取一个请求的rangeStart
/// range bytes=677636-
int getRangeStart(Map<String, String>? reqHeaders) {
var rangeStart = 0;
if (null != reqHeaders && reqHeaders.containsKey(HttpHeaders.rangeHeader)) {
// HttpHeaders.contentRangeHeader
// HttpHeaders
var rangeStr = reqHeaders[HttpHeaders.rangeHeader];
if (TextUtil.isNotEmpty(rangeStr)) {
var arr = rangeStr?.split("=");
if (arr?.isNotEmpty == true && arr!.length > 1) {
var arr2 = arr[1].split("-");
if (arr2.isNotEmpty == true) {
rangeStart = int.parse(arr2[0]);
}
}
}
}
return rangeStart;
}
/// dio 帮助类
class DioCli {
// final BaseOptions options;
late Dio _dio;
DioCli({BaseOptions? options}) {
_dio = createDio(options: options);
}
/// 获取文本
Future<EData<Response<String>>> getStr(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
final opt = options ?? Options();
opt.responseType = ResponseType.plain;
opt.headers = headers ?? {};
return asyncCall(() => _dio.get<String>(url, options: opt, cancelToken: cancelToken));
}
// 获取二进制数据
Future<EData<Response<List<int>>>> getBytes(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
final opt = options ?? Options();
opt.responseType = ResponseType.bytes;
opt.headers = headers ?? {};
return asyncCall(() => _dio.get<List<int>>(url, options: opt, cancelToken: cancelToken));
}
/// 获取数据流
Future<EData<Response<ResponseBody>>> getStream(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
final opt = options ?? Options();
opt.responseType = ResponseType.stream;
opt.headers = headers ?? {};
return asyncCall(() => _dio.get<ResponseBody>(url, options: opt, cancelToken: cancelToken));
}
/// 获取json数据
Future<EData<Response<Map<String, dynamic>>>> getJSON(String url,
{Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
final opt = options ?? Options();
opt.responseType = ResponseType.json;
opt.headers = headers ?? {};
return asyncCall(() => _dio.get<Map<String, dynamic>>(url, options: opt, cancelToken: cancelToken));
}
/// 获取头
Future<EData<Response>> getHeader(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
final opt = options ?? Options();
opt.responseType = ResponseType.bytes;
opt.headers = headers ?? {};
return asyncCall(() => _dio.head(url, options: opt, cancelToken: cancelToken));
}
}
@@ -0,0 +1,354 @@
// ignore_for_file: unnecessary_null_comparison, constant_identifier_names, depend_on_referenced_packages
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:convert/convert.dart';
import 'package:dio/dio.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:path/path.dart' as p;
import 'dio_cli.dart';
import 'e_data.dart';
const String _rangeHeader = "Range";
const String _acceptsRangesHeader = "Accept-Ranges";
const String _etagHeader = "ETag";
const String _contentRangeHeader = "Content-Range";
const int _M = 1024 * 1024;
const int _sliceStep = 2 * _M;
const Duration _readSliceTimeout = Duration(seconds: 60);
class _Chunk {
final Uint8List data;
_Chunk(this.data);
}
class DLError extends Error {
final Object err;
DLError(this.err);
@override
String toString() => err.toString();
}
//校对错误
class CheckSizeError extends DLError {
CheckSizeError() : super("Check Size failed");
}
//网络错误
class NetworkError extends DLError {
NetworkError(err) : super(err);
}
// 使用Dio.download时的错误
class DioDLError extends DLError {
DioDLError(err) : super(err);
}
// 文件系统错误
class FileSystemError extends DLError {
FileSystemError(err) : super(err);
}
_ddPrint(Object msg) => debugLog("oldLog", "[Dio-Downloader] $msg");
class DioSliceDownloader {
final DioCli cli;
final String? url;
final String? saveDirectory;
final String? dioSaveName;
final ProgressCallback? onReceiveProgress;
final Map<String, dynamic>? headers;
final Queue<_Chunk> _cached = Queue();
bool _isWriting = false;
RandomAccessFile? _raf;
String? _remoteETag = "";
int _remoteTotalLength = 0;
int _localInitSavedLength = 0;
int _localCurrentSavedLength = 0;
bool _remoteDownloadFinish = false;
final Completer<String> _downloadCompleter = Completer();
DioSliceDownloader(
this.cli, this.url, this.headers, this.saveDirectory, this.dioSaveName,
{this.onReceiveProgress});
Map<String, dynamic> _fillRangeHeader(int start, int end) {
final Map<String, dynamic> rangeHeader = {
_rangeHeader: "bytes=$start-$end"
};
if (headers != null) rangeHeader.addAll(headers!);
return rangeHeader;
}
// 返回 true OR false 表示是否支持分片下载
Future<EData<bool>> _fetchRemoteTotalLength() async {
final v = await cli.getHeader(url ?? "", headers: _fillRangeHeader(0, 0));
if (v.err != null) return EData(v.err, null);
final statusCode = v.data?.statusCode;
final remoteHeaders = v.data?.headers;
if (remoteHeaders == null) return EData(null, false); //不支持
final acceptRanges = remoteHeaders[_acceptsRangesHeader];
if (acceptRanges == null ||
acceptRanges.isEmpty ||
acceptRanges.first != "bytes") return EData(null, false);
if (statusCode != HttpStatus.partialContent) {
return EData(null, false); //不支持
}
final etags = remoteHeaders[_etagHeader];
if (etags == null || etags.isEmpty) {
return EData(null, false); //没有etag 认为不支持
}
final etag = etags.first;
if (etag.isEmpty) return EData(null, false); // etag为空,认为不支持
final contentRangeHeaders = remoteHeaders[_contentRangeHeader];
if (contentRangeHeaders == null || contentRangeHeaders.isEmpty) {
return EData(null, false); //不支持
}
final contentRangeHeader = contentRangeHeaders.first;
if (contentRangeHeader == null) return EData(null, false); //不支持
final temp = contentRangeHeader.split("/");
if (temp.length != 2) return EData(null, false); //不支持
final totalLengthStr = temp[1];
final totalLength = int.tryParse(totalLengthStr);
if (totalLength == null || totalLength == 0) {
return EData(null, false); //不支持
}
_remoteETag = hex.encode(utf8.encode(etag)); //etag中可能有 " 符号,这里统一处理下
_remoteTotalLength = totalLength;
_ddPrint("远端etag $etag, 标准化后的etag: $_remoteETag");
return EData(null, true);
}
// 使用断点续传的文件名
String _getETagSavePath() => p.join(saveDirectory ?? "", "$_remoteETag.apk");
// 使用原始DIO下载的文件名
String _getDioSavePath() => p.join(saveDirectory ?? "", dioSaveName);
void _closeRAFSync() {
if (_raf == null) return;
_raf?.closeSync();
_raf = null;
}
_clearAndEnsureApkDirectorySync() {
final dir = Directory(saveDirectory ?? "");
if (dir.existsSync()) dir.deleteSync(recursive: true);
dir.createSync(recursive: true);
}
int _getLocalCurrentLengthSync() {
final file = File(_getETagSavePath());
int length = 0;
if (file.existsSync()) {
length = file.lengthSync();
} else {
_clearAndEnsureApkDirectorySync();
}
_raf = file.openSync(mode: FileMode.writeOnlyAppend);
return length;
}
void _completeDownload({String? savePath, DLError? error}) {
syncCall(() => _closeRAFSync());
if (_downloadCompleter.isCompleted) return;
if (error == null) {
assert(savePath != null || savePath != "");
_downloadCompleter.complete(savePath);
} else {
_downloadCompleter.completeError(error);
}
}
Future<String> download() async {
_ddPrint("开始下载 $url");
_fetchRemoteTotalLength().then((support) {
if (support.err != null) {
_ddPrint("获取远端大小失败 ${support.err}");
_completeDownload(error: NetworkError(support.err));
return;
}
if (support.data == true) {
final mb = (_remoteTotalLength / _M).toStringAsFixed(2);
_ddPrint(
"远端支持断点下载 文件总大小:${_remoteTotalLength}B(${mb}MB) 文件标识:$_remoteETag");
try {
final localCurrentLength = _getLocalCurrentLengthSync();
_localInitSavedLength = localCurrentLength;
_localCurrentSavedLength = localCurrentLength;
_ddPrint("获取本地文件大小成功 已下载:$_localCurrentSavedLength");
} catch (e) {
_ddPrint("获取本地文件大小失败 错误 $e");
_completeDownload(error: FileSystemError(e));
return;
}
_progress();
if (_localCurrentSavedLength == _remoteTotalLength) {
_ddPrint("本地文件大小和远端文件大小相等,直接完成下载");
_completeDownload(savePath: _getETagSavePath());
} else if (_localCurrentSavedLength > _remoteTotalLength) {
_ddPrint("本地文件大小大于远端文件大小,清除本地目录");
syncCall(() => _clearAndEnsureApkDirectorySync());
_completeDownload(error: CheckSizeError());
} else {
_fetchSlice();
}
} else {
_ddPrint("远端不支持断点下载 使用Dio直接下载 ${_getDioSavePath()}");
//直接调用dio.
final savePath = _getDioSavePath();
Dio()
.download(url ?? "", savePath, onReceiveProgress: onReceiveProgress)
.then((_) {
_completeDownload(savePath: savePath);
}).catchError((err) {
_completeDownload(error: DioDLError(err));
});
}
});
return _downloadCompleter.future;
}
void _progress() {
if (onReceiveProgress == null) return;
syncCall(
() => onReceiveProgress!(_localCurrentSavedLength, _remoteTotalLength));
}
Future _fetch(int rangeStart, int rangeEnd) async {
final v = await cli.getStream(url ?? "",
headers: _fillRangeHeader(rangeStart, rangeEnd));
if (v.err != null) {
_ddPrint("Dio fetch 获取失败 range:$rangeStart-$rangeEnd err: ${v.err}");
return EData(v.err, null);
}
final completer = Completer();
final stream = v.data?.data?.stream;
stream?.timeout(_readSliceTimeout, onTimeout: (sink) {
sink.addError("Read Stream Timeout");
sink.close();
}).listen((data) {
if (data.isNotEmpty) _cached.add(_Chunk(data));
Future.microtask(() => _tryWrite());
}, onDone: () {
completer.complete();
}, onError: (err) {
_ddPrint("Dio fetch 获取流失败 range:$rangeStart-$rangeEnd err: $err");
completer.completeError(err);
}, cancelOnError: true);
return completer.future;
}
void _fetchSlice() async {
int currentStart = _localInitSavedLength;
while (true) {
if (_downloadCompleter.isCompleted) return;
final start = currentStart;
final end = start + _sliceStep;
if (start >= _remoteTotalLength) {
_ddPrint("片段下载起始位置已大于总长度, 网络下载完成");
_remoteDownloadFinish = true;
_tryWrite();
break;
}
_ddPrint("开始下载 $start-$end, 总共$_remoteTotalLength");
final v = await asyncCall(() => _fetch(start, end));
if (v.err != null) {
_ddPrint("错误下载 $start-$end, 总共$_remoteTotalLength err:${v.err}");
_completeDownload(error: NetworkError(v.err));
return;
}
currentStart = end + 1; //闭区间
}
}
void _tryWrite() async {
if (_isWriting || _cached.isEmpty || _downloadCompleter.isCompleted) return;
_isWriting = true;
final first = _cached.removeFirst();
final list = List<int>.from(first.data);
final writeRet = await asyncCall(() => _raf?.writeFrom(list));
if (writeRet.err != null) {
_ddPrint("写入错误: ${writeRet.err}");
_completeDownload(error: FileSystemError(writeRet.err));
} else {
_localCurrentSavedLength += first.data.length;
//_ddPrint("写入大小 ${list.length} 当前实际大小:${_raf.lengthSync()} 内存计算大小: $_localCurrentSavedLength");
if (_remoteDownloadFinish && _cached.isEmpty) {
try {
_ddPrint("写入完成 开始刷新磁盘缓存");
_raf?.flushSync();
_ddPrint("刷新磁盘缓存完成,开始关闭RAF");
_closeRAFSync();
_ddPrint("关闭RAF完成,开始检查文件大小");
//做最后的检查
final localLength = File(_getETagSavePath()).lengthSync();
if (localLength != _remoteTotalLength) {
_ddPrint("检查文件大小失败 local:$localLength, Target:$_remoteTotalLength");
_clearAndEnsureApkDirectorySync();
_completeDownload(error: CheckSizeError());
} else {
_ddPrint("检查文件大小完成, 下载完成:${_getETagSavePath()} 大小: $localLength");
_completeDownload(savePath: _getETagSavePath());
}
} catch (e) {
_ddPrint("网络下载完成,磁盘刷新错误 $e");
_completeDownload(error: FileSystemError(e));
}
}
_progress();
}
_isWriting = false;
_tryWrite();
}
}
typedef OnRetry = void Function(int retryCount);
class DioSliceRetryDownloader {
final DioCli cli;
final String url;
final Map<String, dynamic>? headers;
final String saveDirectory;
final String dioSaveName;
final ProgressCallback? onReceiveProgress;
final int retry;
final Duration retryInterval;
final OnRetry? onRetry;
DioSliceRetryDownloader(
this.cli, this.url, this.headers, this.saveDirectory, this.dioSaveName,
{this.onReceiveProgress,
this.retry = 3,
this.retryInterval = const Duration(seconds: 3),
this.onRetry});
Future<String> _download() {
return DioSliceDownloader(cli, url, headers, saveDirectory, dioSaveName,
onReceiveProgress: onReceiveProgress)
.download();
}
Future<String> download() async {
int tryCount = 0;
while (true) {
tryCount++;
try {
return await _download();
} catch (e) {
if (tryCount >= retry) rethrow;
syncCall(() => onRetry!(tryCount));
await Future.delayed(retryInterval);
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
/// 同步和异步调用,
/// 错误和正确数据
class EData<T> {
final dynamic _e;
final T? _d;
EData(dynamic e, T? d)
: _e = e,
_d = d;
get err => _e;
T? get data => (_d is T) ? _d : null;
}
/// 同步调用
EData<T> syncCall<T>(Function f) {
try {
return EData(null, f() as T);
} catch (e) {
return EData(e, null);
}
}
//异步调用
Future<EData<T>> asyncCall<T>(Function f) async {
try {
return EData(null, (await f()) as T);
} catch (e) {
return EData(e, null);
}
}
+56
View File
@@ -0,0 +1,56 @@
///错误编码
// ignore_for_file: constant_identifier_names
class Code {
///网络异常
static const NETWORK_ERROR = 2001;
///网络超时
static const NETWORK_TIMEOUT = 2002;
///无网络
static const LOCAL_NO_NETWORK = 2003;
///取消请求
static const LOCAL_CANCEL_REQUEST = 2004;
///账号被封禁
static const ACCOUNT_INVISIBLE = 1000;
///数据返回异常
static const PARSE_DATE_ERROR = 1001;
///需要进行强制更新
static const FORCE_UPDATE_VERSION = 1006;
///token异常
static const TOKEN_ABNORMAL = 5009;
///验证码1分钟重复异常
static const VERIFY_CODE_REPEAT = 5007;
///重放请求(同一个 X-Request-ID 换了业务参数)
static const REPLAY_ATTACK = 4009;
///参数错误 / 资源不存在或已下架
static const PARAM_INVALID = 4001;
///扣次事务失败、结果不确定:必须用**同一个** X-Request-ID 重试,换新 id 会重复扣
static const CHARGE_UNCERTAIN = 5003;
///钱包下载次数不足
static const NOT_ENOUGH_DOWNLOAD = 7017;
///金币余额不足
static const NOT_ENOUGH_MONEY = 8000;
///重复购买(视为已解锁)
static const REPEAT_BUY = 8005;
static const SUCCESS = 200;
}
///错误文案
class Lang {
static const PARSE_DATE_ERROR = '数据返回异常';
}
+118
View File
@@ -0,0 +1,118 @@
// ignore_for_file: constant_identifier_names, deprecated_member_use
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/net/http_manager.dart';
import '../../config/address.dart';
import '../../hj_utils/light_model.dart';
import '../../hj_utils/store_keys.dart';
import '../../hj_utils/text_util.dart';
// import 'client_api.dart';
import '../../track_event_manager/device_service.dart';
/// 连接超时15秒
const int CONNECT_TIME_OUT = 15 * 1000;
final netManager = NetManager();
class NetManager {
static bool _inited = false;
init(String baseUrl) {
httpManager.init(baseUrl);
_inited = true;
}
reset() {
netManager.init(Address.baseApiPath ?? "");
}
static bool get isInited => _inited;
Future<String> getToken() async {
// 优先读内存(每次启动自动登录后 setToken 已写入),
// 兜底再读 lightKViOS release 包若 MMKV 异常,至少内存 token 能保证可用
var token = Address.token ?? '';
if (token.isEmpty) {
token = (await lightKV.getString(StoreKeys.NET_TOKEN)) ?? '';
if (token.isNotEmpty) {
Address.token = token;
}
}
return token;
}
Future setToken(String? token) async {
// 该key不共享,不放全局变量
Address.token = token;
return lightKV.setString(StoreKeys.NET_TOKEN, token);
}
/// 获取 UA:优先命中 KV 缓存。
Future<String> userAgent() => DeviceInfoService.getCachedUserAgent();
/// 强制重新生成并写回缓存(扫码登录、设备切换等场景)。
Future<String> refreshUserAgent() => DeviceInfoService.getCachedUserAgent(
deviceId: DeviceInfoService.deviceId);
/// 清除ua
Future clearUserAgent() => DeviceInfoService.clearCachedUserAgent();
/// 服务器时间
DateTime? _serverTime;
/// 服务器时间和本地时间的差值
int _diffTimeInSeconds = 0;
/// 获取上一次服务器返回的时间
DateTime? getServerTime() {
if (null != _serverTime) {
return _serverTime;
} else {
return DateTime.now();
}
}
/// 设置服务器时间
void setServerTime(String? serverTimeS) {
if (TextUtil.isNotEmpty(serverTimeS)) {
_serverTime = DateTime.parse(serverTimeS!);
_diffTimeInSeconds = DateTime.now().difference(_serverTime!).inSeconds;
debugLog(
"============>server diff from local in seconds:$_diffTimeInSeconds");
}
}
/// 获取修复后的本地时间,应该是和服务器时间是同步的
DateTime getFixedCurTime() {
return DateTime.now().add(Duration(seconds: -_diffTimeInSeconds));
}
}
Dio createDio({BaseOptions? options}) {
final defaultOptions = BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
// dio 原生会加上 request header:accept-encoding gzip,导致部分请求失败
// headers: {HttpHeaders.acceptEncodingHeader: "*"},
validateStatus: (int? status) => (status ?? 600) < 600,
);
options ??= defaultOptions;
options.headers[HttpHeaders.acceptEncodingHeader] = "*";
var dio = Dio(options);
var adapter = DefaultHttpClientAdapter();
// var adapter = DefaultHttpClientAdapter();
adapter.onHttpClientCreate = (client) {
client.badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
return client;
};
dio.httpClientAdapter = adapter;
return dio;
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; // 复用 CustomRefreshView 的手感调优
import 'package:pull_to_refresh/pull_to_refresh.dart';
/// 横向列表"拉到底查看更多"组件(手感同上拉加载更多,只是横向)。
///
/// 正常不占位不显示,滑到末尾继续拉才从右侧冒出 footer,
/// 拉过 [triggerDistance] 松手即触发 [onTrigger](跳页/加载等,由调用方决定)。
///
/// 触发以"松手瞬间是否仍过阈值"为准:拉出"释放查看"后又拖回阈值内再松手,不触发。
///
/// [child] 传横向滚动体(如 `scrollDirection: Axis.horizontal` 的 ListView)。
/// 内部自建/释放 RefreshController,并用独立 RefreshConfiguration 兜住过拉距离,
/// 嵌在竖向 SmartRefresher 里也不会被外层配置限制。
class HorizontalLoadMore extends StatefulWidget {
final Widget child;
final VoidCallback onTrigger; // 拉过阈值松手触发
final String idleText; // 未拉够文案
final String releaseText; // 拉够文案
final double triggerDistance; // 触发拉动距离(px)
final double footerWidth; // footer 宽度
const HorizontalLoadMore({
super.key,
required this.child,
required this.onTrigger,
this.idleText = '查看\n更多',
this.releaseText = '释放\n查看',
this.triggerDistance = 50,
this.footerWidth = 50,
});
@override
State<HorizontalLoadMore> createState() => _HorizontalLoadMoreState();
}
class _HorizontalLoadMoreState extends State<HorizontalLoadMore> {
final RefreshController _refreshCtr = RefreshController();
final ValueNotifier<bool> _armed =
ValueNotifier(false); // 当前过拉是否已过阈值(驱动 footer 文案 + 触发判定)
@override
void dispose() {
_refreshCtr.dispose();
_armed.dispose();
super.dispose();
}
// 拖动中持续更新是否已过阈值(公式同 SmartRefresher footer 的 offset 计算)
bool _onScroll(ScrollNotification n) {
if (n.metrics.axis != Axis.horizontal) return false;
final double overscroll =
n.metrics.pixels - n.metrics.maxScrollExtent; // 拉过末尾的距离
_armed.value = overscroll >= widget.triggerDistance;
return false;
}
// 手指抬起瞬间仍过阈值才触发(拖回阈值内松手不触发)。用原始指针事件最可靠,
// 不依赖 SmartRefresher 的 canLoading/onLoading(受滚动物理影响、拖回会锁死)
void _onPointerUp() {
if (_armed.value) {
_armed.value = false;
Future.microtask(widget.onTrigger); // 避免在事件派发中直接 push 路由
}
}
@override
Widget build(BuildContext context) {
return RefreshConfiguration(
// 手感对齐 CustomRefreshView:同一套回弹弹簧 + 拖拽速率 + 滚动行为
springDescription: kRefreshSpring,
dragSpeedRatio: kRefreshDragSpeedRatio,
maxOverScrollExtent: 60,
maxUnderScrollExtent: 150, // 竖向那套是0,横向靠拖动触发需给足距离
enableBallisticLoad: false, // 不靠惯性,只松手触发
footerTriggerDistance: widget.triggerDistance,
child: ScrollConfiguration(
behavior: NoStretchScrollBehavior(), // BouncingPhysics/去拉伸,同竖向
child: Listener(
onPointerUp: (_) => _onPointerUp(),
child: NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: SmartRefresher(
controller: _refreshCtr,
scrollDirection: Axis.horizontal,
enablePullDown: false,
enablePullUp: true,
footer: CustomFooter(
// 正常不占位不显示,只有滑到末尾继续拉才冒出来
loadStyle: LoadStyle.HideAlways,
builder: (_, __) => ValueListenableBuilder<bool>(
valueListenable: _armed,
builder: (_, armed, ___) => _footer(armed),
),
),
onLoading: () =>
_refreshCtr.loadComplete(), // 触发已由指针事件把关,这里只复位 footer
child: widget.child,
),
),
),
),
);
}
// 过阈值(armed)时变"释放"文案、箭头翻转
Widget _footer(bool armed) {
return Container(
width: widget.footerWidth,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedRotation(
turns: armed ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child:
Image.asset("arrow_right_grey.webp".commonImgPath, width: 18),
),
const SizedBox(height: 6),
Text(
armed ? widget.releaseText : widget.idleText,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0x59FFFFFF), fontSize: 10, height: 1.3),
),
],
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
// 回弹弹簧:临界阻尼(≈2√(stiffness·mass)=34.6) → 松手快速收回不过冲
// 注:实测此调优手感优于 3.16.8 包默认(mass2.2/stiffness150/damping16,欠阻尼、回弹更夸张),
// 故采用这套调优值,不复刻 3.16.8 原值
// 提成常量供横向的 HorizontalLoadMore 复用,两处手感必须一致,改这里即全改
const SpringDescription kRefreshSpring =
SpringDescription(mass: 1.5, stiffness: 200, damping: 35);
const double kRefreshDragSpeedRatio = 0.91;
Widget pullYsRefresh({
required RefreshViewOnInit onInit,
required Widget child,
Key? key,
final Function(RefreshController? ctr)? onRefresh,
final Function(RefreshController? ctr)? onLoading,
bool enablePullUp = true,
bool enablePullDown = true,
String noDataText = "—— 没有更多数据了 ——",
Widget? customHeader,
Widget? footerHeader,
}) {
return CustomRefreshView(
onInit: onInit,
onLoading: onLoading,
onRefresh: onRefresh,
customHeader: customHeader,
footerHeader: footerHeader,
enablePullDown: enablePullDown,
enablePullUp: enablePullUp,
noDataText: noDataText,
child: child,
);
}
typedef RefreshViewOnInit = Function(RefreshController controller);
class CustomRefreshView extends StatefulWidget {
final bool enablePullDown;
final bool enablePullUp;
final Function(RefreshController? ctr)? onRefresh;
final Function(RefreshController? ctr)? onLoading;
final Widget? customHeader;
final Widget? footerHeader;
final Widget child;
final String noDataText;
final RefreshViewOnInit onInit;
const CustomRefreshView({
super.key,
required this.child,
required this.onInit,
this.enablePullDown = true,
this.enablePullUp = true,
this.onRefresh,
this.onLoading,
this.customHeader,
this.footerHeader,
this.noDataText = "—— 没有更多数据了 ——",
});
@override
State<CustomRefreshView> createState() => _CustomIRefreshViewState();
}
class _CustomIRefreshViewState extends State<CustomRefreshView> {
final refreshCtr = RefreshController();
@override
void initState() {
widget.onInit(refreshCtr);
super.initState();
}
@override
void dispose() {
//组件创建的 controller 由组件释放(谁创建谁释放)
refreshCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RefreshConfiguration(
springDescription: kRefreshSpring,
dragSpeedRatio: kRefreshDragSpeedRatio,
maxOverScrollExtent: 60,
maxUnderScrollExtent: 0,
enableBallisticRefresh: false, // 下拉不靠惯性触发刷新
enableBallisticLoad: true, // 上拉滑到底自动加载
//去掉 StretchingOverscrollIndicator,避免与下拉刷新冲突
child: ScrollConfiguration(
behavior: NoStretchScrollBehavior(),
child: SmartRefresher(
enablePullDown: widget.enablePullDown,
enablePullUp: widget.enablePullUp,
controller: refreshCtr,
onRefresh: () => widget.onRefresh?.call(refreshCtr),
onLoading: () => widget.onLoading?.call(refreshCtr),
header: widget.customHeader ??
WaterDropHeader(
waterDropColor: AppColors.actionRed,
// iOS 刷新菊花用主题色;Android 保持默认 loading
refresh: Platform.isIOS
? const CupertinoActivityIndicator(
color: AppColors.actionRed)
: null,
complete: const Text(
"刷新完成!",
style: TextStyle(
color: AppColors.tipTextColor99, fontSize: 12),
),
failed: const Text(
"刷新失敗!",
style: TextStyle(
color: AppColors.tipTextColor99, fontSize: 12),
),
),
footer: widget.footerHeader ??
ClassicFooter(
loadingText: "加载中...",
canLoadingText: "松开加载更多...",
noDataText: widget.noDataText,
idleText: "上拉加载更多",
// Android 默认转圈是蓝色,改成主题色;iOS 保持菊花
loadingIcon: SizedBox(
width: 25,
height: 25,
child: Platform.isIOS
? const CupertinoActivityIndicator()
: const CircularProgressIndicator(
strokeWidth: 2.0,
valueColor:
AlwaysStoppedAnimation(AppColors.actionRed),
),
),
textStyle: TextStyle(
color: AppColors.tipTextColor99,
fontSize: 12,
decoration: TextDecoration.none,
fontWeight: FontWeight.normal,
),
),
child: widget.child),
),
);
}
}
//还原 Flutter 3.16 时代的滚动行为
class NoStretchScrollBehavior extends MaterialScrollBehavior {
//去掉 StretchingOverscrollIndicator,避免与下拉刷新冲突
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
//还原旧版多指拖拽策略(3.19+ 改为 latestPointer,拖拽手感有细微差异)
@override
MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) {
return MultitouchDragStrategy.averageBoundaryPointers;
}
//使用 BouncingScrollPhysics 还原弹性下拉手感
@override
ScrollPhysics getScrollPhysics(BuildContext context) {
return const BouncingScrollPhysics();
}
}
@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as parser;
import '../widget/net_image_widget.dart';
import 'html_pro_parser.dart';
class HtmlParser {
HtmlParser({
required this.width,
this.onLinkTap,
this.imgLinkTap,
this.renderNewlines = false,
});
final double width;
final Function(String)? onLinkTap;
final Function(String)? imgLinkTap;
final bool renderNewlines;
/// 解析 html 字符串,返回 body 对应的 widget 列表
List<Widget> parse(String data) {
if (renderNewlines) {
data = data.replaceAll("\n", "<br />");
}
final document = parser.parse(data);
return [_parseNode(document.body)];
}
Widget _parseNode(dom.Node? node) {
if (node is dom.Element) {
switch (node.localName) {
case "a":
return GestureDetector(
onTap: () {
if (node.attributes.containsKey('href') && onLinkTap != null) {
String? url = node.attributes['href'] ?? "";
onLinkTap?.call(url);
}
},
child: DefaultTextStyle.merge(
child: Wrap(children: _parseNodeList(node.nodes)),
style: const TextStyle(
decoration: TextDecoration.underline,
color: Colors.blueAccent,
decorationColor: Colors.blueAccent,
),
),
);
case "abbr":
case "acronym":
return _styledWrap(
node,
const TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
),
);
case "address":
case "cite":
case "dfn":
case "em":
case "i":
case "var":
return _styledWrap(node, const TextStyle(fontStyle: FontStyle.italic));
case "article":
case "aside":
case "body":
case "div":
case "footer":
case "header":
case "main":
case "nav":
case "noscript":
case "section":
return _widthWrap(node);
case "b":
case "strong":
return _styledWrap(node, const TextStyle(fontWeight: FontWeight.bold));
case "bdi":
case "data":
case "dt":
case "figcaption":
case "rp":
case "rt":
case "ruby":
case "time":
return _wrapList(node);
case "bdo":
if (node.attributes["dir"] != null) {
return Directionality(
textDirection: node.attributes["dir"] == "rtl" ? TextDirection.rtl : TextDirection.ltr,
child: Wrap(children: _parseNodeList(node.nodes)),
);
}
//Direction attribute is required, just render the text normally now.
return _wrapList(node);
case "big":
return _styledWrap(node, const TextStyle(fontSize: 20.0));
case "blockquote":
return Padding(
padding: const EdgeInsets.fromLTRB(40.0, 14.0, 40.0, 14.0),
child: _widthWrap(node),
);
case "br":
if (_isNotFirstBreakTag(node)) {
return const Text("\n");
}
return SizedBox(width: width);
case "caption":
case "center":
return SizedBox(
width: width,
child: Wrap(
alignment: WrapAlignment.center,
children: _parseNodeList(node.nodes),
),
);
case "code":
case "kbd":
case "samp":
case "tt":
return _styledWrap(node, const TextStyle(fontFamily: 'monospace'));
case "dd":
return Padding(
padding: const EdgeInsets.only(left: 40.0),
child: _widthWrap(node),
);
case "del":
case "s":
case "strike":
return _styledWrap(node, const TextStyle(decoration: TextDecoration.lineThrough));
case "dl":
return Padding(
padding: const EdgeInsets.only(top: 14.0, bottom: 14.0),
child: _columnStartList(node),
);
case "figure":
return Padding(
padding: const EdgeInsets.fromLTRB(40.0, 14.0, 40.0, 14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: _parseNodeList(node.nodes),
),
);
case "h1":
return _heading(node, 28.0);
case "h2":
return _heading(node, 21.0);
case "h3":
return _heading(node, 16.0);
case "h4":
return _heading(node, 14.0);
case "h5":
return _heading(node, 12.0);
case "h6":
return _heading(node, 10.0);
case "hr":
return Padding(
padding: const EdgeInsets.only(top: 7.0, bottom: 7.0),
child: Container(
height: 1,
decoration: const BoxDecoration(color: Colors.white),
),
);
case "img":
final src = node.attributes['src'];
final alt = node.attributes['alt'];
if (src != null) {
// 原代码此处计算的 isMaxWidth 会被无条件覆盖为 true,等价简化为永远 maxFinite
return GestureDetector(
onTap: () => imgLinkTap?.call(src),
child: NetworkImageLoader(
imageUrl: src,
fit: BoxFit.contain,
width: double.maxFinite,
borderRadius: 0,
placeHolderWidget: Container(color: Colors.white),
),
);
} else if (alt != null) {
//Temp fix for https://github.com/flutter/flutter/issues/736
if (alt.endsWith(" ")) {
return Container(padding: const EdgeInsets.only(right: 2.0), child: Text(alt));
}
return Text(alt);
}
return Container();
case "ins":
case "u":
return _styledWrap(node, const TextStyle(decoration: TextDecoration.underline));
case "li":
String type = node.parent?.localName ?? ""; // Parent type; usually ol or ul
const EdgeInsets markPadding = EdgeInsets.symmetric(horizontal: 4.0);
Widget mark;
switch (type) {
case "ul":
mark = Container(padding: markPadding, child: Text(''));
break;
case "ol":
int index = (node.parent?.children.indexOf(node) ?? 0) + 1;
mark = Container(padding: markPadding, child: Text("$index."));
break;
default: //Fallback to middle dot
mark = const SizedBox.shrink();
break;
}
return SizedBox(
width: width,
child: Wrap(
children: <Widget>[mark, Wrap(children: _parseNodeList(node.nodes))],
),
);
case "mark":
return _styledWrap(
node,
TextStyle(color: Colors.black, background: _getPaint(Colors.yellow)),
);
case "ol":
case "ul":
case "table":
case "tbody":
case "tfoot":
case "thead":
return _columnStartList(node);
case "p":
return DefaultTextStyle.merge(
child: Padding(
padding: EdgeInsets.only(top: 6.0, bottom: 6.0),
child: Container(
alignment: HtmlProParser.alignment(node.attributes["style"]),
width: width,
child: Wrap(
alignment: HtmlProParser.wrapAlignment(node.attributes["style"]),
children: _parseNodeList(node.nodes),
),
),
),
style: TextStyle(height: HtmlProParser.lineHeight(node.attributes["style"])),
);
case "font":
Color? textColor;
String colorStr = node.attributes["color"] ?? "";
if (colorStr.length == 7) {
textColor = HexColor(colorStr);
}
double fontSize = 14;
try {
//字体大小和h5那边约定
Map<String, double> fontSizeMap = {"1": 14, "2": 17, "3": 20, "4": 23, "5": 26, "6": 29, "7": 32};
if (node.attributes["size"] is String) {
fontSize = fontSizeMap[node.attributes["size"]] ?? 14;
}
} catch (_) {}
return _styledWrap(
node,
TextStyle(
color: textColor,
fontSize: fontSize,
backgroundColor: HtmlProParser.spanBg(node.attributes["style"]),
),
);
case "pre":
return Padding(
padding: const EdgeInsets.all(14.0),
child: DefaultTextStyle.merge(
child: Text(node.innerHtml),
style: const TextStyle(fontFamily: 'monospace'),
),
);
case "q":
return DefaultTextStyle.merge(
child: Wrap(children: [
const Text("\""),
..._parseNodeList(node.nodes),
const Text("\""),
]),
style: const TextStyle(fontStyle: FontStyle.italic),
);
case "small":
return _styledWrap(node, const TextStyle(fontSize: 10.0));
case "span":
return Container(
color: HtmlProParser.spanBg(node.attributes['style']),
child: Wrap(children: _parseNodeList(node.nodes)),
);
case "td":
int colspan = 1;
if (node.attributes['colspan'] != null) {
colspan = int.tryParse(node.attributes['colspan'] ?? "1") ?? 1;
}
return Expanded(
flex: colspan,
child: Wrap(children: _parseNodeList(node.nodes)),
);
case "template":
//Not usually displayed in HTML
return Container();
case "th":
int colspan = 1;
if (node.attributes['colspan'] != null) {
colspan = int.tryParse(node.attributes['colspan'] ?? "1") ?? 1;
}
return DefaultTextStyle.merge(
child: Expanded(
flex: colspan,
child: Wrap(
alignment: WrapAlignment.center,
children: _parseNodeList(node.nodes),
),
),
style: const TextStyle(fontWeight: FontWeight.bold),
);
case "tr":
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: _parseNodeList(node.nodes),
);
}
} else if (node is dom.Text) {
//We don't need to worry about rendering extra whitespace
if (node.text.trim() == "" && !node.text.contains(" ")) {
return const Wrap();
}
if (node.text.trim() == "" && node.text.contains(" ")) {
node.text = " ";
}
String finalText = trimStringHtml(node.text);
//Temp fix for https://github.com/flutter/flutter/issues/736
if (finalText.endsWith(" ")) {
return Container(padding: EdgeInsets.only(right: 2.0), child: Text(finalText));
} else {
return Text(finalText);
}
}
return const Wrap();
}
// ========== 私有 helper ==========
/// `DefaultTextStyle.merge(Wrap(parseNodes), style)` 模式复用
Widget _styledWrap(dom.Element node, TextStyle style) {
return DefaultTextStyle.merge(
child: Wrap(children: _parseNodeList(node.nodes)),
style: style,
);
}
/// `SizedBox(width: width, child: Wrap(parseNodes))` 模式复用
Widget _widthWrap(dom.Element node) {
return SizedBox(
width: width,
child: Wrap(children: _parseNodeList(node.nodes)),
);
}
/// 单纯包一层 Wrap
Widget _wrapList(dom.Element node) {
return Wrap(children: _parseNodeList(node.nodes));
}
/// `Column(crossAxisAlignment: start, children: parseNodes)` 模式复用
Widget _columnStartList(dom.Element node) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _parseNodeList(node.nodes),
);
}
/// h1-h6 共用:bold + 指定 fontSize + 全宽 Wrap
Widget _heading(dom.Element node, double fontSize) {
return DefaultTextStyle.merge(
child: SizedBox(
width: width,
child: Wrap(children: _parseNodeList(node.nodes)),
),
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold),
);
}
List<Widget> _parseNodeList(List<dom.Node> nodeList) {
return nodeList.map(_parseNode).toList();
}
Paint _getPaint(Color color) => Paint()..color = color;
String trimStringHtml(String stringToTrim) {
// 去掉换行,并把连续空格合并为单个空格
return stringToTrim.replaceAll("\n", "").replaceAll(RegExp(' +'), ' ');
}
bool _isNotFirstBreakTag(dom.Node? node) {
int? index = node?.parentNode?.nodes.indexOf(node);
if (index == null) return false;
if (index == 0) {
if (node?.parentNode == null) {
return false;
}
return _isNotFirstBreakTag(node?.parentNode);
} else if (node?.parentNode?.nodes[index - 1] is dom.Element) {
if ((node?.parentNode?.nodes[index - 1] as dom.Element).localName == "br") {
return true;
}
return false;
} else if (node?.parentNode?.nodes[index - 1] is dom.Text) {
if ((node?.parentNode?.nodes[index - 1] as dom.Text).text.trim() == "") {
return _isNotFirstBreakTag(node?.parentNode?.nodes[index - 1]);
} else {
return false;
}
}
return false;
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/widgets.dart';
import 'package:hgdj/tools_base/debug_log.dart';
class HtmlProParser {
static Color? spanBg(String? value) {
if (value == null || value.isEmpty) return null;
try {
final splitArr = value.toLowerCase().split(":");
if (splitArr.length != 2 || !splitArr.last.contains("rgb")) return null;
final rgbStr = splitArr.last.replaceAll("rgb(", "").replaceAll(");", "");
final rgbArr = rgbStr.split(",");
if (rgbArr.length != 3) return null;
final r = int.tryParse(rgbArr[0]);
final g = int.tryParse(rgbArr[1]);
final b = int.tryParse(rgbArr[2]);
if (r == null || g == null || b == null) return null;
return Color.fromRGBO(r, g, b, 1);
} catch (e) {
debugLog(e);
}
return null;
}
static AlignmentGeometry alignment(String? value) {
if (value == "text-align:center;") {
return Alignment.topCenter;
} else if (value == "text-align:right;") {
return Alignment.topRight;
} else {
return Alignment.topLeft;
}
}
static WrapAlignment wrapAlignment(String? value) {
if (value == "text-align:right;") {
return WrapAlignment.end;
} else if (value == "text-align:center;") {
return WrapAlignment.center;
} else {
return WrapAlignment.start;
}
}
static double? lineHeight(String? value) {
try {
if (value?.contains("line-height:") == true) {
final sizeStr =
value!.replaceAll("line-height:", "").replaceAll(";", "");
return double.tryParse(sizeStr);
}
} catch (e) {
debugLog(e);
}
return null;
}
}
class HexColor extends Color {
static int _getColorFromHex(String hexColor) {
try {
hexColor = hexColor.toUpperCase().replaceAll("#", "");
if (hexColor.length == 6) {
hexColor = "FF$hexColor";
}
return int.parse(hexColor, radix: 16);
} catch (e) {
return 0xffffffff;
}
}
HexColor(final String hexColor) : super(_getColorFromHex(hexColor));
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:oktoast/oktoast.dart' as ok;
import 'debug_log.dart';
/// Toast 位置
enum ToastGravity { top, center, bottom }
const _duration = Duration(seconds: 2);
/// Toast 队列:一条消失后回调里再弹下一条(时序由 oktoast 精确控制)
final _queue = <_ToastItem>[];
bool _isShowing = false;
class _ToastItem {
final String message;
final ToastGravity gravity;
_ToastItem(this.message, this.gravity);
}
/// 显示 Toast 消息(连续调用会排队,一条消失后再弹下一条)
void showToast(String message, {ToastGravity? gravity}) {
if (message.isEmpty) return;
// 相邻重复的直接忽略,避免同一句刷屏
if (_queue.isNotEmpty && _queue.last.message == message) return;
_queue.add(_ToastItem(message, gravity ?? ToastGravity.center));
_drain();
}
void _drain() {
if (_isShowing || _queue.isEmpty) return;
_isShowing = true;
final item = _queue.removeAt(0);
var done = false;
void next() {
if (done) return;
done = true;
_isShowing = false;
_drain();
}
try {
ok.showToast(
item.message,
duration: _duration,
position: switch (item.gravity) {
ToastGravity.top => ok.ToastPosition.top,
ToastGravity.bottom => ok.ToastPosition.bottom,
ToastGravity.center => ok.ToastPosition.center,
},
onDismiss: next,
);
} catch (e) {
// OKToast 未挂载(启动早期)时 oktoast 直接抛错,不兜住的话 _isShowing 永久为 true → 全 App toast 静默失效
debugLog("showToast()...error:$e");
_queue.clear();
_isShowing = false;
return;
}
// 兜底:toast 被提前 dismiss 或 overlay 已销毁时 oktoast 不回调 onDismiss,超时自愈
Future.delayed(_duration + const Duration(milliseconds: 500), next);
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/widgets.dart';
/// 为「可重复 push / 会多实例并存」的页面提供进程内唯一 tag,
/// 用于 GetBuilder(tag:) 隔离同类型 logic 的多个实例(避免共用 controller 导致状态污染/崩溃)。
///
/// 比 `DateTime.now().millisecondsSinceEpoch` 当 tag 更稳:
/// 后者在 build 里每次 rebuild 都变 → 反复 init 新 logic、旧的泄漏;同毫秒还会撞。
///
/// 用法:
/// ```dart
/// class _XxxPageState extends State<XxxPage> with UniqueTagMixin {
/// @override
/// Widget build(BuildContext context) =>
/// GetBuilder<XxxLogic>(tag: uniqueTag, init: XxxLogic(...), builder: ...);
/// }
/// ```
mixin UniqueTagMixin<T extends StatefulWidget> on State<T> {
// 全局自增序号:保证进程内唯一,同毫秒也不会撞
static int _seq = 0;
// 类型名 + 序号;late final 保证每个 State 只算一次、跨 rebuild 不变
late final String uniqueTag = '${T}_${_seq++}';
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/video_download/media_download_manager.dart';
/// 视频下载按钮,短视频 / 长视频 / 短剧 / 动漫共用。
/// 只管画状态和转发点击;权限、扣次、落记录、进度回调全在 [MediaDownloadManager]
class DownloadButton extends StatefulWidget {
final VideoModel? video;
/// true 竖排(短视频那套图标),false 横排(长视频),只影响外观
final bool isShort;
/// 缓存记录落哪个桶。不传时按 [isShort] 推断(短视频 / 影视 / 动漫);
/// 短剧在操作台里排版同短视频,但要单独归类,所以显式传 [MediaStyle.Drama]
final MediaStyle? style;
const DownloadButton(
{super.key, this.video, this.isShort = true, this.style});
@override
State<DownloadButton> createState() => _DownloadButtonState();
}
class _DownloadButtonState extends State<DownloadButton> {
late DownloadTask _task;
/// 免费下载一套图标,短视频/长视频各一套
String get _icon => (widget.video?.isFreeDownload == true
? "download_free.png"
: widget.isShort
? "download_short.png"
: "download.png")
.videoPath;
/// 保存到缓存库时的媒体类型
MediaStyle get _mediaStyle {
final style = widget.style;
if (style != null) return style;
if (widget.isShort) return MediaStyle.ShortVideo;
return widget.video?.videoType == 1 ? MediaStyle.Cartoon : MediaStyle.Video;
}
@override
void initState() {
super.initState();
_task = MediaDownloadManager.instance
.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
}
@override
void didUpdateWidget(covariant DownloadButton oldWidget) {
super.didUpdateWidget(oldWidget);
//列表/PageView 复用 State 时会换 model,会话得跟着换:不换的话按钮画的是上一条的状态,
//点下载下的也是上一条。同一个对象(父级只是重建)就别白折腾
if (identical(oldWidget.video, widget.video)) return;
_task.detach();
_task = MediaDownloadManager.instance
.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
}
@override
void dispose() {
_task.detach();
super.dispose();
}
/// 本按钮在短视频列表里随滑动大量创建/销毁,下载回调由单例持有、dispose 后仍可能被调到,统一判 mounted
void _refresh() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final isShort = widget.isShort;
final icon = SizedBox(
width: isShort ? 30 : 24,
height: isShort ? 30 : 24,
child: Image.asset(_icon),
);
final label = Text(
_task.state.desc,
style: isShort
? const TextStyle(
color: Color(0xffdcdcdc),
fontSize: 12,
fontWeight: FontWeight.w500)
: const TextStyle(color: Color(0xff989898), fontSize: 12),
);
return InkWell(
enableFeedback: false,
onTap: _task.start,
child: isShort
? Column(mainAxisSize: MainAxisSize.min, children: [icon, label])
: Row(
mainAxisSize: MainAxisSize.min,
children: [icon, 2.sizeBoxW, label]),
);
}
}
@@ -0,0 +1,556 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/statistics.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:path_provider/path_provider.dart';
import '../debug_log.dart';
import 'video_download_manager.dart';
/// iOS 端 m3u8 → mp4 下载器(分片式,支持断点续传)
///
/// 流程:解析 m3u8 → 并发下载 ts 分片 → 落 local.m3u8 → ffmpeg `-c copy` remux 成 mp4 → 写相册。
/// 断点续传靠「分片已存在就跳过」实现,与 Android 端 M3U8DownloadTask 同一思路,
/// 所以暂停后再开始不会从 0 重来(旧版用 ffmpeg 直接拉远程 m3u8 输出 mp4
/// 中断产物只有几十字节的 ftyp 头、moov 没写,既播不了也读不出进度,只能删掉重下)。
///
/// 对外接口语义与 Android 的 M3u8Downloader 对齐,方便 VideoDownloadManager 统一转发。
class IOSVideoDownloader {
IOSVideoDownloader._();
static final IOSVideoDownloader instance = IOSVideoDownloader._();
static String? _basePath;
final Map<String, _IOSDownloadTask> _tasks = {};
Future<bool> _ensureInit() async {
if (_basePath != null) return true;
final dir = await getApplicationDocumentsDirectory();
final root = '${dir.path}/vPlayDownload';
final d = Directory(root);
if (!d.existsSync()) await d.create(recursive: true);
_basePath = root;
return true;
}
/// 下载缓存根目录(确保已初始化),供缓存大小统计/清理使用
Future<String> baseDir() async {
await _ensureInit();
return _basePath!;
}
/// 下载目录的 key —— 只取 url 的 path,不能带 query。
///
/// realVideoUrl 形如 `.../vid/h5/m3u8/$sourceURL?token=xxx&c=$cdnAddress`token 会刷新、
/// cdn 会切换。拿完整 url 做 key 的话,这两者一变目录就跟着变,已下好的文件立刻失联:
/// 列表退回用 DB 里的旧记录显示"已完成",一点"保存到相册"就报"视频文件不存在"。
/// path 唯一对应一个 sourceURL,稳定。Android 侧 M3U8Util.getSaveFileDir 就是这么做的。
String _dirFor(String url) {
final hash = md5.convert(utf8.encode(VideoDownloadManager.taskKey(url))).toString();
return '$_basePath/$hash';
}
String _outputFor(String url) => '${_dirFor(url)}/download.mp4';
/// 查询 url 当前状态,语义同 M3u8Downloader.searchInfo
/// - 已完成:{localPath, progress: "100.00", status: "2"}
/// - 下载中:{isLoaderRunning: "1", progress}
/// - 已暂停但下过一部分:{isLoaderRunning: "0", progress}
/// (进程重启后 _tasks 是空的,只能靠磁盘上残留的分片还原进度,否则续传的进度看不见)
/// - 未下载:null
Future<dynamic> searchInfo(String url, {DownloadCallback? callback}) async {
if (url.isEmpty) return null;
await _ensureInit();
// 必须先查活动任务再查文件:remux 阶段 outputPath 已经存在但还没写完,
// 先 File.exists 会把进行中的任务误判为"已完成"。
// 用 taskKey(剥掉 token/cdn) 匹配:切线路/刷 token 后整条 url 变了,用原 url 会找不到进行中的任务
final task = _tasks[VideoDownloadManager.taskKey(url)];
if (task != null) {
if (callback != null) task.addCallback(callback);
return {
'isLoaderRunning': '1',
'progress': task.currentProgress,
};
}
final outputPath = _outputFor(url);
if (await File(outputPath).exists()) {
return {
'localPath': outputPath,
'progress': '100.00',
'status': '2',
};
}
// 没有 mp4 但目录里还留着分片 → 是暂停/中断的任务,把已下比例报出去
final partial = await _partialProgress(url);
if (partial != null) {
return {
'isLoaderRunning': '0',
'progress': partial,
};
}
return null;
}
/// 用磁盘上残留的分片估算已下进度:已下分片数 / 总分片数 * 下载权重。
/// 总数从上次落盘的 manifest 读,避免为了算进度再联网拉一次 m3u8。
Future<String?> _partialProgress(String url) async {
try {
final dir = _dirFor(url);
final manifest = File('$dir/segments.count');
if (!manifest.existsSync()) return null;
final total = int.tryParse((await manifest.readAsString()).trim()) ?? 0;
if (total <= 0) return null;
final done = Directory(dir)
.listSync()
.whereType<File>()
.where((f) => f.path.contains('/seg_') && f.path.endsWith('.ts') && f.lengthSync() > 0)
.length;
if (done <= 0) return null;
return (_IOSDownloadTask.downloadWeight * 100 * done / total).toStringAsFixed(2);
} catch (e) {
debugLog('[iOSDownload] 残留进度读取失败: $e');
return null;
}
}
/// 启动下载
/// 返回值与 Android 端 download 对齐:null=新任务,"正在执行"=已在跑,"已下载完成"=已完成,其它为错误文案
Future<dynamic> download({required String url, DownloadCallback? callback}) async {
if (url.isEmpty) return '视频链接为空';
await _ensureInit();
final outputPath = _outputFor(url);
if (await File(outputPath).exists()) return '已下载完成';
// key 剥掉 token/cdn,保证同一视频切线路/刷 token 后仍认作同一个任务
final key = VideoDownloadManager.taskKey(url);
if (_tasks.containsKey(key)) {
if (callback != null) _tasks[key]!.addCallback(callback);
return '正在执行';
}
final task = _IOSDownloadTask(url: url, dir: _dirFor(url), outputPath: outputPath);
if (callback != null) task.addCallback(callback);
_tasks[key] = task;
// 不 await,后台跑
task.start().whenComplete(() {
_tasks.remove(key);
});
return null;
}
Future<bool> delete(String url) async {
await _ensureInit();
try {
await pause(url);
final dir = Directory(_dirFor(url));
if (dir.existsSync()) await dir.delete(recursive: true);
return true;
} catch (e) {
debugLog(e);
return false;
}
}
/// 暂停:停掉正在跑的任务,但**保留已下分片**,下次 download 会接着下
Future<void> pause(String url) async {
final key = VideoDownloadManager.taskKey(url);
final task = _tasks[key];
if (task != null) {
await task.cancel();
_tasks.remove(key);
}
}
/// 只摘掉某个 url 上的某个回调(组件 dispose 时用)。
/// 必须提供:回调注册在 _IOSDownloadTask 内部,manager 的 removeCallback 清不到这里
void removeCallback(String url, DownloadCallback cb) {
_tasks[VideoDownloadManager.taskKey(url)]?.removeCallback(cb);
}
/// 清掉所有正在跑的任务的回调,但不停止下载
/// 用途:widget dispose 时调用,防止后台任务还在 setState
/// 任务本身继续跑,用户下次进页面再注册新回调
void removeAllCallbacks() {
for (final t in _tasks.values) {
t._callbacks.clear();
}
}
Future<void> emptyCache() async {
await _ensureInit();
for (final t in List.of(_tasks.values)) {
await t.cancel();
}
_tasks.clear();
try {
final root = Directory(_basePath!);
if (root.existsSync()) {
await root.delete(recursive: true);
await root.create();
}
} catch (e) {
debugLog(e);
}
}
}
/// 解析出来的 m3u8
class _Playlist {
/// 分片的绝对 url
final List<String> segments;
/// 每个分片的时长(秒),与 [segments] 一一对应;累加得总时长,用于算 remux 进度
final List<double> durations;
/// AES-128 key 的绝对 url;未加密为空
final String keyUrl;
/// #EXT-X-KEY 原始行;写 local.m3u8 时只把里面的 URI 换成本地 keyMETHOD/IV 保持原样
final String keyLine;
final int mediaSequence;
_Playlist({
required this.segments,
required this.durations,
required this.keyUrl,
required this.keyLine,
required this.mediaSequence,
});
double get totalSeconds => durations.fold(0.0, (a, b) => a + b);
}
class _IOSDownloadTask {
final String url;
final String dir;
final String outputPath;
final List<DownloadCallback> _callbacks = [];
final CancelToken _cancelToken = CancelToken();
/// 分片下载用独立 Dio:项目的 httpManager.dio 挂了 HttpResponseInterceptor
/// 它会把响应硬解析成 BaseRespBean 并在失败时弹 toast —— 二进制分片走那条链路
/// 既解析不了、几百个分片失败还会弹几百次 toast。这里只要超时和证书策略。
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
validateStatus: (int? status) => (status ?? 600) < 400,
));
String currentProgress = '0.00';
bool _cancelled = false;
int? _sessionId;
/// 分片并发数;再高对服务端不友好,收益也有限
static const _concurrency = 6;
/// 单个分片的重试次数:一次网络抖动不该让整条任务失败
static const _maxRetry = 3;
/// 下载占 0~70%remux 占 70~100%
static const downloadWeight = 0.7;
_IOSDownloadTask({required this.url, required this.dir, required this.outputPath});
void addCallback(DownloadCallback cb) {
_callbacks.remove(cb);
_callbacks.add(cb);
}
void removeCallback(DownloadCallback cb) => _callbacks.remove(cb);
Future<void> start() async {
try {
final d = Directory(dir);
if (!d.existsSync()) await d.create(recursive: true);
debugLog('[iOSDownload] start url=$url');
final playlist = await _fetchPlaylist(url);
if (playlist.segments.isEmpty) throw Exception('m3u8 里没有 ts 分片');
debugLog('[iOSDownload] 分片数=${playlist.segments.length} 加密=${playlist.keyUrl.isNotEmpty}');
// 记下总分片数,进程重启后 searchInfo 靠它还原暂停进度
await File('$dir/segments.count').writeAsString('${playlist.segments.length}');
// key 只有一份,先拉;已存在则跳过(同样支持续传)
String? keyPath;
if (playlist.keyUrl.isNotEmpty) {
keyPath = '$dir/sec.key';
if (!await File(keyPath).exists()) {
await _dio.download(playlist.keyUrl, keyPath, cancelToken: _cancelToken);
}
}
final localSegments = await _downloadSegments(playlist.segments);
if (_cancelled) return; // 保留已下分片,下次接着下
// 落 local.m3u8ts 指向本地 file://KEY 的 URI 换成本地 sec.key 交给 ffmpeg 自行解密
final m3u8Path = '$dir/local.m3u8';
await File(m3u8Path).writeAsString(_buildLocalM3u8(playlist, localSegments, keyPath));
await _remux(m3u8Path, playlist.totalSeconds);
if (_cancelled) {
await _deleteOutput(); // 半成品 mp4 会被 searchInfo 误判为已完成,必须删;ts 留着
return;
}
// mp4 已生成,中间产物就没用了,删掉免得占双份空间
await _cleanupIntermediates(localSegments, m3u8Path, keyPath);
currentProgress = '100.00';
try {
await ImageGallerySaver.saveFile(outputPath);
debugLog('[iOSDownload] saved to gallery');
} catch (e) {
debugLog('[iOSDownload] 保存到相册失败: $e');
}
_notifySuccess();
} catch (e, st) {
if (_cancelled || (e is DioException && CancelToken.isCancel(e))) {
debugLog('[iOSDownload] 已取消');
await _deleteOutput();
return;
}
debugLog('[iOSDownload] 失败: $e\n$st');
// 只删半成品 mp4,**保留已下分片**:否则一次网络失败就把几百个分片清空,续传白做
await _deleteOutput();
_notifyFail(e.toString());
}
}
/// 拉 m3u8 并解析;遇到 master playlist 就跟进第一个变体
Future<_Playlist> _fetchPlaylist(String playlistUrl, {int depth = 0}) async {
final resp = await _dio.get<String>(
playlistUrl,
options: Options(responseType: ResponseType.plain),
cancelToken: _cancelToken,
);
final body = resp.data ?? '';
final lines = const LineSplitter().convert(body);
// master playlist#EXT-X-STREAM-INF 的下一行是变体地址,取第一个跟进去。
// 老版本靠 ffmpeg 自动做这一步,自己解析就必须补上,否则解析不出任何分片
if (depth == 0 && lines.any((l) => l.startsWith('#EXT-X-STREAM-INF'))) {
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#EXT-X-STREAM-INF')) continue;
for (var j = i + 1; j < lines.length; j++) {
final v = lines[j].trim();
if (v.isEmpty || v.startsWith('#')) continue;
debugLog('[iOSDownload] master playlist,跟进变体: $v');
return _fetchPlaylist(_resolve(playlistUrl, v), depth: 1);
}
}
}
final segments = <String>[];
final durations = <double>[];
var keyUrl = '';
var keyLine = '';
var mediaSequence = 0;
double pendingDuration = 0;
for (final raw in lines) {
final line = raw.trim();
if (line.isEmpty) continue;
if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
mediaSequence = int.tryParse(line.split(':').last.trim()) ?? 0;
} else if (line.startsWith('#EXT-X-KEY')) {
keyLine = line;
final m = RegExp(r'URI="([^"]*)"').firstMatch(line);
if (m != null) keyUrl = _resolve(playlistUrl, m.group(1) ?? '');
} else if (line.startsWith('#EXTINF:')) {
final raw2 = line.substring('#EXTINF:'.length).split(',').first.trim();
pendingDuration = double.tryParse(raw2) ?? 0;
} else if (!line.startsWith('#')) {
segments.add(_resolve(playlistUrl, line));
durations.add(pendingDuration);
pendingDuration = 0;
}
}
return _Playlist(
segments: segments,
durations: durations,
keyUrl: keyUrl,
keyLine: keyLine,
mediaSequence: mediaSequence,
);
}
/// 相对路径转绝对(分片和 key 都可能是相对地址)
String _resolve(String base, String ref) {
if (ref.startsWith('http')) return ref;
return Uri.parse(base).resolve(ref).toString();
}
/// 并发下载分片,返回本地路径(顺序与入参一致)
Future<List<String>> _downloadSegments(List<String> urls) async {
final paths = List<String>.filled(urls.length, '');
var done = 0;
var next = 0;
Future<void> worker() async {
while (true) {
if (_cancelled) return;
final i = next++; // Dart 单线程,自增不会被打断
if (i >= urls.length) return;
final path = '$dir/seg_$i.ts';
// 断点续跑:已经下过的分片直接跳过
final f = File(path);
if (!f.existsSync() || f.lengthSync() == 0) {
await _downloadWithRetry(urls[i], path);
}
paths[i] = path;
done++;
_setProgress(downloadWeight * done / urls.length);
}
}
await Future.wait(
List.generate(urls.length < _concurrency ? urls.length : _concurrency, (_) => worker()),
);
return paths;
}
/// 单分片下载带重试:网络抖动不该让整条任务失败(失败会连带丢掉本次已下的所有分片进度)
Future<void> _downloadWithRetry(String segUrl, String path) async {
for (var attempt = 1; attempt <= _maxRetry; attempt++) {
if (_cancelled) return;
try {
await _dio.download(segUrl, path, cancelToken: _cancelToken);
return;
} catch (e) {
if (_cancelled || (e is DioException && CancelToken.isCancel(e))) rethrow;
// 失败残留的空/半截文件要清掉,否则下次续传会把它当成已下好的分片跳过
try {
final f = File(path);
if (f.existsSync()) await f.delete();
} catch (_) {}
if (attempt == _maxRetry) rethrow;
debugLog('[iOSDownload] 分片重试 $attempt/$_maxRetry: $segUrl');
await Future.delayed(Duration(milliseconds: 300 * attempt));
}
}
}
/// ffmpeg 侧要能读到 ts 和 keyts 写 file:// 绝对路径,KEY 行只替换 URI、保留 METHOD/IV
/// (整行重写会丢 IV,带显式 IV 的流会因默认 IV(分片序号)解密错位)
String _buildLocalM3u8(_Playlist playlist, List<String> localSegments, String? keyPath) {
final b = StringBuffer()
..writeln('#EXTM3U')
..writeln('#EXT-X-VERSION:3')
..writeln('#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence}');
if (playlist.keyLine.isNotEmpty && keyPath != null) {
b.writeln(playlist.keyLine.replaceFirst(RegExp(r'URI="[^"]*"'), 'URI="$keyPath"'));
}
for (var i = 0; i < localSegments.length; i++) {
final d = i < playlist.durations.length ? playlist.durations[i] : 0.0;
b
..writeln('#EXTINF:$d,')
..writeln('file://${localSegments[i]}');
}
b.writeln('#EXT-X-ENDLIST');
return b.toString();
}
/// 本地 ts → mp4`-c copy` 不重编码。
/// 这段刻意不复用 VideoSaveUtil:那是 Android 存相册的路径,抽公共方法就会动到 Android
Future<void> _remux(String m3u8Path, double totalSeconds) async {
final totalMs = (totalSeconds * 1000).toInt();
final command = '-y '
'-allowed_extensions ALL '
'-protocol_whitelist "file,http,https,tcp,tls,crypto" '
'-i "$m3u8Path" '
'-c copy -bsf:a aac_adtstoasc -movflags +faststart '
'"$outputPath"';
debugLog('[iOSDownload] remux cmd=$command');
final completer = Completer<void>();
final session = await FFmpegKit.executeAsync(
command,
(s) async {
final rc = await s.getReturnCode();
if (!ReturnCode.isSuccess(rc) && !_cancelled) {
final logs = await s.getAllLogsAsString();
debugLog('[iOSDownload] remux 失败 rc=${rc?.getValue()} logs:\n$logs');
if (!completer.isCompleted) {
completer.completeError(Exception('remux 失败: ${rc?.getValue()}'));
}
return;
}
if (!completer.isCompleted) completer.complete();
},
(log) => debugLog('[ffmpeg] ${log.getMessage()}'),
(Statistics stat) {
if (_cancelled || totalMs <= 0) return;
_setProgress(downloadWeight + (1 - downloadWeight) * (stat.getTime() / totalMs));
},
);
_sessionId = session.getSessionId();
await completer.future;
}
Future<void> _cleanupIntermediates(
List<String> segments,
String m3u8Path,
String? keyPath,
) async {
for (final s in segments) {
try {
final f = File(s);
if (f.existsSync()) await f.delete();
} catch (_) {}
}
for (final p in [m3u8Path, '$dir/segments.count', if (keyPath != null) keyPath]) {
try {
final f = File(p);
if (f.existsSync()) await f.delete();
} catch (_) {}
}
}
/// 只删半成品 mp4:它会被 searchInfo 误判成"已下载完成"。ts 分片一律保留给续传用
Future<void> _deleteOutput() async {
try {
final f = File(outputPath);
if (f.existsSync()) await f.delete();
} catch (e) {
debugLog(e);
}
}
Future<void> cancel() async {
_cancelled = true;
if (!_cancelToken.isCancelled) _cancelToken.cancel('用户取消');
if (_sessionId != null) await FFmpegKit.cancel(_sessionId!);
}
void _setProgress(double p) {
if (p < 0) p = 0;
if (p > 1) p = 1;
currentProgress = (p * 100).toStringAsFixed(2);
_notifyProgress(currentProgress);
}
/// 逐个回调独立 try-catch:任一监听方抛异常(典型如已 dispose 的组件 setState
/// 都不能中断循环,否则排在它后面的监听方会永久收不到事件
void _each(String tag, void Function(DownloadCallback cb) action) {
for (final cb in List.of(_callbacks)) {
try {
action(cb);
} catch (e) {
debugLog('[iOSDownload] $tag 回调异常(已隔离): $e');
}
}
}
void _notifySuccess() => _each('success', (cb) => cb.success?.call(url));
void _notifyFail(String error) => _each('fail', (cb) => cb.fail?.call(url, error));
void _notifyProgress(String progress) => _each('progress', (cb) => cb.progress?.call(url, progress));
}
@@ -0,0 +1,376 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hgdj/alert/mine/vip_level_dialog.dart';
import 'package:hgdj/alert/video/buy_vip_alert.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_model/drama/drama_models.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/drama/view/drama_video_player_logic.dart';
import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_service.dart';
import 'package:hgdj/hj_page/video/view/long_video_status.dart';
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/codec_support.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
import 'package:hgdj/tools_base/net/net_code.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/video_download/video_cache_store.dart';
import 'package:hgdj/tools_base/video_download/video_download_manager.dart';
import 'package:permission_handler/permission_handler.dart';
/// 下载状态(对应 VideoModel.isLoaderRunning 的 "0"/"1"/"2"
enum DownloadState {
idle('下载'),
loading('下载中'),
done('已下载');
const DownloadState(this.desc);
final String desc;
}
/// 一条内容的下载会话:盯着哪条任务地址、进度回调挂在谁身上。
/// 由 [MediaDownloadManager.attach] 创建,UI 拿到后只管在 onChanged 里重绘、点击时调 [start]
class DownloadTask {
DownloadTask._(this.video, this.style, this._onChanged);
// ===== 外部传入 =====
final VideoModel? video;
final MediaStyle style;
final VoidCallback _onChanged;
// ===== 会话状态 =====
/// 本会话盯着的下载地址。短剧的下载地址由授权接口现签,和播放地址不是同一条 path,
/// 所以不能到处直接用 video.realVideoUrl
String? taskUrl;
DownloadCallback? _callback;
bool _detached = false;
bool _busy = false; // 本次点击还没走完,防连点
// ===== 派生 =====
bool get _isDrama => style == MediaStyle.Drama;
String get _mediaId => video?.id ?? '';
String get _contentId => video?.subid ?? ''; // 短剧的分集 id 存在 subid,不是 video.id
DownloadState get state {
if (video?.localPath?.isNotEmpty == true || video?.isLoaderRunning == "2")
return DownloadState.done;
if (video?.isDownloading == true) return DownloadState.loading;
return DownloadState.idle;
}
/// 回调匹配用 taskKey(剥掉 token/cdn)token 刷新或切线路后整条 url 会变,
/// 拿整条 url 比对会匹配失败,进度条卡死不再刷新(下载其实仍按 path 继续跑)
bool _isMine(String url) =>
VideoDownloadManager.taskKey(taskUrl) ==
VideoDownloadManager.taskKey(url);
/// 点下载。连点必须挡住:两次并发会各自生成一个幂等键(第一次还没落盘第二次就读到了空表),
/// 服务端当成两次下载扣两次次数;长视频那条 reduceDownloadCount 同理
Future<void> start() async {
if (_busy) return;
_busy = true;
try {
await MediaDownloadManager.instance._start(this);
} finally {
_busy = false;
}
}
/// 只摘本会话自己的回调,不能清光单例里的全部回调——那会连别的组件/页面的一起清掉
void detach() {
_detached = true;
final cb = _callback;
if (cb != null) VideoDownloadManager.instance.removeCallback(taskUrl, cb);
}
}
/// 下载业务层:能不能下(VIP/次数/短剧权益)、下哪条地址、扣不扣次、记录落哪个桶,
/// 连同引导弹窗和提示全收在这里,UI 只负责画状态和转发点击。
/// 底层任务(原生 m3u8 下载器、进度事件分发)仍归 [VideoDownloadManager],两层别混
class MediaDownloadManager {
MediaDownloadManager._();
static final instance = MediaDownloadManager._();
// ===== 会话 =====
/// 挂上进度回调并回填这条内容当前的缓存状态。同步返回句柄,绑定在后台走完——
/// UI 在 initState 里拿到就能持有,dispose 时不用等
DownloadTask attach(
{required VideoModel? video,
required MediaStyle style,
required VoidCallback onChanged}) {
final task = DownloadTask._(video, style, onChanged);
unawaited(_bind(task));
return task;
}
Future<void> _bind(DownloadTask task) async {
final video = task.video;
//短剧下载的是授权接口现签的那条地址,只按播放地址查会认不出「已经下过」,
//得先从缓存记录里把当时那条捞回来
final resolved = (task._isDrama
? (await VideoCacheStore.instance.find(MediaStyle.Drama, video))
?.realVideoUrl
: null) ??
video?.realVideoUrl;
if (task._detached) return;
//用 ??=:查记录这一路是异步的,用户抢先点了下载的话地址已被 _enqueue 定过,别再顶回去
task.taskUrl ??= resolved;
task._callback = DownloadCallback(
success: (url) {
if (task._isMine(url)) {
video?.isLoaderRunning = "2";
video?.loadProgress = "100.00";
}
task._onChanged();
},
fail: (url, _) {
if (task._isMine(url)) video?.isLoaderRunning = "0";
showToast("缓存加载失败");
task._onChanged();
},
progress: (url, progress) {
if (!task._isMine(url)) return;
video?.isLoaderRunning = "1";
video?.loadProgress = progress;
task._onChanged();
},
);
final info = await VideoDownloadManager.instance
.searchInfo(url: task.taskUrl, callback: task._callback);
if (task._detached) {
task.detach(); // searchInfo 顺手挂上的回调,走到这里已经没人要了
return;
}
if (info == null) return;
if (info.localPath?.isNotEmpty == true) {
video?.localPath = info.localPath;
} else if (info.isDownloading) {
video?.isLoaderRunning = "1";
video?.loadProgress = info.progress;
}
task._onChanged();
}
// ===== 点下载 =====
/// 点下载:鉴权/扣次/引导全在这里走完,成功就把任务交给 [VideoDownloadManager]
Future<void> _start(DownloadTask task) async {
//在下/下完的先回话再说,别让它去走鉴权:否则下完的视频点一下还要刷钱包、
//不是会员还会弹一个「开通VIP」,最后才告诉人家早就下好了
switch (task.state) {
case DownloadState.loading:
showToast("正在下载中...");
return;
case DownloadState.done:
showToast("已下载完成");
return;
case DownloadState.idle:
break;
}
//短剧另有一套:权限、次数、扣次全在授权接口里一次做完,不走下面这条通用链路
if (task._isDrama) {
await _startDrama(task);
return;
}
final video = task.video;
if (video?.isFreeDownload == true) {
await _enqueue(task);
return;
}
// 预售特权:还有今日下载次数就消耗一次特权,消耗失败不下载
if (presaleProvider.isOpen &&
presaleProvider.hasLimit == true &&
(presaleProvider.remain?.todayDownloadCount ?? 0) > 0) {
final resp =
await PreSaleService.consumePrivilege(type: PrivilegeType.download);
if (resp.isSuccess) {
presaleProvider.reduceDownload();
await _enqueue(task);
}
return;
}
//钱包不在 attach 时刷:短视频列表每个 item 都有下载按钮,滑动会创建大量会话,那就是刷一路钱包接口。
//点下载时刷一次,次数 gating 仍拿最新值
await globalStore.refreshWallet();
if (!globalStore.isVIP) {
showVipLevelDialog("下载视频需要开通VIP会员\n\n开通会员 即可享会员专属特权");
return;
}
if (longVideoStatus(video).isNeedBuy) {
showToast("您未购买当前视频,无法使用下载功能");
return;
}
if ((globalStore.wallet?.downloadCount ?? 0) == 0) {
showToast("今日下载次数已使用完,明日再来");
return;
}
if (await MineService.reduceDownloadCount()) {
globalStore.refreshWallet();
await _enqueue(task);
}
}
// ===== 短剧 =====
/// 短剧下载:登录态、上下架、短剧权益、下载次数校验和扣 1 次全在授权接口里一次做完,
/// 客户端不再自己判 VIP/次数,也不能调 `/mine/download/use`——那个只扣次不给资源,会重复扣
Future<void> _startDrama(DownloadTask task) async {
//这一集还没解锁的先去开卡,省一次注定失败的授权请求
if (task.video?.dramaEpisode?.canPlay != true) {
await _openDramaCard(task);
return;
}
if (task._mediaId.isEmpty || task._contentId.isEmpty) {
showToast("剧集信息不完整");
return;
}
await _authorize(task, allowRetry: true);
}
/// 授权 → 拿地址 → 开下载。
/// [allowRetry] 只留给 5003(扣次结果不确定)自动补问一次,且必须复用同一个 requestId
Future<void> _authorize(DownloadTask task, {required bool allowRetry}) async {
//幂等键先落盘再发请求:响应丢了/超时重试都得拿同一个键回来,换新键服务端会再扣一次次数
final requestId =
await VideoCacheStore.instance.dramaRequestId(task._contentId);
final resp = await DramaService.authorizeDownload(
mediaId: task._mediaId,
contentId: task._contentId,
requestId: requestId,
);
final auth = resp.data;
if (resp.isSuccess && auth is DramaDownloadAuth) {
//次数在服务端已经扣掉了,哪怕这会儿用户已经划走也得把任务开起来,否则钱花了没下成
await _onAuthorized(task, auth);
return;
}
if (task._detached) return; // 失败的引导弹窗没必要追着已经离开的人弹
await _onAuthorizeFailed(task, resp, allowRetry: allowRetry);
}
/// 授权通过:剩余次数以服务端回的为准(别本地 -1,幂等重试那次压根没扣),
/// 地址按设备解码能力二选一
Future<void> _onAuthorized(DownloadTask task, DramaDownloadAuth auth) async {
globalStore.wallet?.downloadCount = auth.remainingDownloadCount;
globalStore.refreshWallet();
final h265 = auth.h265DownloadUrl ?? '';
final source = CodecSupport.useH265 && h265.isNotEmpty
? h265
: (auth.downloadUrl ?? '');
if (source.isEmpty) {
showToast("资源异常,请稍后再试"); // 两条地址都空,别建一个下不动的空任务
return;
}
//落记录用副本:授权地址只属于这次下载,写回在播的那个 model 会顶掉播放地址
final record = VideoModel.fromJson(task.video?.toJson())
..sourceURL = source
..h265Url = '';
await _enqueue(task, url: record.realVideoUrl, record: record);
}
/// 授权失败分流。有响应的失败网络层已经按 tip 弹过提示,这里只做要额外引导/善后的几种
Future<void> _onAuthorizeFailed(DownloadTask task, BaseRespBean resp,
{required bool allowRetry}) async {
final data = resp.data;
switch (resp.code) {
//1000 既是封号也是「没有短剧权益」,只有 data.reason 能区分,封号那种交给网络层的提示
case Code.ACCOUNT_INVISIBLE
when data is Map && data['reason'] == 'DRAMA_ENTITLEMENT_REQUIRED':
await _openDramaCard(task);
//次数不足:引导买带下载次数的商品
case Code.NOT_ENOUGH_DOWNLOAD:
globalStore.refreshWallet();
showVipLevelDialog("下载次数已用完\n\n开通会员 即可获取下载次数");
//剧集已下架/参数不对:本地那份过期了,重拉分集详情,别原地重试
case Code.PARAM_INVALID:
await _refreshEpisode(task);
//同一个键被用到别的剧集上了:清掉脏映射,下次点算全新的一次下载
case Code.REPLAY_ATTACK:
await VideoCacheStore.instance.dropDramaRequestId(task._contentId);
//扣次结果不确定:拿同一个键再问一次,问出来是成功就直接给地址,不会重复扣
case Code.CHARGE_UNCERTAIN:
if (allowRetry) await _authorize(task, allowRetry: false);
//断网/超时这类没响应的,网络层不弹提示,自己兜一句;幂等键留着等下次重试复用
case Code.NETWORK_ERROR || Code.NETWORK_TIMEOUT || Code.LOCAL_NO_NETWORK:
showToast(resp.toast);
}
}
/// 未解锁的短剧集:走付费墙同一个开卡弹窗,默认选中 ping 下发的短剧卡。
/// 关掉后重拉一次分集详情,服务端放行了再点下载就能下
Future<void> _openDramaCard(DownloadTask task) async {
final video = task.video;
final mediaId = video?.dramaInfo?.id;
final episode = video?.dramaEpisode;
await BuyVipAlert.show(
vipId: Config.shortDramaCardId,
orderTrack: PayOrderTrackInfo(
sourcePage: PaySourcePage.dramaPaywall,
sourceRef: mediaId,
videoId: mediaId,
mediaId: mediaId,
contentId: episode?.id,
checkoutContextId: episode?.paywall?.checkoutContextId,
),
);
await _refreshEpisode(task);
//开卡放行了要叫醒播放器:下载这条链自己不碰播放器,不广播的话买完卡付费墙还杵在原地
if (task.video?.dramaEpisode?.canPlay == true)
DramaVideoPlayerLogic.broadcastUnlock();
}
/// 重拉分集详情:本地那份的解锁状态/地址可能已经过期
Future<void> _refreshEpisode(DownloadTask task) async {
final fresh = await DramaService.fetchEpisode(task.video?.subid);
if (fresh != null) task.video?.dramaEpisode = fresh;
task._onChanged();
}
// ===== 交给底层 =====
/// [url] 本次真正要下的地址(短剧走授权接口现签的那条),不传就用播放地址;
/// [record] 落进缓存库的记录,不传就用当前 model
Future<void> _enqueue(DownloadTask task,
{String? url, VideoModel? record}) async {
//拿不到权限也照旧下(部分机型无需存储权限),所以不看结果
final status = await Permission.storage.status;
if (!status.isGranted) await Permission.storage.request();
final video = task.video;
final target = url ?? video?.realVideoUrl ?? "";
//换地址要把回调从老 key 上摘掉,否则那条既收不到事件、detach 也清不掉
final cb = task._callback;
if (cb != null &&
VideoDownloadManager.taskKey(target) !=
VideoDownloadManager.taskKey(task.taskUrl)) {
VideoDownloadManager.instance.removeCallback(task.taskUrl, cb);
}
task.taskUrl = target;
//返回值是 VideoDownloadManager.download 的约定:null=新任务开起来了,两个中文串=已有任务,其余是错误文案
switch (await VideoDownloadManager.instance
.download(url: target, callback: cb)) {
case null:
VideoCacheStore.instance.saveVideoInfo(task.style, record ?? video);
video?.isLoaderRunning = "1";
showToast("开始下载...");
case "正在执行":
video?.isLoaderRunning = "1";
showToast("正在下载中...");
case "已下载完成了":
video?.isLoaderRunning = "2";
showToast("已下载完成");
case final err:
showToast(err.toString());
}
task._onChanged();
}
}
@@ -0,0 +1,185 @@
import 'dart:convert';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:uuid/uuid.dart';
import '../../hj_utils/const.dart';
import '../../hj_utils/light_model.dart';
import '../../hj_utils/store_keys.dart';
/// 下载缓存记录的本地存储(lightKV),按 [MediaStyle] 分影视 / 短视频 / 动漫 / 短剧四个 key 存放。
/// 只管记录的增删查与每日下载次数,实际下载任务见 [VideoDownloadManager]
class VideoCacheStore {
// 工厂方法构造函数
factory VideoCacheStore() => _getInstance();
static VideoCacheStore get instance => _getInstance();
// 静态变量_instance,存储唯一对象
static VideoCacheStore? _instance;
VideoCacheStore._internal();
// 获取对象
static VideoCacheStore _getInstance() {
_instance ??= VideoCacheStore._internal();
return _instance!;
}
/// 有独立存储桶的业务;[_listKey] 未列到的一律落影视桶
static const _buckets = [
MediaStyle.Video,
MediaStyle.ShortVideo,
MediaStyle.Cartoon,
MediaStyle.Drama
];
/// 各业务的存储 key,未列到的一律落影视桶
static String _listKey(MediaStyle type) => switch (type) {
MediaStyle.ShortVideo => StoreKeys.SHORT_CACHE_LIST,
MediaStyle.Cartoon => StoreKeys.CARTOON_CACHE_LIST,
MediaStyle.Drama => StoreKeys.DRAMA_CACHE_LIST,
_ => StoreKeys.MOVIE_CACHE_LIST,
};
/// 记录去重/删除时的身份。
/// 短剧一部剧下多集,而 [VideoModel.id] 存的是**剧** id、分集 id 在 subid
/// 只比 id 会让第二集把第一集的记录挤掉,所以短剧必须带上 subid。
/// 其余业务维持原样只认 id(动漫虽然也有 subid,但它一条记录就是一部作品)
static String _identity(MediaStyle type, VideoModel video) =>
type == MediaStyle.Drama
? '${video.id}#${video.subid}'
: (video.id ?? '');
/// 缓存记录
Future<List<VideoModel>> getMovieCacheVideoList(MediaStyle type) async {
final listString = await lightKV.getStringList(_listKey(type)) ?? [];
return listString.map((e) => VideoModel.fromJson(json.decode(e))).toList();
}
Future<int> getVideoLoadCount() async {
String todayKey = DateTimeUtil.utc3YearMonthDay(DateTime.now());
var localStr = await lightKV.getString(StoreKeys.MOVIE_CACHE_COUNT) ?? "";
if (localStr.isEmpty) return 0;
var jsonMap = json.decode(localStr);
if (jsonMap[todayKey] != null && jsonMap[todayKey] is int) {
return jsonMap[todayKey];
}
return 0;
}
saveVideoLoadCount(int count) async {
try {
String todayKey = DateTimeUtil.utc3YearMonthDay(DateTime.now());
if (todayKey.isEmpty) return;
Map<String, int> countMap = {todayKey: count};
String mapString = json.encode(countMap);
await lightKV.setString(StoreKeys.MOVIE_CACHE_COUNT, mapString);
} catch (e) {
debugLog(e);
}
}
Future<bool> isExistLoadVideoByUrl(MediaStyle type, String url) async {
final videoArr = await getMovieCacheVideoList(type);
return videoArr.any((model) => model.sourceURL == url);
}
/// 落一条缓存记录,新的排最前;同身份的旧记录被顶掉
Future<void> saveVideoInfo(MediaStyle type, VideoModel? video) async {
if (video == null) return;
final id = _identity(type, video);
final rest = (await getMovieCacheVideoList(type))
.where((e) => _identity(type, e) != id);
await _write(type, [video, ...rest]);
}
Future<void> removeVideo(MediaStyle type, VideoModel? video) async {
if (video == null) return;
await removeVideoList(type, [video]);
}
Future<void> removeVideoList(MediaStyle type, List<VideoModel> videos) async {
final ids = videos.map((e) => _identity(type, e)).toSet();
final rest = (await getMovieCacheVideoList(type))
.where((e) => !ids.contains(_identity(type, e)));
await _write(type, rest);
//删了就是这次下载结束了,幂等键跟着清;留着的话重下会命中上一次的授权(不扣次也拿不到新地址)
if (type == MediaStyle.Drama)
await _dropDramaRequestIds(videos.map((e) => e.subid));
}
/// 按身份取一条已存的记录。短剧下载地址是授权接口现签的,和播放地址不是同一条 path,
/// 重进播放页只按播放地址查会认不出"已经下过",得先用它把当时那条地址捞回来
Future<VideoModel?> find(MediaStyle type, VideoModel? video) async {
if (video == null) return null;
final id = _identity(type, video);
for (final e in await getMovieCacheVideoList(type)) {
if (_identity(type, e) == id) return e;
}
return null;
}
// ===== 短剧下载幂等键 =====
/// 这一集下载用的 `X-Request-ID`,没有就新建并**先落盘再返回**——
/// 授权请求超时/响应丢了都得拿同一个键重试,换新键服务端会再扣一次下载次数
Future<String> dramaRequestId(String contentId) async {
final map = await _dramaRequestIds();
final exist = map[contentId];
if (exist is String && exist.isNotEmpty) return exist;
final id = const Uuid().v4();
map[contentId] = id;
await _writeDramaRequestIds(map);
return id;
}
/// 丢弃这一集的幂等键:下次点下载算新的一次,服务端会正常扣次。
/// 4009(同一个键换了剧集)也走它把脏映射清掉
Future<void> dropDramaRequestId(String? contentId) =>
_dropDramaRequestIds([contentId]);
Future<void> _dropDramaRequestIds(Iterable<String?> contentIds) async {
final map = await _dramaRequestIds();
if (map.isEmpty) return;
map.removeWhere((k, _) => contentIds.contains(k));
await _writeDramaRequestIds(map);
}
Future<Map<String, dynamic>> _dramaRequestIds() async {
final str =
await lightKV.getString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID) ?? '';
if (str.isEmpty) return {};
try {
return Map<String, dynamic>.from(json.decode(str));
} catch (e) {
debugLog(e);
return {};
}
}
Future<void> _writeDramaRequestIds(Map<String, dynamic> map) =>
lightKV.setString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID, json.encode(map));
/// 不确定记录落在哪个桶时逐个桶删(缓存页多选删除用)
Future<void> removeVideoListNoType(List<VideoModel> videos) async {
for (final type in _buckets) {
await removeVideoList(type, videos);
}
}
/// 清空全部桶的缓存记录(磁盘文件由 [VideoDownloadManager.emptyCache] 删)。
/// 不能只清影视桶——文件是整个目录删掉的,剩下的桶会留一堆指向已删文件的死记录
Future<void> removeAll() async {
await lightKV.setString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID, '');
for (final type in _buckets) {
await _write(type, const []);
}
}
Future<void> _write(MediaStyle type, Iterable<VideoModel> videos) =>
lightKV.setStringList(
_listKey(type), videos.map((e) => json.encode(e.toJson())).toList());
}
@@ -0,0 +1,315 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:hgdj/tools_base/video_download/video_cache_store.dart';
import 'package:m3u8_downloader/m3u8_downloader.dart';
import 'package:path_provider/path_provider.dart';
import '../debug_log.dart';
import 'ios_video_downloader.dart';
/// 下载回调,每个 url 可注册多个
/// - success(url)
/// - fail(url, errorMsg)
/// - progress(url, progressStr) — progress 形如 "12.34",完成时为 "100.00"
class DownloadCallback {
final Function(String)? success;
final Function(String, String)? fail;
final Function(String, String)? progress;
DownloadCallback({this.success, this.fail, this.progress});
}
/// m3u8 视频下载/缓存管理,按平台转发到两套完全不同的实现:
/// - Android:调三方插件 M3u8Downloader,下载在原生侧执行,产物是本地 m3u8 + ts 分片。
/// 原生通过 CallbackHandle 在 Dart 后台 isolate 里执行本文件底部那几个顶层回调,
/// 回调再用 IsolateNameServer 找到主 isolate 的 [_port] 把事件发回来,
/// 最后分发给已注册的 [DownloadCallback]。要存相册需再经 VideoSaveUtil 转 mp4。
/// - iOS:转发到 [IOSVideoDownloader]ffmpeg 边下边转,产物直接是 mp4,下载完成即自动写入相册。
class VideoDownloadManager {
// ===== 单例 =====
factory VideoDownloadManager() => instance;
static VideoDownloadManager get instance =>
_instance ??= VideoDownloadManager._();
static VideoDownloadManager? _instance;
// ===== 状态 =====
/// 缓存根目录,[init] 完成后才可用;跨平台取值走 [cacheDir]
static late String _basePath;
static bool _isInited = false;
final ReceivePort _port = ReceivePort();
/// url(taskKey) → 该 url 的回调列表(成功/失败/进度全部走这里分发)
final Map<String, List<DownloadCallback>> _callbacks = {};
/// 下载任务的稳定标识:只取 m3u8 的 path(/vid/h5/m3u8/xxx),剥掉 token / c(cdn) 这些易变 query。
/// realVideoUrl 是用全局 Address.token / Address.cdnAddress 现拼的,token 刷新或用户切线路后整条 url 就变了。
/// 拿整条 url 当回调 key / 匹配条件,会导致原生回传的老 url 和现拼的新 url 对不上——
/// 进度回调匹配失败,进度条卡在切换那一刻的百分比不再刷新(下载其实还在按 path 继续跑)。
static String taskKey(String? url) {
if (url == null || url.isEmpty) return '';
return Uri.tryParse(url)?.path ?? url;
}
VideoDownloadManager._() {
// Dart 后台 isolate(由原生触发)通过 IsolateNameServer 找到这个 port 把下载事件发回来。
// 必须先 remove 再 registerregisterPortWithName 遇到同名已注册会直接返回 false、不覆盖。
// 热重启后旧端口(已随旧 isolate 失效)仍占着这个名字,不先清掉的话新 _port 注册失败,
// 后台 isolate lookup 到的是旧死端口 → send 进黑洞 → 实时进度回调全丢(要退出重进靠 searchInfo 才看到)。
IsolateNameServer.removePortNameMapping(_portName);
IsolateNameServer.registerPortWithName(_port.sendPort, _portName);
_port.listen(_onIsolateEvent);
}
/// 后台 isolate 回传的下载事件:按 taskKey 找到监听方逐个分发
void _onIsolateEvent(dynamic data) {
if (data is! Map) return;
debugLog("isolate message:$data");
final String url = data["url"];
final key = taskKey(url);
final list = _callbacks[key] ?? const <DownloadCallback>[];
// 逐个回调独立 try-catch:任一监听方抛异常(典型如已 dispose 的组件 setState
// 都不能中断循环,否则排在它后面的监听方永久收不到事件
void notify(String type, void Function(DownloadCallback cb) action) {
for (final cb in List.of(list)) {
try {
action(cb);
} catch (e) {
debugLog('download $type 回调异常(已隔离): $e');
}
}
}
switch (data["status"]) {
case 0: // 缓存失败
notify('fail', (cb) => cb.fail?.call(url, data["error"]));
_callbacks.remove(key);
case 1: // 缓存成功
notify('success', (cb) => cb.success?.call(url));
_callbacks.remove(key);
case 2: // 进度
final raw = data["progress"];
final progress =
raw is double ? raw.toStringAsFixed(2) : raw.toString();
notify('progress', (cb) => cb.progress?.call(url, progress));
}
}
// ===== 初始化 / 目录 =====
/// 初始化原生下载器(Android 专用)
/// iOS 端走 [IOSVideoDownloader],不需要走这里
Future<bool> init() async {
if (Platform.isIOS) return false;
if (_isInited) return true;
final dir = Platform.isAndroid
? await getExternalStorageDirectory()
: await getApplicationDocumentsDirectory();
_basePath = '${dir!.path}/vPlayDownload';
final root = Directory(_basePath);
if (!root.existsSync()) await root.create();
debugLog(_basePath);
// onSelect 返回 null 表示采用默认清晰度,不弹选择框
_isInited = await M3u8Downloader.initialize(onSelect: () async => null);
if (_isInited) {
_isInited = await M3u8Downloader.config(
saveDir: _basePath,
progressCallback: _onProgress,
successCallback: _onSuccess,
errorCallback: _onError,
);
}
return _isInited;
}
/// 已下载视频缓存根目录(跨平台安全获取)
/// iOS 的 _basePath 不在 [init] 里赋值(直接 return false),直接读 static late 字段会抛 LateInitializationError
/// 统计缓存大小时必须走这里
Future<String> cacheDir() async {
if (Platform.isIOS) return IOSVideoDownloader.instance.baseDir();
if (!_isInited) await init();
return _basePath;
}
/// 清空所有缓存:删除磁盘文件 + 清空数据库记录 + 清空回调
Future<void> emptyCache() async {
try {
if (Platform.isIOS) {
await IOSVideoDownloader.instance.emptyCache();
} else {
// 走 cacheDir 确保 _basePath 已初始化,避免未下载过就清理时读 late 字段抛 LateInitializationError
final root = Directory(await cacheDir());
if (root.existsSync()) await root.delete(recursive: true);
if (!root.existsSync()) await root.create();
}
await VideoCacheStore.instance.removeAll();
_clearCallbacks();
} catch (e) {
debugLog(e);
}
}
// ===== 任务 =====
/// 查询 url 当前缓存状态,两端的裸 Map 统一解析成 [DownloadInfo]
/// 返回 null 表示:url 为空 / 未下载过 / 缓存初始化失败
/// [callback] 仅在查到任务正在下载时挂上,用于后续接收进度回调
Future<DownloadInfo?> searchInfo(
{String? url, DownloadCallback? callback}) async {
if (Platform.isIOS) {
final ret = await IOSVideoDownloader.instance
.searchInfo(url ?? '', callback: callback);
return ret is Map ? DownloadInfo.fromJson(ret) : null;
}
if (!_isInited && !await init()) {
debugLog("缓存初始化失败");
return null;
}
if (url?.isNotEmpty != true) return null;
final ret = await M3u8Downloader.searchInfo(url!);
debugLog("======= result:$ret");
if (ret is! Map) return null;
final info = DownloadInfo.fromJson(ret);
if (info.isDownloading && callback != null) _addCallback(url, callback);
return info;
}
/// 开始下载
/// 返回值:
/// - null / "正在执行": 任务已开启或已在跑,会自动挂上 [callback]
/// - 其它 String: 错误提示,如 "缓存加载失败" / "视频链接为空" / 异常文案
Future<dynamic> download(
{required String url, DownloadCallback? callback}) async {
try {
if (Platform.isIOS) {
return IOSVideoDownloader.instance
.download(url: url, callback: callback);
}
if (!_isInited && !await init()) return "缓存加载失败";
if (url.isEmpty) return "视频链接为空";
final result = await M3u8Downloader.download(url: url, name: "m3u8");
if ((result == null || result == "正在执行") && callback != null) {
_addCallback(url, callback);
}
return result;
} catch (e) {
return e.toString();
}
}
/// 暂停指定 url 的下载任务,并清掉其回调,避免暂停后还收到进度
Future<void> pause(String url) async {
if (Platform.isIOS) {
await IOSVideoDownloader.instance.pause(url);
} else {
await M3u8Downloader.pause(url);
}
_callbacks.remove(taskKey(url));
}
/// 删除指定 url 的缓存文件,同时清掉它的回调
Future<bool> delete(String url) async {
final ret = Platform.isIOS
? await IOSVideoDownloader.instance.delete(url)
: await M3u8Downloader.delete(url);
_callbacks.remove(taskKey(url));
return ret;
}
// ===== 回调注册 =====
/// 注册回调;先 remove 再 add 是为了**防止同一 callback 重复注册**导致一次事件触发多次
void _addCallback(String url, DownloadCallback callback) {
final list = _callbacks.putIfAbsent(taskKey(url), () => []);
list.remove(callback);
list.add(callback);
}
/// 只移除某 url 上的某个回调(组件 dispose 时用,避免清掉别的组件/页面的回调)
/// iOS 的回调注册在 [_IOSDownloadTask] 内部而非本类的 _callbacks,必须转发过去,
/// 否则组件 dispose 后回调仍残留在任务里 → 被调到就抛 setState after dispose
/// 还会掐断同一任务上排在它后面的回调(缓存页进度就此静止)
void removeCallback(String? url, DownloadCallback callback) {
if (Platform.isIOS) {
IOSVideoDownloader.instance.removeCallback(url ?? '', callback);
return;
}
final key = taskKey(url);
final list = _callbacks[key];
if (list == null) return;
list.remove(callback);
if (list.isEmpty) _callbacks.remove(key);
}
//只在 emptyCache 里用:清光全部监听方。别在页面/组件里调,那会连别处的回调一起清掉
void _clearCallbacks() {
_callbacks.clear();
if (Platform.isIOS) IOSVideoDownloader.instance.removeAllCallbacks();
}
}
// ==== 以下顶层函数在 Dart 后台 isolate 中执行(原生侧经 CallbackHandle 触发),必须是顶层/静态,不能放进类里 ====
// 都要标 @pragma('vm:entry-point'):后台 isolate 通过 getCallbackFromHandle 取它们,
// release AOT 下不标会被 tree-shake,导致回调拿不到、进度/成功事件丢失。
const _portName = "downloader_send_port";
@pragma('vm:entry-point')
_onProgress(dynamic args) {
final port = IsolateNameServer.lookupPortByName(_portName);
if (port == null) return;
args["status"] = 2;
port.send(args);
}
@pragma('vm:entry-point')
_onSuccess(dynamic args) {
debugLog("=======load success!!!!!!");
debugLog(args);
IsolateNameServer.lookupPortByName(_portName)?.send({
"status": 1,
"url": args["url"],
"filePath": args["filePath"],
"dir": args["dir"]
});
}
@pragma('vm:entry-point')
_onError(dynamic args) {
IsolateNameServer.lookupPortByName(_portName)
?.send({"status": 0, "url": args["url"]});
}
/// [VideoDownloadManager.searchInfo] 的查询结果:某个 url 当前的下载状态。
/// 两端(Android 插件 / iOS ffmpeg)回的都是裸 Map,统一在这里收口,调用方别再按字符串取键
class DownloadInfo {
String? localPath; // 本地路径(下载完才有)
String? progress; // 进度百分比字符串,完成为 "100.00"
String? isLoaderRunning; // "1" 下载中 / "0" 已暂停;已完成时不下发
String? status; // "2" 下载完成;下载中时由 isLoaderRunning 推出 "1"
DownloadInfo(
{this.progress, this.localPath, this.status, this.isLoaderRunning});
DownloadInfo.fromJson(Map json) {
localPath = json['localPath'];
progress = json['progress'];
isLoaderRunning = json['isLoaderRunning'];
if (json['isLoaderRunning'] == "1") {
status = '1';
}
if (json['status'] != null) {
status = json['status'];
}
}
//下载任务是否正在跑
bool get isDownloading => isLoaderRunning == "1";
}
@@ -0,0 +1,254 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/statistics.dart';
import 'package:hgdj/hj_utils/image_util.dart';
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import '../debug_log.dart';
import 'video_download_manager.dart';
/// 把**已下载完成**的视频导出到系统相册(不负责下载,下载见 [VideoDownloadManager]
/// - Android:本地 m3u8 + ts 用 ffmpeg 转 mp4(不重编码),再存相册
/// - iOS:下载产物本身就是 mp4,直接存相册
/// 同一时刻只转一个,其余任务排队([_taskList] / [_queueList]
class VideoSaveUtil {
VideoSaveUtil._();
static final VideoSaveUtil instance = VideoSaveUtil._();
final List<String> _taskList = [];
final List<String> _queueList = [];
/// 视频转换成 mp4 保存到本地相册中
/// 在 VideoDownloadManager 下载完成后调用
Future<void> convertVideoMp4(String url,
{DownloadInfo? loadInfo, bool isShowLoading = true}) async {
// iOS 下载产物本身就是 mp4(ffmpeg 下载时已转码)localPath 直接指向 mp4
// 不能走下面的 m3u8 转码逻辑(会把 mp4 当 m3u8 文本读),直接保存即可
if (Platform.isIOS) {
await _saveIosMp4(url, loadInfo: loadInfo, isShowLoading: isShowLoading);
return;
}
if (_taskList.contains(url)) {
showToast("视频正在保存中...");
return;
}
if (_taskList.isNotEmpty) {
_queueList.add(url);
showToast("视频正在处理中,请耐心等待...");
return;
}
if (isShowLoading) {
LoadingAlertWidget.show(canCancel: true);
}
try {
//队列里补跑的任务(_consumeQueue)不带 loadInfo,得自己查一次
final localFileInfo =
loadInfo ?? await VideoDownloadManager.instance.searchInfo(url: url);
if (localFileInfo?.localPath?.isNotEmpty != true) {
showToast("视频文件不存在");
if (isShowLoading) LoadingAlertWidget.cancel();
return;
}
_taskList.add(url);
final m3u8Path = File(localFileInfo!.localPath!);
final dirPath = m3u8Path.parent.path;
final outputPath = '$dirPath/download.mp4';
// 之前转好的 mp4 还在就不用再转一遍,直接存相册
if (await File(outputPath).exists()) {
await _saveToAlbum(outputPath);
if (isShowLoading) LoadingAlertWidget.cancel();
_taskList.remove(url);
_consumeQueue();
return;
}
final m3u8ContentList = await m3u8Path.readAsLines();
final keyLineIndex =
m3u8ContentList.indexWhere((e) => e.startsWith('#EXT-X-KEY'));
// local.m3u8 里 ts 是本地文件,但 KEY 的 URI 仍是远程地址:把 key 拉到本地,
// 再把 m3u8 的 KEY 行改成指向本地 sec.key,交给 ffmpeg 自行解密(不重编码)
if (keyLineIndex >= 0) {
final keyUrl =
await _resolveKeyUrl(dirPath, m3u8ContentList[keyLineIndex], url);
if (keyUrl.isEmpty) throw "密钥地址解析失败";
final secKeyPath = '$dirPath/sec.key';
if (!await _prepareKeyFile(keyUrl, secKeyPath)) throw "密钥获取失败,请重试";
// 只替换 KEY 行里的 URI,保留 METHOD/IV 等其它属性。
// 整行重写会丢掉 IV,带显式 IV 的流会因默认 IV(分片序号)解密错位。
// 连到下一个逗号为止整段换掉:老数据的 URI 可能被原生插件多包了一层引号
m3u8ContentList[keyLineIndex] = m3u8ContentList[keyLineIndex]
.replaceFirst(RegExp(r'URI=[^,]*'), 'URI="$secKeyPath"');
}
final localM3u8File = File('$dirPath/download.m3u8');
await localM3u8File.writeAsString(m3u8ContentList.join('\n'));
// 转换进度要的总时长直接累加 m3u8 的 EXTINF:省一次 VideoPlayer 初始化+释放,
// 也不会因为播放器在老机型上初始化失败把整个保存流程带崩
final totalDurationMs = _totalDurationMs(m3u8ContentList);
final command =
'-allowed_extensions ALL -i "${localM3u8File.path}" -c copy -bsf:a aac_adtstoasc -movflags +faststart "$outputPath"';
FFmpegKit.executeAsync(
command,
(session) async {
_taskList.remove(url);
// --- 完成回调 ---
if (isShowLoading) {
LoadingAlertWidget.cancel();
}
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
debugLog('转换成功,文件路径:$outputPath');
await _saveToAlbum(outputPath);
} else {
try {
await File(outputPath).delete();
} catch (_) {}
showToast("保存失败");
debugLog('转换失败,返回码:${returnCode?.getValue()}',
await session.getFailStackTrace());
}
_consumeQueue();
},
(log) => debugLog(log.getMessage()),
(Statistics statistics) {
// --- 统计回调:上报转换进度 ---
if (totalDurationMs <= 0) return;
double percentage = statistics.getTime() / totalDurationMs;
if (percentage < 0) percentage = 0;
if (percentage > 1) percentage = 1;
if (isShowLoading) {
LoadingAlertWidget.showExchangeTitle(
"视频转换中: ${(percentage * 100).toStringAsFixed(1)}%",
);
}
},
);
} catch (e) {
_taskList.remove(url);
if (isShowLoading) {
LoadingAlertWidget.cancel();
}
debugLog('转换失败:$e');
showToast(e is String ? e : "保存失败");
_consumeQueue();
}
}
/// iOS:下载完成的文件已是 mp4,直接保存到相册
Future<void> _saveIosMp4(String url,
{DownloadInfo? loadInfo, bool isShowLoading = true}) async {
String? mp4Path = loadInfo?.localPath;
if (mp4Path?.isNotEmpty != true) {
mp4Path =
(await VideoDownloadManager.instance.searchInfo(url: url))?.localPath;
}
if (mp4Path?.isNotEmpty != true || !await File(mp4Path!).exists()) {
showToast("视频文件不存在");
return;
}
if (isShowLoading) LoadingAlertWidget.show();
await _saveToAlbum(mp4Path);
if (isShowLoading) LoadingAlertWidget.cancel();
}
/// 写入相册。插件失败时只返回 isSuccess=false 并不抛异常,
/// 不看返回值会把「没存进去」报成保存成功
Future<void> _saveToAlbum(String path) async {
try {
if (!await ImageUtil.requestAlbumPermission()) {
showToast("请先开启相册权限");
return;
}
//插件在个别机型上拿不到回调会一直挂着,给个兜底(大视频拷贝慢,给足 60s)
final result = await ImageGallerySaver.saveFile(path).timeout(
const Duration(seconds: 60),
onTimeout: () => null,
);
if (result is Map && result["isSuccess"] == true) {
showToast("视频已保存到相册");
return;
}
debugLog('写入相册失败', result);
} catch (e) {
debugLog('写入相册异常', e);
}
showToast("保存到相册失败");
}
/// 取 key 的真实下载地址。
/// local.m3u8 的 KEY 行是原生插件重新拼的,两种情况会拼坏:绝对地址会多包一层引号
/// (取到空串)、带 query 的地址会被 '=' 截断(取到半截地址),所以优先从同目录的
/// remote.m3u8(下载时存下的原始 m3u8 全文)里取原始 URI,相对地址再按视频地址补全
Future<String> _resolveKeyUrl(
String dirPath, String localKeyLine, String videoUrl) async {
String raw = '';
final remoteFile = File('$dirPath/remote.m3u8');
if (await remoteFile.exists()) {
final keyLine = (await remoteFile.readAsLines()).firstWhere(
(e) => e.startsWith('#EXT-X-KEY'),
orElse: () => '',
);
raw = RegExp(r'URI="([^"]*)"').firstMatch(keyLine)?.group(1) ?? '';
}
//remote.m3u8 丢了才退回 local 的 KEY 行,多余的引号一并去掉
if (raw.isEmpty) {
raw = (RegExp(r'URI=([^,]*)').firstMatch(localKeyLine)?.group(1) ?? '')
.replaceAll('"', '');
}
if (raw.isEmpty) return '';
try {
return Uri.parse(videoUrl).resolve(raw).toString();
} catch (e) {
debugLog('key 地址补全失败:$raw', e);
return raw.startsWith('http') ? raw : '';
}
}
/// 确保本地有可用的 key 文件。AES-128 的 key 固定 16 字节,
/// 大小不对说明上次拉到的是错误页/半截文件,重下一次;再不对就判失败,
/// 别拿错 key 去解密——ffmpeg copy 不校验内容,会产出一个能存进相册但花屏的 mp4
Future<bool> _prepareKeyFile(String keyUrl, String path) async {
final keyFile = File(path);
if (await keyFile.exists() && await keyFile.length() == 16) return true;
try {
await Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)).download(keyUrl, path);
} catch (e) {
debugLog('下载 key 失败:$keyUrl', e);
return false;
}
if (await keyFile.exists() && await keyFile.length() == 16) return true;
debugLog('key 内容异常:$keyUrl');
try {
await keyFile.delete();
} catch (_) {}
return false;
}
/// m3u8 里所有 EXTINF 之和(毫秒),用于算转换进度
int _totalDurationMs(List<String> lines) {
double seconds = 0;
for (final line in lines) {
if (!line.startsWith('#EXTINF:')) continue;
seconds +=
double.tryParse(line.substring(8).split(',').first.trim()) ?? 0;
}
return (seconds * 1000).round();
}
/// 消费 _queueList 中下一个待转换任务
void _consumeQueue() {
if (_queueList.isEmpty) return;
final next = _queueList.removeAt(0);
convertVideoMp4(next);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:mobkit_dashed_border/mobkit_dashed_border.dart';
class AddMediaSourceButton extends StatelessWidget {
final bool isVideo;
final Function() onTap;
final double width;
final double height;
final double radius;
final Color borderColor;
final Color backgroundColor;
final String? title;
AddMediaSourceButton({
super.key,
this.isVideo = false,
required this.onTap,
this.width = 111,
this.height = 111,
this.radius = 10,
this.title = '添加视频',
this.borderColor = const Color(0x3cffffff),
this.backgroundColor = Colors.white,
});
@override
Widget build(BuildContext context) {
final title = this.title ?? (isVideo ? '添加视频' : '添加图片');
return InkWell(
enableFeedback: false,
onTap: onTap,
child: Container(
height: height,
width: width,
decoration: BoxDecoration(
color: const Color(0x0Dffffff),
border: DashedBorder.fromBorderSide(
dashLength: 2,
side: BorderSide(color: borderColor, width: 1),
),
borderRadius: BorderRadius.all(Radius.circular(radius)),
),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
((111 - 24) / 2).sizeBoxH,
Image.asset(
'add_grey.png'.communityPath,
width: 24,
color: const Color(0xffDCDCDC),
),
9.sizeBoxH,
Text(
title,
style: TextStyle(
color: const Color(0xff999999),
fontSize: 14.sp,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,12 @@
/// This library is for swiper
//修改源码,适配库与pageview滚动冲突
library card_swiper;
export 'src/flutter_page_indicator/flutter_page_indicator.dart';
export 'src/swiper.dart';
export 'src/swiper_control.dart';
export 'src/swiper_controller.dart';
export 'src/swiper_pagination.dart';
export 'src/swiper_plugin.dart';
export 'src/transformer_page_view/index_controller.dart';
@@ -0,0 +1,456 @@
part of 'swiper.dart';
abstract class _CustomLayoutStateBase<T extends _SubSwiper> extends State<T> with SingleTickerProviderStateMixin {
late double _swiperWidth;
late double _swiperHeight;
late Animation<double> _animation;
late AnimationController _animationController;
SwiperController get _controller => widget.controller;
late int _startIndex;
int? _animationCount;
int _currentIndex = 0;
@override
void initState() {
_currentIndex = widget.index ?? 0;
if (widget.itemWidth == null) {
throw Exception(
'==============\n\nwidget.itemWidth must not be null when use stack layout.\n========\n',
);
}
_createAnimationController();
_controller.addListener(_onController);
super.initState();
}
void _createAnimationController() {
_animationController = AnimationController(vsync: this, value: 0.5);
final tween = Tween(begin: 0.0, end: 1.0);
_animation = tween.animate(_animationController);
}
@override
void didChangeDependencies() {
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback(_getSize);
super.didChangeDependencies();
}
void _getSize(Duration _) {
if (!mounted) return;
afterRender();
}
@mustCallSuper
void afterRender() {
final renderObject = context.findRenderObject()!;
final size = renderObject.paintBounds.size;
_swiperWidth = size.width;
_swiperHeight = size.height;
setState(() {});
}
@override
void didUpdateWidget(T oldWidget) {
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_onController);
widget.controller.addListener(_onController);
}
if (widget.loop != oldWidget.loop) {
if (!widget.loop) {
_currentIndex = _ensureIndex(_currentIndex);
}
}
if (widget.axisDirection != oldWidget.axisDirection) {
afterRender();
}
super.didUpdateWidget(oldWidget);
}
int _ensureIndex(int index) {
var res = index;
res = index % widget.itemCount;
if (res < 0) {
res += widget.itemCount;
}
return res;
}
@override
void dispose() {
widget.controller.removeListener(_onController);
_animationController.dispose();
super.dispose();
}
Widget _buildItem(int i, int realIndex, double animationValue);
Widget _buildContainer(List<Widget> list) {
return Stack(
children: list,
);
}
Widget _buildAnimation(BuildContext context, Widget? w) {
final list = <Widget>[];
final animationValue = _animation.value;
for (var i = 0; i < _animationCount! && widget.itemCount > 0; ++i) {
final itemIndex = _currentIndex + i + _startIndex;
if (!widget.loop && (itemIndex >= widget.itemCount || itemIndex < 0)) {
continue;
}
var realIndex = itemIndex % widget.itemCount;
if (realIndex < 0) {
realIndex += widget.itemCount;
}
if (widget.axisDirection == AxisDirection.right) {
list.insert(0, _buildItem(i, realIndex, animationValue));
} else {
list.add(_buildItem(i, realIndex, animationValue));
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: _onPanStart,
onHorizontalDragEnd: _onPanEnd,
onHorizontalDragUpdate: _onPanUpdate,
child: ClipRect(
child: Center(
child: _buildContainer(list),
),
),
);
}
@override
Widget build(BuildContext context) {
if (_animationCount == null) {
return Container();
}
return AnimatedBuilder(
animation: _animationController,
builder: _buildAnimation,
);
}
late double _currentValue;
late double _currentPos;
bool _lockScroll = false;
Future<void> _move(double position, {int? nextIndex}) async {
if (_lockScroll) return;
try {
_lockScroll = true;
await _animationController.animateTo(
position,
duration: Duration(milliseconds: widget.duration!),
curve: widget.curve,
);
if (nextIndex != null) {
widget.onIndexChanged!(widget.getCorrectIndex(nextIndex));
}
} catch (e, st) {
log('error animating _animationController', error: e, stackTrace: st);
} finally {
if (nextIndex != null) {
try {
_animationController.value = 0.5;
} catch (e, st) {
log(
'error setting _animationController.value',
error: e,
stackTrace: st,
);
}
_currentIndex = nextIndex;
}
_lockScroll = false;
}
}
int _getProperNewIndex(int newIndex) {
var res = newIndex;
if (!widget.loop && newIndex >= widget.itemCount - 1) {
res = widget.itemCount - 1;
} else if (!widget.loop && newIndex < 0) {
res = 0;
}
return res;
}
Future<void> _onController() async {
final controller = widget.controller;
final event = controller.event;
if (event is StepBasedIndexControllerEvent) {
final newIndex = event.calcNextIndex(
currentIndex: _currentIndex,
itemCount: widget.itemCount,
loop: widget.loop,
reverse: false,
);
if (_currentIndex == newIndex) return;
return _move(event.targetPosition, nextIndex: newIndex);
} else if (event is MoveIndexControllerEvent) {
final newIndex = _getProperNewIndex(event.newIndex);
if (_currentIndex == newIndex) return;
return _move(event.targetPosition, nextIndex: newIndex);
}
}
Future<void> _onPanEnd(DragEndDetails details) async {
if (_lockScroll) return;
final velocity = widget.scrollDirection == Axis.horizontal ? details.velocity.pixelsPerSecond.dx : details.velocity.pixelsPerSecond.dy;
if (_animationController.value >= 0.75 || velocity > 500.0) {
if (_currentIndex <= 0 && !widget.loop) {
return _move(0.5);
}
return _move(1.0, nextIndex: _currentIndex - 1);
} else if (_animationController.value < 0.25 || velocity < -500.0) {
if (_currentIndex >= widget.itemCount - 1 && !widget.loop) {
return _move(0.5);
}
return _move(0.0, nextIndex: _currentIndex + 1);
} else {
return _move(0.5);
}
}
void _onPanStart(DragStartDetails details) {
if (_lockScroll) return;
_currentValue = _animationController.value;
_currentPos = widget.scrollDirection == Axis.horizontal ? details.globalPosition.dx : details.globalPosition.dy;
}
void _onPanUpdate(DragUpdateDetails details) {
if (_lockScroll) return;
var value = _currentValue +
((widget.scrollDirection == Axis.horizontal ? details.globalPosition.dx : details.globalPosition.dy) - _currentPos) /
_swiperWidth /
2;
// no loop ?
if (!widget.loop) {
if (widget.itemCount == 1) {
value = 0.5;
}
if (_currentIndex >= widget.itemCount - 1) {
if (value < 0.5) {
value = 0.5;
}
} else if (_currentIndex <= 0) {
if (value > 0.5) {
value = 0.5;
}
}
}
_animationController.value = value;
}
}
double _getValue(List<double> values, double animationValue, int index) {
var s = values[index];
if (animationValue >= 0.5) {
if (index < values.length - 1) {
s = s + (values[index + 1] - s) * (animationValue - 0.5) * 2.0;
}
} else {
if (index != 0) {
s = s - (s - values[index - 1]) * (0.5 - animationValue) * 2.0;
}
}
return s;
}
Offset _getOffsetValue(List<Offset> values, double animationValue, int index) {
final s = values[index];
var dx = s.dx;
var dy = s.dy;
if (animationValue >= 0.5) {
if (index < values.length - 1) {
dx = dx + (values[index + 1].dx - dx) * (animationValue - 0.5) * 2.0;
dy = dy + (values[index + 1].dy - dy) * (animationValue - 0.5) * 2.0;
}
} else {
if (index != 0) {
dx = dx - (dx - values[index - 1].dx) * (0.5 - animationValue) * 2.0;
dy = dy - (dy - values[index - 1].dy) * (0.5 - animationValue) * 2.0;
}
}
return Offset(dx, dy);
}
abstract class TransformBuilder<T> {
TransformBuilder({required this.values});
final List<T> values;
Widget build(int i, double animationValue, Widget widget);
}
class ScaleTransformBuilder extends TransformBuilder<double> {
ScaleTransformBuilder({
required List<double> values,
this.alignment = Alignment.center,
}) : super(values: values);
final Alignment alignment;
@override
Widget build(int i, double animationValue, Widget widget) {
final s = _getValue(values, animationValue, i);
return Transform.scale(scale: s, child: widget);
}
}
class OpacityTransformBuilder extends TransformBuilder<double> {
OpacityTransformBuilder({required List<double> values}) : super(values: values);
@override
Widget build(int i, double animationValue, Widget widget) {
final v = _getValue(values, animationValue, i);
return Opacity(
opacity: v,
child: widget,
);
}
}
class RotateTransformBuilder extends TransformBuilder<double> {
RotateTransformBuilder({required List<double> values}) : super(values: values);
@override
Widget build(int i, double animationValue, Widget widget) {
final v = _getValue(values, animationValue, i);
return Transform.rotate(
angle: v,
child: widget,
);
}
}
class TranslateTransformBuilder extends TransformBuilder<Offset> {
TranslateTransformBuilder({required List<Offset> values}) : super(values: values);
@override
Widget build(int i, double animationValue, Widget widget) {
final s = _getOffsetValue(values, animationValue, i);
return Transform.translate(
offset: s,
child: widget,
);
}
}
class CustomLayoutOption {
CustomLayoutOption({this.stateCount, required this.startIndex});
final List<TransformBuilder<dynamic>> builders = [];
final int startIndex;
final int? stateCount;
void addOpacity(List<double> values) {
builders.add(OpacityTransformBuilder(values: values));
}
void addTranslate(List<Offset> values) {
builders.add(TranslateTransformBuilder(values: values));
}
void addScale(List<double> values, Alignment alignment) {
builders.add(ScaleTransformBuilder(values: values, alignment: alignment));
}
void addRotate(List<double> values) {
builders.add(RotateTransformBuilder(values: values));
}
}
class _CustomLayoutSwiper extends _SubSwiper {
const _CustomLayoutSwiper({
required this.option,
double? itemWidth,
required bool loop,
double? itemHeight,
ValueChanged<int>? onIndexChanged,
Key? key,
IndexedWidgetBuilder? itemBuilder,
required Curve curve,
int? duration,
int? index,
required int itemCount,
Axis? scrollDirection,
required SwiperController controller,
}) : super(
loop: loop,
onIndexChanged: onIndexChanged,
itemWidth: itemWidth,
itemHeight: itemHeight,
key: key,
itemBuilder: itemBuilder,
curve: curve,
duration: duration,
index: index,
itemCount: itemCount,
controller: controller,
scrollDirection: scrollDirection);
final CustomLayoutOption option;
@override
State<StatefulWidget> createState() {
return _CustomLayoutState();
}
}
class _CustomLayoutState extends _CustomLayoutStateBase<_CustomLayoutSwiper> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_startIndex = widget.option.startIndex;
_animationCount = widget.option.stateCount;
}
@override
void didUpdateWidget(_CustomLayoutSwiper oldWidget) {
_startIndex = widget.option.startIndex;
_animationCount = widget.option.stateCount;
super.didUpdateWidget(oldWidget);
}
@override
Widget _buildItem(int index, int realIndex, double animationValue) {
final builders = widget.option.builders;
Widget child = SizedBox(
width: widget.itemWidth ?? double.infinity,
height: widget.itemHeight ?? double.infinity,
child: widget.itemBuilder!(context, realIndex));
for (var i = builders.length - 1; i >= 0; --i) {
final builder = builders[i];
child = builder.build(index, animationValue, child);
}
return child;
}
}
/// Ref: https://docs.flutter.dev/development/tools_base/sdk/release-notes/release-notes-3.0.0#your-code
/// This allows a value of type T or T?
/// to be treated as a value of type T?.
///
/// We use this so that APIs that have become
/// non-nullable can still be used with `!` and `?`
/// to support older versions of the API as well.
T? _ambiguate<T>(T? value) => value;
@@ -0,0 +1,356 @@
// ignore_for_file: constant_identifier_names
/// page indicator library
library flutter_page_indicator;
import 'package:flutter/material.dart';
import '../transformer_page_view/transformer_page_view.dart';
class WarmPainter extends BasePainter {
WarmPainter(PageIndicator widget, double page, int index, Paint paint)
: super(widget, page, index, paint);
@override
void draw(Canvas canvas, double space, double size, double radius) {
final progress = page - index;
final distance = size + space;
final start = index * (size + space);
if (progress > 0.5) {
final right = start + size + distance;
//progress=>0.5-1.0
//left:0.0=>distance
final left = index * distance + distance * (progress - 0.5) * 2;
canvas.drawRRect(
RRect.fromLTRBR(left, 0.0, right, size, Radius.circular(radius)),
_paint);
} else {
final right = start + size + distance * progress * 2;
canvas.drawRRect(
RRect.fromLTRBR(start, 0.0, right, size, Radius.circular(radius)),
_paint);
}
}
}
class DropPainter extends BasePainter {
DropPainter(PageIndicator widget, double page, int index, Paint paint)
: super(widget, page, index, paint);
@override
void draw(Canvas canvas, double space, double size, double radius) {
final progress = page - index;
final dropHeight = widget.dropHeight;
final rate = (0.5 - progress).abs() * 2;
final scale = widget.scale;
//lerp(begin, end, progress)
canvas.drawCircle(
Offset(radius + ((page) * (size + space)),
radius - dropHeight * (1 - rate)),
radius * (scale + rate * (1.0 - scale)),
_paint);
}
}
class NonePainter extends BasePainter {
NonePainter(PageIndicator widget, double page, int index, Paint paint)
: super(widget, page, index, paint);
@override
void draw(Canvas canvas, double space, double size, double radius) {
final progress = page - index;
final secondOffset = index == widget.count - 1
? radius
: radius + ((index + 1) * (size + space));
if (progress > 0.5) {
canvas.drawCircle(Offset(secondOffset, radius), radius, _paint);
} else {
canvas.drawCircle(
Offset(radius + (index * (size + space)), radius), radius, _paint);
}
}
}
class SlidePainter extends BasePainter {
SlidePainter(PageIndicator widget, double page, int index, Paint paint)
: super(widget, page, index, paint);
@override
void draw(Canvas canvas, double space, double size, double radius) {
canvas.drawCircle(
Offset(radius + (page * (size + space)), radius), radius, _paint);
}
}
class ScalePainter extends BasePainter {
ScalePainter(PageIndicator widget, double page, int index, Paint paint)
: super(widget, page, index, paint);
@override
bool _shouldSkip(int index) {
if (this.index == widget.count - 1) {
return index == 0 || index == this.index;
}
return (index == this.index || index == this.index + 1);
}
@override
void paint(Canvas canvas, Size size) {
_paint.color = widget.color;
final space = widget.space;
final size = widget.size;
final radius = size / 2;
final c = widget.count;
for (var i = 0; i < c; ++i) {
if (_shouldSkip(i)) {
continue;
}
canvas.drawCircle(Offset(i * (size + space) + radius, radius),
radius * widget.scale, _paint);
}
_paint.color = widget.activeColor;
draw(canvas, space, size, radius);
}
@override
void draw(Canvas canvas, double space, double size, double radius) {
final secondOffset = index == widget.count - 1
? radius
: radius + ((index + 1) * (size + space));
final progress = page - index;
_paint.color = Color.lerp(widget.activeColor, widget.color, progress)!;
//last
canvas.drawCircle(Offset(radius + (index * (size + space)), radius),
lerp(radius, radius * widget.scale, progress), _paint);
//first
_paint.color = Color.lerp(widget.color, widget.activeColor, progress)!;
canvas.drawCircle(Offset(secondOffset, radius),
lerp(radius * widget.scale, radius, progress), _paint);
}
}
class ColorPainter extends BasePainter {
ColorPainter(PageIndicator widget, double page, int index, Paint paint)
: super(widget, page, index, paint);
@override
bool _shouldSkip(int index) {
if (this.index == widget.count - 1) {
return index == 0 || index == this.index;
}
return (index == this.index || index == this.index + 1);
}
@override
void draw(Canvas canvas, double space, double size, double radius) {
final progress = page - index;
final secondOffset = index == widget.count - 1
? radius
: radius + ((index + 1) * (size + space));
_paint.color = Color.lerp(widget.activeColor, widget.color, progress)!;
//left
canvas.drawCircle(
Offset(radius + (index * (size + space)), radius), radius, _paint);
//right
_paint.color = Color.lerp(widget.color, widget.activeColor, progress)!;
canvas.drawCircle(Offset(secondOffset, radius), radius, _paint);
}
}
abstract class BasePainter extends CustomPainter {
BasePainter(this.widget, this.page, this.index, this._paint);
final PageIndicator widget;
final double page;
final int index;
final Paint _paint;
double lerp(double begin, double end, double progress) {
return begin + (end - begin) * progress;
}
void draw(Canvas canvas, double space, double size, double radius);
bool _shouldSkip(int index) {
return false;
}
//double secondOffset = index == widget.count-1 ? radius : radius + ((index + 1) * (size + space));
@override
void paint(Canvas canvas, Size size) {
_paint.color = widget.color;
final space = widget.space;
final size = widget.size;
final radius = size / 2;
final c = widget.count;
for (var i = 0; i < c; ++i) {
if (_shouldSkip(i)) {
continue;
}
canvas.drawCircle(
Offset(i * (size + space) + radius, radius), radius, _paint);
}
var page = this.page;
if (page < index) {
page = 0.0;
}
_paint.color = widget.activeColor;
draw(canvas, space, size, radius);
}
@override
bool shouldRepaint(BasePainter oldDelegate) {
return oldDelegate.page != page;
}
}
class _PageIndicatorState extends State<PageIndicator> {
int index = 0;
double page = 0;
final _paint = Paint();
BasePainter _createPainter() {
switch (widget.layout) {
case PageIndicatorLayout.NONE:
return NonePainter(widget, page, index, _paint);
case PageIndicatorLayout.SLIDE:
return SlidePainter(widget, page, index, _paint);
case PageIndicatorLayout.WARM:
return WarmPainter(widget, page, index, _paint);
case PageIndicatorLayout.COLOR:
return ColorPainter(widget, page, index, _paint);
case PageIndicatorLayout.SCALE:
return ScalePainter(widget, page, index, _paint);
case PageIndicatorLayout.DROP:
return DropPainter(widget, page, index, _paint);
default:
throw Exception('Not a valid layout');
}
}
@override
Widget build(BuildContext context) {
Widget child = SizedBox(
width: widget.count * widget.size + (widget.count - 1) * widget.space,
height: widget.size,
child: CustomPaint(
painter: _createPainter(),
),
);
if (widget.layout == PageIndicatorLayout.SCALE ||
widget.layout == PageIndicatorLayout.COLOR) {
child = ClipRect(
child: child,
);
}
return IgnorePointer(
child: child,
);
}
void _setInitialPage() {
// use the initial page index but cut off
// the offset specified when looping (kMiddleValue)
index = widget.controller.initialPage % kMiddleValue;
page = index.toDouble();
}
void _onController() {
if (!widget.controller.hasClients) return;
page = widget.controller.page ?? 0.0;
index = page.floor();
setState(() {});
}
@override
void initState() {
super.initState();
widget.controller.addListener(_onController);
_setInitialPage();
}
@override
void didUpdateWidget(PageIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_onController);
widget.controller.addListener(_onController);
}
}
@override
void dispose() {
widget.controller.removeListener(_onController);
super.dispose();
}
}
enum PageIndicatorLayout {
NONE,
SLIDE,
WARM,
COLOR,
SCALE,
DROP,
}
class PageIndicator extends StatefulWidget {
const PageIndicator({
Key? key,
this.size = 20.0,
this.space = 5.0,
required this.count,
this.activeSize = 20.0,
required this.controller,
this.color = Colors.white30,
this.layout = PageIndicatorLayout.SLIDE,
this.activeColor = Colors.white,
this.scale = 0.6,
this.dropHeight = 20.0,
}) : super(key: key);
/// size of the dots
final double size;
/// space between dots.
final double space;
/// count of dots
final int count;
/// active color
final Color activeColor;
/// normal color
final Color color;
/// layout of the dots,default is [PageIndicatorLayout.SLIDE]
final PageIndicatorLayout? layout;
// Only valid when layout==PageIndicatorLayout.scale
final double scale;
// Only valid when layout==PageIndicatorLayout.drop
final double dropHeight;
final PageController controller;
final double activeSize;
@override
State<StatefulWidget> createState() {
return _PageIndicatorState();
}
}
@@ -0,0 +1,996 @@
// ignore_for_file: constant_identifier_names
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import '../card_swiper.dart';
import 'transformer_page_view/transformer_page_view.dart';
part 'custom_layout.dart';
typedef SwiperOnTap = void Function(int index);
typedef SwiperDataBuilder<T> = Widget Function(
BuildContext context,
T data,
int index,
);
/// default auto play delay
const int kDefaultAutoplayDelayMs = 3000;
/// Default auto play transition duration (in millisecond)
const int kDefaultAutoplayTransactionDuration = 300;
const int kMaxValue = 2000000000;
const int kMiddleValue = 1000000000;
enum SwiperLayout {
DEFAULT,
STACK,
TINDER,
CUSTOM,
}
class Swiper extends StatefulWidget {
const Swiper({
this.itemBuilder,
this.indicatorLayout = PageIndicatorLayout.NONE,
///
this.transformer,
required this.itemCount,
this.autoplay = false,
this.layout = SwiperLayout.DEFAULT,
this.autoplayDelay = kDefaultAutoplayDelayMs,
this.autoplayDisableOnInteraction = true,
this.duration = kDefaultAutoplayTransactionDuration,
this.onIndexChanged,
this.index,
this.onTap,
this.control,
this.loop = true,
this.curve = Curves.ease,
this.scrollDirection = Axis.horizontal,
this.axisDirection = AxisDirection.left,
this.pagination,
this.plugins,
this.physics,
Key? key,
this.controller,
this.customLayoutOption,
/// since v1.0.0
this.containerHeight,
this.containerWidth,
this.viewportFraction = 1.0,
this.itemHeight,
this.itemWidth,
this.outer = false,
this.scale,
this.fade,
this.allowImplicitScrolling = false,
}) : assert(
itemBuilder != null || transformer != null,
'itemBuilder and transformItemBuilder must not be both null',
),
assert(
!loop ||
((loop &&
layout == SwiperLayout.DEFAULT &&
(indicatorLayout == PageIndicatorLayout.SCALE ||
indicatorLayout == PageIndicatorLayout.COLOR ||
indicatorLayout == PageIndicatorLayout.NONE)) ||
(loop && layout != SwiperLayout.DEFAULT)),
'Only support `PageIndicatorLayout.SCALE` and `PageIndicatorLayout.COLOR`when layout==SwiperLayout.DEFAULT in loop mode'),
super(key: key);
factory Swiper.children({
required List<Widget> children,
bool autoplay = false,
PageTransformer? transformer,
int autoplayDelay = kDefaultAutoplayDelayMs,
bool autoplayDisableOnInteraction = true,
int duration = kDefaultAutoplayTransactionDuration,
ValueChanged<int>? onIndexChanged,
int? index,
SwiperOnTap? onTap,
bool loop = true,
Curve curve = Curves.ease,
Axis scrollDirection = Axis.horizontal,
AxisDirection axisDirection = AxisDirection.left,
SwiperPlugin? pagination,
SwiperPlugin? control,
List<SwiperPlugin>? plugins,
SwiperController? controller,
Key? key,
CustomLayoutOption? customLayoutOption,
ScrollPhysics? physics,
double? containerHeight,
double? containerWidth,
double viewportFraction = 1.0,
double? itemHeight,
double? itemWidth,
bool outer = false,
double scale = 1.0,
double? fade,
PageIndicatorLayout indicatorLayout = PageIndicatorLayout.NONE,
SwiperLayout layout = SwiperLayout.DEFAULT,
}) =>
Swiper(
fade: fade,
indicatorLayout: indicatorLayout,
layout: layout,
transformer: transformer,
customLayoutOption: customLayoutOption,
containerHeight: containerHeight,
containerWidth: containerWidth,
viewportFraction: viewportFraction,
itemHeight: itemHeight,
itemWidth: itemWidth,
outer: outer,
scale: scale,
autoplay: autoplay,
autoplayDelay: autoplayDelay,
autoplayDisableOnInteraction: autoplayDisableOnInteraction,
duration: duration,
onIndexChanged: onIndexChanged,
index: index,
onTap: onTap,
curve: curve,
scrollDirection: scrollDirection,
axisDirection: axisDirection,
pagination: pagination,
control: control,
controller: controller,
loop: loop,
plugins: plugins,
physics: physics,
key: key,
itemBuilder: (context, index) {
return children[index];
},
itemCount: children.length,
);
/// If set true , the pagination will display 'outer' of the 'content' container.
final bool outer;
/// Inner item height, this property is valid if layout=STACK or layout=TINDER or LAYOUT=CUSTOM,
final double? itemHeight;
/// Inner item width, this property is valid if layout=STACK or layout=TINDER or LAYOUT=CUSTOM,
final double? itemWidth;
// height of the inside container,this property is valid when outer=true,otherwise the inside container size is controlled by parent widget
final double? containerHeight;
// width of the inside container,this property is valid when outer=true,otherwise the inside container size is controlled by parent widget
final double? containerWidth;
/// Build item on index
final IndexedWidgetBuilder? itemBuilder;
/// Support transform like Android PageView did
/// `itemBuilder` and `transformItemBuilder` must have one not null
final PageTransformer? transformer;
/// count of the display items
final int itemCount;
final ValueChanged<int>? onIndexChanged;
///auto play config
final bool autoplay;
///Duration of the animation between transactions (in millisecond).
final int autoplayDelay;
///disable auto play when interaction
final bool autoplayDisableOnInteraction;
///auto play transition duration (in millisecond)
final int duration;
///horizontal/vertical
final Axis scrollDirection;
///left/right for Stack Layout
final AxisDirection axisDirection;
///transition curve
final Curve curve;
/// Set to false to disable continuous loop mode.
final bool loop;
///Index number of initial slide.
///If not set , the `Swiper` is 'uncontrolled', which means manage index by itself
///If set , the `Swiper` is 'controlled', which means the index is fully managed by parent widget.
final int? index;
///Called when tap
final SwiperOnTap? onTap;
///The swiper pagination plugin
final SwiperPlugin? pagination;
///the swiper control button plugin
final SwiperPlugin? control;
///other plugins, you can custom your own plugin
final List<SwiperPlugin>? plugins;
///
final SwiperController? controller;
final ScrollPhysics? physics;
///
final double viewportFraction;
/// Build in layouts
final SwiperLayout layout;
/// this value is valid when layout == SwiperLayout.CUSTOM
final CustomLayoutOption? customLayoutOption;
// This value is valid when viewportFraction is set and < 1.0
final double? scale;
// This value is valid when viewportFraction is set and < 1.0
final double? fade;
final PageIndicatorLayout indicatorLayout;
final bool allowImplicitScrolling;
static Swiper list<T>({
PageTransformer? transformer,
required List<T> list,
CustomLayoutOption? customLayoutOption,
required SwiperDataBuilder<T> builder,
bool autoplay = false,
int autoplayDelay = kDefaultAutoplayDelayMs,
bool reverse = false,
bool autoplayDisableOnInteraction = true,
int duration = kDefaultAutoplayTransactionDuration,
ValueChanged<int>? onIndexChanged,
int? index,
SwiperOnTap? onTap,
bool loop = true,
Curve curve = Curves.ease,
Axis scrollDirection = Axis.horizontal,
AxisDirection axisDirection = AxisDirection.left,
SwiperPlugin? pagination,
SwiperPlugin? control,
List<SwiperPlugin>? plugins,
SwiperController? controller,
Key? key,
ScrollPhysics? physics,
double? containerHeight,
double? containerWidth,
double viewportFraction = 1.0,
double? itemHeight,
double? itemWidth,
bool outer = false,
double scale = 1.0,
double? fade,
PageIndicatorLayout indicatorLayout = PageIndicatorLayout.NONE,
SwiperLayout layout = SwiperLayout.DEFAULT,
}) =>
Swiper(
fade: fade,
indicatorLayout: indicatorLayout,
layout: layout,
transformer: transformer,
customLayoutOption: customLayoutOption,
containerHeight: containerHeight,
containerWidth: containerWidth,
viewportFraction: viewportFraction,
itemHeight: itemHeight,
itemWidth: itemWidth,
outer: outer,
scale: scale,
autoplay: autoplay,
autoplayDelay: autoplayDelay,
autoplayDisableOnInteraction: autoplayDisableOnInteraction,
duration: duration,
onIndexChanged: onIndexChanged,
index: index,
onTap: onTap,
curve: curve,
key: key,
scrollDirection: scrollDirection,
axisDirection: axisDirection,
pagination: pagination,
control: control,
controller: controller,
loop: loop,
plugins: plugins,
physics: physics,
itemBuilder: (context, index) {
return builder(context, list[index], index);
},
itemCount: list.length,
);
@override
State<StatefulWidget> createState() => _SwiperState();
}
abstract class _SwiperTimerMixin extends State<Swiper> {
Timer? _timer;
late SwiperController _controller;
@override
void initState() {
super.initState();
_controller = widget.controller ?? SwiperController();
_controller.addListener(_onController);
if (widget.autoplay) {
_controller.startAutoplay();
} else {
_controller.stopAutoplay();
}
}
void _onController() {
final event = _controller.event;
if (event is AutoPlaySwiperControllerEvent) {
if (event.autoplay) {
if (_timer == null) {
_startAutoplay();
}
} else {
_stopAutoplay();
}
}
}
@override
void didUpdateWidget(Swiper oldWidget) {
super.didUpdateWidget(oldWidget);
if (_controller != oldWidget.controller) {
final oldController = oldWidget.controller;
if (oldController != null) {
oldController.removeListener(_onController);
_controller = oldController;
_controller.addListener(_onController);
}
}
if (widget.autoplay != oldWidget.autoplay) {
if (widget.autoplay) {
_controller.startAutoplay();
} else {
_controller.stopAutoplay();
}
}
}
@override
void dispose() {
_controller.removeListener(_onController);
_stopAutoplay();
super.dispose();
}
void _startAutoplay() {
_stopAutoplay();
_timer = Timer.periodic(
Duration(
milliseconds: widget.autoplayDelay,
),
_onTimer,
);
}
Future<void> _onTimer(Timer timer) async {
return _controller.next(animation: true);
}
void _stopAutoplay() {
_timer?.cancel();
_timer = null;
}
}
class _SwiperState extends _SwiperTimerMixin {
late int _activeIndex;
TransformerPageController? _pageController;
Widget _wrapTap(BuildContext context, int index) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onTap!(index),
child: widget.itemBuilder!(context, index),
);
}
@override
void initState() {
super.initState();
_activeIndex = widget.index ?? widget.controller?.index ?? 0;
if (_isPageViewLayout()) {
_pageController = TransformerPageController(
initialPage: widget.index ?? widget.controller?.index ?? 0,
loop: widget.loop,
itemCount: widget.itemCount,
reverse: widget.transformer?.reverse ?? false,
viewportFraction: widget.viewportFraction,
);
}
}
bool _isPageViewLayout() {
return widget.layout == SwiperLayout.DEFAULT;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
bool _getReverse(Swiper widget) => widget.transformer?.reverse ?? false;
@override
void didUpdateWidget(Swiper oldWidget) {
super.didUpdateWidget(oldWidget);
if (_isPageViewLayout()) {
if (_pageController == null ||
(widget.index != oldWidget.index ||
widget.loop != oldWidget.loop ||
widget.itemCount != oldWidget.itemCount ||
widget.viewportFraction != oldWidget.viewportFraction ||
_getReverse(widget) != _getReverse(oldWidget))) {
_pageController = TransformerPageController(
initialPage: widget.index ?? widget.controller?.index ?? 0,
loop: widget.loop,
itemCount: widget.itemCount,
reverse: _getReverse(widget),
viewportFraction: widget.viewportFraction,
);
}
} else {
scheduleMicrotask(() {
// So that we have a chance to do `removeListener` in child widgets.
if (_pageController != null) {
_pageController!.dispose();
_pageController = null;
}
});
}
if (widget.index != null && widget.index != _activeIndex) {
_activeIndex = widget.index!;
}
}
void _onIndexChanged(int index) {
setState(() {
_activeIndex = index;
});
final event = _controller.event;
if ((event is MoveIndexControllerEvent) && (event.newIndex != index)) {
return;
}
widget.onIndexChanged?.call(index);
}
Widget _buildSwiper() {
IndexedWidgetBuilder? itemBuilder;
if (widget.onTap != null) {
itemBuilder = _wrapTap;
} else {
itemBuilder = widget.itemBuilder;
}
if (widget.layout == SwiperLayout.STACK) {
return _StackSwiper(
loop: widget.loop,
itemWidth: widget.itemWidth,
itemHeight: widget.itemHeight,
itemCount: widget.itemCount,
itemBuilder: itemBuilder,
index: _activeIndex,
curve: widget.curve,
duration: widget.duration,
onIndexChanged: _onIndexChanged,
controller: _controller,
scrollDirection: widget.scrollDirection,
axisDirection: widget.axisDirection,
);
} else if (_isPageViewLayout()) {
//default
var transformer = widget.transformer;
if (widget.scale != null || widget.fade != null) {
transformer = ScaleAndFadeTransformer(scale: widget.scale, fade: widget.fade);
}
final child = TransformerPageView(
pageController: _pageController,
loop: widget.loop,
itemCount: widget.itemCount,
itemBuilder: itemBuilder,
transformer: transformer,
viewportFraction: widget.viewportFraction,
index: _activeIndex,
duration: Duration(milliseconds: widget.duration),
scrollDirection: widget.scrollDirection,
onPageChanged: _onIndexChanged,
curve: widget.curve,
physics: widget.physics,
controller: _controller,
allowImplicitScrolling: widget.allowImplicitScrolling,
);
if (widget.autoplayDisableOnInteraction && widget.autoplay) {
return NotificationListener(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
if (notification.dragDetails != null) {
//by human
if (_timer != null) _stopAutoplay();
}
} else if (notification is ScrollEndNotification) {
if (_timer == null) _startAutoplay();
}
return false;
},
child: child,
);
}
return child;
} else if (widget.layout == SwiperLayout.TINDER) {
return _TinderSwiper(
loop: widget.loop,
itemWidth: widget.itemWidth,
itemHeight: widget.itemHeight,
itemCount: widget.itemCount,
itemBuilder: itemBuilder,
index: _activeIndex,
curve: widget.curve,
duration: widget.duration,
onIndexChanged: _onIndexChanged,
controller: _controller,
scrollDirection: widget.scrollDirection,
);
} else if (widget.layout == SwiperLayout.CUSTOM) {
return _CustomLayoutSwiper(
loop: widget.loop,
option: widget.customLayoutOption!,
itemWidth: widget.itemWidth,
itemHeight: widget.itemHeight,
itemCount: widget.itemCount,
itemBuilder: itemBuilder,
index: _activeIndex,
curve: widget.curve,
duration: widget.duration,
onIndexChanged: _onIndexChanged,
controller: _controller,
scrollDirection: widget.scrollDirection,
);
} else {
return const SizedBox.shrink();
}
}
SwiperPluginConfig _ensureConfig(SwiperPluginConfig? config) {
final con = config ??
SwiperPluginConfig(
outer: widget.outer,
itemCount: widget.itemCount,
layout: widget.layout,
indicatorLayout: widget.indicatorLayout,
pageController: _pageController,
activeIndex: _activeIndex,
scrollDirection: widget.scrollDirection,
axisDirection: widget.axisDirection,
controller: _controller,
loop: widget.loop,
);
return con;
}
List<Widget>? _ensureListForStack({
required Widget swiper,
required List<Widget>? listForStack,
required Widget widget,
}) {
final resList = <Widget>[];
if (listForStack == null) {
resList.addAll([swiper, widget]);
} else {
resList.addAll([...listForStack, widget]);
}
return resList;
}
@override
Widget build(BuildContext context) {
final swiper = _buildSwiper();
List<Widget>? listForStack;
SwiperPluginConfig? config;
if (widget.control != null) {
//Stack
config = _ensureConfig(config);
listForStack = _ensureListForStack(
swiper: swiper,
listForStack: listForStack,
widget: widget.control!.build(context, config),
);
}
if (widget.plugins != null) {
config = _ensureConfig(config);
for (final plugin in widget.plugins!) {
listForStack = _ensureListForStack(
swiper: swiper,
listForStack: listForStack,
widget: plugin.build(context, config),
);
}
}
if (widget.pagination != null) {
config = _ensureConfig(config);
if (widget.outer) {
return _buildOuterPagination(
widget.pagination! as SwiperPagination, listForStack == null ? swiper : Stack(children: listForStack), config);
} else {
listForStack = _ensureListForStack(
swiper: swiper,
listForStack: listForStack,
widget: widget.pagination!.build(context, config),
);
}
}
if (listForStack != null) {
return Stack(
children: listForStack,
);
}
return swiper;
}
Widget _buildOuterPagination(
SwiperPagination pagination,
Widget swiper,
SwiperPluginConfig config,
) {
final list = <Widget>[];
//Only support bottom yet!
if (widget.containerHeight != null || widget.containerWidth != null) {
list.add(swiper);
} else {
list.add(Expanded(child: swiper));
}
list.add(Align(
alignment: Alignment.center,
child: pagination.build(context, config),
));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: list,
);
}
}
abstract class _SubSwiper extends StatefulWidget {
const _SubSwiper({
Key? key,
required this.loop,
this.itemHeight,
this.itemWidth,
this.duration,
required this.curve,
this.itemBuilder,
required this.controller,
this.index,
required this.itemCount,
this.scrollDirection = Axis.horizontal,
this.axisDirection = AxisDirection.left,
this.onIndexChanged,
}) : super(key: key);
final IndexedWidgetBuilder? itemBuilder;
final int itemCount;
final int? index;
final ValueChanged<int>? onIndexChanged;
final SwiperController controller;
final int? duration;
final Curve curve;
final double? itemWidth;
final double? itemHeight;
final bool loop;
final Axis? scrollDirection;
final AxisDirection? axisDirection;
@override
State<StatefulWidget> createState();
int getCorrectIndex(int indexNeedsFix) {
if (itemCount == 0) return 0;
var value = indexNeedsFix % itemCount;
if (value < 0) {
value += itemCount;
}
return value;
}
}
class _TinderSwiper extends _SubSwiper {
const _TinderSwiper({
Key? key,
required Curve curve,
int? duration,
required SwiperController controller,
ValueChanged<int>? onIndexChanged,
double? itemHeight,
double? itemWidth,
IndexedWidgetBuilder? itemBuilder,
int? index,
required bool loop,
required int itemCount,
Axis? scrollDirection,
}) : assert(itemWidth != null && itemHeight != null),
super(
loop: loop,
key: key,
itemWidth: itemWidth,
itemHeight: itemHeight,
itemBuilder: itemBuilder,
curve: curve,
duration: duration,
controller: controller,
index: index,
onIndexChanged: onIndexChanged,
itemCount: itemCount,
scrollDirection: scrollDirection);
@override
State<StatefulWidget> createState() {
return _TinderState();
}
}
class _StackSwiper extends _SubSwiper {
const _StackSwiper({
Key? key,
required Curve curve,
int? duration,
required SwiperController controller,
ValueChanged<int>? onIndexChanged,
double? itemHeight,
double? itemWidth,
IndexedWidgetBuilder? itemBuilder,
int? index,
required bool loop,
required int itemCount,
Axis? scrollDirection,
AxisDirection? axisDirection,
}) : super(
loop: loop,
key: key,
itemWidth: itemWidth,
itemHeight: itemHeight,
itemBuilder: itemBuilder,
curve: curve,
duration: duration,
controller: controller,
index: index,
onIndexChanged: onIndexChanged,
itemCount: itemCount,
scrollDirection: scrollDirection,
axisDirection: axisDirection,
);
@override
State<StatefulWidget> createState() => _StackViewState();
}
class _TinderState extends _CustomLayoutStateBase<_TinderSwiper> {
late List<double> scales;
late List<double> offsetsX;
late List<double> offsetsY;
late List<double> opacity;
late List<double> rotates;
double getOffsetY(double scale) {
return widget.itemHeight! - widget.itemHeight! * scale;
}
@override
void didUpdateWidget(_TinderSwiper oldWidget) {
_updateValues();
super.didUpdateWidget(oldWidget);
}
@override
void afterRender() {
super.afterRender();
_startIndex = -3;
_animationCount = 5;
opacity = [0.0, 0.9, 0.9, 1.0, 0.0, 0.0];
scales = [0.80, 0.80, 0.85, 0.90, 1.0, 1.0, 1.0];
rotates = [0.0, 0.0, 0.0, 0.0, 20.0, 25.0];
_updateValues();
}
void _updateValues() {
if (widget.scrollDirection == Axis.horizontal) {
offsetsX = [0.0, 0.0, 0.0, 0.0, _swiperWidth, _swiperWidth];
offsetsY = [
0.0,
0.0,
-5.0,
-10.0,
-15.0,
-20.0,
];
} else {
offsetsX = [
0.0,
0.0,
5.0,
10.0,
15.0,
20.0,
];
offsetsY = [0.0, 0.0, 0.0, 0.0, _swiperHeight, _swiperHeight];
}
}
@override
Widget _buildItem(int i, int realIndex, double animationValue) {
final s = _getValue(scales, animationValue, i);
final f = _getValue(offsetsX, animationValue, i);
final fy = _getValue(offsetsY, animationValue, i);
final o = _getValue(opacity, animationValue, i);
final a = _getValue(rotates, animationValue, i);
final alignment = widget.scrollDirection == Axis.horizontal ? Alignment.bottomCenter : Alignment.centerLeft;
return Opacity(
opacity: o,
child: Transform.rotate(
angle: a / 180.0,
child: Transform.translate(
key: ValueKey<int>(_currentIndex + i),
offset: Offset(f, fy),
child: Transform.scale(
scale: s,
alignment: alignment,
child: SizedBox(
width: widget.itemWidth ?? double.infinity,
height: widget.itemHeight ?? double.infinity,
child: widget.itemBuilder!(context, realIndex),
),
),
),
),
);
}
}
class _StackViewState extends _CustomLayoutStateBase<_StackSwiper> {
late List<double> scales;
late List<double> offsets;
late List<double> opacity;
void _updateValues() {
if (widget.scrollDirection == Axis.horizontal) {
final space = (_swiperWidth - widget.itemWidth!) / 2;
offsets = widget.axisDirection == AxisDirection.left
? [-space, -space / 3 * 2, -space / 3, 0.0, _swiperWidth]
: [_swiperWidth, 0.0, -space / 3, -space / 3 * 2, -space];
} else {
final space = (_swiperHeight - widget.itemHeight!) / 2;
offsets = [-space, -space / 3 * 2, -space / 3, 0.0, _swiperHeight];
}
}
@override
void didUpdateWidget(_StackSwiper oldWidget) {
_updateValues();
super.didUpdateWidget(oldWidget);
}
@override
void afterRender() {
super.afterRender();
final isRightSide = widget.axisDirection == AxisDirection.right;
//length of the values array below
_animationCount = 5;
//Array below this line, '0' index is 1.0, which is the first item show in swiper.
_startIndex = isRightSide ? -1 : -3;
scales = isRightSide ? [1.0, 1.0, 0.9, 0.8, 0.7] : [0.7, 0.8, 0.9, 1.0, 1.0];
opacity = isRightSide ? [1.0, 1.0, 1.0, 0.5, 0.0] : [0.0, 0.5, 1.0, 1.0, 1.0];
_updateValues();
}
@override
Widget _buildItem(int i, int realIndex, double animationValue) {
final s = _getValue(scales, animationValue, i);
final f = _getValue(offsets, animationValue, i);
final o = _getValue(opacity, animationValue, i);
final offset = widget.scrollDirection == Axis.horizontal
? widget.axisDirection == AxisDirection.left
? Offset(f, 0.0)
: Offset(-f, 0.0)
: Offset(0.0, f);
final alignment = widget.scrollDirection == Axis.horizontal
? widget.axisDirection == AxisDirection.left
? Alignment.centerLeft
: Alignment.centerRight
: Alignment.topCenter;
return Opacity(
opacity: o,
child: Transform.translate(
key: ValueKey<int>(_currentIndex + i),
offset: offset,
child: Transform.scale(
scale: s,
alignment: alignment,
child: SizedBox(
width: widget.itemWidth ?? double.infinity,
height: widget.itemHeight ?? double.infinity,
child: widget.itemBuilder!(context, realIndex),
),
),
),
);
}
}
class ScaleAndFadeTransformer extends PageTransformer {
ScaleAndFadeTransformer({double? fade = 0.3, double? scale = 0.8})
: _fade = fade,
_scale = scale;
final double? _scale;
final double? _fade;
@override
Widget transform(Widget child, TransformInfo info) {
final position = info.position;
var c = child;
if (_scale != null) {
final scaleFactor = (1 - position!.abs()) * (1 - _scale!);
final scale = _scale! + scaleFactor;
c = Transform.scale(
scale: scale,
child: c,
);
}
if (_fade != null) {
final fadeFactor = (1 - position!.abs()) * (1 - _fade!);
final opacity = _fade! + fadeFactor;
c = Opacity(
opacity: opacity,
child: c,
);
}
return c;
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:hgdj/tools_base/widget/card_swiper/src/swiper_plugin.dart';
class SwiperControl extends SwiperPlugin {
const SwiperControl({
this.iconPrevious = Icons.arrow_back_ios,
this.iconNext = Icons.arrow_forward_ios,
this.color,
this.disableColor,
this.key,
this.size = 30.0,
this.padding = const EdgeInsets.all(5.0),
});
///IconData for previous
final IconData iconPrevious;
///iconData for next
final IconData iconNext;
///icon size
final double size;
///Icon normal color, The theme's [ThemeData.primaryColor] by default.
final Color? color;
///if set loop=false on Swiper, this color will be used when swiper goto the last slide.
///The theme's [ThemeData.disabledColor] by default.
final Color? disableColor;
final EdgeInsetsGeometry padding;
final Key? key;
Widget buildButton({
required SwiperPluginConfig? config,
required Color color,
required IconData iconData,
required int quarterTurns,
required bool previous,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
if (previous) {
await config!.controller.previous(animation: true);
} else {
await config!.controller.next(animation: true);
}
},
child: Padding(
padding: padding,
child: RotatedBox(
quarterTurns: quarterTurns,
child: Icon(
iconData,
semanticLabel: previous ? 'Previous' : 'Next',
size: size,
color: color,
))),
);
}
@override
Widget build(BuildContext context, SwiperPluginConfig config) {
final themeData = Theme.of(context);
final color = this.color ?? themeData.primaryColor;
final disableColor = this.disableColor ?? themeData.disabledColor;
Color prevColor;
Color nextColor;
if (config.loop) {
prevColor = nextColor = color;
} else {
final next = config.activeIndex < config.itemCount - 1;
final prev = config.activeIndex > 0;
prevColor = prev ? color : disableColor;
nextColor = next ? color : disableColor;
}
Widget child;
if (config.scrollDirection == Axis.horizontal) {
child = Row(
key: key,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
buildButton(
config: config,
color: prevColor,
iconData: iconPrevious,
quarterTurns: 0,
previous: true,
),
buildButton(
config: config,
color: nextColor,
iconData: iconNext,
quarterTurns: 0,
previous: false,
)
],
);
} else {
child = Column(
key: key,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
buildButton(
config: config,
color: prevColor,
iconData: iconPrevious,
quarterTurns: -3,
previous: true,
),
buildButton(
config: config,
color: nextColor,
iconData: iconNext,
quarterTurns: -3,
previous: false,
)
],
);
}
return SizedBox.expand(
child: child,
);
}
}
@@ -0,0 +1,45 @@
import 'swiper_plugin.dart';
import 'transformer_page_view/index_controller.dart';
class SwipeIndexControllerEvent extends IndexControllerEventBase {
SwipeIndexControllerEvent({
required this.pos,
required bool animation,
}) : super(animation: animation);
final double pos;
}
class BuildIndexControllerEvent extends IndexControllerEventBase {
BuildIndexControllerEvent({
required bool animation,
required this.config,
}) : super(animation: animation);
final SwiperPluginConfig config;
}
class AutoPlaySwiperControllerEvent extends IndexControllerEventBase {
AutoPlaySwiperControllerEvent({
required bool animation,
required this.autoplay,
}) : super(animation: animation);
AutoPlaySwiperControllerEvent.start({
required bool animation,
}) : this(animation: animation, autoplay: true);
AutoPlaySwiperControllerEvent.stop({
required bool animation,
}) : this(animation: animation, autoplay: false);
final bool autoplay;
}
class SwiperController extends IndexController {
void startAutoplay({bool animation = true}) {
event = AutoPlaySwiperControllerEvent.start(animation: animation);
notifyListeners();
}
void stopAutoplay({bool animation = true}) {
event = AutoPlaySwiperControllerEvent.stop(animation: animation);
notifyListeners();
}
}
@@ -0,0 +1,303 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import '../card_swiper.dart';
class FractionPaginationBuilder extends SwiperPlugin {
const FractionPaginationBuilder({
this.color,
this.fontSize = 20.0,
this.key,
this.activeColor,
this.activeFontSize = 35.0,
});
///color ,if set null , will be Theme.of(context).scaffoldBackgroundColor
final Color? color;
///color when active,if set null , will be Theme.of(context).primaryColor
final Color? activeColor;
////font size
final double fontSize;
///font size when active
final double activeFontSize;
final Key? key;
@override
Widget build(BuildContext context, SwiperPluginConfig? config) {
final themeData = Theme.of(context);
final activeColor = this.activeColor ?? themeData.primaryColor;
final color = this.color ?? themeData.scaffoldBackgroundColor;
if (Axis.vertical == config!.scrollDirection) {
return Column(
key: key,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
'${config.activeIndex + 1}',
style: TextStyle(color: activeColor, fontSize: activeFontSize),
),
Text(
'/',
style: TextStyle(color: color, fontSize: fontSize),
),
Text(
'${config.itemCount}',
style: TextStyle(color: color, fontSize: fontSize),
)
],
);
} else {
return Row(
key: key,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
'${config.activeIndex + 1}',
style: TextStyle(color: activeColor, fontSize: activeFontSize),
),
Text(
' / ${config.itemCount}',
style: TextStyle(color: color, fontSize: fontSize),
)
],
);
}
}
}
class RectSwiperPaginationBuilder extends SwiperPlugin {
const RectSwiperPaginationBuilder({
this.activeColor,
this.color,
this.key,
this.size = const Size(10.0, 2.0),
this.activeSize = const Size(10.0, 2.0),
this.space = 3.0,
});
///color when current index,if set null , will be Theme.of(context).primaryColor
final Color? activeColor;
///,if set null , will be Theme.of(context).scaffoldBackgroundColor
final Color? color;
///Size of the rect when activate
final Size activeSize;
///Size of the rect
final Size size;
/// Space between rects
final double space;
final Key? key;
@override
Widget build(BuildContext context, SwiperPluginConfig config) {
final themeData = Theme.of(context);
final activeColor = this.activeColor ?? themeData.primaryColor;
final color = this.color ?? themeData.scaffoldBackgroundColor;
final list = <Widget>[];
final itemCount = config.itemCount;
final activeIndex = config.activeIndex;
if (itemCount > 20) {
log(
'The itemCount is too big, we suggest use FractionPaginationBuilder '
'instead of DotSwiperPaginationBuilder in this situation',
);
}
for (var i = 0; i < itemCount; ++i) {
final active = i == activeIndex;
final size = active ? activeSize : this.size;
list.add(SizedBox(
width: size.width,
height: size.height,
child: Container(
color: active ? activeColor : color,
key: Key('pagination_$i'),
margin: EdgeInsets.all(space),
),
));
}
if (config.scrollDirection == Axis.vertical) {
return Column(
key: key,
mainAxisSize: MainAxisSize.min,
children: list,
);
} else {
return Row(
key: key,
mainAxisSize: MainAxisSize.min,
children: list,
);
}
}
}
class DotSwiperPaginationBuilder extends SwiperPlugin {
const DotSwiperPaginationBuilder({
this.activeColor,
this.color,
this.key,
this.size = 10.0,
this.activeSize = 10.0,
this.space = 3.0,
});
///color when current index,if set null , will be Theme.of(context).primaryColor
final Color? activeColor;
///,if set null , will be Theme.of(context).scaffoldBackgroundColor
final Color? color;
///Size of the dot when activate
final double activeSize;
///Size of the dot
final double size;
/// Space between dots
final double space;
final Key? key;
@override
Widget build(BuildContext context, SwiperPluginConfig config) {
if (config.itemCount > 20) {
log(
'The itemCount is too big, we suggest use FractionPaginationBuilder '
'instead of DotSwiperPaginationBuilder in this situation',
);
}
var activeColor = this.activeColor;
var color = this.color;
if (activeColor == null || color == null) {
final themeData = Theme.of(context);
activeColor = this.activeColor ?? themeData.primaryColor;
color = this.color ?? themeData.scaffoldBackgroundColor;
}
if (config.indicatorLayout != PageIndicatorLayout.NONE &&
config.layout == SwiperLayout.DEFAULT) {
return PageIndicator(
count: config.itemCount,
controller: config.pageController!,
layout: config.indicatorLayout,
size: size,
activeColor: activeColor,
color: color,
space: space,
);
}
final list = <Widget>[];
final itemCount = config.itemCount;
final activeIndex = config.activeIndex;
for (var i = 0; i < itemCount; ++i) {
final active = i == activeIndex;
list.add(Container(
key: Key('pagination_$i'),
margin: EdgeInsets.all(space),
child: ClipOval(
child: Container(
color: active ? activeColor : color,
width: active ? activeSize : size,
height: active ? activeSize : size,
),
),
));
}
if (config.scrollDirection == Axis.vertical) {
return Column(
key: key,
mainAxisSize: MainAxisSize.min,
children: list,
);
} else {
return Row(
key: key,
mainAxisSize: MainAxisSize.min,
children: list,
);
}
}
}
typedef SwiperPaginationBuilder = Widget Function(
BuildContext context,
SwiperPluginConfig config,
);
class SwiperCustomPagination extends SwiperPlugin {
const SwiperCustomPagination({required this.builder});
final SwiperPaginationBuilder builder;
@override
Widget build(BuildContext context, SwiperPluginConfig config) {
return builder(context, config);
}
}
class SwiperPagination extends SwiperPlugin {
const SwiperPagination({
this.alignment,
this.key,
this.margin = const EdgeInsets.all(10.0),
this.builder = SwiperPagination.dots,
});
/// dot style pagination
static const SwiperPlugin dots = DotSwiperPaginationBuilder();
/// fraction style pagination
static const SwiperPlugin fraction = FractionPaginationBuilder();
static const SwiperPlugin rect = RectSwiperPaginationBuilder();
/// Alignment.bottomCenter by default when scrollDirection== Axis.horizontal
/// Alignment.centerRight by default when scrollDirection== Axis.vertical
final Alignment? alignment;
/// Distance between pagination and the container
final EdgeInsetsGeometry margin;
/// Build the widget
final SwiperPlugin builder;
final Key? key;
@override
Widget build(BuildContext context, SwiperPluginConfig config) {
final defaultAlignment = config.scrollDirection == Axis.horizontal
? Alignment.bottomCenter
: Alignment.centerRight;
Widget child = Container(
margin: margin,
child: builder.build(context, config),
);
if (!config.outer!) {
child = Align(
key: key,
alignment: alignment ?? defaultAlignment,
child: child,
);
}
return child;
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/widgets.dart';
import '../card_swiper.dart';
/// plugin to display swiper components
///
abstract class SwiperPlugin {
const SwiperPlugin();
Widget build(BuildContext context, SwiperPluginConfig config);
}
class SwiperPluginConfig {
const SwiperPluginConfig({
required this.scrollDirection,
required this.controller,
required this.activeIndex,
required this.itemCount,
this.axisDirection,
this.indicatorLayout,
this.outer,
this.pageController,
this.layout,
this.loop = false,
});
final Axis scrollDirection;
final AxisDirection? axisDirection;
final SwiperController controller;
final int activeIndex;
final int itemCount;
final PageIndicatorLayout? indicatorLayout;
final bool loop;
final bool? outer;
final PageController? pageController;
final SwiperLayout? layout;
}
class SwiperPluginView extends StatelessWidget {
const SwiperPluginView({
Key? key,
required this.plugin,
required this.config,
}) : super(key: key);
final SwiperPlugin plugin;
final SwiperPluginConfig config;
@override
Widget build(BuildContext context) {
return plugin.build(context, config);
}
}
@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
abstract class IndexControllerEventBase {
IndexControllerEventBase({
required this.animation,
});
final bool animation;
final completer = Completer<void>();
Future<void> get future => completer.future;
void complete() {
if (!completer.isCompleted) {
completer.complete();
}
}
}
mixin TargetedPositionControllerEvent on IndexControllerEventBase {
double get targetPosition;
}
mixin StepBasedIndexControllerEvent on TargetedPositionControllerEvent {
int get step;
int calcNextIndex({
required int currentIndex,
required int itemCount,
required bool loop,
required bool reverse,
}) {
var cIndex = currentIndex;
if (reverse) {
cIndex -= step;
} else {
cIndex += step;
}
if (!loop) {
if (cIndex >= itemCount) {
cIndex = itemCount - 1;
} else if (cIndex < 0) {
cIndex = 0;
}
}
return cIndex;
}
}
class NextIndexControllerEvent extends IndexControllerEventBase
with TargetedPositionControllerEvent, StepBasedIndexControllerEvent {
NextIndexControllerEvent({
required bool animation,
}) : super(
animation: animation,
);
@override
int get step => 1;
@override
double get targetPosition => 0;
}
class PrevIndexControllerEvent extends IndexControllerEventBase
with TargetedPositionControllerEvent, StepBasedIndexControllerEvent {
PrevIndexControllerEvent({
required bool animation,
}) : super(
animation: animation,
);
@override
int get step => -1;
@override
double get targetPosition => 1;
}
class MoveIndexControllerEvent extends IndexControllerEventBase
with TargetedPositionControllerEvent {
MoveIndexControllerEvent({
required this.newIndex,
required this.oldIndex,
required bool animation,
}) : super(
animation: animation,
);
final int newIndex;
final int oldIndex;
@override
double get targetPosition => newIndex > oldIndex ? 1 : 0;
}
class IndexController extends ChangeNotifier {
IndexControllerEventBase? event;
int index = 0;
Future<void> move(int index, {bool animation = true}) {
final e = event = MoveIndexControllerEvent(
animation: animation,
newIndex: index,
oldIndex: this.index,
);
notifyListeners();
return e.future;
}
Future<void> next({bool animation = true}) {
final e = event = NextIndexControllerEvent(animation: animation);
notifyListeners();
return e.future;
}
Future<void> previous({bool animation = true}) {
final e = event = PrevIndexControllerEvent(animation: animation);
notifyListeners();
return e.future;
}
}
@@ -0,0 +1,611 @@
/// transformer page view library
library transformer_page_view;
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'index_controller.dart';
///
/// NOTICE::
///
/// In order to make package smaller,currently we're not supporting any build-in page transformers
/// You can find build in transforms here:
///
///
///
const int kMaxValue = 2000000000;
const int kMiddleValue = 1000000000;
/// Default auto play transition duration (in millisecond)
const int kDefaultTransactionDuration = 300;
class TransformInfo {
TransformInfo({
this.index,
this.position,
this.width,
this.height,
this.activeIndex,
required this.fromIndex,
this.forward,
this.done,
this.viewportFraction,
this.scrollDirection,
});
/// The `width` of the `TransformerPageView`
final double? width;
/// The `height` of the `TransformerPageView`
final double? height;
/// The `position` of the widget pass to [PageTransformer.transform]
/// A `position` describes how visible the widget is.
/// The widget in the center of the screen' which is full visible, position is 0.0.
/// The widget in the left ,may be hidden, of the screen's position is less than 0.0, -1.0 when out of the screen.
/// The widget in the right ,may be hidden, of the screen's position is greater than 0.0, 1.0 when out of the screen
///
///
final double? position;
/// The `index` of the widget pass to [PageTransformer.transform]
final int? index;
/// The `activeIndex` of the PageView
final int? activeIndex;
/// The `activeIndex` of the PageView, from user start to swipe
/// It will change when user end drag
final int fromIndex;
/// Next `index` is greater than this `index`
final bool? forward;
/// User drag is done.
final bool? done;
/// Same as [TransformerPageView.viewportFraction]
final double? viewportFraction;
/// Copy from [TransformerPageView.scrollDirection]
final Axis? scrollDirection;
}
abstract class PageTransformer {
PageTransformer({this.reverse = false});
///
final bool reverse;
/// Return a transformed widget, based on child and TransformInfo
Widget transform(Widget child, TransformInfo info);
}
typedef PageTransformerBuilderCallback = Widget Function(
Widget child,
TransformInfo info,
);
class PageTransformerBuilder extends PageTransformer {
PageTransformerBuilder({bool reverse = false, required this.builder})
: super(reverse: reverse);
final PageTransformerBuilderCallback builder;
@override
Widget transform(Widget child, TransformInfo info) {
return builder(child, info);
}
}
class TransformerPageController extends PageController {
TransformerPageController({
int initialPage = 0,
bool keepPage = true,
double viewportFraction = 1.0,
this.loop = false,
this.itemCount = 0,
this.reverse = false,
}) : super(
initialPage: TransformerPageController._getRealIndexFromRenderIndex(
initialPage, loop, itemCount, reverse),
keepPage: keepPage,
viewportFraction: viewportFraction);
final bool loop;
final int itemCount;
final bool reverse;
int getRenderIndexFromRealIndex(num index) {
return _getRenderIndexFromRealIndex(index, loop, itemCount, reverse);
}
int? getRealItemCount() {
if (itemCount == 0) return 0;
return loop ? itemCount + kMaxValue : itemCount;
}
static int _getRenderIndexFromRealIndex(
num index,
bool loop,
int itemCount,
bool reverse,
) {
if (itemCount == 0) return 0;
int renderIndex;
if (loop) {
renderIndex = (index - kMiddleValue).toInt();
renderIndex = renderIndex % itemCount;
if (renderIndex < 0) {
renderIndex += itemCount;
}
} else {
renderIndex = index.toInt();
}
if (reverse) {
renderIndex = itemCount - renderIndex - 1;
}
return renderIndex;
}
double get realPage => super.page ?? 0.0;
static double? _getRenderPageFromRealPage(
double page,
bool loop,
int itemCount,
bool reverse,
) {
double? renderPage;
if (loop) {
renderPage = page - kMiddleValue;
renderPage = renderPage % itemCount;
if (renderPage < 0) {
renderPage += itemCount;
}
} else {
renderPage = page;
}
if (reverse) {
renderPage = itemCount - renderPage - 1;
}
return renderPage;
}
@override
double? get page {
return loop
? _getRenderPageFromRealPage(realPage, loop, itemCount, reverse)
: realPage;
}
int getRealIndexFromRenderIndex(num index) {
return _getRealIndexFromRenderIndex(index, loop, itemCount, reverse);
}
static int _getRealIndexFromRenderIndex(
num index, bool loop, int itemCount, bool reverse) {
var result = reverse ? itemCount - index - 1 as int : index as int;
if (loop) {
result += kMiddleValue;
}
return result;
}
}
class TransformerPageView extends StatefulWidget {
/// Creates a scrollable list that works page by page using widgets that are
/// created on demand.
///
/// This constructor is appropriate for page views with a large (or infinite)
/// number of children because the builder is called only for those children
/// that are actually visible.
///
/// Providing a non-null [itemCount] lets the [PageView] compute the maximum
/// scroll extent.
///
/// [itemBuilder] will be called only with indices greater than or equal to
/// zero and less than [itemCount].
const TransformerPageView({
Key? key,
this.index,
Duration? duration,
this.curve = Curves.ease,
this.viewportFraction = 1.0,
required this.loop,
this.scrollDirection = Axis.horizontal,
this.physics,
this.pageSnapping = true,
this.onPageChanged,
this.controller,
this.transformer,
this.allowImplicitScrolling = false,
this.itemBuilder,
this.pageController,
required this.itemCount,
}) : assert(itemCount == 0 || itemBuilder != null || transformer != null),
duration = duration ??
const Duration(milliseconds: kDefaultTransactionDuration),
super(key: key);
factory TransformerPageView.children({
Key? key,
int? index,
Duration? duration,
Curve curve = Curves.ease,
double viewportFraction = 1.0,
bool loop = false,
Axis scrollDirection = Axis.horizontal,
ScrollPhysics? physics,
bool pageSnapping = true,
ValueChanged<int?>? onPageChanged,
IndexController? controller,
PageTransformer? transformer,
bool allowImplicitScrolling = false,
required List<Widget> children,
TransformerPageController? pageController,
}) {
return TransformerPageView(
itemCount: children.length,
itemBuilder: (context, index) {
return children[index];
},
pageController: pageController,
transformer: transformer,
pageSnapping: pageSnapping,
key: key,
index: index,
loop: loop,
duration: duration,
curve: curve,
viewportFraction: viewportFraction,
scrollDirection: scrollDirection,
physics: physics,
allowImplicitScrolling: allowImplicitScrolling,
onPageChanged: onPageChanged,
controller: controller,
);
}
/// Create a `transformed` widget base on the widget that has been passed to the [PageTransformer.transform].
/// See [TransformInfo]
///
final PageTransformer? transformer;
/// Same as [PageView.scrollDirection]
///
/// Defaults to [Axis.horizontal].
final Axis scrollDirection;
/// Same as [PageView.physics]
final ScrollPhysics? physics;
/// Set to false to disable page snapping, useful for custom scroll behavior.
/// Same as [PageView.pageSnapping]
final bool pageSnapping;
/// Called whenever the page in the center of the viewport changes.
/// Same as [PageView.onPageChanged]
final ValueChanged<int>? onPageChanged;
final IndexedWidgetBuilder? itemBuilder;
// See [IndexController.mode],[IndexController.next],[IndexController.previous]
final IndexController? controller;
/// Animation duration
final Duration duration;
/// Animation curve
final Curve curve;
final TransformerPageController? pageController;
/// Set true to open infinity loop mode.
final bool loop;
/// This value is only valid when `pageController` is not set,
final int itemCount;
/// This value is only valid when `pageController` is not set,
final double viewportFraction;
/// If not set, it is controlled by this widget.
final int? index;
final bool allowImplicitScrolling;
@override
State<StatefulWidget> createState() => _TransformerPageViewState();
static int getRealIndexFromRenderIndex({
required bool reverse,
int index = 0,
int itemCount = 0,
required bool loop,
}) {
var initPage = reverse ? (itemCount - index - 1) : index;
if (loop) {
initPage += kMiddleValue;
}
return initPage;
}
static PageController createPageController({
required bool reverse,
int index = 0,
int itemCount = 0,
required bool loop,
required double viewportFraction,
}) {
return PageController(
initialPage: getRealIndexFromRenderIndex(
reverse: reverse,
index: index,
itemCount: itemCount,
loop: loop,
),
viewportFraction: viewportFraction,
);
}
}
class _TransformerPageViewState extends State<TransformerPageView> {
Size? _size;
int _activeIndex = 0;
late double _currentPixels;
bool _done = false;
///This value will not change until user end drag.
late int _fromIndex;
PageTransformer? _transformer;
late TransformerPageController _pageController;
Widget _buildItemNormal(BuildContext context, int index) {
final renderIndex = _pageController.getRenderIndexFromRealIndex(index);
return widget.itemBuilder!(context, renderIndex);
}
Widget _buildItem(BuildContext context, int index) {
return AnimatedBuilder(
animation: _pageController,
builder: (c, w) {
final renderIndex =
_pageController.getRenderIndexFromRealIndex(index);
final child = widget.itemBuilder?.call(context, renderIndex) ??
const SizedBox.shrink();
if (_size == null) {
return child;
}
double position;
final page = _pageController.realPage;
if (_transformer!.reverse) {
position = page - index;
} else {
position = index - page;
}
position *= widget.viewportFraction;
final info = TransformInfo(
index: renderIndex,
width: _size!.width,
height: _size!.height,
position: position.clamp(-1.0, 1.0),
activeIndex:
_pageController.getRenderIndexFromRealIndex(_activeIndex),
fromIndex: _fromIndex,
forward: _pageController.position.pixels - _currentPixels >= 0,
done: _done,
scrollDirection: widget.scrollDirection,
viewportFraction: widget.viewportFraction,
);
return _transformer!.transform(child, info);
});
}
double? _calcCurrentPixels() {
_currentPixels = _pageController.getRenderIndexFromRealIndex(_activeIndex) *
_pageController.position.viewportDimension *
widget.viewportFraction;
// print("activeIndex:$_activeIndex , pix:$_currentPixels");
return _currentPixels;
}
@override
Widget build(BuildContext context) {
final builder = _transformer == null ? _buildItemNormal : _buildItem;
final child = PageView.builder(
allowImplicitScrolling: widget.allowImplicitScrolling,
itemBuilder: builder,
itemCount: _pageController.getRealItemCount(),
onPageChanged: _onIndexChanged,
controller: _pageController,
scrollDirection: widget.scrollDirection,
physics: widget.physics,
pageSnapping: widget.pageSnapping,
reverse: _pageController.reverse,
);
if (_transformer == null) {
return child;
}
return NotificationListener(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
_calcCurrentPixels();
_done = false;
_fromIndex = _activeIndex;
} else if (notification is ScrollEndNotification) {
_calcCurrentPixels();
_fromIndex = _activeIndex;
_done = true;
}
return false;
},
child: child,
);
}
void _onIndexChanged(int index) {
_activeIndex = index;
widget.onPageChanged
?.call(_pageController.getRenderIndexFromRealIndex(index));
}
void _onGetSize(Duration _) {
if (!mounted) return;
Size? size;
final renderObject = context.findRenderObject();
if (renderObject != null) {
final bounds = renderObject.paintBounds;
size = bounds.size;
}
_calcCurrentPixels();
onGetSize(size);
}
void onGetSize(Size? size) {
if (mounted) {
setState(() {
_size = size;
});
}
}
IndexController? _controller;
@override
void initState() {
_transformer = widget.transformer;
// int index = widget.index ?? 0;
_pageController = widget.pageController ??
TransformerPageController(
initialPage: widget.index ?? 0,
itemCount: widget.itemCount,
loop: widget.loop,
reverse: widget.transformer?.reverse ?? false,
);
// int initPage = _getRealIndexFromRenderIndex(index);
// _pageController = PageController(initialPage: initPage,viewportFraction: widget.viewportFraction);
_fromIndex = _activeIndex = _pageController.initialPage;
_controller = widget.controller;
_controller?.addListener(onChangeNotifier);
super.initState();
}
@override
void didUpdateWidget(TransformerPageView oldWidget) {
_transformer = widget.transformer;
final index = widget.index ?? 0;
var created = false;
if (_pageController != widget.pageController) {
if (widget.pageController != null) {
_pageController = widget.pageController!;
} else {
created = true;
_pageController = TransformerPageController(
initialPage: widget.index ?? 0,
itemCount: widget.itemCount,
loop: widget.loop,
reverse: widget.transformer?.reverse ?? false,
);
}
}
if (_pageController.getRenderIndexFromRealIndex(_activeIndex) != index) {
_fromIndex = _activeIndex = _pageController.initialPage;
if (!created) {
final initPage = _pageController.getRealIndexFromRenderIndex(index);
if (_pageController.hasClients) {
unawaited(_pageController.animateToPage(
initPage,
duration: widget.duration,
curve: widget.curve,
));
}
}
}
if (_transformer != null) {
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback(_onGetSize);
}
if (_controller != widget.controller) {
_controller?.removeListener(onChangeNotifier);
_controller = widget.controller;
_controller?.addListener(onChangeNotifier);
}
super.didUpdateWidget(oldWidget);
}
@override
void didChangeDependencies() {
if (_transformer != null) {
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback(_onGetSize);
}
super.didChangeDependencies();
}
Future<void> onChangeNotifier() async {
final controller = widget.controller!;
final event = controller.event;
int index;
if (event == null) return;
if (event is MoveIndexControllerEvent) {
index = _pageController.getRealIndexFromRenderIndex(event.newIndex);
} else if (event is StepBasedIndexControllerEvent) {
index = event.calcNextIndex(
currentIndex: _activeIndex,
itemCount: _pageController.itemCount,
loop: _pageController.loop,
reverse: _pageController.reverse,
);
} else {
//ignore other events
return;
}
if (_pageController.hasClients) {
if (event.animation) {
await _pageController
.animateToPage(
index,
duration: widget.duration,
curve: widget.curve,
)
.whenComplete(event.complete);
} else {
event.complete();
}
} else {
event.complete();
}
}
@override
void dispose() {
_controller?.removeListener(onChangeNotifier);
super.dispose();
}
}
/// Ref: https://docs.flutter.dev/development/tools_base/sdk/release-notes/release-notes-3.0.0#your-code
/// This allows a value of type T or T?
/// to be treated as a value of type T?.
///
/// We use this so that APIs that have become
/// non-nullable can still be used with `!` and `?`
/// to support older versions of the API as well.
T? _ambiguate<T>(T? value) => value;
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
/// 全站统一的提示/确认弹窗:标题 + 分割线 + 正文 + 副文案 + 灰「取消」红「确定」双按钮。
/// 壳走 [CommonDialog],业务结果一律用返回值传(点确定=true),别在弹窗里塞跳转逻辑。
/// 统一走 [show],别自己套 Get.dialog——barrierDismissible 要同时给到两层才生效。
class CommonAlert extends StatelessWidget {
final String title;
final String? content; //正文,16/半透明白
final String? subContent; //副文案,接在正文下方
final bool showDivider; //标题下的分割线
final bool showCancel; //false = 只有一个确定按钮
final String cancelText;
final String confirmText;
final bool barrierDismissible; //false = 只能点按钮,见 CommonDialog 同名参数
const CommonAlert({
super.key,
this.title = '温馨提示',
this.content,
this.subContent,
this.showDivider = true,
this.showCancel = true,
this.cancelText = '取消',
this.confirmText = '确定',
this.barrierDismissible = true,
});
/// 弹出并等待结果:true = 点了确定,点取消/遮罩/返回键都是 false。
/// [barrierDismissible] 传 false 则必须点按钮才能关(权限、强制重试这类场景)
static Future<bool> show({
String title = '温馨提示',
String? content,
String? subContent,
bool showDivider = true,
bool showCancel = true,
String cancelText = '取消',
String confirmText = '确定',
bool barrierDismissible = true,
}) async {
final res = await Get.dialog<bool>(
CommonAlert(
title: title,
content: content,
subContent: subContent,
showDivider: showDivider,
showCancel: showCancel,
cancelText: cancelText,
confirmText: confirmText,
barrierDismissible: barrierDismissible,
),
barrierDismissible: barrierDismissible,
);
return res ?? false;
}
@override
Widget build(BuildContext context) {
return CommonDialog(
//点正文不关:结果只认按钮,误触关掉会被当成「取消」
canTapClose: false,
barrierDismissible: barrierDismissible,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 20,
fontWeight: FontWeight.w600),
),
if (showDivider) ...[
12.sizeBoxH,
0.5.line,
],
if (content?.isNotEmpty == true) ...[
12.sizeBoxH,
Text(
content!,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: .5),
fontSize: 16,
fontWeight: FontWeight.w500),
),
],
if (subContent?.isNotEmpty == true) ...[
20.sizeBoxH,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
subContent!,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: .5), fontSize: 16),
),
),
],
31.sizeBoxH,
SizedBox(
height: 44,
child: Row(
children: [
if (showCancel) ...[
InkWell(
enableFeedback: false,
onTap: () => Get.back(result: false),
child: Container(
width: 112,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
cancelText,
style: const TextStyle(
color: Color(0xff989898),
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
),
12.sizeBoxW,
],
Expanded(
child: InkWell(
enableFeedback: false,
onTap: () => Get.back(result: true),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: Text(
confirmText,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
),
),
],
),
),
],
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
/// 通用居中弹窗壳:背景高斯模糊 + 金棕渐变卡片,内容由调用方传。
/// 弹出走 Get.dialog,关闭统一 Get.back()
class CommonDialog extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry margin;
final EdgeInsetsGeometry padding;
/// 点卡片内容区能不能关:默认能(点空白处的手势冒泡给外层)。
/// 带输入框/需要用户明确选择的弹窗传 false,否则点一下正文就误关了
final bool canTapClose;
/// 点卡片外的模糊区能不能关。
/// ⚠️ 本组件铺满全屏并吃掉点击,[Get.dialog] 的 barrierDismissible 到不了这层,
/// 要做「必须点按钮」的弹窗只能靠这个参数
final bool barrierDismissible;
const CommonDialog({
super.key,
required this.child,
this.margin = const EdgeInsets.symmetric(horizontal: 32),
this.padding = const EdgeInsets.fromLTRB(24, 32, 24, 24),
this.canTapClose = true,
this.barrierDismissible = true,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent, //别设成实色,渲染会有延迟
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: barrierDismissible ? Get.back : null, //点模糊背景关闭;null 则整层只是吃掉点击
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
child: Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
//空回调=在手势竞技场吃掉这一下,外层收不到就关不掉;给 null 则让外层关
onTap: canTapClose ? null : () {},
child: Container(
margin: margin,
padding: padding,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Color(0xff4d2817), Color(0xff12110f), Color(0xff302814)]),
border: Border.fromBorderSide(BorderSide(color: Color(0xff61563a))),
borderRadius: BorderRadius.all(Radius.circular(20)),
),
child: child,
),
),
),
),
),
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
/// 「加入购物车」式飞行动画:把某个组件的副本从原位置缩小飞到目标位置。
///
/// 挂在 Overlay 上而不是弹窗内部——先测量、再关弹窗、最后飞,这样蒙层干净消失,
/// 飞行层浮在真实页面之上;放弹窗里做的话背景遮罩会一直压着,不像"收进去"。
class FlyToOverlay {
FlyToOverlay._();
/// [context] 调用方(弹窗)的 context,用来找根 Overlay —— 不能用 Get.overlayContext
/// 那拿到的是 Overlay 自身的 context,而 Overlay.of 只往祖先找,必然抛 "No Overlay widget found"
/// [sourceKey] 起飞组件(**必须在关闭弹窗前调用**,否则拿不到位置)
/// [target] 目标区域(屏幕坐标);[child] 飞行途中显示的内容,通常是起飞组件的副本
static void play({
required BuildContext context,
required GlobalKey sourceKey,
required Rect target,
required Widget child,
Duration duration = const Duration(milliseconds: 700),
}) {
final box = sourceKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return; // 量不到就不飞,不能因为动画报错
final begin = box.localToGlobal(Offset.zero) & box.size;
// rootOverlay:挂到最顶层,弹窗 pop 掉之后飞行层还得继续存活
final overlay = Overlay.maybeOf(context, rootOverlay: true);
if (overlay == null) return;
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _FlyView(
begin: begin,
end: target,
duration: duration,
// Overlay 被整体销毁时 entry 已不在树上,再 remove 会踩 assert
onDone: () {
if (entry.mounted) entry.remove();
},
child: child,
),
);
overlay.insert(entry);
}
}
class _FlyView extends StatefulWidget {
final Rect begin;
final Rect end;
final Duration duration;
final VoidCallback onDone;
final Widget child;
const _FlyView({
required this.begin,
required this.end,
required this.duration,
required this.onDone,
required this.child,
});
@override
State<_FlyView> createState() => _FlyViewState();
}
class _FlyViewState extends State<_FlyView> with SingleTickerProviderStateMixin {
late final AnimationController _ctr = AnimationController(vsync: this, duration: widget.duration);
// easeInOutCubic:起步慢、中段快、**末尾减速**。不用 easeIn 系是因为那样末尾越飞越快,
// 到落点时一闪而过,用户来不及把弹窗和浮窗对应起来
late final Animation<Rect?> _rect = RectTween(begin: widget.begin, end: widget.end)
.animate(CurvedAnimation(parent: _ctr, curve: Curves.easeInOutCubic));
@override
void initState() {
super.initState();
_ctr.addStatusListener((s) {
if (s == AnimationStatus.completed) widget.onDone();
});
_ctr.forward();
}
@override
void dispose() {
_ctr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctr,
builder: (_, child) {
final r = _rect.value ?? widget.begin;
// 全程基本保持不透明,只在最后 12% 收尾淡出:太早淡出会让人看不清落到哪儿了
final t = _ctr.value;
final opacity = t < 0.88 ? 1.0 : (1 - (t - 0.88) / 0.12).clamp(0.0, 1.0);
return Positioned(
left: r.left,
top: r.top,
width: r.width,
height: r.height,
child: IgnorePointer(
child: Opacity(opacity: opacity, child: child),
),
);
},
// 内容按起飞尺寸渲染一次,交给 FittedBox 等比缩放,避免每帧重新布局图片
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox(
width: widget.begin.width,
height: widget.begin.height,
child: widget.child,
),
),
);
}
}
+278
View File
@@ -0,0 +1,278 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../assets_tool/app_colors.dart';
import '../../hj_utils/api_service/acg_service.dart';
import '../../hj_utils/api_service/mine_service.dart';
import '../../hj_utils/widget_util.dart';
enum FollowEnum {
collect, //收藏,加入书架
cartoon, //动画,加入书架
actress1, // 女优
actress2, // 网黄
user, // 用户
voiceActor, // 声优
tag, // 帖子标签
noval, //小说收藏
}
class FollowButton extends StatefulWidget {
final String? mediaId; //id
final FollowEnum? followType; //ui样式
final bool? isFollow; //
final Color? borderColor;
final Function(bool isSuccess)? successsAction; //成功回调
FollowButton({
super.key,
this.mediaId,
this.followType,
this.isFollow,
this.successsAction,
this.borderColor,
});
@override
State<FollowButton> createState() => _FollowButtonState();
}
class _FollowButtonState extends State<FollowButton> {
String? get mediaId => widget.mediaId;
FollowEnum get followType => widget.followType ?? FollowEnum.collect;
bool get isFollow => widget.isFollow ?? false;
bool loading = false;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
//1.收藏/加入书架样式
if (followType == FollowEnum.cartoon) return _buildCollectionView();
//2.用户关注样式
if (followType == FollowEnum.actress1 ||
followType == FollowEnum.actress2 ||
followType == FollowEnum.user ||
followType == FollowEnum.voiceActor ||
followType == FollowEnum.tag) {
return _buildUserFollowView();
}
//3.文字小说收藏
if (followType == FollowEnum.noval) return _buildNovalCollect();
return Container();
}
//收藏/加入书架样式
_buildCollectionView() {
return InkWell(
enableFeedback: false,
onTap: onCollectAction,
child: loading
? _loadingView()
: isFollow
? Image.asset(
'collect_red.png'.commonImgPath,
width: 24,
)
: Image.asset(
'collect_path.png'.commonImgPath,
width: 24,
),
);
}
//关注样式
_buildUserFollowView() {
return InkWell(
enableFeedback: false,
onTap: onFollowEvent,
child: isFollow
? Container(
alignment: Alignment.center,
height: 24,
padding: EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: Colors.white.withValues(alpha: .05),
),
child: Row(
children: [
loading
? _loadingView()
: Icon(
Icons.check,
size: 14,
color: Color(0xff989898),
),
4.sizeBoxW,
Text(
'关注',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Color(0xff989898)),
),
],
),
)
: Container(
height: 24,
padding: EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
// border: Border.all(color: widget.borderColor ?? AppColors.primaryHighColor, width: 1),
color: Color(0x1AF68804),
borderRadius: BorderRadius.circular(3),
),
child: Row(
children: [
loading
? _loadingView()
: Icon(
Icons.add,
size: 14,
color: AppColors.actionRed,
),
4.sizeBoxW,
Text(
'关注',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.actionRed,
),
),
],
),
),
);
}
_buildNovalCollect() {
return InkWell(
enableFeedback: false,
onTap: onCollectAction,
child: loading
? _loadingView()
: Container(
width: 62,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isFollow ? Color(0xff3D3D3D) : AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: isFollow
? Text(
'已收藏',
style: textStyle(10, Colors.white, FontWeight.w400),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'add_red.png'.commonImgPath,
width: 16,
color: Colors.white,
),
2.sizeBoxW,
Text(
'收藏',
style: textStyle(10, Colors.white, FontWeight.w400),
)
],
),
));
}
Widget _loadingView() {
return CupertinoActivityIndicator(
color: Colors.white,
radius: 8,
);
}
//加入书架
onCollectAction() {
isFollow == true ? cancelFollowAction() : addFollowAction();
}
//取消收藏
cancelFollowAction() async {
if (loading) return;
setState(() => loading = true);
final res = await ACGService.deleteBookshelf(mediaId ?? '');
if (res) {
widget.successsAction?.call(false);
}
loading = false;
if (mounted) setState(() {});
}
addFollowAction() async {
if (loading) return;
setState(() => loading = true);
final res = await ACGService.addBookshelf(mediaId ?? '');
if (res) {
widget.successsAction?.call(true);
// isFollow = true;
}
loading = false;
if (mounted) setState(() {});
}
//关注声优
onFollowEvent() async {
if (loading) return;
setState(() => loading = true);
if (widget.followType == FollowEnum.tag) {
_onCollectEvent();
} else if (widget.followType == FollowEnum.user) {
await _onFollowUser();
} else {
await _onFollowOther();
}
loading = false;
if (mounted) setState(() {});
}
Future _onCollectEvent() async {
String typeValue = 'tag';
bool result = await MineService.postCollect(mediaId, typeValue, !isFollow);
if (result) {
showToast(isFollow ? '关注成功' : '取消关注');
widget.successsAction?.call(isFollow);
}
}
Future _onFollowUser() async {
bool followStatus = !isFollow;
bool result =
await MineService.getFollow(int.tryParse(mediaId ?? ""), followStatus);
if (result) {
showToast(followStatus ? '关注成功' : '取消关注');
widget.successsAction?.call(isFollow);
}
}
Future _onFollowOther() async {
String type = "actress";
if (widget.followType == FollowEnum.actress1) {
type = "actress1";
} else if (widget.followType == FollowEnum.actress2) {
type = "actress2";
} else if (widget.followType == FollowEnum.voiceActor) {
type = "actress4";
}
bool result = await MineService.postCollect(mediaId, type, !isFollow);
if (result) {
widget.successsAction?.call(isFollow);
}
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class GroupTextFiled extends StatelessWidget {
final TextEditingController? controller;
final String? placeholder;
final double? height;
final int? maxLines;
final int? maxLength;
final Alignment? alignment;
final EdgeInsets? padding;
final double? radius;
final Color? bgColor;
final bool autoFocus;
final ValueChanged<String>? onSubmitted;
final TextInputAction? textInputAction;
final TextInputType? keyboardType;
final FocusNode? focusNode;
final TextStyle? textStyle;
final TextStyle? placeholderTextStyle;
final List<TextInputFormatter>? inputFormatters;
final bool? enabled;
final Decoration? decoration;
final TextAlign textAlign;
final Function(String)? onChangeCallback;
const GroupTextFiled({
super.key,
this.controller,
this.placeholder,
this.height,
this.maxLines,
this.maxLength,
this.alignment,
this.padding,
this.radius,
this.bgColor,
this.autoFocus = false,
this.onSubmitted,
this.textInputAction,
this.keyboardType,
this.focusNode,
this.textStyle,
this.placeholderTextStyle,
this.inputFormatters,
this.enabled,
this.decoration,
this.onChangeCallback,
this.textAlign = TextAlign.start,
});
@override
Widget build(BuildContext context) {
return InkWell(enableFeedback: false,
onTap: focusNode != null ? () => focusNode!.requestFocus() : null,
child: Container(
height: height ?? 40,
padding: padding ?? const EdgeInsets.fromLTRB(0, 0, 0, 0),
alignment: alignment ?? Alignment.centerLeft,
decoration: decoration,
child: TextField(
cursorColor: Colors.blue.withValues(alpha: 0.5),
autofocus: autoFocus,
keyboardType: keyboardType,
textInputAction: textInputAction,
style: textStyle ?? const TextStyle(color: Colors.white, fontSize: 14),
controller: controller,
inputFormatters: inputFormatters,
maxLines: maxLines,
maxLength: maxLength,
onSubmitted: onSubmitted,
focusNode: focusNode,
enabled: enabled,
onChanged: onChangeCallback,
textAlign: textAlign,
decoration: InputDecoration(
hintText: placeholder,
border: InputBorder.none,
labelText: "",
counterText: "",
isDense: true,
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.always,
hintStyle: placeholderTextStyle ?? const TextStyle(color: Color(0xff999999), fontSize: 14),
),
),
),
);
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
///用户头像(包括VIP
class HeaderWidget extends StatelessWidget {
final String headPath;
final double headWidth;
final double headHeight;
//vip显示的高度
final double? borderHeight;
final Widget? defaultHead;
final int level;
final bool isCircle;
final double? radius;
final VoidCallback? tabCallback; //点击头像
const HeaderWidget({
super.key,
required this.headPath,
required this.level,
required this.headWidth,
required this.headHeight,
this.borderHeight,
this.defaultHead,
this.tabCallback,
this.isCircle = true,
this.radius,
});
@override
Widget build(BuildContext context) {
Color _borderColor = _configVIPInfo();
return GestureDetector(
onTap: tabCallback,
child: Container(
width: headWidth,
height: headHeight,
decoration: isCircle
? BoxDecoration(
border:
Border.all(width: borderHeight ?? 4, color: _borderColor),
shape: BoxShape.circle,
)
: null,
child: ClipRRect(
borderRadius:
BorderRadius.circular(isCircle ? headWidth : radius ?? 0),
child: NetworkImageLoader(
imageUrl: headPath,
width: headWidth,
height: headHeight,
fit: BoxFit.fitWidth,
),
),
),
);
}
///获取对应VIP的边框颜色和图片
Color _configVIPInfo() {
return const Color.fromRGBO(253, 45, 85, 1);
}
}
@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/image_util.dart';
import 'package:hgdj/tools_base/cache/image_cache_manager.dart';
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
/// 微信风格图片浏览器
/// 横滑切图 + 双指/双击缩放 + 上下滑拖拽退出(图片跟手、背景随拖动渐隐露出下层页面) + 长按保存
///
/// 用法(透明路由,拖动渐隐才能看到下层):
/// ```dart
/// ImageBrowserPage.open(['url1', 'url2'], index: 0);
/// ```
class ImageBrowserPage extends StatefulWidget {
final List<String> images;
final int initialIndex;
final bool showSaveButton; // 右上角显式"保存到相册"按钮(AI 生成图等场景);默认只支持长按保存
const ImageBrowserPage(
{super.key,
required this.images,
this.initialIndex = 0,
this.showSaveButton = false});
/// 打开浏览器(透明路由 + 淡入)
/// [showSaveButton] 显示右上角"保存到相册"按钮
static void open(List<String> images,
{int index = 0, bool showSaveButton = false}) {
final valid = images.where((e) => e.isNotEmpty).toList();
if (valid.isEmpty) return;
// fullscreenDialog:true → GetX canTransitionTo 返回 false,下层页面不做外出转场,
// 保持完整渲染 → 拖拽偷看时看到的是整页而非"pop 一半"。
// 瞬时 fadeIn:打开无残影。仍是 Get.to → Get.back 能正常 pop,下滑退出正常
Get.to(
() => ImageBrowserPage(
images: valid,
initialIndex: index.clamp(0, valid.length - 1),
showSaveButton: showSaveButton),
opaque: false,
transition: Transition.fadeIn,
duration: Duration.zero,
fullscreenDialog: true,
);
}
@override
State<ImageBrowserPage> createState() => _ImageBrowserPageState();
}
class _ImageBrowserPageState extends State<ImageBrowserPage>
with TickerProviderStateMixin {
// ========== 分页 ==========
late final PageController _pageCtr;
late int _curIndex;
bool _isForward = true; // 页码翻滚方向:下一张上滚、上一张下滚
// ========== 缩放(双指/双击) ==========
late final List<TransformationController> _transCtrs; // 每张图各自的缩放/平移矩阵
late final AnimationController _zoomCtr; // 双击缩放过渡
Offset? _tapPos; // 双击落点,作为放大锚点
// ========== 拖拽退出 ==========
double _dragY = 0; // 只跟竖直方向
bool _isDragging = false;
late final AnimationController _resetCtr; // 松手未达阈值时回弹
// ========== 入场 / 退出 ==========
late final AnimationController _enterCtr; // 入场:图片在实底上淡入
late final AnimationController _exitCtr; // 退出:整体淡出
bool _isExiting = false;
// ========== 保存 ==========
bool _isSaving = false; // 动图转码耗时,挡重复触发并盖个转圈
@override
void initState() {
super.initState();
_curIndex = widget.initialIndex;
_pageCtr = PageController(initialPage: _curIndex);
_transCtrs =
List.generate(widget.images.length, (_) => TransformationController());
_resetCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
_zoomCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
_enterCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 220))
..forward();
_exitCtr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
}
@override
void dispose() {
_pageCtr.dispose();
for (final c in _transCtrs) {
c.dispose();
}
_resetCtr.dispose();
_zoomCtr.dispose();
_enterCtr.dispose();
_exitCtr.dispose();
super.dispose();
}
// ========== 派生状态 ==========
// 当前图是否已放大(放大时禁用下滑退出,交给 InteractiveViewer 平移)
bool get _isZoomed => _transCtrs[_curIndex].value.getMaxScaleOnAxis() > 1.05;
// 背景不透明度:随竖直拖动距离渐隐(下拉偷看下层)
double get _bgOpacity =>
(1 - _dragY.abs() / (Get.height * 0.6)).clamp(0.0, 1.0);
// 拖动时图片轻微缩小
double get _dragScale =>
(1 - _dragY.abs() / (Get.height * 2)).clamp(0.85, 1.0);
// 退出淡出系数:1→0
double get _exitFactor => 1 - _exitCtr.value;
// ========== 拖拽退出 ==========
void _onDragUpdate(DragUpdateDetails d) {
setState(() {
_isDragging = true;
_dragY += d.delta.dy;
});
}
void _onDragEnd(DragEndDetails d) {
// 拖够距离或甩动够快 → 退出,否则回弹
if (_dragY.abs() > 120 || d.velocity.pixelsPerSecond.dy.abs() > 800) {
_exit();
return;
}
final anim = Tween<double>(begin: _dragY, end: 0)
.animate(CurvedAnimation(parent: _resetCtr, curve: Curves.easeOut));
void listener() => setState(() => _dragY = anim.value);
anim.addListener(listener);
// whenComplete 在 dispose 取消 ticker 时也会触发,一律先判 mounted
_resetCtr.forward(from: 0).whenComplete(() {
anim.removeListener(listener);
if (mounted) setState(() => _isDragging = false);
});
}
// 退出:图片+背景就地整体淡出再 pop —— 还原点赞那版手感,也避免"背景先没图片还在"的残留
void _exit() {
setState(() => _isExiting = true); // 隐藏返回/保存/页码,只留图片淡出
// 淡出途中被系统返回键 pop 掉时 whenComplete 照样触发,不判 mounted 会把下层页面也弹掉
_exitCtr.forward(from: 0).whenComplete(() {
if (mounted) Get.back();
});
}
// ========== 双击缩放 ==========
void _onDoubleTap() {
final ctr = _transCtrs[_curIndex];
final Matrix4 target;
if (_isZoomed) {
target = Matrix4.identity();
} else {
// 以双击点为锚点放大到 2.5 倍(列主序构造缩放+平移,避开已废弃的 Matrix4.translate/scale)
final pos = _tapPos ?? Offset(Get.width / 2, Get.height / 2);
const scale = 2.5;
target = Matrix4(
scale, 0, 0, 0, //
0, scale, 0, 0, //
0, 0, 1, 0, //
-pos.dx * (scale - 1), -pos.dy * (scale - 1), 0, 1, //
);
}
final anim = Matrix4Tween(begin: ctr.value, end: target)
.animate(CurvedAnimation(parent: _zoomCtr, curve: Curves.easeOut));
void listener() => ctr.value = anim.value;
anim.addListener(listener);
_zoomCtr.forward(from: 0).whenComplete(() {
anim.removeListener(listener);
if (mounted) setState(() {}); // 刷新 _isZoomed → 下滑退出开关
});
}
// ========== 长按保存 ==========
// 缓存存的是解密后的图(见 ImageCacheManager.CustomFileRespons),直接取字节保存
// 动图(AI 图生视频的结果就是多帧 webp)由 saveImageToAlbum 内部转 mp4,否则相册里只有第一帧
Future<void> _saveCurrent() async {
if (_isSaving) return;
final url = widget.images[_curIndex];
if (url.isEmpty) return;
setState(() => _isSaving = true);
try {
final file = await ImageCacheManager().getSingleFile(url);
final ok = await ImageUtil.saveImageToAlbum(await file.readAsBytes());
showToast(ok ? '已保存到相册' : '保存失败');
} catch (_) {
showToast('保存失败');
} finally {
if (mounted) setState(() => _isSaving = false); //可能保存途中已被下滑退出
}
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
// 退出淡出逐帧重建交给 AnimatedBuilder,不用自己挂 listener + 空 setState
child: AnimatedBuilder(
animation: _exitCtr,
builder: (_, __) => Stack(
children: [
// 黑色背景,随拖动/退出渐隐
Positioned.fill(
child: ColoredBox(
color: Colors.black
.withValues(alpha: _bgOpacity * _exitFactor))),
GestureDetector(
onLongPress: _saveCurrent, // 长按保存
onDoubleTapDown: (d) => _tapPos = d.localPosition,
onDoubleTap: _onDoubleTap,
// 未放大才下滑退出;放大时为 null,竖直拖动交给 InteractiveViewer 平移
onVerticalDragUpdate: _isZoomed ? null : _onDragUpdate,
onVerticalDragEnd: _isZoomed ? null : _onDragEnd,
child: Opacity(
opacity: _exitFactor, // 退出时整体淡出
child: FadeTransition(
opacity: _enterCtr,
child: Transform.translate(
offset: Offset(0, _dragY),
child:
Transform.scale(scale: _dragScale, child: _pageView()),
),
),
),
),
// 拖拽/退出中只留图片,顶部按钮和页码全隐藏
if (!_isDragging && !_isExiting) ...[
_backButton(),
if (widget.showSaveButton) _saveButton(),
if (widget.images.length > 1) _indicator(), // 多图才显示页码
],
// 保存中(动图要解码+转码,要几秒),压在最上层挡住交互
// 用和上传图片一致的 LoadingAlertWidget,但内嵌而非 .show() 弹窗——
// 弹窗的 cancel() 是 Get.back(),本页自身也靠 Get.back() 退出,容易互相弹错
if (_isSaving)
const Positioned.fill(
child: AbsorbPointer(
child: ColoredBox(
color: Color(0x80000000),
child: LoadingAlertWidget(title: '保存中...')),
),
),
],
),
),
);
}
Widget _pageView() {
return PageView.builder(
controller: _pageCtr,
itemCount: widget.images.length,
onPageChanged: (i) {
_transCtrs[_curIndex].value = Matrix4.identity(); // 离开的图复位缩放
setState(() {
_isForward = i >= _curIndex;
_curIndex = i;
});
},
itemBuilder: (_, i) => InteractiveViewer(
transformationController: _transCtrs[i],
minScale: 1,
maxScale: 4,
onInteractionEnd: (_) => setState(() {}), // 缩放结束刷新 _isZoomed
child: SizedBox(
width: Get.width,
height: Get.height,
child: NetworkImageLoader(
imageUrl: widget.images[i],
fit: BoxFit.contain,
borderRadius: 0,
isResizeImage: false, // 看大图用原图,不按组件尺寸压缩
),
),
),
);
}
// 页码指示(白色胶囊,当前页红色) —— 沿用 CommunityImagePage 样式
Widget _indicator() {
return Positioned(
bottom: 50,
right: 20,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 2),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 当前页码:切换时上下翻滚(下一张上滚、上一张下滚)
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, anim) {
final isIncoming = child.key == ValueKey(_curIndex);
// 进入的从对向滑入到原位,离开的从原位滑出到反向
final begin = _isForward
? (isIncoming ? const Offset(0, 1) : const Offset(0, -1))
: (isIncoming ? const Offset(0, -1) : const Offset(0, 1));
return ClipRect(
child: SlideTransition(
position: Tween<Offset>(begin: begin, end: Offset.zero)
.animate(anim),
child: child,
),
);
},
child: Text(
'${_curIndex + 1}',
key: ValueKey(_curIndex),
style: TextStyle(color: AppColors.actionRed, fontSize: 12),
),
),
Text('/${widget.images.length}',
style: const TextStyle(color: Color(0xff3D3D3D), fontSize: 12)),
],
),
),
);
}
// 左上返回按钮 —— 沿用 CommunityImagePage
Widget _backButton() {
return Positioned(
top: 0,
left: 16,
child: SafeArea(
child: InkWell(
enableFeedback: false,
onTap: _exit,
child: Image.asset('back_circle.png'.commonImgPath, width: 24),
),
),
);
}
// 右上"保存到相册"按钮 —— 沿用 AiNewImageView 样式
Widget _saveButton() {
return Positioned(
top: 0,
right: 16,
child: SafeArea(
child: InkWell(
enableFeedback: false,
onTap: _saveCurrent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: const Color(0xFFE57310)),
child: const Text('保存到相册',
style: TextStyle(color: Colors.white, fontSize: 14)),
),
),
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
/// 保持widget 活跃
/// 使用:KeepAliveWidget(widget);
class KeepAliveWidget extends StatefulWidget {
final Widget child;
const KeepAliveWidget(this.child, {super.key});
@override
KeepAliveState createState() => KeepAliveState();
}
class KeepAliveState extends State<KeepAliveWidget> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
extension KeepWrapper on Widget {
Widget get keepAlive {
return KeepAliveWidget(this);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
/// 懒加载 + 保活的 IndexedStack(底部 tab 内容页标准写法)
///
/// 没进过的 tab 用空盒占位,首次切到才真正 build;进过一次就一直留在树里,
/// 切走只是不 paintstate / 滚动位置 / 播放器全部保留。
/// 相比 PageView:不需要 AutomaticKeepAlive 保活,也不会预建相邻页。
///
/// 非当前 tab 用 TickerMode(enabled: false) 关掉:IndexedStack 只是不 paint
/// 离屏页的动画/定时器照样在跑(跑马灯、Shimmer、轮播),看不见还空转。
class LazyIndexedStack extends StatefulWidget {
final int index;
final List<Widget> children;
const LazyIndexedStack({super.key, required this.index, required this.children});
@override
State<LazyIndexedStack> createState() => _LazyIndexedStackState();
}
class _LazyIndexedStackState extends State<LazyIndexedStack> {
//已建过的下标:进过一次就永久保活,不再退回占位
final _loaded = <int>{};
@override
void initState() {
super.initState();
_loaded.add(widget.index);
}
@override
void didUpdateWidget(covariant LazyIndexedStack oldWidget) {
super.didUpdateWidget(oldWidget);
_loaded.add(widget.index);
}
@override
Widget build(BuildContext context) {
return IndexedStack(
// 必须 expand:默认的 loose 会让子页拿到非 tight 约束,从而不再是 relayout boundary
// 离屏页一脏就把整个 stack 拖着重新布局(PageView 原来给的是 tight,每页各自隔离)。
// 顺带保证「根节点自身不撑满」的页也能满屏,不依赖各页自觉
sizing: StackFit.expand,
index: widget.index,
children: List.generate(
widget.children.length,
(i) => _loaded.contains(i)
? TickerMode(enabled: i == widget.index, child: widget.children[i])
: const SizedBox.shrink(),
),
);
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
/// 双击点赞爱心动效的触发器。onDoubleTapDown 里 [markAt] 记落点,onDoubleTap 里 [burst] 放动画
class LikeBurstController extends ChangeNotifier {
Offset? at;
/// 记下双击落点(相对 Stack 左上角)
void markAt(Offset offset) => at = offset;
/// 在落点弹一颗爱心;没记过落点就不弹
void burst() {
if (at == null) return;
notifyListeners();
}
}
/// 双击处弹出的爱心:弹起放大 → 停顿 → 淡出。必须直接挂在 Stack 里
class LikeBurstView extends StatefulWidget {
const LikeBurstView({
super.key,
required this.controller,
this.icon = 'like_red.png',
this.size = 90,
});
final LikeBurstController controller;
final String icon; // assets/images/common 下的图名
final double size;
@override
State<LikeBurstView> createState() => _LikeBurstViewState();
}
class _LikeBurstViewState extends State<LikeBurstView>
with SingleTickerProviderStateMixin {
late final AnimationController _ctr;
Offset? _at;
@override
void initState() {
super.initState();
_ctr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 600))
..addStatusListener((status) {
if (status == AnimationStatus.completed && mounted)
setState(() => _at = null);
});
widget.controller.addListener(_onBurst);
}
@override
void didUpdateWidget(covariant LikeBurstView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onBurst);
widget.controller.addListener(_onBurst);
}
}
@override
void dispose() {
widget.controller.removeListener(_onBurst);
_ctr.dispose();
super.dispose();
}
void _onBurst() {
setState(() => _at = widget.controller.at);
_ctr.forward(from: 0); // 连点就从头重放
}
@override
Widget build(BuildContext context) {
final at = _at;
if (at == null) return const SizedBox.shrink();
return Positioned(
left: at.dx - widget.size / 2,
top: at.dy - widget.size / 2,
child: IgnorePointer(
child: FadeTransition(
opacity: TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 20),
TweenSequenceItem(tween: ConstantTween(1.0), weight: 40),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 40),
]).animate(_ctr),
child: ScaleTransition(
scale: Tween(begin: 0.5, end: 1.3).animate(
CurvedAnimation(parent: _ctr, curve: Curves.easeOutBack)),
child: Image.asset(widget.icon.commonImgPath,
width: widget.size, height: widget.size),
),
),
),
);
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
// 上下滚动的消息轮播
class MarqueeWidget extends StatefulWidget {
/// 子视图数量
final int count;
/// 子视图构建器
final IndexedWidgetBuilder itemBuilder;
/// 轮播的时间间隔(秒)
final int loopSeconds;
/// 当前展示项变化回调(返回逻辑下标)
final ValueChanged<int>? onIndexChanged;
const MarqueeWidget({
super.key,
required this.count,
required this.itemBuilder,
this.loopSeconds = 3,
this.onIndexChanged,
});
@override
State<MarqueeWidget> createState() => _MarqueeWidgetState();
}
class _MarqueeWidgetState extends State<MarqueeWidget> {
final pageCtr = PageController();
Timer? loopTimer;
@override
void initState() {
super.initState();
loopTimer = Timer.periodic(Duration(seconds: widget.loopSeconds), (_) {
final page = pageCtr.page;
if (page == null) return;
// 滚到末尾占位页(内容同第一页)时无感跳回首页,实现无限循环
if (page.round() >= widget.count) {
pageCtr.jumpToPage(0);
}
pageCtr.nextPage(duration: const Duration(seconds: 1), curve: Curves.linear);
// 上报即将展示的逻辑下标
widget.onIndexChanged?.call((page.round() + 1) % widget.count);
});
}
@override
void dispose() {
loopTimer?.cancel();
pageCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PageView.builder(
scrollDirection: Axis.vertical,
controller: pageCtr,
itemCount: widget.count + 1,
// 末尾多一页占位、内容取第一页,配合 jumpToPage(0) 做无限循环
itemBuilder: (ctx, index) => widget.itemBuilder(ctx, index < widget.count ? index : 0),
);
}
}
@@ -0,0 +1,33 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
//子/父都响应点击
class MultiTapGestureRecognizer extends TapGestureRecognizer {
@override
void rejectGesture(int pointer) {
// 让手势同时被多个识别器处理
acceptGesture(pointer);
}
}
class MultiTap extends StatelessWidget {
final Widget child;
final VoidCallback? onTap;
const MultiTap({super.key, required this.child, this.onTap});
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
MultiTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<MultiTapGestureRecognizer>(
() => MultiTapGestureRecognizer(),
(instance) {
instance.onTap = () => onTap?.call();
},
),
},
child: child,
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/cache/image_cache_manager.dart';
class NetworkImageLoader extends StatelessWidget {
final double borderRadius;
final BorderRadius? imgBorderRadius;
final String? imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final double? placeHolderH;
final double? placeHolderW;
final bool blur; //高斯模糊
final Alignment alignment;
final bool encrypt; //是否需要加密
final Widget? placeHolderWidget;
final int? loadWidth; // 服务端根据宽度等比例压缩
final bool? isResizeImage; //是否需要根据组件大小压缩
const NetworkImageLoader({
super.key,
required this.imageUrl,
this.borderRadius = 8,
this.imgBorderRadius,
this.height,
this.fit = BoxFit.cover,
this.width,
this.blur = false,
this.placeHolderH,
this.placeHolderW,
this.alignment = Alignment.center,
this.encrypt = true,
this.placeHolderWidget,
this.loadWidth,
this.isResizeImage = true,
});
bool get isGif => imageUrl?.contains(".gif") ?? false;
String get realImgUrl {
if (imageUrl == null) return "";
return imageUrl!;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, cons) {
final imageWidget = CachedNetworkImage(
alignment: alignment,
imageUrl: realImgUrl,
width: width,
height: height,
fit: fit,
memCacheWidth: memCacheWidth(cons),
// 默认 FilterQuality.low 用廉价 bilineariOS retina(dpr=3) 上肉眼可见的模糊
// medium 是质量/性能平衡点,high 会卡列表滚动
filterQuality: FilterQuality.medium,
cacheManager: encrypt ? ImageCacheManager() : null,
placeholder: (context, url) => _buildPlaceHolder(),
errorWidget: (context, url, err) => _buildPlaceHolder(),
fadeInCurve: Curves.linear,
fadeOutCurve: Curves.linear,
);
final clip = borderRadius == 0
? imageWidget
: ClipRRect(
borderRadius:
imgBorderRadius ?? BorderRadius.circular(borderRadius),
child: imageWidget,
);
final backdrop = blur
? ClipRRect(
borderRadius:
imgBorderRadius ?? BorderRadius.circular(borderRadius),
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 5,
sigmaY: 5,
),
child: clip,
),
)
: clip;
return backdrop;
});
}
//resize的宽度
int? memCacheWidth(BoxConstraints cons) {
final dpr = screen.devicePixelRatio;
if (isResizeImage == true) {
//约束无限时用屏幕宽兜底,避免退化成按原图解码(图片过载)
final w = (cons.maxWidth == double.infinity || cons.maxWidth.isNaN)
? screen.screenWidth
: cons.maxWidth;
return (w * dpr).toInt();
} else {
return loadWidth != null
? loadWidth!
: (screen.screenWidth * dpr).toInt();
}
}
_buildPlaceHolder() {
if (placeHolderWidget != null) return placeHolderWidget;
return Container(
padding: EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(color: Color(0xff343434)),
alignment: Alignment.center,
child: Image.asset(
'place_holder_logo.webp'.commonImgPath,
width: placeHolderW ?? 136,
height: placeHolderW ?? 96,
),
);
}
}
@@ -0,0 +1,96 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// A registry to track some [Element]s in the tree.
class RegistryWidget extends StatefulWidget {
/// Creates a [RegistryWidget].
const RegistryWidget({Key? key, this.elementNotifier, required this.child})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Contains the current set of all [Element]s created by
/// [RegisteredElementWidget]s in the tree below this widget.
///
/// Note that if there is another [RegistryWidget] in this widget's subtree
/// that registry, and not this one, will collect elements in its subtree.
final ValueNotifier<Set<Element>?>? elementNotifier;
@override
State<StatefulWidget> createState() => _RegistryWidgetState();
}
/// A widget whose [Element] will be added its nearest ancestor
/// [RegistryWidget].
class RegisteredElementWidget extends ProxyWidget {
/// Creates a [RegisteredElementWidget].
const RegisteredElementWidget({Key? key, required Widget child})
: super(key: key, child: child);
@override
Element createElement() => _RegisteredElement(this);
}
class _RegistryWidgetState extends State<RegistryWidget> {
final Set<Element> registeredElements = {};
@override
Widget build(BuildContext context) => _InheritedRegistryWidget(
state: this,
child: widget.child,
);
}
class _InheritedRegistryWidget extends InheritedWidget {
final _RegistryWidgetState state;
const _InheritedRegistryWidget(
{Key? key, required this.state, required Widget child})
: super(key: key, child: child);
@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;
}
class _RegisteredElement extends ProxyElement {
_RegisteredElement(ProxyWidget widget) : super(widget);
@override
void notifyClients(ProxyWidget oldWidget) {}
late _RegistryWidgetState _registryWidgetState;
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void unmount() {
_registryWidgetState.registeredElements.remove(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
super.unmount();
}
}
@@ -0,0 +1,61 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'item_positions_notifier.dart';
import 'scrollable_positioned_list.dart';
/// Provides a listenable iterable of [itemPositions] of items that are on
/// screen and their locations.
abstract class ItemPositionsListener {
/// Creates an [ItemPositionsListener] that can be used by a
/// [ScrollablePositionedList] to return the current position of items.
factory ItemPositionsListener.create() => ItemPositionsNotifier();
/// The position of items that are at least partially visible in the viewport.
ValueListenable<Iterable<ItemPosition>> get itemPositions;
}
/// Position information for an item in the list.
class ItemPosition {
/// Create an [ItemPosition].
const ItemPosition(
{required this.index,
required this.itemLeadingEdge,
required this.itemTrailingEdge});
/// Index of the item.
final int index;
/// Distance in proportion of the viewport's main axis length from the leading
/// edge of the viewport to the leading edge of the item.
///
/// May be negative if the item is partially visible.
final double itemLeadingEdge;
/// Distance in proportion of the viewport's main axis length from the leading
/// edge of the viewport to the trailing edge of the item.
///
/// May be greater than one if the item is partially visible.
final double itemTrailingEdge;
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) return false;
final ItemPosition otherPosition = other;
return otherPosition.index == index &&
otherPosition.itemLeadingEdge == itemLeadingEdge &&
otherPosition.itemTrailingEdge == itemTrailingEdge;
}
@override
int get hashCode =>
31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) +
itemTrailingEdge.hashCode;
@override
String toString() =>
'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)';
}
@@ -0,0 +1,13 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'item_positions_listener.dart';
/// Internal implementation of [ItemPositionsListener].
class ItemPositionsNotifier implements ItemPositionsListener {
@override
final ValueNotifier<Iterable<ItemPosition>> itemPositions = ValueNotifier([]);
}
@@ -0,0 +1,370 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'element_registry.dart';
import 'item_positions_listener.dart';
import 'item_positions_notifier.dart';
import 'scroll_view.dart';
import 'wrapping.dart';
/// A list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset.
///
/// [PositionedList] lays out children in the same way as [ListView].
///
/// The list can be displayed with the item at [positionIndex] positioned at a
/// particular [alignment]. See [ItemScrollController.jumpTo] for an
/// explanation of alignment.
///
/// All other parameters are the same as specified in [ListView].
class PositionedList extends StatefulWidget {
/// Create a [PositionedList].
const PositionedList({
Key? key,
required this.itemCount,
required this.itemBuilder,
this.separatorBuilder,
this.controller,
this.itemPositionsNotifier,
this.positionedIndex = 0,
this.alignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.shrinkWrap = false,
this.physics,
this.padding,
this.cacheExtent,
this.semanticChildCount,
this.addSemanticIndexes = true,
this.addRepaintBoundaries = true,
this.addAutomaticKeepAlives = true,
}) : assert(itemCount != null),
assert(itemBuilder != null),
assert((positionedIndex == 0) || (positionedIndex < itemCount)),
super(key: key);
/// Number of items the [itemBuilder] can produce.
final int itemCount;
/// Called to build children for the list with
/// 0 <= index < itemCount.
final IndexedWidgetBuilder itemBuilder;
/// If not null, called to build separators for between each item in the list.
/// Called with 0 <= index < itemCount - 1.
final IndexedWidgetBuilder? separatorBuilder;
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
final ScrollController? controller;
/// Notifier that reports the items laid out in the list after each frame.
final ItemPositionsNotifier? itemPositionsNotifier;
/// Index of an item to initially align to a position within the viewport
/// defined by [alignment].
final int positionedIndex;
/// Determines where the leading edge of the item at [positionedIndex]
/// should be placed.
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
final double alignment;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the view scrolls in the reading direction.
///
/// Defaults to false.
///
/// See [ScrollView.reverse].
final bool reverse;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// Defaults to false.
///
/// See [ScrollView.shrinkWrap].
final bool shrinkWrap;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// See [ScrollView.physics].
final ScrollPhysics? physics;
/// {@macro flutter.widgets.scrollable.cacheExtent}
final double? cacheExtent;
/// The number of children that will contribute semantic information.
///
/// See [ScrollView.semanticChildCount] for more information.
final int? semanticChildCount;
/// Whether to wrap each child in an [IndexedSemantics].
///
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
final bool addSemanticIndexes;
/// The amount of space by which to inset the children.
final EdgeInsets? padding;
/// Whether to wrap each child in a [RepaintBoundary].
///
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
final bool addRepaintBoundaries;
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives;
@override
State<StatefulWidget> createState() => _PositionedListState();
}
class _PositionedListState extends State<PositionedList> {
final Key _centerKey = UniqueKey();
final registeredElements = ValueNotifier<Set<Element>?>(null);
late final ScrollController scrollController;
bool updateScheduled = false;
@override
void initState() {
super.initState();
scrollController = widget.controller ?? ScrollController();
scrollController.addListener(_schedulePositionNotificationUpdate);
_schedulePositionNotificationUpdate();
}
@override
void dispose() {
scrollController.removeListener(_schedulePositionNotificationUpdate);
super.dispose();
}
@override
void didUpdateWidget(PositionedList oldWidget) {
super.didUpdateWidget(oldWidget);
_schedulePositionNotificationUpdate();
}
@override
Widget build(BuildContext context) => RegistryWidget(
elementNotifier: registeredElements,
child: UnboundedCustomScrollView(
anchor: widget.alignment,
center: _centerKey,
controller: scrollController,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: widget.cacheExtent,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
semanticChildCount: widget.semanticChildCount ?? widget.itemCount,
slivers: <Widget>[
if (widget.positionedIndex > 0)
SliverPadding(
padding: _leadingSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(widget.positionedIndex - (index + 1))
: _buildSeparatedListElement(
2 * widget.positionedIndex - (index + 1)),
childCount: widget.separatorBuilder == null
? widget.positionedIndex
: 2 * widget.positionedIndex,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
SliverPadding(
key: _centerKey,
padding: _centerSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex)
: _buildSeparatedListElement(
index + 2 * widget.positionedIndex),
childCount: widget.itemCount != 0 ? 1 : 0,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
if (widget.positionedIndex >= 0 &&
widget.positionedIndex < widget.itemCount - 1)
SliverPadding(
padding: _trailingSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex + 1)
: _buildSeparatedListElement(
index + 2 * widget.positionedIndex + 1),
childCount: widget.separatorBuilder == null
? widget.itemCount - widget.positionedIndex - 1
: 2 * (widget.itemCount - widget.positionedIndex - 1),
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
],
),
);
Widget _buildSeparatedListElement(int index) {
if (index.isEven) {
return _buildItem(index ~/ 2);
} else {
return widget.separatorBuilder!(context, index ~/ 2);
}
}
Widget _buildItem(int index) {
return RegisteredElementWidget(
key: ValueKey(index),
child: widget.addSemanticIndexes
? IndexedSemantics(
index: index, child: widget.itemBuilder(context, index))
: widget.itemBuilder(context, index),
);
}
EdgeInsets get _leadingSliverPadding =>
(widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(top: 0)
: widget.padding?.copyWith(bottom: 0)
: widget.reverse
? widget.padding?.copyWith(left: 0)
: widget.padding?.copyWith(right: 0)) ??
EdgeInsets.all(0);
EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(
top: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.top
: 0,
bottom: widget.positionedIndex == 0
? widget.padding!.bottom
: 0) ??
EdgeInsets.all(0)
: widget.padding?.copyWith(
top: widget.positionedIndex == 0 ? widget.padding!.top : 0,
bottom: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.bottom
: 0) ??
EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(
left: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.left
: 0,
right: widget.positionedIndex == 0
? widget.padding!.right
: 0) ??
EdgeInsets.all(0)
: widget.padding?.copyWith(
left: widget.positionedIndex == 0 ? widget.padding!.left : 0,
right: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.right
: 0,
) ??
EdgeInsets.all(0);
EdgeInsets get _trailingSliverPadding =>
widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.all(0)
: widget.padding?.copyWith(top: 0) ?? EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(right: 0) ?? EdgeInsets.all(0)
: widget.padding?.copyWith(left: 0) ?? EdgeInsets.all(0);
void _schedulePositionNotificationUpdate() {
if (!updateScheduled) {
updateScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
final elements = registeredElements.value;
if (elements == null) {
updateScheduled = false;
return;
}
final positions = <ItemPosition>[];
RenderViewportBase? viewport;
for (var element in elements) {
final RenderBox box = element.renderObject as RenderBox;
viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?;
var anchor = 0.0;
if (viewport is RenderViewport) {
anchor = viewport.anchor;
}
if (viewport is CustomRenderViewport) {
anchor = viewport.anchor;
}
final ValueKey<int> key = element.widget.key as ValueKey<int>;
// Skip this element if `box` has never been laid out.
if (!box.hasSize) continue;
if (widget.scrollDirection == Axis.vertical) {
final reveal = viewport!.getOffsetToReveal(box, 0).offset;
if (!reveal.isFinite) continue;
final itemOffset =
reveal - viewport.offset.pixels + anchor * viewport.size.height;
positions.add(ItemPosition(
index: key.value,
itemLeadingEdge: itemOffset.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (itemOffset + box.size.height).round() /
scrollController.position.viewportDimension));
} else {
final itemOffset =
box.localToGlobal(Offset.zero, ancestor: viewport).dx;
if (!itemOffset.isFinite) continue;
positions.add(ItemPosition(
index: key.value,
itemLeadingEdge: (widget.reverse
? scrollController.position.viewportDimension -
(itemOffset + box.size.width)
: itemOffset)
.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (widget.reverse
? scrollController.position.viewportDimension -
itemOffset
: (itemOffset + box.size.width))
.round() /
scrollController.position.viewportDimension));
}
}
widget.itemPositionsNotifier?.itemPositions.value = positions;
updateScheduled = false;
});
}
}
}
@@ -0,0 +1,35 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// Widget whose [Element] calls a callback when the element is mounted.
class PostMountCallback extends StatelessWidget {
/// Creates a [PostMountCallback] widget.
const PostMountCallback({required this.child, this.callback, Key? key})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Callback to call when the element for this widget is mounted.
final void Function()? callback;
@override
StatelessElement createElement() => _PostMountCallbackElement(this);
@override
Widget build(BuildContext context) => child;
}
class _PostMountCallbackElement extends StatelessElement {
_PostMountCallbackElement(PostMountCallback widget) : super(widget);
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final PostMountCallback postMountCallback = widget as PostMountCallback;
postMountCallback.callback?.call();
}
}
@@ -0,0 +1,21 @@
import 'dart:async';
import 'scroll_offset_notifier.dart';
/// Provides an affordance for listening to scroll offset changes.
///
/// This is an experimental API and is subject to change.
/// Behavior may be ill-defined in some cases. Please file bugs.
abstract class ScrollOffsetListener {
/// Stream of scroll offset deltas.
Stream<double> get changes;
/// Construct a ScrollOffsetListener.
///
/// Set [recordProgrammaticScrolls] to false to prevent reporting of
/// programmatic scrolls.
factory ScrollOffsetListener.create(
{bool recordProgrammaticScrolls = true}) =>
ScrollOffsetNotifier(
recordProgrammaticScrolls: recordProgrammaticScrolls);
}
@@ -0,0 +1,20 @@
import 'dart:async';
import 'scroll_offset_listener.dart';
class ScrollOffsetNotifier implements ScrollOffsetListener {
final bool recordProgrammaticScrolls;
ScrollOffsetNotifier({this.recordProgrammaticScrolls = true});
final _streamController = StreamController<double>();
@override
Stream<double> get changes => _streamController.stream;
StreamController get changeController => _streamController;
void dispose() {
_streamController.close();
}
}
@@ -0,0 +1,83 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'wrapping.dart';
import 'viewport.dart';
/// A version of [CustomScrollView] that allows does not constrict the extents
/// to be within 0 and 1. See [CustomScrollView] for more information.
class UnboundedCustomScrollView extends CustomScrollView {
final bool _shrinkWrap;
const UnboundedCustomScrollView({
Key? key,
Axis scrollDirection = Axis.vertical,
bool reverse = false,
ScrollController? controller,
bool? primary,
ScrollPhysics? physics,
bool shrinkWrap = false,
Key? center,
double anchor = 0.0,
double? cacheExtent,
List<Widget> slivers = const <Widget>[],
int? semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
}) : _shrinkWrap = shrinkWrap,
_anchor = anchor,
super(
key: key,
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: false,
center: center,
cacheExtent: cacheExtent,
semanticChildCount: semanticChildCount,
dragStartBehavior: dragStartBehavior,
slivers: slivers,
);
// [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so
// we need our own version.
final double _anchor;
@override
double get anchor => _anchor;
/// Build the viewport.
@override
@protected
Widget buildViewport(
BuildContext context,
ViewportOffset offset,
AxisDirection axisDirection,
List<Widget> slivers,
) {
if (_shrinkWrap) {
return CustomShrinkWrappingViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
);
}
return UnboundedViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
);
}
}
@@ -0,0 +1,681 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart';
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_notifier.dart';
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scroll_offset_listener.dart';
import 'positioned_list.dart';
import 'post_mount_callback.dart';
import 'scroll_offset_notifier.dart';
/// Number of screens to scroll when scrolling a long distance.
const int _screenScrollCount = 2;
/// A scrollable list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset.
///
/// [ScrollablePositionedList] lays out children in the same way as [ListView].
///
/// The list can be displayed with the item at [initialScrollIndex] positioned
/// at a particular [initialAlignment].
///
/// The [itemScrollController] can be used to scroll or jump to particular items
/// in the list. The [itemPositionsNotifier] can be used to get a list of items
/// currently laid out by the list.
///
/// The [scrollOffsetListener] can be used to get updates about scroll position
/// changes.
///
/// All other parameters are the same as specified in [ListView].
class ScrollablePositionedList extends StatefulWidget {
/// Create a [ScrollablePositionedList] whose items are provided by
/// [itemBuilder].
const ScrollablePositionedList.builder({
required this.itemCount,
required this.itemBuilder,
Key? key,
this.itemScrollController,
this.shrinkWrap = false,
ItemPositionsListener? itemPositionsListener,
this.scrollOffsetController,
ScrollOffsetListener? scrollOffsetListener,
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.semanticChildCount,
this.padding,
this.addSemanticIndexes = true,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.minCacheExtent,
this.scrollAction,
}) : assert(itemCount != null),
assert(itemBuilder != null),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
separatorBuilder = null,
super(key: key);
/// Create a [ScrollablePositionedList] whose items are provided by
/// [itemBuilder] and separators provided by [separatorBuilder].
const ScrollablePositionedList.separated({
required this.itemCount,
required this.itemBuilder,
required this.separatorBuilder,
Key? key,
this.shrinkWrap = false,
this.itemScrollController,
ItemPositionsListener? itemPositionsListener,
this.scrollOffsetController,
ScrollOffsetListener? scrollOffsetListener,
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.semanticChildCount,
this.padding,
this.addSemanticIndexes = true,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.minCacheExtent,
this.scrollAction,
}) : assert(itemCount != null),
assert(itemBuilder != null),
assert(separatorBuilder != null),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
super(key: key);
/// Number of items the [itemBuilder] can produce.
final int itemCount;
/// Called to build children for the list with
/// 0 <= index < itemCount.
final IndexedWidgetBuilder itemBuilder;
/// Called to build separators for between each item in the list.
/// Called with 0 <= index < itemCount - 1.
final IndexedWidgetBuilder? separatorBuilder;
/// Controller for jumping or scrolling to an item.
final ItemScrollController? itemScrollController;
/// Notifier that reports the items laid out in the list after each frame.
final ItemPositionsNotifier? itemPositionsNotifier;
final ScrollOffsetController? scrollOffsetController;
/// Notifier that reports the changes to the scroll offset.
final ScrollOffsetNotifier? scrollOffsetNotifier;
/// Index of an item to initially align within the viewport.
final int initialScrollIndex;
/// Determines where the leading edge of the item at [initialScrollIndex]
/// should be placed.
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
final double initialAlignment;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the view scrolls in the reading direction.
///
/// Defaults to false.
///
/// See [ScrollView.reverse].
final bool reverse;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// Defaults to false.
///
/// See [ScrollView.shrinkWrap].
final bool shrinkWrap;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// See [ScrollView.physics].
final ScrollPhysics? physics;
/// The number of children that will contribute semantic information.
///
/// See [ScrollView.semanticChildCount] for more information.
final int? semanticChildCount;
/// The amount of space by which to inset the children.
final EdgeInsets? padding;
/// Whether to wrap each child in an [IndexedSemantics].
///
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
final bool addSemanticIndexes;
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives;
/// Whether to wrap each child in a [RepaintBoundary].
///
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
final bool addRepaintBoundaries;
/// The minimum cache extent used by the underlying scroll lists.
/// See [ScrollView.cacheExtent].
///
/// Note that the [ScrollablePositionedList] uses two lists to simulate long
/// scrolls, so using the [ScrollController.scrollTo] method may result
/// in builds of widgets that would otherwise already be built in the
/// cache extent.
final double? minCacheExtent;
final Function(ScrollController)? scrollAction;
@override
State<StatefulWidget> createState() => _ScrollablePositionedListState();
}
/// Controller to jump or scroll to a particular position in a
/// [ScrollablePositionedList].
class ItemScrollController {
/// Whether any ScrollablePositionedList objects are attached this object.
///
/// If `false`, then [jumpTo] and [scrollTo] must not be called.
bool get isAttached => _scrollableListState != null;
_ScrollablePositionedListState? _scrollableListState;
/// Immediately, without animation, reconfigure the list so that the item at
/// [index]'s leading edge is at the given [alignment].
///
/// The [alignment] specifies the desired position for the leading edge of the
/// item. The [alignment] is expected to be a value in the range \[0.0, 1.0\]
/// and represents a proportion along the main axis of the viewport.
///
/// For a vertically scrolling view that is not reversed:
/// * 0 aligns the top edge of the item with the top edge of the view.
/// * 1 aligns the top edge of the item with the bottom of the view.
/// * 0.5 aligns the top edge of the item with the center of the view.
///
/// For a horizontally scrolling view that is not reversed:
/// * 0 aligns the left edge of the item with the left edge of the view
/// * 1 aligns the left edge of the item with the right edge of the view.
/// * 0.5 aligns the left edge of the item with the center of the view.
void jumpTo({required int index, double alignment = 0}) {
_scrollableListState!._jumpTo(index: index, alignment: alignment);
}
/// Animate the list over [duration] using the given [curve] such that the
/// item at [index] ends up with its leading edge at the given [alignment].
/// See [jumpTo] for an explanation of alignment.
///
/// The [duration] must be greater than 0; otherwise, use [jumpTo].
///
/// When item position is not available, because it's too far, the scroll
/// is composed into three phases:
///
/// 1. The currently displayed list view starts scrolling.
/// 2. Another list view, which scrolls with the same speed, fades over the
/// first one and shows items that are close to the scroll target.
/// 3. The second list view scrolls and stops on the target.
///
/// The [opacityAnimationWeights] can be used to apply custom weights to these
/// three stages of this animation. The default weights, `[40, 20, 40]`, are
/// good with default [Curves.linear]. Different weights might be better for
/// other cases. For example, if you use [Curves.easeOut], consider setting
/// [opacityAnimationWeights] to `[20, 20, 60]`.
///
/// See [TweenSequenceItem.weight] for more info.
Future<void> scrollTo({
required int index,
double alignment = 0,
required Duration duration,
Curve curve = Curves.linear,
List<double> opacityAnimationWeights = const [40, 20, 40],
}) {
assert(_scrollableListState != null);
assert(opacityAnimationWeights.length == 3);
assert(duration > Duration.zero);
return _scrollableListState!._scrollTo(
index: index,
alignment: alignment,
duration: duration,
curve: curve,
opacityAnimationWeights: opacityAnimationWeights,
);
}
void _attach(_ScrollablePositionedListState scrollableListState) {
assert(_scrollableListState == null);
_scrollableListState = scrollableListState;
}
void _detach() {
_scrollableListState = null;
}
}
/// Controller to scroll a certain number of pixels relative to the current
/// scroll offset.
///
/// Scrolls [offset] pixels relative to the current scroll offset. [offset] can
/// be positive or negative.
///
/// This is an experimental API and is subject to change.
/// Behavior may be ill-defined in some cases. Please file bugs.
class ScrollOffsetController {
Future<void> animateScroll(
{required double offset,
required Duration duration,
Curve curve = Curves.linear}) async {
final currentPosition =
_scrollableListState!.primary.scrollController.offset;
final newPosition = currentPosition + offset;
await _scrollableListState!.primary.scrollController.animateTo(
newPosition,
duration: duration,
curve: curve,
);
}
_ScrollablePositionedListState? _scrollableListState;
void _attach(_ScrollablePositionedListState scrollableListState) {
assert(_scrollableListState == null);
_scrollableListState = scrollableListState;
}
void _detach() {
_scrollableListState = null;
}
}
class _ScrollablePositionedListState extends State<ScrollablePositionedList>
with TickerProviderStateMixin {
/// Details for the primary (active) [ListView].
var primary = _ListDisplayDetails(const ValueKey('Ping'));
/// Details for the secondary (transitional) [ListView] that is temporarily
/// shown when scrolling a long distance.
var secondary = _ListDisplayDetails(const ValueKey('Pong'));
final opacity = ProxyAnimation(const AlwaysStoppedAnimation<double>(0));
void Function() startAnimationCallback = () {};
bool _isTransitioning = false;
var _animationController;
double previousOffset = 0;
@override
void initState() {
super.initState();
ItemPosition? initialPosition = PageStorage.of(context).readState(context);
primary.target = initialPosition?.index ?? widget.initialScrollIndex;
primary.alignment =
initialPosition?.itemLeadingEdge ?? widget.initialAlignment;
if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) {
primary.target = widget.itemCount - 1;
}
widget.itemScrollController?._attach(this);
widget.scrollOffsetController?._attach(this);
primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
primary.scrollController.addListener(() {
final currentOffset = primary.scrollController.offset;
final offsetChange = currentOffset - previousOffset;
previousOffset = currentOffset;
if (!_isTransitioning |
(widget.scrollOffsetNotifier?.recordProgrammaticScrolls ?? false)) {
widget.scrollOffsetNotifier?.changeController.add(offsetChange);
}
if (widget.scrollAction != null) {
widget.scrollAction?.call(primary.scrollController);
}
});
}
@override
void activate() {
super.activate();
widget.itemScrollController?._attach(this);
widget.scrollOffsetController?._attach(this);
}
@override
void deactivate() {
widget.itemScrollController?._detach();
widget.scrollOffsetController?._detach();
super.deactivate();
}
@override
void dispose() {
primary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
_animationController?.dispose();
super.dispose();
}
@override
void didUpdateWidget(ScrollablePositionedList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemScrollController?._scrollableListState == this) {
oldWidget.itemScrollController?._detach();
}
if (widget.itemScrollController?._scrollableListState != this) {
widget.itemScrollController?._detach();
widget.itemScrollController?._attach(this);
}
if (widget.itemCount == 0) {
setState(() {
primary.target = 0;
secondary.target = 0;
});
} else {
if (primary.target > widget.itemCount - 1) {
setState(() {
primary.target = widget.itemCount - 1;
});
}
if (secondary.target > widget.itemCount - 1) {
setState(() {
secondary.target = widget.itemCount - 1;
});
}
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final cacheExtent = _cacheExtent(constraints);
return Listener(
onPointerDown: (_) => _stopScroll(canceled: true),
child: Stack(
children: <Widget>[
PostMountCallback(
key: primary.key,
callback: startAnimationCallback,
child: FadeTransition(
opacity: ReverseAnimation(opacity),
child: NotificationListener<ScrollNotification>(
onNotification: (_) => _isTransitioning,
child: PositionedList(
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount,
positionedIndex: primary.target,
controller: primary.scrollController,
itemPositionsNotifier: primary.itemPositionsNotifier,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: cacheExtent,
alignment: primary.alignment,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount,
padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
),
),
),
),
if (_isTransitioning)
PostMountCallback(
key: secondary.key,
callback: startAnimationCallback,
child: FadeTransition(
opacity: opacity,
child: NotificationListener<ScrollNotification>(
onNotification: (_) => false,
child: PositionedList(
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount,
itemPositionsNotifier: secondary.itemPositionsNotifier,
positionedIndex: secondary.target,
controller: secondary.scrollController,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: cacheExtent,
alignment: secondary.alignment,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount,
padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
),
),
),
),
],
),
);
},
);
}
double _cacheExtent(BoxConstraints constraints) => max(
(widget.scrollDirection == Axis.vertical
? constraints.maxHeight
: constraints.maxWidth) *
_screenScrollCount,
widget.minCacheExtent ?? 0,
);
void _jumpTo({required int index, required double alignment}) {
_stopScroll(canceled: true);
if (index > widget.itemCount - 1) {
index = widget.itemCount - 1;
}
setState(() {
primary.scrollController.jumpTo(0);
primary.target = index;
primary.alignment = alignment;
});
}
Future<void> _scrollTo({
required int index,
required double alignment,
required Duration duration,
Curve curve = Curves.linear,
required List<double> opacityAnimationWeights,
}) async {
if (index > widget.itemCount - 1) {
index = widget.itemCount - 1;
}
if (_isTransitioning) {
final scrollCompleter = Completer<void>();
_stopScroll(canceled: true);
SchedulerBinding.instance.addPostFrameCallback((_) async {
await _startScroll(
index: index,
alignment: alignment,
duration: duration,
curve: curve,
opacityAnimationWeights: opacityAnimationWeights,
);
scrollCompleter.complete();
});
await scrollCompleter.future;
} else {
await _startScroll(
index: index,
alignment: alignment,
duration: duration,
curve: curve,
opacityAnimationWeights: opacityAnimationWeights,
);
}
}
Future<void> _startScroll({
required int index,
required double alignment,
required Duration duration,
Curve curve = Curves.linear,
required List<double> opacityAnimationWeights,
}) async {
final direction = index > primary.target ? 1 : -1;
final itemPosition = primary.itemPositionsNotifier.itemPositions.value
.firstWhereOrNull(
(ItemPosition itemPosition) => itemPosition.index == index);
if (itemPosition != null) {
// Scroll directly.
final localScrollAmount = itemPosition.itemLeadingEdge *
primary.scrollController.position.viewportDimension;
await primary.scrollController.animateTo(
primary.scrollController.offset +
localScrollAmount -
alignment * primary.scrollController.position.viewportDimension,
duration: duration,
curve: curve);
} else {
final scrollAmount = _screenScrollCount *
primary.scrollController.position.viewportDimension;
final startCompleter = Completer<void>();
final endCompleter = Completer<void>();
startAnimationCallback = () {
SchedulerBinding.instance.addPostFrameCallback((_) {
startAnimationCallback = () {};
_animationController?.dispose();
_animationController =
AnimationController(vsync: this, duration: duration)..forward();
opacity.parent = _opacityAnimation(opacityAnimationWeights)
.animate(_animationController);
secondary.scrollController.jumpTo(-direction *
(_screenScrollCount *
primary.scrollController.position.viewportDimension -
alignment *
secondary.scrollController.position.viewportDimension));
startCompleter.complete(primary.scrollController.animateTo(
primary.scrollController.offset + direction * scrollAmount,
duration: duration,
curve: curve));
endCompleter.complete(secondary.scrollController
.animateTo(0, duration: duration, curve: curve));
});
};
setState(() {
// TODO: _startScroll can be re-entrant, which invalidates this assert.
// assert(!_isTransitioning);
secondary.target = index;
secondary.alignment = alignment;
_isTransitioning = true;
});
await Future.wait<void>([startCompleter.future, endCompleter.future]);
_stopScroll();
}
}
void _stopScroll({bool canceled = false}) {
if (!_isTransitioning) {
return;
}
if (canceled) {
if (primary.scrollController.hasClients) {
primary.scrollController.jumpTo(primary.scrollController.offset);
}
if (secondary.scrollController.hasClients) {
secondary.scrollController.jumpTo(secondary.scrollController.offset);
}
}
if (mounted) {
setState(() {
if (opacity.value >= 0.5) {
// Secondary [ListView] is more visible than the primary; make it the
// new primary.
var temp = primary;
primary = secondary;
secondary = temp;
}
_isTransitioning = false;
opacity.parent = const AlwaysStoppedAnimation<double>(0);
});
}
}
Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) {
final startOpacity = 0.0;
final endOpacity = 1.0;
return TweenSequence<double>(<TweenSequenceItem<double>>[
TweenSequenceItem<double>(
tween: ConstantTween<double>(startOpacity),
weight: opacityAnimationWeights[0]),
TweenSequenceItem<double>(
tween: Tween<double>(begin: startOpacity, end: endOpacity),
weight: opacityAnimationWeights[1]),
TweenSequenceItem<double>(
tween: ConstantTween<double>(endOpacity),
weight: opacityAnimationWeights[2]),
]);
}
void _updatePositions() {
final itemPositions = primary.itemPositionsNotifier.itemPositions.value
.where((ItemPosition position) =>
position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0);
if (itemPositions.isNotEmpty) {
PageStorage.of(context).writeState(
context,
itemPositions.reduce((value, element) =>
value.itemLeadingEdge < element.itemLeadingEdge
? value
: element));
}
widget.itemPositionsNotifier?.itemPositions.value = itemPositions;
}
}
class _ListDisplayDetails {
_ListDisplayDetails(this.key);
final itemPositionsNotifier = ItemPositionsNotifier();
final scrollController = ScrollController(keepScrollOffset: false);
/// The index of the item to scroll to.
int target = 0;
/// The desired alignment for [target].
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
double alignment = 0;
final Key key;
}
@@ -0,0 +1,315 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// A render object that is bigger on the inside.
///
/// Version of [Viewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [Viewport]
/// for more information.
class UnboundedViewport extends Viewport {
UnboundedViewport({
Key? key,
AxisDirection axisDirection = AxisDirection.down,
AxisDirection? crossAxisDirection,
double anchor = 0.0,
required ViewportOffset offset,
Key? center,
double? cacheExtent,
List<Widget> slivers = const <Widget>[],
}) : _anchor = anchor,
super(
key: key,
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
slivers: slivers);
// [Viewport] enforces constraints on [Viewport.anchor], so we need our own
// version.
final double _anchor;
@override
double get anchor => _anchor;
@override
RenderViewport createRenderObject(BuildContext context) {
return UnboundedRenderViewport(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor,
offset: offset,
cacheExtent: cacheExtent,
);
}
}
/// A render object that is bigger on the inside.
///
/// Version of [RenderViewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [RenderViewport]
/// for more information.
///
// Differences from [RenderViewport] are marked with a //***** Differences
// comment.
class UnboundedRenderViewport extends RenderViewport {
/// Creates a viewport for [RenderSliver] objects.
UnboundedRenderViewport({
AxisDirection axisDirection = AxisDirection.down,
required AxisDirection crossAxisDirection,
required ViewportOffset offset,
double anchor = 0.0,
List<RenderSliver>? children,
RenderSliver? center,
double? cacheExtent,
}) : _anchor = anchor,
super(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
children: children);
static const int _maxLayoutCycles = 10;
double _anchor;
// Out-of-band data computed during layout.
late double _minScrollExtent;
late double _maxScrollExtent;
bool _hasVisualOverflow = false;
/// This value is set during layout based on the [CacheExtentStyle].
///
/// When the style is [CacheExtentStyle.viewport], it is the main axis extent
/// of the viewport multiplied by the requested cache extent, which is still
/// expressed in pixels.
double? _calculatedCacheExtent;
@override
double get anchor => _anchor;
@override
set anchor(double value) {
assert(value != null);
if (value == _anchor) return;
_anchor = value;
markNeedsLayout();
}
@override
void performResize() {
super.performResize();
// TODO: Figure out why this override is needed as a result of
// https://github.com/flutter/flutter/pull/61973 and see if it can be
// removed somehow.
switch (axis) {
case Axis.vertical:
offset.applyViewportDimension(size.height);
break;
case Axis.horizontal:
offset.applyViewportDimension(size.width);
break;
}
}
@override
Rect describeSemanticsClip(RenderSliver? child) {
assert(axis != null);
if (_calculatedCacheExtent == null) {
return semanticBounds;
}
switch (axis) {
case Axis.vertical:
return Rect.fromLTRB(
semanticBounds.left,
semanticBounds.top - _calculatedCacheExtent!,
semanticBounds.right,
semanticBounds.bottom + _calculatedCacheExtent!,
);
default:
return Rect.fromLTRB(
semanticBounds.left - _calculatedCacheExtent!,
semanticBounds.top,
semanticBounds.right + _calculatedCacheExtent!,
semanticBounds.bottom,
);
}
}
@override
void performLayout() {
if (center == null) {
assert(firstChild == null);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
assert(center!.parent == this);
late double mainAxisExtent;
late double crossAxisExtent;
switch (axis) {
case Axis.vertical:
mainAxisExtent = size.height;
crossAxisExtent = size.width;
break;
case Axis.horizontal:
mainAxisExtent = size.width;
crossAxisExtent = size.height;
break;
}
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
double correction;
var count = 0;
do {
assert(offset.pixels != null);
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
// *** Difference from [RenderViewport].
final top = _minScrollExtent + mainAxisExtent * anchor;
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
final maxScrollOffset = math.max(math.min(0.0, top), bottom);
final minScrollOffset = math.min(top, maxScrollOffset);
if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset))
break;
// *** End of difference from [RenderViewport].
}
count += 1;
} while (count < _maxLayoutCycles);
assert(() {
if (count >= _maxLayoutCycles) {
assert(count != 1);
throw FlutterError(
'A RenderViewport exceeded its maximum number of layout cycles.\n'
'RenderViewport render objects, during layout, can retry if either their '
'slivers or their ViewportOffset decide that the offset should be corrected '
'to take into account information collected during that layout.\n'
'In the case of this RenderViewport object, however, this happened $count '
'times and still there was no consensus on the scroll offset. This usually '
'indicates a bug. Specifically, it means that one of the following three '
'problems is being experienced by the RenderViewport object:\n'
' * One of the RenderSliver children or the ViewportOffset have a bug such'
' that they always think that they need to correct the offset regardless.\n'
' * Some combination of the RenderSliver children and the ViewportOffset'
' have a bad interaction such that one applies a correction then another'
' applies a reverse correction, leading to an infinite loop of corrections.\n'
' * There is a pathological case that would eventually resolve, but it is'
' so complicated that it cannot be resolved in any reasonable number of'
' layout passes.');
}
return true;
}());
}
double _attemptLayout(
double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
assert(!mainAxisExtent.isNaN);
assert(mainAxisExtent >= 0.0);
assert(crossAxisExtent.isFinite);
assert(crossAxisExtent >= 0.0);
assert(correctedOffset.isFinite);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
// centerOffset is the offset from the leading edge of the RenderViewport
// to the zero scroll offset (the line between the forward slivers and the
// reverse slivers).
final double centerOffset = mainAxisExtent * anchor - correctedOffset;
final double reverseDirectionRemainingPaintExtent =
centerOffset.clamp(0.0, mainAxisExtent);
final double forwardDirectionRemainingPaintExtent =
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
_calculatedCacheExtent = cacheExtent;
break;
case CacheExtentStyle.viewport:
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
break;
}
final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
final double centerCacheOffset = centerOffset + _calculatedCacheExtent!;
final double reverseDirectionRemainingCacheExtent =
centerCacheOffset.clamp(0.0, fullCacheExtent);
final double forwardDirectionRemainingCacheExtent =
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
final RenderSliver? leadingNegativeChild = childBefore(center!);
if (leadingNegativeChild != null) {
// negative scroll offsets
final double result = layoutChildSequence(
child: leadingNegativeChild,
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
overlap: 0.0,
layoutOffset: forwardDirectionRemainingPaintExtent,
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.reverse,
advance: childBefore,
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
cacheOrigin: (mainAxisExtent - centerOffset)
.clamp(-_calculatedCacheExtent!, 0.0),
);
if (result != 0.0) return -result;
}
// positive scroll offsets
return layoutChildSequence(
child: center,
scrollOffset: math.max(0.0, -centerOffset),
overlap:
leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
layoutOffset: centerOffset >= mainAxisExtent
? centerOffset
: reverseDirectionRemainingPaintExtent,
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.forward,
advance: childAfter,
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
);
}
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
}
}
@@ -0,0 +1,995 @@
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// A widget that is bigger on the inside and shrink wraps its children in the
/// main axis.
///
/// [ShrinkWrappingViewport] displays a subset of its children according to its
/// own dimensions and the given [offset]. As the offset varies, different
/// children are visible through the viewport.
///
/// [ShrinkWrappingViewport] differs from [Viewport] in that [Viewport] expands
/// to fill the main axis whereas [ShrinkWrappingViewport] sizes itself to match
/// its children in the main axis. This shrink wrapping behavior is expensive
/// because the children, and hence the viewport, could potentially change size
/// whenever the [offset] changes (e.g., because of a collapsing header).
///
/// [ShrinkWrappingViewport] cannot contain box children directly. Instead, use
/// a [SliverList], [SliverFixedExtentList], [SliverGrid], or a
/// [SliverToBoxAdapter], for example.
///
/// See also:
///
/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine
/// [Scrollable] and [ShrinkWrappingViewport] into widgets that are easier to
/// use.
/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a
/// sliver context (the opposite of this widget).
/// * [Viewport], a viewport that does not shrink-wrap its contents.
class CustomShrinkWrappingViewport extends CustomViewport {
/// Creates a widget that is bigger on the inside and shrink wraps its
/// children in the main axis.
///
/// The viewport listens to the [offset], which means you do not need to
/// rebuild this widget when the [offset] changes.
///
/// The [offset] argument must not be null.
CustomShrinkWrappingViewport({
Key? key,
AxisDirection axisDirection = AxisDirection.down,
AxisDirection? crossAxisDirection,
double anchor = 0.0,
required ViewportOffset offset,
List<RenderSliver>? children,
Key? center,
double? cacheExtent,
List<Widget> slivers = const <Widget>[],
}) : _anchor = anchor,
super(
key: key,
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
slivers: slivers);
// [Viewport] enforces constraints on [Viewport.anchor], so we need our own
// version.
final double _anchor;
@override
double get anchor => _anchor;
@override
CustomRenderShrinkWrappingViewport createRenderObject(BuildContext context) {
return CustomRenderShrinkWrappingViewport(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
offset: offset,
anchor: anchor,
cacheExtent: cacheExtent,
);
}
@override
void updateRenderObject(
BuildContext context, CustomRenderShrinkWrappingViewport renderObject) {
renderObject
..axisDirection = axisDirection
..crossAxisDirection = crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection)
..anchor = anchor
..offset = offset
..cacheExtent = cacheExtent
..cacheExtentStyle = cacheExtentStyle
..clipBehavior = clipBehavior;
}
}
/// A render object that is bigger on the inside and shrink wraps its children
/// in the main axis.
///
/// [RenderShrinkWrappingViewport] displays a subset of its children according
/// to its own dimensions and the given [offset]. As the offset varies, different
/// children are visible through the viewport.
///
/// [RenderShrinkWrappingViewport] differs from [RenderViewport] in that
/// [RenderViewport] expands to fill the main axis whereas
/// [RenderShrinkWrappingViewport] sizes itself to match its children in the
/// main axis. This shrink wrapping behavior is expensive because the children,
/// and hence the viewport, could potentially change size whenever the [offset]
/// changes (e.g., because of a collapsing header).
///
/// [RenderShrinkWrappingViewport] cannot contain [RenderBox] children directly.
/// Instead, use a [RenderSliverList], [RenderSliverFixedExtentList],
/// [RenderSliverGrid], or a [RenderSliverToBoxAdapter], for example.
///
/// See also:
///
/// * [RenderViewport], a viewport that does not shrink-wrap its contents.
/// * [RenderSliver], which explains more about the Sliver protocol.
/// * [RenderBox], which explains more about the Box protocol.
/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be
/// placed inside a [RenderSliver] (the opposite of this class).
class CustomRenderShrinkWrappingViewport extends CustomRenderViewport {
/// Creates a viewport (for [RenderSliver] objects) that shrink-wraps its
/// contents.
///
/// The [offset] must be specified. For testing purposes, consider passing a
/// [ViewportOffset.zero] or [ViewportOffset.fixed].
CustomRenderShrinkWrappingViewport({
AxisDirection axisDirection = AxisDirection.down,
required AxisDirection crossAxisDirection,
required ViewportOffset offset,
double anchor = 0.0,
List<RenderSliver>? children,
RenderSliver? center,
double? cacheExtent,
}) : _anchor = anchor,
super(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
children: children,
);
double _anchor;
@override
double get anchor => _anchor;
@override
bool get sizedByParent => false;
double lastMainAxisExtent = -1;
@override
set anchor(double value) {
if (value == _anchor) return;
_anchor = value;
markNeedsLayout();
}
late double _shrinkWrapExtent;
/// This value is set during layout based on the [CacheExtentStyle].
///
/// When the style is [CacheExtentStyle.viewport], it is the main axis extent
/// of the viewport multiplied by the requested cache extent, which is still
/// expressed in pixels.
double? _calculatedCacheExtent;
/// While List in a wrapping container, eg. ListViewthe mainAxisExtent will
/// be infinite. This time need to change mainAxisExtent to this value.
final double _maxMainAxisExtent = double.maxFinite;
@override
void performLayout() {
if (center == null) {
assert(firstChild == null);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
assert(center!.parent == this);
final BoxConstraints constraints = this.constraints;
if (firstChild == null) {
switch (axis) {
case Axis.vertical:
assert(constraints.hasBoundedWidth);
size = Size(constraints.maxWidth, constraints.minHeight);
break;
case Axis.horizontal:
assert(constraints.hasBoundedHeight);
size = Size(constraints.minWidth, constraints.maxHeight);
break;
}
offset.applyViewportDimension(0.0);
_maxScrollExtent = 0.0;
_shrinkWrapExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
double mainAxisExtent;
final double crossAxisExtent;
switch (axis) {
case Axis.vertical:
assert(constraints.hasBoundedWidth);
mainAxisExtent = constraints.maxHeight;
crossAxisExtent = constraints.maxWidth;
break;
case Axis.horizontal:
assert(constraints.hasBoundedHeight);
mainAxisExtent = constraints.maxWidth;
crossAxisExtent = constraints.maxHeight;
break;
}
if (mainAxisExtent.isInfinite) {
mainAxisExtent = _maxMainAxisExtent;
}
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
double correction;
double effectiveExtent;
do {
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
switch (axis) {
case Axis.vertical:
effectiveExtent = constraints.constrainHeight(_shrinkWrapExtent);
break;
case Axis.horizontal:
effectiveExtent = constraints.constrainWidth(_shrinkWrapExtent);
break;
}
// *** Difference from [RenderViewport].
final top = _minScrollExtent + mainAxisExtent * anchor;
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
final maxScrollOffset = math.max(math.min(0.0, top), bottom);
final minScrollOffset = math.min(top, maxScrollOffset);
final bool didAcceptViewportDimension =
offset.applyViewportDimension(effectiveExtent);
final bool didAcceptContentDimension =
offset.applyContentDimensions(minScrollOffset, maxScrollOffset);
if (didAcceptViewportDimension && didAcceptContentDimension) {
break;
}
}
} while (true);
switch (axis) {
case Axis.vertical:
size =
constraints.constrainDimensions(crossAxisExtent, effectiveExtent);
break;
case Axis.horizontal:
size =
constraints.constrainDimensions(effectiveExtent, crossAxisExtent);
break;
}
}
double _attemptLayout(
double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
assert(!mainAxisExtent.isNaN);
assert(mainAxisExtent >= 0.0);
assert(crossAxisExtent.isFinite);
assert(crossAxisExtent >= 0.0);
assert(correctedOffset.isFinite);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
_shrinkWrapExtent = 0.0;
// centerOffset is the offset from the leading edge of the RenderViewport
// to the zero scroll offset (the line between the forward slivers and the
// reverse slivers).
final centerOffset = mainAxisExtent * anchor - correctedOffset;
final reverseDirectionRemainingPaintExtent =
centerOffset.clamp(0.0, mainAxisExtent);
final forwardDirectionRemainingPaintExtent =
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
_calculatedCacheExtent = cacheExtent;
break;
case CacheExtentStyle.viewport:
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
break;
}
final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
final centerCacheOffset = centerOffset + _calculatedCacheExtent!;
final reverseDirectionRemainingCacheExtent =
centerCacheOffset.clamp(0.0, fullCacheExtent);
final forwardDirectionRemainingCacheExtent =
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
final leadingNegativeChild = childBefore(center!);
if (leadingNegativeChild != null) {
// negative scroll offsets
final result = layoutChildSequence(
child: leadingNegativeChild,
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
overlap: 0.0,
layoutOffset: forwardDirectionRemainingPaintExtent,
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.reverse,
advance: childBefore,
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
cacheOrigin: (mainAxisExtent - centerOffset)
.clamp(-_calculatedCacheExtent!, 0.0),
);
if (result != 0.0) return -result;
}
// positive scroll offsets
return layoutChildSequence(
child: center,
scrollOffset: math.max(0.0, -centerOffset),
overlap:
leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
layoutOffset: centerOffset >= mainAxisExtent
? centerOffset
: reverseDirectionRemainingPaintExtent,
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.forward,
advance: childAfter,
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
);
}
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
_shrinkWrapExtent += childLayoutGeometry.maxPaintExtent;
growSize = _shrinkWrapExtent;
}
@override
String labelForChild(int index) => 'child $index';
}
/// A widget that is bigger on the inside.
///
/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a
/// subset of its children according to its own dimensions and the given
/// [offset]. As the offset varies, different children are visible through
/// the viewport.
///
/// [Viewport] hosts a bidirectional list of slivers, anchored on a [center]
/// sliver, which is placed at the zero scroll offset. The center widget is
/// displayed in the viewport according to the [anchor] property.
///
/// Slivers that are earlier in the child list than [center] are displayed in
/// reverse order in the reverse [axisDirection] starting from the [center]. For
/// example, if the [axisDirection] is [AxisDirection.down], the first sliver
/// before [center] is placed above the [center]. The slivers that are later in
/// the child list than [center] are placed in order in the [axisDirection]. For
/// example, in the preceding scenario, the first sliver after [center] is
/// placed below the [center].
///
/// [Viewport] cannot contain box children directly. Instead, use a
/// [SliverList], [SliverFixedExtentList], [SliverGrid], or a
/// [SliverToBoxAdapter], for example.
///
/// See also:
///
/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine
/// [Scrollable] and [Viewport] into widgets that are easier to use.
/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a
/// sliver context (the opposite of this widget).
/// * [ShrinkWrappingViewport], a variant of [Viewport] that shrink-wraps its
/// contents along the main axis.
abstract class CustomViewport extends MultiChildRenderObjectWidget {
/// Creates a widget that is bigger on the inside.
///
/// The viewport listens to the [offset], which means you do not need to
/// rebuild this widget when the [offset] changes.
///
/// The [offset] argument must not be null.
///
/// The [cacheExtent] must be specified if the [cacheExtentStyle] is
/// not [CacheExtentStyle.pixel].
CustomViewport({
Key? key,
this.axisDirection = AxisDirection.down,
this.crossAxisDirection,
this.anchor = 0.0,
required this.offset,
this.center,
this.cacheExtent,
this.cacheExtentStyle = CacheExtentStyle.pixel,
this.clipBehavior = Clip.hardEdge,
List<Widget> slivers = const <Widget>[],
}) : assert(offset != null),
assert(slivers != null),
assert(center == null ||
slivers.where((Widget child) => child.key == center).length == 1),
assert(cacheExtentStyle != null),
assert(cacheExtentStyle != CacheExtentStyle.viewport ||
cacheExtent != null),
assert(clipBehavior != null),
super(key: key, children: slivers);
/// The direction in which the [offset]'s [ViewportOffset.pixels] increases.
///
/// For example, if the [axisDirection] is [AxisDirection.down], a scroll
/// offset of zero is at the top of the viewport and increases towards the
/// bottom of the viewport.
final AxisDirection axisDirection;
/// The direction in which child should be laid out in the cross axis.
///
/// If the [axisDirection] is [AxisDirection.down] or [AxisDirection.up], this
/// property defaults to [AxisDirection.left] if the ambient [Directionality]
/// is [TextDirection.rtl] and [AxisDirection.right] if the ambient
/// [Directionality] is [TextDirection.ltr].
///
/// If the [axisDirection] is [AxisDirection.left] or [AxisDirection.right],
/// this property defaults to [AxisDirection.down].
final AxisDirection? crossAxisDirection;
/// The relative position of the zero scroll offset.
///
/// For example, if [anchor] is 0.5 and the [axisDirection] is
/// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is
/// vertically centered within the viewport. If the [anchor] is 1.0, and the
/// [axisDirection] is [AxisDirection.right], then the zero scroll offset is
/// on the left edge of the viewport.
final double anchor;
/// Which part of the content inside the viewport should be visible.
///
/// The [ViewportOffset.pixels] value determines the scroll offset that the
/// viewport uses to select which part of its content to display. As the user
/// scrolls the viewport, this value changes, which changes the content that
/// is displayed.
///
/// Typically a [ScrollPosition].
final ViewportOffset offset;
/// The first child in the [GrowthDirection.forward] growth direction.
///
/// Children after [center] will be placed in the [axisDirection] relative to
/// the [center]. Children before [center] will be placed in the opposite of
/// the [axisDirection] relative to the [center].
///
/// The [center] must be the key of a child of the viewport.
final Key? center;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
///
/// See also:
///
/// * [cacheExtentStyle], which controls the units of the [cacheExtent].
final double? cacheExtent;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtentStyle}
final CacheExtentStyle cacheExtentStyle;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// Given a [BuildContext] and an [AxisDirection], determine the correct cross
/// axis direction.
///
/// This depends on the [Directionality] if the `axisDirection` is vertical;
/// otherwise, the default cross axis direction is downwards.
static AxisDirection getDefaultCrossAxisDirection(
BuildContext context, AxisDirection axisDirection) {
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
assert(debugCheckHasDirectionality(
context,
why:
'to determine the cross-axis direction when the viewport has an \'up\' axisDirection',
alternative:
'Alternatively, consider specifying the \'crossAxisDirection\' argument on the Viewport.',
));
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.right:
return AxisDirection.down;
case AxisDirection.down:
assert(debugCheckHasDirectionality(
context,
why:
'to determine the cross-axis direction when the viewport has a \'down\' axisDirection',
alternative:
'Alternatively, consider specifying the \'crossAxisDirection\' argument on the Viewport.',
));
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.left:
return AxisDirection.down;
}
}
@override
CustomRenderViewport createRenderObject(BuildContext context);
@override
_ViewportElement createElement() => _ViewportElement(this);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
properties.add(EnumProperty<AxisDirection>(
'crossAxisDirection', crossAxisDirection,
defaultValue: null));
properties.add(DoubleProperty('anchor', anchor));
properties.add(DiagnosticsProperty<ViewportOffset>('offset', offset));
if (center != null) {
properties.add(DiagnosticsProperty<Key>('center', center));
} else if (children.isNotEmpty && children.first.key != null) {
properties.add(DiagnosticsProperty<Key>('center', children.first.key,
tooltip: 'implicit'));
}
properties.add(DiagnosticsProperty<double>('cacheExtent', cacheExtent));
properties.add(DiagnosticsProperty<CacheExtentStyle>(
'cacheExtentStyle', cacheExtentStyle));
}
}
class _ViewportElement extends MultiChildRenderObjectElement {
/// Creates an element that uses the given widget as its configuration.
_ViewportElement(CustomViewport widget) : super(widget);
@override
CustomViewport get widget => super.widget as CustomViewport;
@override
CustomRenderViewport get renderObject =>
super.renderObject as CustomRenderViewport;
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
_updateCenter();
}
@override
void update(MultiChildRenderObjectWidget newWidget) {
super.update(newWidget);
_updateCenter();
}
void _updateCenter() {
if (widget.center != null) {
renderObject.center = children
.singleWhere((Element element) => element.widget.key == widget.center)
.renderObject as RenderSliver?;
} else if (children.isNotEmpty) {
renderObject.center = children.first.renderObject as RenderSliver?;
} else {
renderObject.center = null;
}
}
@override
void debugVisitOnstageChildren(ElementVisitor visitor) {
children.where((Element e) {
final RenderSliver renderSliver = e.renderObject! as RenderSliver;
return renderSliver.geometry!.visible;
}).forEach(visitor);
}
}
class CustomSliverPhysicalContainerParentData
extends SliverPhysicalContainerParentData {
/// The position of the child relative to the zero scroll offset.
///
/// The number of pixels from from the zero scroll offset of the parent sliver
/// (the line at which its [SliverConstraints.scrollOffset] is zero) to the
/// side of the child closest to that offset. A [layoutOffset] can be null
/// when it cannot be determined. The value will be set after layout.
///
/// In a typical list, this does not change as the parent is scrolled.
///
/// Defaults to null.
double? layoutOffset;
GrowthDirection? growthDirection;
}
/// A render object that is bigger on the inside.
///
/// [RenderViewport] is the visual workhorse of the scrolling machinery. It
/// displays a subset of its children according to its own dimensions and the
/// given [offset]. As the offset varies, different children are visible through
/// the viewport.
///
/// [RenderViewport] hosts a bidirectional list of slivers, anchored on a
/// [center] sliver, which is placed at the zero scroll offset. The center
/// widget is displayed in the viewport according to the [anchor] property.
///
/// Slivers that are earlier in the child list than [center] are displayed in
/// reverse order in the reverse [axisDirection] starting from the [center]. For
/// example, if the [axisDirection] is [AxisDirection.down], the first sliver
/// before [center] is placed above the [center]. The slivers that are later in
/// the child list than [center] are placed in order in the [axisDirection]. For
/// example, in the preceding scenario, the first sliver after [center] is
/// placed below the [center].
///
/// [RenderViewport] cannot contain [RenderBox] children directly. Instead, use
/// a [RenderSliverList], [RenderSliverFixedExtentList], [RenderSliverGrid], or
/// a [RenderSliverToBoxAdapter], for example.
///
/// See also:
///
/// * [RenderSliver], which explains more about the Sliver protocol.
/// * [RenderBox], which explains more about the Box protocol.
/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be
/// placed inside a [RenderSliver] (the opposite of this class).
/// * [RenderShrinkWrappingViewport], a variant of [RenderViewport] that
/// shrink-wraps its contents along the main axis.
abstract class CustomRenderViewport
extends RenderViewportBase<CustomSliverPhysicalContainerParentData> {
/// Creates a viewport for [RenderSliver] objects.
///
/// If the [center] is not specified, then the first child in the `children`
/// list, if any, is used.
///
/// The [offset] must be specified. For testing purposes, consider passing a
/// [ViewportOffset.zero] or [ViewportOffset.fixed].
CustomRenderViewport({
AxisDirection axisDirection = AxisDirection.down,
required AxisDirection crossAxisDirection,
required ViewportOffset offset,
double anchor = 0.0,
List<RenderSliver>? children,
RenderSliver? center,
double? cacheExtent,
CacheExtentStyle cacheExtentStyle = CacheExtentStyle.pixel,
Clip clipBehavior = Clip.hardEdge,
}) : assert(anchor != null),
assert(anchor >= 0.0 && anchor <= 1.0),
assert(cacheExtentStyle != CacheExtentStyle.viewport ||
cacheExtent != null),
assert(clipBehavior != null),
_center = center,
super(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
cacheExtent: cacheExtent,
cacheExtentStyle: cacheExtentStyle,
clipBehavior: clipBehavior,
) {
addAll(children);
if (center == null && firstChild != null) _center = firstChild;
}
/// If a [RenderAbstractViewport] overrides
/// [RenderObject.describeSemanticsConfiguration] to add the [SemanticsTag]
/// [useTwoPaneSemantics] to its [SemanticsConfiguration], two semantics nodes
/// will be used to represent the viewport with its associated scrolling
/// actions in the semantics tree.
///
/// Two semantics nodes (an inner and an outer node) are necessary to exclude
/// certain child nodes (via the [excludeFromScrolling] tag) from the
/// scrollable area for semantic purposes: The [SemanticsNode]s of children
/// that should be excluded from scrolling will be attached to the outer node.
/// The semantic scrolling actions and the [SemanticsNode]s of scrollable
/// children will be attached to the inner node, which itself is a child of
/// the outer node.
///
/// See also:
///
/// * [RenderViewportBase.describeSemanticsConfiguration], which adds this
/// tag to its [SemanticsConfiguration].
static const SemanticsTag useTwoPaneSemantics =
SemanticsTag('RenderViewport.twoPane');
/// When a top-level [SemanticsNode] below a [RenderAbstractViewport] is
/// tagged with [excludeFromScrolling] it will not be part of the scrolling
/// area for semantic purposes.
///
/// This behavior is only active if the [RenderAbstractViewport]
/// tagged its [SemanticsConfiguration] with [useTwoPaneSemantics].
/// Otherwise, the [excludeFromScrolling] tag is ignored.
///
/// As an example, a [RenderSliver] that stays on the screen within a
/// [Scrollable] even though the user has scrolled past it (e.g. a pinned app
/// bar) can tag its [SemanticsNode] with [excludeFromScrolling] to indicate
/// that it should no longer be considered for semantic actions related to
/// scrolling.
static const SemanticsTag excludeFromScrolling =
SemanticsTag('RenderViewport.excludeFromScrolling');
@override
void setupParentData(RenderObject child) {
if (child.parentData is! CustomSliverPhysicalContainerParentData)
child.parentData = CustomSliverPhysicalContainerParentData();
}
/// The relative position of the zero scroll offset.
///
/// For example, if [anchor] is 0.5 and the [axisDirection] is
/// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is
/// vertically centered within the viewport. If the [anchor] is 1.0, and the
/// [axisDirection] is [AxisDirection.right], then the zero scroll offset is
/// on the left edge of the viewport.
double get anchor;
set anchor(double value);
/// The first child in the [GrowthDirection.forward] growth direction.
///
/// This child that will be at the position defined by [anchor] when the
/// [ViewportOffset.pixels] of [offset] is `0`.
///
/// Children after [center] will be placed in the [axisDirection] relative to
/// the [center]. Children before [center] will be placed in the opposite of
/// the [axisDirection] relative to the [center].
///
/// The [center] must be a child of the viewport.
RenderSliver? get center => _center;
RenderSliver? _center;
set center(RenderSliver? value) {
if (value == _center) return;
_center = value;
markNeedsLayout();
}
@override
bool get sizedByParent => true;
@override
Size computeDryLayout(BoxConstraints constraints) {
assert(() {
if (!constraints.hasBoundedHeight || !constraints.hasBoundedWidth) {
switch (axis) {
case Axis.vertical:
if (!constraints.hasBoundedHeight) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Vertical viewport was given unbounded height.'),
ErrorDescription(
'Viewports expand in the scrolling direction to fill their container. '
'In this case, a vertical viewport was given an unlimited amount of '
'vertical space in which to expand. This situation typically happens '
'when a scrollable widget is nested inside another scrollable widget.'),
ErrorHint(
'If this widget is always nested in a scrollable widget there '
'is no need to use a viewport because there will always be enough '
'vertical space for the children. In this case, consider using a '
'Column instead. Otherwise, consider using the "shrinkWrap" property '
'(or a ShrinkWrappingViewport) to size the height of the viewport '
'to the sum of the heights of its children.')
]);
}
if (!constraints.hasBoundedWidth) {
throw FlutterError(
'Vertical viewport was given unbounded width.\n'
'Viewports expand in the cross axis to fill their container and '
'constrain their children to match their extent in the cross axis. '
'In this case, a vertical viewport was given an unlimited amount of '
'horizontal space in which to expand.');
}
break;
case Axis.horizontal:
if (!constraints.hasBoundedWidth) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Horizontal viewport was given unbounded width.'),
ErrorDescription(
'Viewports expand in the scrolling direction to fill their container. '
'In this case, a horizontal viewport was given an unlimited amount of '
'horizontal space in which to expand. This situation typically happens '
'when a scrollable widget is nested inside another scrollable widget.'),
ErrorHint(
'If this widget is always nested in a scrollable widget there '
'is no need to use a viewport because there will always be enough '
'horizontal space for the children. In this case, consider using a '
'Row instead. Otherwise, consider using the "shrinkWrap" property '
'(or a ShrinkWrappingViewport) to size the width of the viewport '
'to the sum of the widths of its children.')
]);
}
if (!constraints.hasBoundedHeight) {
throw FlutterError(
'Horizontal viewport was given unbounded height.\n'
'Viewports expand in the cross axis to fill their container and '
'constrain their children to match their extent in the cross axis. '
'In this case, a horizontal viewport was given an unlimited amount of '
'vertical space in which to expand.');
}
break;
}
}
return true;
}());
return constraints.biggest;
}
// Out-of-band data computed during layout.
late double _minScrollExtent;
late double _maxScrollExtent;
bool _hasVisualOverflow = false;
double growSize = 0;
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
}
@override
void updateChildLayoutOffset(RenderSliver child, double layoutOffset,
GrowthDirection growthDirection) {
final CustomSliverPhysicalContainerParentData childParentData =
child.parentData! as CustomSliverPhysicalContainerParentData;
childParentData.layoutOffset = layoutOffset;
childParentData.growthDirection = growthDirection;
}
@override
Offset paintOffsetOf(RenderSliver child) {
final CustomSliverPhysicalContainerParentData childParentData =
child.parentData! as CustomSliverPhysicalContainerParentData;
return computeAbsolutePaintOffset(
child, childParentData.layoutOffset!, childParentData.growthDirection!);
}
@override
double scrollOffsetOf(RenderSliver child, double scrollOffsetWithinChild) {
assert(child.parent == this);
final GrowthDirection growthDirection = child.constraints.growthDirection;
switch (growthDirection) {
case GrowthDirection.forward:
double scrollOffsetToChild = 0.0;
RenderSliver? current = center;
while (current != child) {
scrollOffsetToChild += current!.geometry!.scrollExtent;
current = childAfter(current);
}
return scrollOffsetToChild + scrollOffsetWithinChild;
case GrowthDirection.reverse:
double scrollOffsetToChild = 0.0;
RenderSliver? current = childBefore(center!);
while (current != child) {
scrollOffsetToChild -= current!.geometry!.scrollExtent;
current = childBefore(current);
}
return scrollOffsetToChild - scrollOffsetWithinChild;
}
}
@override
double maxScrollObstructionExtentBefore(RenderSliver child) {
assert(child.parent == this);
final GrowthDirection growthDirection = child.constraints.growthDirection;
switch (growthDirection) {
case GrowthDirection.forward:
double pinnedExtent = 0.0;
RenderSliver? current = center;
while (current != child) {
pinnedExtent += current!.geometry!.maxScrollObstructionExtent;
current = childAfter(current);
}
return pinnedExtent;
case GrowthDirection.reverse:
double pinnedExtent = 0.0;
RenderSliver? current = childBefore(center!);
while (current != child) {
pinnedExtent += current!.geometry!.maxScrollObstructionExtent;
current = childBefore(current);
}
return pinnedExtent;
}
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
final Offset offset = paintOffsetOf(child as RenderSliver);
transform.translate(offset.dx, offset.dy);
}
@override
double computeChildMainAxisPosition(
RenderSliver child, double parentMainAxisPosition) {
final CustomSliverPhysicalContainerParentData childParentData =
child.parentData! as CustomSliverPhysicalContainerParentData;
switch (applyGrowthDirectionToAxisDirection(
child.constraints.axisDirection, child.constraints.growthDirection)) {
case AxisDirection.down:
case AxisDirection.right:
return parentMainAxisPosition - childParentData.layoutOffset!;
case AxisDirection.up:
return (size.height - parentMainAxisPosition) -
childParentData.layoutOffset!;
case AxisDirection.left:
return (size.width - parentMainAxisPosition) -
childParentData.layoutOffset!;
}
}
@override
int get indexOfFirstChild {
assert(center != null);
assert(center!.parent == this);
assert(firstChild != null);
int count = 0;
RenderSliver? child = center;
while (child != firstChild) {
count -= 1;
child = childBefore(child!);
}
return count;
}
@override
String labelForChild(int index) {
if (index == 0) return 'center child';
return 'child $index';
}
@override
Iterable<RenderSliver> get childrenInPaintOrder sync* {
if (firstChild == null) return;
RenderSliver? child = firstChild;
while (child != center) {
yield child!;
child = childAfter(child);
}
child = lastChild;
while (true) {
yield child!;
if (child == center) return;
child = childBefore(child);
}
}
@override
Iterable<RenderSliver> get childrenInHitTestOrder sync* {
if (firstChild == null) return;
RenderSliver? child = center;
while (child != null) {
yield child;
child = childAfter(child);
}
child = childBefore(center!);
while (child != null) {
yield child;
child = childBefore(child);
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('anchor', anchor));
}
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
/// 吸底弹窗顶部的拖动条(36×5 胶囊),全站 bottomSheet 共用。
class SheetHandleBar extends StatelessWidget {
/// 深色弹窗默认白色 20% 透明;浅底或设计另配了色的弹窗自己传
final Color color;
const SheetHandleBar({super.key, this.color = const Color(0x33FFFFFF)});
@override
Widget build(BuildContext context) {
return Container(
width: 36,
height: 5,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(100)),
);
}
}
+847
View File
@@ -0,0 +1,847 @@
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
class ShrinkWrap extends MultiChildRenderObjectWidget {
ShrinkWrap({
super.key,
this.direction = Axis.horizontal,
this.alignment = WrapAlignment.start,
this.spacing = 0.0,
this.runAlignment = WrapAlignment.start,
this.runSpacing = 0.0,
this.crossAxisAlignment = WrapCrossAlignment.start,
this.textDirection,
this.verticalDirection = VerticalDirection.down,
this.clipBehavior = Clip.none,
this.maxLines = 0,
super.children,
}) : assert(maxLines >= 0, 'maxLines must be >= 0');
final Axis direction;
final WrapAlignment alignment;
final double spacing;
final WrapAlignment runAlignment;
final double runSpacing;
final WrapCrossAlignment crossAxisAlignment;
final TextDirection? textDirection;
final VerticalDirection verticalDirection;
final Clip clipBehavior;
/// maximum rows when expand; when it is 0, the maximum rows is not limited;
final int maxLines;
@override
RenderObject createRenderObject(BuildContext context) {
RenderShrinkWrap renderShrinkWrap = RenderShrinkWrap(
direction: direction,
alignment: alignment,
spacing: spacing,
runAlignment: runAlignment,
runSpacing: runSpacing,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection ?? Directionality.maybeOf(context),
verticalDirection: verticalDirection,
clipBehavior: clipBehavior,
maxLines: maxLines,
);
return renderShrinkWrap;
}
@override
void updateRenderObject(BuildContext context, RenderShrinkWrap renderObject) {
renderObject
..alignment = alignment
..spacing = spacing
..runAlignment = runAlignment
..runSpacing = runSpacing
..crossAxisAlignment = crossAxisAlignment
..textDirection = textDirection ?? Directionality.maybeOf(context)
..verticalDirection = verticalDirection
..clipBehavior = clipBehavior
..maxLines = maxLines;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<Axis>('direction', direction));
properties.add(EnumProperty<WrapAlignment>('alignment', alignment));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<WrapAlignment>('runAlignment', runAlignment));
properties.add(DoubleProperty('runSpacing', runSpacing));
properties.add(EnumProperty<WrapCrossAlignment>('crossAxisAlignment', crossAxisAlignment));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
properties.add(EnumProperty<VerticalDirection>('verticalDirection', verticalDirection, defaultValue: VerticalDirection.down));
properties.add(IntProperty('maxLines', maxLines, defaultValue: 0));
}
}
class _RunMetrics {
_RunMetrics(this.mainAxisExtent, this.crossAxisExtent, this.childCount);
final double mainAxisExtent;
final double crossAxisExtent;
final int childCount;
}
/// Parent data for use with [RenderWrap].
class ShrinkWrapParentData extends ContainerBoxParentData<RenderBox> {
int _runIndex = 0;
}
/// Displays its children in multiple horizontal or vertical runs.
///
/// A [RenderWrap] lays out each child and attempts to place the child adjacent
/// to the previous child in the main axis, given by [direction], leaving
/// [spacing] space in between. If there is not enough space to fit the child,
/// [RenderWrap] creates a new _run_ adjacent to the existing children in the
/// cross axis.
///
/// After all the children have been allocated to runs, the children within the
/// runs are positioned according to the [alignment] in the main axis and
/// according to the [crossAxisAlignment] in the cross axis.
///
/// The runs themselves are then positioned in the cross axis according to the
/// [runSpacing] and [runAlignment].
class RenderShrinkWrap extends RenderBox
with ContainerRenderObjectMixin<RenderBox, ShrinkWrapParentData>, RenderBoxContainerDefaultsMixin<RenderBox, ShrinkWrapParentData> {
/// Creates a wrap render object.
///
/// By default, the wrap layout is horizontal and both the children and the
/// runs are aligned to the start.
RenderShrinkWrap({
List<RenderBox>? children,
Axis direction = Axis.horizontal,
WrapAlignment alignment = WrapAlignment.start,
double spacing = 0.0,
WrapAlignment runAlignment = WrapAlignment.start,
double runSpacing = 0.0,
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.start,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
Clip clipBehavior = Clip.none,
int maxLines = 0,
}) : _direction = direction,
_alignment = alignment,
_spacing = spacing,
_runAlignment = runAlignment,
_runSpacing = runSpacing,
_crossAxisAlignment = crossAxisAlignment,
_textDirection = textDirection,
_verticalDirection = verticalDirection,
_clipBehavior = clipBehavior,
_maxLines = maxLines {
addAll(children);
}
/// The direction to use as the main axis.
///
/// For example, if [direction] is [Axis.horizontal], the default, the
/// children are placed adjacent to one another in a horizontal run until the
/// available horizontal space is consumed, at which point a subsequent
/// children are placed in a new run vertically adjacent to the previous run.
Axis get direction => _direction;
Axis _direction;
set direction(Axis value) {
if (_direction == value) return;
_direction = value;
markNeedsLayout();
}
/// How the children within a run should be placed in the main axis.
///
/// For example, if [alignment] is [WrapAlignment.center], the children in
/// each run are grouped together in the center of their run in the main axis.
///
/// Defaults to [WrapAlignment.start].
///
/// See also:
///
/// * [runAlignment], which controls how the runs are placed relative to each
/// other in the cross axis.
/// * [crossAxisAlignment], which controls how the children within each run
/// are placed relative to each other in the cross axis.
WrapAlignment get alignment => _alignment;
WrapAlignment _alignment;
set alignment(WrapAlignment value) {
if (_alignment == value) return;
_alignment = value;
markNeedsLayout();
}
/// How much space to place between children in a run in the main axis.
///
/// For example, if [spacing] is 10.0, the children will be spaced at least
/// 10.0 logical pixels apart in the main axis.
///
/// If there is additional free space in a run (e.g., because the wrap has a
/// minimum size that is not filled or because some runs are longer than
/// others), the additional free space will be allocated according to the
/// [alignment].
///
/// Defaults to 0.0.
double get spacing => _spacing;
double _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
/// How the runs themselves should be placed in the cross axis.
///
/// For example, if [runAlignment] is [WrapAlignment.center], the runs are
/// grouped together in the center of the overall [RenderWrap] in the cross
/// axis.
///
/// Defaults to [WrapAlignment.start].
///
/// See also:
///
/// * [alignment], which controls how the children within each run are placed
/// relative to each other in the main axis.
/// * [crossAxisAlignment], which controls how the children within each run
/// are placed relative to each other in the cross axis.
WrapAlignment get runAlignment => _runAlignment;
WrapAlignment _runAlignment;
set runAlignment(WrapAlignment value) {
if (_runAlignment == value) return;
_runAlignment = value;
markNeedsLayout();
}
/// How much space to place between the runs themselves in the cross axis.
///
/// For example, if [runSpacing] is 10.0, the runs will be spaced at least
/// 10.0 logical pixels apart in the cross axis.
///
/// If there is additional free space in the overall [RenderWrap] (e.g.,
/// because the wrap has a minimum size that is not filled), the additional
/// free space will be allocated according to the [runAlignment].
///
/// Defaults to 0.0.
double get runSpacing => _runSpacing;
double _runSpacing;
set runSpacing(double value) {
if (_runSpacing == value) return;
_runSpacing = value;
markNeedsLayout();
}
/// How the children within a run should be aligned relative to each other in
/// the cross axis.
///
/// For example, if this is set to [WrapCrossAlignment.end], and the
/// [direction] is [Axis.horizontal], then the children within each
/// run will have their bottom edges aligned to the bottom edge of the run.
///
/// Defaults to [WrapCrossAlignment.start].
///
/// See also:
///
/// * [alignment], which controls how the children within each run are placed
/// relative to each other in the main axis.
/// * [runAlignment], which controls how the runs are placed relative to each
/// other in the cross axis.
WrapCrossAlignment get crossAxisAlignment => _crossAxisAlignment;
WrapCrossAlignment _crossAxisAlignment;
set crossAxisAlignment(WrapCrossAlignment value) {
if (_crossAxisAlignment == value) return;
_crossAxisAlignment = value;
markNeedsLayout();
}
/// Determines the order to lay children out horizontally and how to interpret
/// `start` and `end` in the horizontal direction.
///
/// If the [direction] is [Axis.horizontal], this controls the order in which
/// children are positioned (left-to-right or right-to-left), and the meaning
/// of the [alignment] property's [WrapAlignment.start] and
/// [WrapAlignment.end] values.
///
/// If the [direction] is [Axis.horizontal], and either the
/// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or
/// there's more than one child, then the [textDirection] must not be null.
///
/// If the [direction] is [Axis.vertical], this controls the order in
/// which runs are positioned, the meaning of the [runAlignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
/// [WrapCrossAlignment.end] values.
///
/// If the [direction] is [Axis.vertical], and either the
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
/// [WrapCrossAlignment.end], or there's more than one child, then the
/// [textDirection] must not be null.
TextDirection? get textDirection => _textDirection;
TextDirection? _textDirection;
set textDirection(TextDirection? value) {
if (_textDirection == value) return;
_textDirection = value;
markNeedsLayout();
}
/// Determines the order to lay children out vertically and how to interpret
/// `start` and `end` in the vertical direction.
///
/// If the [direction] is [Axis.vertical], this controls which order children
/// are painted in (down or up), the meaning of the [alignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values.
///
/// If the [direction] is [Axis.vertical], and either the [alignment]
/// is either [WrapAlignment.start] or [WrapAlignment.end], or there's
/// more than one child, then the [verticalDirection] must not be null.
///
/// If the [direction] is [Axis.horizontal], this controls the order in which
/// runs are positioned, the meaning of the [runAlignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
/// [WrapCrossAlignment.end] values.
///
/// If the [direction] is [Axis.horizontal], and either the
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
/// [WrapCrossAlignment.end], or there's more than one child, then the
/// [verticalDirection] must not be null.
VerticalDirection get verticalDirection => _verticalDirection;
VerticalDirection _verticalDirection;
set verticalDirection(VerticalDirection value) {
if (_verticalDirection == value) return;
_verticalDirection = value;
markNeedsLayout();
}
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.none], and must not be null.
Clip get clipBehavior => _clipBehavior;
Clip _clipBehavior = Clip.none;
set clipBehavior(Clip value) {
if (value == _clipBehavior) return;
_clipBehavior = value;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
/// maximum rows when expand; when it is 0, the maximum rows is not limited;
int _maxLines;
int get maxLines => _maxLines;
set maxLines(int value) {
if (_maxLines == value) return;
_maxLines = value;
markNeedsLayout();
}
bool get _debugHasNecessaryDirections {
if (firstChild != null && lastChild != firstChild) {
// i.e. there's more than one child
switch (direction) {
case Axis.horizontal:
assert(textDirection != null,
'Horizontal $runtimeType with multiple children has a null textDirection, so the layout order is undefined.');
break;
case Axis.vertical:
break;
}
}
if (alignment == WrapAlignment.start || alignment == WrapAlignment.end) {
switch (direction) {
case Axis.horizontal:
assert(textDirection != null,
'Horizontal $runtimeType with alignment $alignment has a null textDirection, so the alignment cannot be resolved.');
break;
case Axis.vertical:
break;
}
}
if (runAlignment == WrapAlignment.start || runAlignment == WrapAlignment.end) {
switch (direction) {
case Axis.horizontal:
break;
case Axis.vertical:
assert(textDirection != null,
'Vertical $runtimeType with runAlignment $runAlignment has a null textDirection, so the alignment cannot be resolved.');
break;
}
}
if (crossAxisAlignment == WrapCrossAlignment.start || crossAxisAlignment == WrapCrossAlignment.end) {
switch (direction) {
case Axis.horizontal:
break;
case Axis.vertical:
assert(textDirection != null,
'Vertical $runtimeType with crossAxisAlignment $crossAxisAlignment has a null textDirection, so the alignment cannot be resolved.');
break;
}
}
return true;
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! ShrinkWrapParentData) {
child.parentData = ShrinkWrapParentData();
}
}
@override
double computeMinIntrinsicWidth(double height) {
switch (direction) {
case Axis.horizontal:
double width = 0.0;
RenderBox? child = firstChild;
while (child != null) {
width = math.max(width, child.getMinIntrinsicWidth(double.infinity));
child = childAfter(child);
}
return width;
case Axis.vertical:
return computeDryLayout(BoxConstraints(maxHeight: height)).width;
}
}
@override
double computeMaxIntrinsicWidth(double height) {
switch (direction) {
case Axis.horizontal:
double width = 0.0;
RenderBox? child = firstChild;
while (child != null) {
width += child.getMaxIntrinsicWidth(double.infinity);
child = childAfter(child);
}
return width;
case Axis.vertical:
return computeDryLayout(BoxConstraints(maxHeight: height)).width;
}
}
@override
double computeMinIntrinsicHeight(double width) {
switch (direction) {
case Axis.horizontal:
return computeDryLayout(BoxConstraints(maxWidth: width)).height;
case Axis.vertical:
double height = 0.0;
RenderBox? child = firstChild;
while (child != null) {
height = math.max(height, child.getMinIntrinsicHeight(double.infinity));
child = childAfter(child);
}
return height;
}
}
@override
double computeMaxIntrinsicHeight(double width) {
switch (direction) {
case Axis.horizontal:
return computeDryLayout(BoxConstraints(maxWidth: width)).height;
case Axis.vertical:
double height = 0.0;
RenderBox? child = firstChild;
while (child != null) {
height += child.getMaxIntrinsicHeight(double.infinity);
child = childAfter(child);
}
return height;
}
}
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
return defaultComputeDistanceToHighestActualBaseline(baseline);
}
double _getMainAxisExtent(Size childSize) {
switch (direction) {
case Axis.horizontal:
return childSize.width;
case Axis.vertical:
return childSize.height;
}
}
double _getCrossAxisExtent(Size childSize) {
switch (direction) {
case Axis.horizontal:
return childSize.height;
case Axis.vertical:
return childSize.width;
}
}
Offset _getOffset(double mainAxisOffset, double crossAxisOffset) {
switch (direction) {
case Axis.horizontal:
return Offset(mainAxisOffset, crossAxisOffset);
case Axis.vertical:
return Offset(crossAxisOffset, mainAxisOffset);
}
}
double _getChildCrossAxisOffset(bool flipCrossAxis, double runCrossAxisExtent, double childCrossAxisExtent) {
final double freeSpace = runCrossAxisExtent - childCrossAxisExtent;
switch (crossAxisAlignment) {
case WrapCrossAlignment.start:
return flipCrossAxis ? freeSpace : 0.0;
case WrapCrossAlignment.end:
return flipCrossAxis ? 0.0 : freeSpace;
case WrapCrossAlignment.center:
return freeSpace / 2.0;
}
}
bool _hasVisualOverflow = false;
@override
Size computeDryLayout(BoxConstraints constraints) {
return _computeDryLayout(constraints);
}
Size _computeDryLayout(BoxConstraints constraints, [ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild]) {
final BoxConstraints childConstraints;
double mainAxisLimit = 0.0;
switch (direction) {
case Axis.horizontal:
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
mainAxisLimit = constraints.maxWidth;
break;
case Axis.vertical:
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
mainAxisLimit = constraints.maxHeight;
break;
}
double mainAxisExtent = 0.0;
double crossAxisExtent = 0.0;
double runMainAxisExtent = 0.0;
double runCrossAxisExtent = 0.0;
int childCount = 0;
RenderBox? child = firstChild;
int runMainIndex = 0;
while (child != null) {
final Size childSize = layoutChild(child, childConstraints);
final double childMainAxisExtent = _getMainAxisExtent(childSize);
final double childCrossAxisExtent = _getCrossAxisExtent(childSize);
// There must be at least one child before we move on to the next run.
if (childCount > 0 && runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) {
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
crossAxisExtent += runCrossAxisExtent + runSpacing;
runMainAxisExtent = 0.0;
runCrossAxisExtent = 0.0;
childCount = 0;
if (_maxLines > 0 && ++runMainIndex > _maxLines) break;
}
runMainAxisExtent += childMainAxisExtent;
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
if (childCount > 0) runMainAxisExtent += spacing;
childCount += 1;
child = childAfter(child);
}
crossAxisExtent += runCrossAxisExtent;
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
switch (direction) {
case Axis.horizontal:
return constraints.constrain(Size(mainAxisExtent, crossAxisExtent));
case Axis.vertical:
return constraints.constrain(Size(crossAxisExtent, mainAxisExtent));
}
}
/// 获取真实行数
int _getRowCount(BoxConstraints constraints) {
ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild;
final BoxConstraints childConstraints;
double mainAxisLimit = 0.0;
switch (direction) {
case Axis.horizontal:
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
mainAxisLimit = constraints.maxWidth;
break;
case Axis.vertical:
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
mainAxisLimit = constraints.maxHeight;
break;
}
double runMainAxisExtent = 0.0;
double runCrossAxisExtent = 0.0;
int childCount = 0;
RenderBox? child = firstChild;
int runMainCount = child != null ? 1 : 0;
while (child != null) {
final Size childSize = layoutChild(child, childConstraints);
final double childMainAxisExtent = _getMainAxisExtent(childSize);
final double childCrossAxisExtent = _getCrossAxisExtent(childSize);
// There must be at least one child before we move on to the next run.
if (childCount > 0 && runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) {
runMainAxisExtent = 0.0;
runCrossAxisExtent = 0.0;
childCount = 0;
runMainCount++;
}
runMainAxisExtent += childMainAxisExtent;
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
if (childCount > 0) runMainAxisExtent += spacing;
childCount += 1;
child = childAfter(child);
}
return runMainCount;
}
/// 总行数
int _totalRowCount = 0;
int get totalRowCount => _totalRowCount;
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
assert(_debugHasNecessaryDirections);
_totalRowCount = _getRowCount(constraints); // 计算总行数
_hasVisualOverflow = false;
RenderBox? child = firstChild;
if (child == null) {
size = constraints.smallest;
return;
}
final BoxConstraints childConstraints;
double mainAxisLimit = 0.0;
bool flipMainAxis = false;
bool flipCrossAxis = false;
switch (direction) {
case Axis.horizontal:
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
mainAxisLimit = constraints.maxWidth;
if (textDirection == TextDirection.rtl) flipMainAxis = true;
if (verticalDirection == VerticalDirection.up) flipCrossAxis = true;
break;
case Axis.vertical:
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
mainAxisLimit = constraints.maxHeight;
if (verticalDirection == VerticalDirection.up) flipMainAxis = true;
if (textDirection == TextDirection.rtl) flipCrossAxis = true;
break;
}
final double spacing = this.spacing;
final double runSpacing = this.runSpacing;
final List<_RunMetrics> runMetrics = <_RunMetrics>[];
double mainAxisExtent = 0.0;
double crossAxisExtent = 0.0;
double runMainAxisExtent = 0.0;
double runCrossAxisExtent = 0.0;
int childCount = 0;
int runMainIndex = 1;
while (child != null) {
final childParentData = child.parentData! as ShrinkWrapParentData;
if (_maxLines > 0 && runMainIndex > _maxLines) {
child.layout(BoxConstraints.loose(Size.zero), parentUsesSize: true);
child = childParentData.nextSibling;
continue;
} else {
child.layout(childConstraints, parentUsesSize: true);
}
final double childMainAxisExtent = _getMainAxisExtent(child.size);
final double childCrossAxisExtent = _getCrossAxisExtent(child.size);
if (childCount > 0 && runMainAxisExtent + spacing + childMainAxisExtent > mainAxisLimit) {
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
crossAxisExtent += runCrossAxisExtent;
if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
runMetrics.add(_RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
runMainAxisExtent = 0.0;
runCrossAxisExtent = 0.0;
childCount = 0;
if (_maxLines > 0 && ++runMainIndex > _maxLines) {
child.layout(BoxConstraints.loose(Size.zero), parentUsesSize: true);
child = childParentData.nextSibling;
continue;
}
}
runMainAxisExtent += childMainAxisExtent;
if (childCount > 0) runMainAxisExtent += spacing;
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
childCount += 1;
childParentData._runIndex = runMetrics.length;
child = childParentData.nextSibling;
}
if (childCount > 0) {
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
crossAxisExtent += runCrossAxisExtent;
if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
runMetrics.add(_RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
}
final int runCount = runMetrics.length;
assert(runCount > 0);
double containerMainAxisExtent = 0.0;
double containerCrossAxisExtent = 0.0;
switch (direction) {
case Axis.horizontal:
size = constraints.constrain(Size(mainAxisExtent, crossAxisExtent));
containerMainAxisExtent = size.width;
containerCrossAxisExtent = size.height;
break;
case Axis.vertical:
size = constraints.constrain(Size(crossAxisExtent, mainAxisExtent));
containerMainAxisExtent = size.height;
containerCrossAxisExtent = size.width;
break;
}
_hasVisualOverflow = containerMainAxisExtent < mainAxisExtent || containerCrossAxisExtent < crossAxisExtent;
final double crossAxisFreeSpace = math.max(0.0, containerCrossAxisExtent - crossAxisExtent);
double runLeadingSpace = 0.0;
double runBetweenSpace = 0.0;
switch (runAlignment) {
case WrapAlignment.start:
break;
case WrapAlignment.end:
runLeadingSpace = crossAxisFreeSpace;
break;
case WrapAlignment.center:
runLeadingSpace = crossAxisFreeSpace / 2.0;
break;
case WrapAlignment.spaceBetween:
runBetweenSpace = runCount > 1 ? crossAxisFreeSpace / (runCount - 1) : 0.0;
break;
case WrapAlignment.spaceAround:
runBetweenSpace = crossAxisFreeSpace / runCount;
runLeadingSpace = runBetweenSpace / 2.0;
break;
case WrapAlignment.spaceEvenly:
runBetweenSpace = crossAxisFreeSpace / (runCount + 1);
runLeadingSpace = runBetweenSpace;
break;
}
runBetweenSpace += runSpacing;
double crossAxisOffset = flipCrossAxis ? containerCrossAxisExtent - runLeadingSpace : runLeadingSpace;
child = firstChild;
for (int i = 0; i < runCount; ++i) {
final _RunMetrics metrics = runMetrics[i];
final double runMainAxisExtent = metrics.mainAxisExtent;
final double runCrossAxisExtent = metrics.crossAxisExtent;
final int childCount = metrics.childCount;
final double mainAxisFreeSpace = math.max(0.0, containerMainAxisExtent - runMainAxisExtent);
double childLeadingSpace = 0.0;
double childBetweenSpace = 0.0;
switch (alignment) {
case WrapAlignment.start:
break;
case WrapAlignment.end:
childLeadingSpace = mainAxisFreeSpace;
break;
case WrapAlignment.center:
childLeadingSpace = mainAxisFreeSpace / 2.0;
break;
case WrapAlignment.spaceBetween:
childBetweenSpace = childCount > 1 ? mainAxisFreeSpace / (childCount - 1) : 0.0;
break;
case WrapAlignment.spaceAround:
childBetweenSpace = mainAxisFreeSpace / childCount;
childLeadingSpace = childBetweenSpace / 2.0;
break;
case WrapAlignment.spaceEvenly:
childBetweenSpace = mainAxisFreeSpace / (childCount + 1);
childLeadingSpace = childBetweenSpace;
break;
}
childBetweenSpace += spacing;
double childMainPosition = flipMainAxis ? containerMainAxisExtent - childLeadingSpace : childLeadingSpace;
if (flipCrossAxis) crossAxisOffset -= runCrossAxisExtent;
while (child != null) {
final ShrinkWrapParentData childParentData = child.parentData! as ShrinkWrapParentData;
if (childParentData._runIndex != i) break;
final double childMainAxisExtent = _getMainAxisExtent(child.size);
final double childCrossAxisExtent = _getCrossAxisExtent(child.size);
final double childCrossAxisOffset = _getChildCrossAxisOffset(flipCrossAxis, runCrossAxisExtent, childCrossAxisExtent);
if (flipMainAxis) childMainPosition -= childMainAxisExtent;
childParentData.offset = _getOffset(childMainPosition, crossAxisOffset + childCrossAxisOffset);
if (flipMainAxis) {
childMainPosition -= childBetweenSpace;
} else {
childMainPosition += childMainAxisExtent + childBetweenSpace;
}
child = childParentData.nextSibling;
}
if (flipCrossAxis) {
crossAxisOffset -= runBetweenSpace;
} else {
crossAxisOffset += runCrossAxisExtent + runBetweenSpace;
}
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
// TODO(ianh): move the debug flex overflow paint logic somewhere common so
// it can be reused here
if (_hasVisualOverflow && clipBehavior != Clip.none) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
defaultPaint,
clipBehavior: clipBehavior,
oldLayer: _clipRectLayer.layer,
);
} else {
_clipRectLayer.layer = null;
defaultPaint(context, offset);
}
}
final LayerHandle<ClipRectLayer> _clipRectLayer = LayerHandle<ClipRectLayer>();
@override
void dispose() {
_clipRectLayer.layer = null;
super.dispose();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<Axis>('direction', direction));
properties.add(EnumProperty<WrapAlignment>('alignment', alignment));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<WrapAlignment>('runAlignment', runAlignment));
properties.add(DoubleProperty('runSpacing', runSpacing));
properties.add(DoubleProperty('crossAxisAlignment', runSpacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
properties.add(EnumProperty<VerticalDirection>('verticalDirection', verticalDirection, defaultValue: VerticalDirection.down));
properties.add(IntProperty('maxLines', maxLines, defaultValue: 0));
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
/// 抽屉/列表项逐个入场动画:淡入 + 轻微上滑,按 index 错峰启动。
/// 仅前 [maxAnimatedIndex] 个 item 播放动画,更靠后的直接显示,
/// 避免长章节列表滚动时反复触发动画。
class StaggerInItem extends StatefulWidget {
final int index;
final Widget child;
final int maxAnimatedIndex;
const StaggerInItem({
super.key,
required this.index,
required this.child,
this.maxAnimatedIndex = 14,
});
@override
State<StaggerInItem> createState() => _StaggerInItemState();
}
class _StaggerInItemState extends State<StaggerInItem> with SingleTickerProviderStateMixin {
AnimationController? _ctr;
bool get _animate => widget.index <= widget.maxAnimatedIndex;
@override
void initState() {
super.initState();
if (!_animate) return;
_ctr = AnimationController(vsync: this, duration: const Duration(milliseconds: 260));
// 按 index 错峰,最多延迟 280ms
final delay = (widget.index * 35).clamp(0, 280);
Future.delayed(Duration(milliseconds: delay), () {
if (mounted) _ctr?.forward();
});
}
@override
void dispose() {
_ctr?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ctr = _ctr;
if (ctr == null) return widget.child; // 靠后的 item 不做动画
return FadeTransition(
opacity: ctr,
child: SlideTransition(
position: Tween(begin: const Offset(0, .12), end: Offset.zero)
.animate(CurvedAnimation(parent: ctr, curve: Curves.easeOut)),
child: widget.child,
),
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
/// 左滑露出操作按钮的列表项包装(如「我的关注」左滑取消关注)。
///
/// 不用 Dismissible:那个滑到底即执行,取关这种不可撤销的操作误触代价太大,
/// 这里做成「左滑露出按钮、点按钮才执行」。
class SwipeActionItem extends StatefulWidget {
final Widget child;
final String actionText;
final VoidCallback onAction;
final Color actionColor;
final double actionWidth;
/// 滑动层的底色。列表项自身多半是透明的,不垫一层实色,底下的按钮会直接透上来
final Color? backgroundColor;
const SwipeActionItem({
super.key,
required this.child,
required this.actionText,
required this.onAction,
this.actionColor = const Color(0xffE03017),
this.actionWidth = 88,
this.backgroundColor,
});
/// 关掉当前展开的那一项(列表滚动时调,避免滑走了还留着一个张开的)
static void closeOpened() => _opened?._close();
@override
State<SwipeActionItem> createState() => _SwipeActionItemState();
}
/// 全局只允许一项展开:展开新的会自动收起旧的
_SwipeActionItemState? _opened;
class _SwipeActionItemState extends State<SwipeActionItem> with SingleTickerProviderStateMixin {
late final AnimationController _ctr = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
bool get _isOpen => _ctr.value > 0;
@override
void dispose() {
if (_opened == this) _opened = null;
_ctr.dispose();
super.dispose();
}
void _open() {
if (_opened != null && _opened != this) _opened!._close();
_opened = this;
_ctr.forward();
}
void _close() {
if (_opened == this) _opened = null;
if (mounted) _ctr.reverse();
}
void _onDragUpdate(DragUpdateDetails d) {
// 手指往左走 primaryDelta 为负,换算成 0~1 的展开进度
_ctr.value -= (d.primaryDelta ?? 0) / widget.actionWidth;
}
void _onDragEnd(DragEndDetails d) {
final v = d.primaryVelocity ?? 0;
if (v < -300) return _open(); // 甩一下就展开,不看位置
if (v > 300) return _close();
_ctr.value > 0.5 ? _open() : _close();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: AnimatedBuilder(
animation: _ctr,
builder: (_, child) {
return Stack(
children: [
// 按钮垫在底下,靠内容左移露出来;跟着内容一起裁,避免收起时露边
Positioned.fill(child: _actionButton()),
Transform.translate(
offset: Offset(-widget.actionWidth * _ctr.value, 0),
child: ColoredBox(
color: widget.backgroundColor ?? Get.theme.scaffoldBackgroundColor,
// 展开状态下先吞掉一次点击用于收起,别直接把用户点进详情页
child: _isOpen
? GestureDetector(
onTap: _close,
child: AbsorbPointer(child: child),
)
: child,
),
),
],
);
},
child: widget.child,
),
);
}
Widget _actionButton() {
return Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () {
_close();
widget.onAction();
},
child: Container(
width: widget.actionWidth,
alignment: Alignment.center,
color: widget.actionColor,
child: Text(
widget.actionText,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
),
);
}
}