初始化
This commit is contained in:
@@ -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.isBarStyle(true=长条红色风格)
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+151
@@ -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
@@ -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
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/BaseRespBean(null/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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+56
@@ -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 = '数据返回异常';
|
||||
}
|
||||
@@ -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 已写入),
|
||||
// 兜底再读 lightKV:iOS 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 换成本地 key,METHOD/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.m3u8:ts 指向本地 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 和 key:ts 写 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 再 register:registerPortWithName 遇到同名已注册会直接返回 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;
|
||||
+356
@@ -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;
|
||||
}
|
||||
}
|
||||
+611
@@ -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;
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;进过一次就一直留在树里,
|
||||
/// 切走只是不 paint,state / 滚动位置 / 播放器全部保留。
|
||||
/// 相比 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 用廉价 bilinear,iOS 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. ListView,the 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user