初始化
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
|
||||
import '../hj_utils/screen.dart';
|
||||
import '../tools_base/widget/net_image_widget.dart';
|
||||
|
||||
/// 暗网板块未开通会员时的全屏毛玻璃遮罩:配了运营图走整图样式,否则走默认文案样式
|
||||
class AwPermissionAlert extends StatelessWidget {
|
||||
const AwPermissionAlert({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: Colors.black.withValues(alpha: .4),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Config.darkWebImg?.isNotEmpty == true
|
||||
? _imageView()
|
||||
: _normalView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
//运营配置的整图样式,点哪都去开通
|
||||
Widget _imageView() {
|
||||
return GestureDetector(
|
||||
onTap: () => pushToWalletPage(vipId: Config.darkWebVipId),
|
||||
//用 DecoratedBox 不用 ColoredBox:后者 hitTest 是 opaque,会让图片没加载出来时整屏都可点,和原行为不一致
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: .7)),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: Config.darkWebImg,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
//默认文案样式
|
||||
Widget _normalView() {
|
||||
final vipName = Config.darkWebVipName ?? '';
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => pushToWalletPage(tabPosition: 0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset('aw_warning.webp'.homePath, height: 96),
|
||||
28.sizeBoxH,
|
||||
const Text(
|
||||
'系统警告\n'
|
||||
'【暗网】含有百万全球禁播封杀资源,全球\n'
|
||||
'上流社会权贵的私密丑闻!\n\n'
|
||||
'萝莉岛事件,N号房,暴力重口,巴以/俄\n'
|
||||
'乌/中东战争,血腥缅北,呦呦破处,变态\n'
|
||||
'恐怖,暗网交易,等稀缺资源!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
//会员卡名标红
|
||||
EasyRichText(
|
||||
'马上开通\n$vipName\n进入世界的黑暗面!',
|
||||
textAlign: TextAlign.center,
|
||||
defaultStyle: const TextStyle(
|
||||
color: Colors.white, fontSize: 14, height: 1.5),
|
||||
patternList: [
|
||||
if (vipName.isNotEmpty)
|
||||
EasyRichTextPattern(
|
||||
targetString: vipName,
|
||||
matchWordBoundaries: false, // 纯中文无 \b 词边界,关掉才能匹配上
|
||||
hasSpecialCharacters:
|
||||
true, // 卡名是服务端下发的,带 ()+ 等正则符会构造出非法正则崩掉
|
||||
style: const TextStyle(color: Color(0xffC40F0B)),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
GestureDetector(
|
||||
onTap: () => pushToWalletPage(vipId: Config.darkWebVipId),
|
||||
child: Image.asset('aw_button.webp'.homePath, height: 62),
|
||||
),
|
||||
30.sizeBoxH,
|
||||
Text(
|
||||
'此板块为黑客破解内容,真实稀缺资源,\n'
|
||||
'可能会引起极度不适,请谨慎进入!\n',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .6), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_dialog.dart';
|
||||
|
||||
import '../../routers/jump_router.dart';
|
||||
|
||||
/// 显示vipdialog isJump 是否在dialog 内部处理跳转事件
|
||||
Future<int?> showVipLevelDialog(
|
||||
String content, {
|
||||
String buttonTitle = '立即开通',
|
||||
String? desc,
|
||||
Function? vipEvent,
|
||||
}) {
|
||||
return Get.dialog(
|
||||
barrierColor: Colors.black.withValues(alpha: .4),
|
||||
barrierDismissible: true,
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
child: CommonDialog(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"溫馨提示",
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Color(0xE5FFFFFF),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
24.sizeBoxH,
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
content,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16, color: Color(0x73FFFFFF), height: 1.5),
|
||||
),
|
||||
),
|
||||
if (desc != null && desc.isNotEmpty)
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
desc,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: Color(0x73FFFFFF)),
|
||||
),
|
||||
),
|
||||
24.sizeBoxH,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
if (vipEvent != null) {
|
||||
vipEvent.call();
|
||||
} else {
|
||||
pushToWalletPage(tabPosition: 0);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: Color(0xffF68804),
|
||||
),
|
||||
child: Text(
|
||||
buttonTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
|
||||
import '../../hj_model/splash/ads_model.dart';
|
||||
import '../../hj_utils/screen.dart';
|
||||
import '../../tools_base/banner/ads_banner_widget.dart';
|
||||
import '../../tools_base/banner/ads_item.dart';
|
||||
|
||||
class AppCenterDialog extends StatefulWidget {
|
||||
final List<AdsInfoModel>? appList;
|
||||
final List<AdsInfoModel>? adsList;
|
||||
final VoidCallback? callback;
|
||||
|
||||
const AppCenterDialog({super.key, this.appList, this.callback, this.adsList});
|
||||
|
||||
@override
|
||||
State<AppCenterDialog> createState() => _AppCenterDialogState();
|
||||
}
|
||||
|
||||
class _AppCenterDialogState extends State<AppCenterDialog> {
|
||||
bool _showClose = false;
|
||||
|
||||
List<AdsInfoModel> get _appList => widget.appList ?? [];
|
||||
|
||||
/// 网格高度上限 = 4 行 + 底部留白,超出滚动。数字跟下面 gridDelegate 对应,改一处要同步。
|
||||
/// 别用「首帧 shrinkWrap 量高度、次帧再改成固定高」那套——两帧布局不同,弹窗又居中,打开瞬间会跳
|
||||
double get _maxGridHeight {
|
||||
final itemWidth = (Get.width - 32 * 2 - 12 * 3) / 4;
|
||||
return itemWidth * 93 / 68 * 4 + 16 * 3 + 25;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 1 秒后再显示关闭按钮,防误触
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
if (mounted) setState(() => _showClose = true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.adsList?.isNotEmpty == true)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
child: AdsBannerWidget(
|
||||
widget.adsList,
|
||||
width: 329,
|
||||
height: 88,
|
||||
autoPlayMs: 5000,
|
||||
),
|
||||
),
|
||||
//Flexible 兜住矮屏:内容再多也不会顶破屏幕
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: _maxGridHeight),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 25),
|
||||
shrinkWrap: true, //不足 4 行时按内容收,不留空
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: _appList.length,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 68 / 93,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
return AdsItem(
|
||||
adInfo: _appList[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
32.sizeBoxH,
|
||||
//一直挂在树上只切显隐:等 _showClose 才 build 的话,asset 解码前图高是 0,
|
||||
//会先塌一下再弹回来,居中布局跟着抖
|
||||
Opacity(
|
||||
opacity: _showClose ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showClose,
|
||||
child: GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Image.asset("close_button.png".commonImgPath,
|
||||
width: 32, height: 32),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_dialog.dart';
|
||||
|
||||
class LineErrorDialog extends StatelessWidget {
|
||||
static Future show({Function? callback}) {
|
||||
return Get.dialog(
|
||||
LineErrorDialog(callback: callback),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
final Function? callback;
|
||||
|
||||
const LineErrorDialog({super.key, this.callback});
|
||||
|
||||
String get url => '91porn01.cc';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonDialog(
|
||||
//线路异常必须让用户处理,Get.dialog 的 barrierDismissible 到不了这层
|
||||
barrierDismissible: false,
|
||||
canTapClose: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'系统提示',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
0.5.line,
|
||||
12.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"APP线路检测失败",
|
||||
style: TextStyle(
|
||||
color: Color(0xffF52C56),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: const Text(
|
||||
"解决方案:",
|
||||
style: TextStyle(
|
||||
color: Color(0xff656565),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
EasyRichText(
|
||||
'''1.请检测您的网络是否正常,手机是否开了VPN或10分钟后再尝试访问。
|
||||
2.前往官网地址 $url下载最新版。
|
||||
3.前往Telegram${Config.appName}官方交流群
|
||||
''',
|
||||
defaultStyle:
|
||||
TextStyle(fontSize: 12, color: Color(0xff656565), height: 1.8),
|
||||
patternList: [
|
||||
EasyRichTextPattern(
|
||||
targetString: url,
|
||||
style: TextStyle(color: AppColors.primaryHighColor),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
launchUrlToWeb('https://$url');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 38,
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
Get.back();
|
||||
callback?.call();
|
||||
},
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
),
|
||||
child: Text(
|
||||
"切换路线",
|
||||
style: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlToWeb('https://t.me/hougong9527');
|
||||
},
|
||||
child: Container(
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: AppColors.actionRed,
|
||||
),
|
||||
child: const Text(
|
||||
"前往群聊",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// ignore_for_file: prefer_const_constructors, use_key_in_widget_constructors, use_full_hex_values_for_flutter_colors, sized_box_for_whitespace, unnecessary_import
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/splash/ads_model.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
|
||||
import '../../tools_base/banner/ads_item.dart';
|
||||
|
||||
/// 系统公告--图片公告
|
||||
class NoticeImageDialog extends StatelessWidget {
|
||||
final AdsInfoModel? model;
|
||||
|
||||
NoticeImageDialog({this.model});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AdsItem(
|
||||
adInfo: model!,
|
||||
showType: AdShowType.img,
|
||||
aspectRatio: 6 / 8,
|
||||
),
|
||||
20.sizeBoxH,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: false),
|
||||
child: Image.asset("close_button.png".commonImgPath, width: 32),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统公告--上下双排图片公告
|
||||
class SystemImagesDialog extends StatelessWidget {
|
||||
final List<AdsInfoModel>? ads;
|
||||
|
||||
SystemImagesDialog({this.ads});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 300 / 169,
|
||||
child: AdsItem(
|
||||
adInfo: ads![index],
|
||||
showType: AdShowType.img,
|
||||
aspectRatio: 6 / 8,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => 6.sizeBoxH,
|
||||
itemCount: ads?.length ?? 0,
|
||||
),
|
||||
20.sizeBoxH,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: false),
|
||||
child: Image.asset("close_button.png".commonImgPath, width: 32),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// ignore_for_file: prefer_const_constructors, use_key_in_widget_constructors
|
||||
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
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';
|
||||
|
||||
import '../../hj_model/splash/ads_model.dart';
|
||||
|
||||
/// 系统公告--文字公告
|
||||
class NoticeTextDialog extends StatelessWidget {
|
||||
const NoticeTextDialog({
|
||||
this.content,
|
||||
this.addr,
|
||||
this.wordBean,
|
||||
});
|
||||
|
||||
final AnnounceInfoBean? wordBean;
|
||||
final String? content;
|
||||
final String? addr;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonDialog(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
wordBean?.title ?? "官方公告",
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
0.5.line,
|
||||
12.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: SingleChildScrollView(
|
||||
child: EasyRichText(
|
||||
content ?? "",
|
||||
defaultStyle: TextStyle(
|
||||
color: Color(0xff656565), fontSize: 12, height: 25 / 12),
|
||||
patternList: [
|
||||
EasyRichTextPattern(
|
||||
targetString:
|
||||
'(http(s)?:\\/\\/.)?(www\\.)?[-a-zA-Z0-9:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)|https:\/\/t\\.me\/[0-9A-Za-z]{2,256}',
|
||||
urlType: 'web',
|
||||
style: TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: AppColors.actionRed),
|
||||
child: Text(
|
||||
"我知道了",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// ignore_for_file: depend_on_referenced_packages
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_utils/install_util.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_dialog.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../hj_model/splash/domain_source_model.dart';
|
||||
import '../../hj_page/mine/identity/mine_identity_page.dart';
|
||||
import '../../routers/jump_router.dart';
|
||||
import '../../tools_base/net/load_apk/dio_cli.dart';
|
||||
import '../../tools_base/net/load_apk/dio_slice_downloader.dart';
|
||||
import '../../track_event_manager/device_service.dart';
|
||||
|
||||
//升级版本对话框
|
||||
class UpdateDialog extends StatefulWidget {
|
||||
static Future show(CheckVersionInfo phoneBean) async {
|
||||
return await Get.dialog(
|
||||
UpdateDialog(updateInfo: phoneBean),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
final CheckVersionInfo updateInfo;
|
||||
|
||||
const UpdateDialog({super.key, required this.updateInfo});
|
||||
|
||||
@override
|
||||
State<UpdateDialog> createState() => _UpdateDialog();
|
||||
}
|
||||
|
||||
class _UpdateDialog extends State<UpdateDialog> {
|
||||
bool isDownloading = false; // 下载中:既防重入,也决定进度条露不露
|
||||
var progress = .0;
|
||||
|
||||
String apkPath = "";
|
||||
|
||||
/// 点「立即升级」:iOS 跳商店,Android 下载 apk,下完再点就是安装
|
||||
void _onUpdate() async {
|
||||
//iOS 没有下载安装 apk 那一套,直接跳 App Store;进度条也就不用起(isDownloading 不置位)
|
||||
if (Platform.isIOS) {
|
||||
final iosUrl = widget.updateInfo.iosUrl ?? '';
|
||||
iosUrl.isEmpty ? showToast("暂无更新地址,请稍后再试") : await launchUrlToWeb(iosUrl);
|
||||
return;
|
||||
}
|
||||
if (progress == 1.0) {
|
||||
await InstallUtil.installApk(apkPath);
|
||||
return;
|
||||
}
|
||||
if (isDownloading) {
|
||||
return;
|
||||
}
|
||||
//开始下载
|
||||
isDownloading = true;
|
||||
String devId = DeviceInfoService.deviceId;
|
||||
Clipboard.setData(ClipboardData(text: "***$devId^^^"));
|
||||
_downloadApk(widget.updateInfo.url ?? "");
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonDialog(
|
||||
//强更场景要挡住点空白关闭,Get.dialog 的 barrierDismissible 到不了这层
|
||||
barrierDismissible: false,
|
||||
canTapClose: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'系统升级',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
0.5.line,
|
||||
12.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"更新版本 v ${widget.updateInfo.verName}",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
12.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 175,
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Row(
|
||||
children: [
|
||||
//Row 不给约束,Text 会按自然宽度排版撑出去,必须 Expanded 收回可用宽度才会换行
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.updateInfo.description
|
||||
?.replaceAll("|", "\n") ??
|
||||
"",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .55),
|
||||
fontSize: 12,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(() => const MineAccountIdentityPage()),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"保存账号凭证",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .55),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
3.sizeBoxW,
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
size: 14,
|
||||
color: Color(0xffDCDCDC),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
if (isDownloading) ...[
|
||||
Container(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: const Color(0xffBEBEBE),
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.actionRed),
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Text(
|
||||
"${((progress * 100).toInt())}%",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: .55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else
|
||||
_buildButtonMenu(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButtonMenu() {
|
||||
if (widget.updateInfo.forcedUpdate == true) {
|
||||
return _buildUpdateBtn();
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
),
|
||||
child: Text(
|
||||
"暂不升级",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xff989898),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildUpdateBtn()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildUpdateBtn() {
|
||||
return GestureDetector(
|
||||
onTap: _onUpdate,
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: const Text(
|
||||
"立即升级",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
///下载 apk(仅 Android,iOS 走商店)
|
||||
Future<void> _downloadApk(String url) async {
|
||||
final saveDirectory =
|
||||
path.join((await getExternalStorageDirectory())?.path ?? "", "apk");
|
||||
DioSliceRetryDownloader(DioCli(), url, null, saveDirectory, "paofu.apk",
|
||||
onReceiveProgress: (count, total) {
|
||||
progress = (count / total);
|
||||
setState(() {});
|
||||
}, onRetry: (int retryCount) {
|
||||
showToast("下载发生错误正在自动重试中...($retryCount)");
|
||||
}).download().then((String savePath) async {
|
||||
apkPath = savePath;
|
||||
await InstallUtil.installApk(apkPath);
|
||||
}).catchError((e) {
|
||||
showToast("下载发生错误,点击重新进行下载.");
|
||||
setState(() => isDownloading = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:carousel_slider/carousel_slider.dart';
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../hj_model/splash/domain_source_model.dart';
|
||||
import '../../hj_page/main_page/provider/msg_provider.dart';
|
||||
import '../../hj_page/mine/mine_vip/mine_charge_vip_logic.dart';
|
||||
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_card_item.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_pay_button.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_product_manager.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_ui_kit.dart';
|
||||
import '../../routers/jump_router.dart';
|
||||
import '../../tools_base/loading/loading_center_widget.dart';
|
||||
import 'buy_vip_alert_logic.dart';
|
||||
|
||||
///购买视频对话框
|
||||
class BuyVipAlert extends StatefulWidget {
|
||||
final String? vipId; //外部指定卡ID(可选),不传走分层卡/升级卡兜底
|
||||
final String? videoId; //在看的那条内容 id,下单时透传给服务端做归因
|
||||
final PayOrderTrackInfo?
|
||||
orderTrack; //整份下单上下文,短剧付费墙要带 mediaId/contentId/checkoutContextId
|
||||
const BuyVipAlert({super.key, this.vipId, this.videoId, this.orderTrack});
|
||||
|
||||
static Future show(
|
||||
{String? vipId, String? videoId, PayOrderTrackInfo? orderTrack}) {
|
||||
return Get.bottomSheet(
|
||||
BuyVipAlert(vipId: vipId, videoId: videoId, orderTrack: orderTrack),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<BuyVipAlert> createState() => _BuyVipAlertState();
|
||||
}
|
||||
|
||||
// 可叠弹(试看结束/超时长等多处触发),用 per-实例唯一 tag 隔离 controller
|
||||
class _BuyVipAlertState extends State<BuyVipAlert> with UniqueTagMixin {
|
||||
late final logic = BuyVipAlertLogic(
|
||||
vId: widget.vipId,
|
||||
videoId: widget.videoId,
|
||||
orderTrack: widget.orderTrack);
|
||||
|
||||
static const _sheetRadius = 26.0;
|
||||
|
||||
/// 顶部可见边:上→下 #66EBD097 → #15EBD097 → #00EBD097(顶边最实,圆角向下渐隐)
|
||||
static const _borderGradient = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0x66EBD097),
|
||||
Color(0x15EBD097),
|
||||
Color(0x00EBD097),
|
||||
],
|
||||
);
|
||||
|
||||
/// 只订阅「选中卡变化」的局部刷新:标题/价格/卡片选中态/权益/支付按钮用它,
|
||||
/// 滑卡时不再整个弹窗重建(与会员中心页共用 [MineChargeVipLogic.kSelection])
|
||||
Widget _selectionBuilder(Widget Function(BuyVipAlertLogic logic) builder) {
|
||||
return GetBuilder<BuyVipAlertLogic>(
|
||||
tag: uniqueTag,
|
||||
id: MineChargeVipLogic.kSelection,
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<BuyVipAlertLogic>(
|
||||
tag: uniqueTag,
|
||||
init: logic,
|
||||
builder: (_) => Material(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox(
|
||||
height: screen.screenHeight * 0.8,
|
||||
width: screen.screenWidth,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(_sheetRadius),
|
||||
topRight: Radius.circular(_sheetRadius),
|
||||
),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xff2B2010),
|
||||
Color(0xff1A140B),
|
||||
Color(0xff100C07),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
16.sizeBoxH,
|
||||
const SheetHandleBar(color: Color(0x22FFFFFF)),
|
||||
16.sizeBoxH,
|
||||
// 标题 + 分层倒计时(对齐设计稿:新人特惠/卡名 + 红块 HH:MM:SS)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_selectionBuilder(
|
||||
(_) => Text(
|
||||
_title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xE5FFFFFF),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
_buildCountdown(),
|
||||
],
|
||||
),
|
||||
14.sizeBoxH,
|
||||
_selectionBuilder(
|
||||
(_) => _buildPriceInfo()), //原价 / 限时特价 / 优惠
|
||||
16.sizeBoxH,
|
||||
Expanded(
|
||||
child: _buildContent(),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 只描顶部 + 左右上圆角;竖向渐变让顶边最亮、沿圆角向下淡出
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: _sheetRadius + 2,
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _BuyVipTopBorderPainter(
|
||||
gradient: _borderGradient,
|
||||
strokeWidth: 1,
|
||||
topRadius: _sheetRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return Consumer<VipProductManager>(
|
||||
builder: (_, mgr, __) {
|
||||
// 与会员中心一致:仅 ACTIVE+B 用改版;A / DISABLED 用 classic
|
||||
final classic = !mgr.isNewVipUi;
|
||||
if (mgr.isLoading) return LoadingCenterWidget();
|
||||
if (mgr.vipCards.isEmpty) return CErrorWidget();
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// 会员卡实体
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: CarouselSlider.builder(
|
||||
carouselController: logic.pageCtr,
|
||||
itemCount: mgr.vipCards.length + 2,
|
||||
itemBuilder: (_, index, __) => _selectionBuilder(
|
||||
(l) => l.instanceChildItem(index)),
|
||||
options: CarouselOptions(
|
||||
height: logic.cardHeight,
|
||||
viewportFraction: logic.itemRatio,
|
||||
initialPage: 0,
|
||||
enableInfiniteScroll: false,
|
||||
reverse: false,
|
||||
autoPlay: false,
|
||||
enlargeCenterPage: false,
|
||||
padEnds: false,
|
||||
clipBehavior: Clip.none,
|
||||
onPageChanged: (index, __) =>
|
||||
logic.onPageChanged(index),
|
||||
scrollDirection: Axis.horizontal,
|
||||
),
|
||||
),
|
||||
),
|
||||
// B:卡皮与核心权益标题间距略收;A 保持原 14
|
||||
(classic ? 14 : 4).sizeBoxH,
|
||||
_buildPrivilegeSections(classic: classic),
|
||||
100.sizeBoxH,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
bottom: screen.paddingBottom + 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_selectionBuilder((l) => VipPayButton(l, classic: classic)),
|
||||
12.sizeBoxH,
|
||||
EasyRichText(
|
||||
'支付问题反馈,点击联系 在线客服',
|
||||
patternList: [
|
||||
EasyRichTextPattern(
|
||||
targetString: '在线客服',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFD460), fontSize: 12),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => pushToCustomService(),
|
||||
)
|
||||
],
|
||||
defaultStyle:
|
||||
const TextStyle(color: Color(0xffDCDCDC), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹窗标题:新人分层固定"新人特惠",其他分层跟随当前选中卡名
|
||||
String get _title {
|
||||
if (MineMsgProvider().payTier?.status == PayTier.newUnpay) return '新人特惠';
|
||||
return logic.currentProductModel?.productName ?? '会员特惠';
|
||||
}
|
||||
|
||||
/// 价格行:原价(标题+价格删除线) / 限时特价 / 优惠
|
||||
Widget _buildPriceInfo() {
|
||||
final p = logic.currentProductModel;
|
||||
if (p == null) return const SizedBox();
|
||||
final original = p.originalPriceUI;
|
||||
final discounted = p.discountedPriceUI;
|
||||
const muted = TextStyle(
|
||||
color: Color(0x8cFFFFFF),
|
||||
fontSize: 14,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
decorationColor: Color(0x8cFFFFFF),
|
||||
);
|
||||
const normal = TextStyle(
|
||||
color: Color(0x8cFFFFFF), fontSize: 14, fontWeight: FontWeight.w500);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_priceItem("原价", "¥$original", muted, titleStyle: muted),
|
||||
24.sizeBoxW,
|
||||
_priceItem("限时特价", "¥$discounted", normal),
|
||||
24.sizeBoxW,
|
||||
_priceItem(
|
||||
"优惠", "¥${(original - discounted).clamp(0, original)}", normal),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _priceItem(String title, String value, TextStyle valueStyle,
|
||||
{TextStyle? titleStyle}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title,
|
||||
style: titleStyle ??
|
||||
const TextStyle(color: Color(0x99FFFFFF), fontSize: 14)),
|
||||
4.sizeBoxW,
|
||||
Text(value, style: valueStyle),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 分层倒计时:按 config.lastDiscountTime,有则显示(监听 tick 每秒刷新),过期自动收起
|
||||
Widget _buildCountdown() {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: MineMsgProvider().tick,
|
||||
builder: (_, __, ___) {
|
||||
final c = MineMsgProvider().payTier?.config;
|
||||
if (c == null || !c.hasDiscountCountdown)
|
||||
return const SizedBox.shrink();
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
12.sizeBoxW,
|
||||
_timeItem(c.discountHour),
|
||||
_colon(),
|
||||
_timeItem(c.discountMin),
|
||||
_colon(),
|
||||
_timeItem(c.discountSec),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeItem(String value) {
|
||||
return Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffE1351F),
|
||||
borderRadius: BorderRadius.circular(3)),
|
||||
child: Text(value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colon() => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2),
|
||||
child:
|
||||
Text(":", style: TextStyle(color: Color(0xffDD001B), fontSize: 20)),
|
||||
);
|
||||
|
||||
/// 权益区:与会员中心同实验分支 —— classic=A/DISABLED,改版=ACTIVE+B
|
||||
Widget _buildPrivilegeSections({required bool classic}) {
|
||||
return _selectionBuilder((l) {
|
||||
final privileges = l.currentProductModel?.newPrivilege ?? [];
|
||||
final coreList = privileges.where((p) => p.isCore == true).toList();
|
||||
final moreList = privileges.where((p) => p.isCore != true).toList();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
classic
|
||||
? _buildClassicCorePrivileges(coreList)
|
||||
: _buildCorePrivileges(coreList),
|
||||
classic
|
||||
? _buildClassicMorePrivileges(moreList)
|
||||
: _buildMorePrivileges(moreList),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 核心权益:横向滚动卡片 —— B 改版
|
||||
Widget _buildCorePrivileges(List<NewPrivilege> list) {
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
const VipCoreSectionTitleImage(),
|
||||
16.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 92,
|
||||
child: ListView.separated(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 26),
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, __) => 8.sizeBoxW,
|
||||
itemBuilder: (_, i) => VipCorePrivilegeCard(list[i]),
|
||||
),
|
||||
),
|
||||
16.sizeBoxH,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 核心权益:一行金边方卡横向滑动 —— A / DISABLED
|
||||
Widget _buildClassicCorePrivileges(List<NewPrivilege> list) {
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
const VipSectionTitle('我的核心权益', classic: true),
|
||||
16.sizeBoxH,
|
||||
SizedBox(
|
||||
//设计稿一行放得下 4 张多一点,第 5 张露半张提示可以划
|
||||
height: 72,
|
||||
child: ListView.separated(
|
||||
clipBehavior: Clip.none, // 金边有外发光,裁了会缺一圈
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, __) => 8.sizeBoxW,
|
||||
itemBuilder: (_, i) => SizedBox(
|
||||
width: 72, child: VipCorePrivilegeCard(list[i], classic: true)),
|
||||
),
|
||||
),
|
||||
16.sizeBoxH,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 更多权益:深色圆角 + 四列网格 —— B 改版
|
||||
Widget _buildMorePrivileges(List<NewPrivilege> list) {
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x0DFFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const VipMoreSectionTitleImage(),
|
||||
12.sizeBoxH,
|
||||
GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 0,
|
||||
crossAxisSpacing: 14,
|
||||
childAspectRatio: 66 / 110,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) => VipProductPrivilegeItem(list[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 会员特权:直接四列网格 —— A / DISABLED
|
||||
Widget _buildClassicMorePrivileges(List<NewPrivilege> list) {
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
const VipSectionTitle('我的会员特权', classic: true),
|
||||
12.sizeBoxH,
|
||||
GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 18,
|
||||
childAspectRatio: 63 / 100,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) =>
|
||||
VipProductPrivilegeItem(list[i], classic: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅绘制底部弹窗顶部可见边界(上边 + 左右上圆角)
|
||||
class _BuyVipTopBorderPainter extends CustomPainter {
|
||||
final Gradient gradient;
|
||||
final double strokeWidth;
|
||||
final double topRadius;
|
||||
|
||||
_BuyVipTopBorderPainter({
|
||||
required this.gradient,
|
||||
required this.strokeWidth,
|
||||
required this.topRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final r = topRadius;
|
||||
final half = strokeWidth / 2;
|
||||
// Flutter arcTo:0=右、正角度顺时针。左上圆心(r,r) 由 π 扫到 3π/2,右上圆心(w-r,r) 由 3π/2 扫到 2π
|
||||
final path = Path()
|
||||
..moveTo(half, r)
|
||||
..arcTo(
|
||||
Rect.fromCircle(center: Offset(r, r), radius: r - half),
|
||||
math.pi,
|
||||
math.pi / 2,
|
||||
false,
|
||||
)
|
||||
..lineTo(w - r, half)
|
||||
..arcTo(
|
||||
Rect.fromCircle(center: Offset(w - r, r), radius: r - half),
|
||||
math.pi * 1.5,
|
||||
math.pi / 2,
|
||||
false,
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..shader = gradient.createShader(Rect.fromLTWH(0, 0, w, r + 2));
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _BuyVipTopBorderPainter oldDelegate) {
|
||||
return oldDelegate.gradient != gradient ||
|
||||
oldDelegate.strokeWidth != strokeWidth ||
|
||||
oldDelegate.topRadius != topRadius;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import '../../hj_page/mine/mine_vip/mine_charge_vip_logic.dart';
|
||||
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
|
||||
class BuyVipAlertLogic extends MineChargeVipLogic {
|
||||
//外部指定卡ID(可选);不传则走分层卡/升级卡兜底,对齐 mrhs
|
||||
//视频场景购买弹窗 → VIDEO_BOTTOM_SHEET
|
||||
BuyVipAlertLogic({String? vId, String? videoId, PayOrderTrackInfo? orderTrack})
|
||||
: super(vipID: vId, sourcePage: PaySourcePage.videoBottomSheet, videoId: videoId, orderTrack: orderTrack);
|
||||
|
||||
@override
|
||||
bool get preferFreshData => false; //弹窗优先用 manager 已加载的缓存,避免重复请求
|
||||
|
||||
@override
|
||||
bool get enableEntryGuidePopup => false; //购买弹窗本身就是付费引导,不再叠「进会员页」倒计时弹窗
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../assets_tool/app_colors.dart';
|
||||
import '../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
import '../../hj_utils/pay/pay_manager.dart';
|
||||
import '../../routers/jump_router.dart';
|
||||
import '../../tools_base/global_store/store.dart';
|
||||
|
||||
///购买漫画/小说弹窗
|
||||
class ComicBuyAlert extends StatefulWidget {
|
||||
//// 返回值: 1 购买单集, 2 购买全集,null 啥都没购买
|
||||
static Future<int?> show({
|
||||
ComicChapterInfo? curModel, // 单集数据
|
||||
CartoonMediaInfo? mediaInfo, // 全集数据
|
||||
}) async {
|
||||
return await Get.bottomSheet(ComicBuyAlert(
|
||||
mediaInfo: mediaInfo,
|
||||
curModel: curModel,
|
||||
));
|
||||
}
|
||||
|
||||
final ComicChapterInfo? curModel; // 单集数据
|
||||
final CartoonMediaInfo? mediaInfo; // 全集数据
|
||||
|
||||
const ComicBuyAlert({
|
||||
super.key,
|
||||
this.curModel,
|
||||
this.mediaInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ComicBuyAlert> createState() => _ComicBuyAlertState();
|
||||
}
|
||||
|
||||
class _ComicBuyAlertState extends State<ComicBuyAlert> {
|
||||
bool _isBuying = false;
|
||||
|
||||
//整套价格(产品当前只开整套购买)
|
||||
int get _price => widget.mediaInfo?.price ?? 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//刷完钱包会 notifyListeners,下面的 Consumer 自动重建,不用再 setState
|
||||
globalStore.refreshWallet();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Consumer<GlobalStore>(
|
||||
builder: (_, store, __) {
|
||||
final enough = (store.wallet?.amount ?? 0) >= _price;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SheetHandleBar(),
|
||||
18.sizeBoxH,
|
||||
const Text(
|
||||
'购买作品',
|
||||
style: TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
36.sizeBoxH,
|
||||
//作品解锁价
|
||||
Row(
|
||||
children: [
|
||||
const Text('作品解锁',
|
||||
style:
|
||||
TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => pushToWalletPage(tabPosition: 1),
|
||||
child: Text(
|
||||
'$_price 金币',
|
||||
style: const TextStyle(
|
||||
color: Color(0xffFF9000),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Divider(
|
||||
height: 0.5, color: Colors.white.withValues(alpha: .05)),
|
||||
18.sizeBoxH,
|
||||
//我的金币余额 + 充值入口
|
||||
Row(
|
||||
children: [
|
||||
const Text('我的金币',
|
||||
style:
|
||||
TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${store.wallet?.amount?.toDouble().fixed(2) ?? 0}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => pushToWalletPage(tabPosition: 1),
|
||||
child: const Text(
|
||||
'充值',
|
||||
style: TextStyle(
|
||||
color: Color(0xffA4634D),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Color(0xffA4634D),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
//支付按钮:余额不足直接去充值
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => enough
|
||||
? _buyProduct(true)
|
||||
: pushToWalletPage(tabPosition: 1),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 47,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffF68804),
|
||||
borderRadius: BorderRadius.circular(3)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
enough ? '$_price金币/立即支付' : '余额不足,前往充值',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
///购买作品:[isAll] true 整本(19),false 单集(25)
|
||||
Future<void> _buyProduct(bool isAll) async {
|
||||
if (_isBuying) return; //纯防重入,build 不依赖它,改了不用 setState
|
||||
_isBuying = true;
|
||||
final productType = isAll ? ProductType.mediaAll : ProductType.mediaChapter;
|
||||
final productID = isAll
|
||||
? (widget.mediaInfo?.id ?? widget.curModel?.mediaId)
|
||||
: widget.curModel?.id;
|
||||
await PayManager().buy(
|
||||
productID,
|
||||
productType,
|
||||
source: 'comic_buy',
|
||||
onSuccess: (data) {
|
||||
globalStore.refreshWallet();
|
||||
showToast("购买成功");
|
||||
Get.back(result: isAll ? 2 : 1);
|
||||
},
|
||||
);
|
||||
_isBuying = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_model/user/user_info_model.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_dialog.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../../config/address.dart';
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../../hj_utils/image_util.dart';
|
||||
import '../../tools_base/global_store/store.dart';
|
||||
|
||||
///分享二维码对话框
|
||||
class ShareMediaDialog extends StatefulWidget {
|
||||
final VideoModel? videoModel;
|
||||
final bool needDecrypt; //封面是否需要解密(直播封面不加密,传 false)
|
||||
|
||||
const ShareMediaDialog({
|
||||
super.key,
|
||||
this.videoModel,
|
||||
this.needDecrypt = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShareMediaDialog> createState() => _ShareMediaDialogState();
|
||||
}
|
||||
|
||||
class _ShareMediaDialogState extends State<ShareMediaDialog> {
|
||||
final _shotKey = GlobalKey(); //保存图片时截取的范围
|
||||
|
||||
UserInfoModel? get _me => globalStore.meInfo;
|
||||
|
||||
//推广链接:弹窗打开时取一次即可(二维码/复制都用它)
|
||||
late final String _shareUrl = _me?.promoteURL ?? "";
|
||||
|
||||
/// 复制链接
|
||||
void _onCopyLink() {
|
||||
if (_shareUrl.isEmpty) {
|
||||
showToast("链接生成错误");
|
||||
return;
|
||||
}
|
||||
Clipboard.setData(ClipboardData(text: _shareUrl));
|
||||
showToast("复制成功,快去分享吧");
|
||||
Get.back();
|
||||
}
|
||||
|
||||
/// 保存图片(必须等截图完成再关弹窗,否则 _shotKey 对应的 widget 已销毁)
|
||||
Future<void> _onSaveImg() async {
|
||||
final ok = await ImageUtil.saveWidgetToAlbum(_shotKey);
|
||||
showToast(ok ? "保存成功,快去分享吧" : "保存失败,请重试");
|
||||
if (mounted) Get.back(); // 保存期间弹窗可能已被关掉(连点/点了复制),别多 pop 一层
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonDialog(
|
||||
padding: EdgeInsets.zero,
|
||||
child: RepaintBoundary(
|
||||
key: _shotKey,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 23, vertical: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
//app 图标 + 名称 + 标语
|
||||
Row(
|
||||
children: [
|
||||
Image.asset('ic_launcher.webp'.commonImgPath,
|
||||
width: 48.w, height: 48.w),
|
||||
10.w.sizeBoxW,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
Config.appName,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14),
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Text("亚洲最火成人网站",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .55),
|
||||
fontSize: 10)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
//视频封面
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 27),
|
||||
child: NetworkImageLoader(
|
||||
width: double.infinity,
|
||||
imageUrl: widget.videoModel?.cover ?? "",
|
||||
fit: BoxFit.fitHeight,
|
||||
encrypt: widget.needDecrypt,
|
||||
),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
//二维码 + 邀请码 + 官网地址
|
||||
SizedBox(
|
||||
height: 94,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 94,
|
||||
height: 94,
|
||||
padding: const EdgeInsets.all(2),
|
||||
color: Colors.white,
|
||||
child: QrImageView(
|
||||
data: _shareUrl, padding: EdgeInsets.zero),
|
||||
),
|
||||
16.sizeBoxW,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"邀请码${_me?.promotionCode ?? ""}",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white.withValues(alpha: .9)),
|
||||
),
|
||||
20.sizeBoxH,
|
||||
Text(
|
||||
'官方网址:${Address.groundUrl ?? ""}',
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: .55)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
24.sizeBoxH,
|
||||
//底部两个按钮
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _button(
|
||||
"复制链接",
|
||||
bgColor: Colors.white.withValues(alpha: .1),
|
||||
textColor: const Color(0xff989898),
|
||||
onTap: _onCopyLink,
|
||||
),
|
||||
),
|
||||
16.sizeBoxW,
|
||||
Expanded(
|
||||
child: _button(
|
||||
"保存图片",
|
||||
bgColor: AppColors.actionRed,
|
||||
textColor: Colors.white,
|
||||
onTap: _onSaveImg,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _button(String text,
|
||||
{required Color bgColor,
|
||||
required Color textColor,
|
||||
required VoidCallback onTap}) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: bgColor,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 16, color: textColor, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart';
|
||||
import 'package:hgdj/hj_page/mine/widgets/gradient_text.dart';
|
||||
import 'package:hgdj/hj_utils/pay/pay_manager.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
///金币购买作品面板
|
||||
class VideoBuyBottomSheet extends StatefulWidget {
|
||||
final VideoModel video;
|
||||
|
||||
const VideoBuyBottomSheet({super.key, required this.video});
|
||||
|
||||
@override
|
||||
State<VideoBuyBottomSheet> createState() => _VideoBuyBottomSheetState();
|
||||
}
|
||||
|
||||
class _VideoBuyBottomSheetState extends State<VideoBuyBottomSheet> {
|
||||
VideoModel get _video => widget.video;
|
||||
|
||||
bool get _isCartoon => _video.videoType == 1;
|
||||
|
||||
bool get _isSeedLink => _video.newsType == 'SEED_LINK';
|
||||
|
||||
//图集和黄游金币解锁,没有vip折扣
|
||||
bool get _noDiscount => _video.newsType == "PIC" || _isSeedLink;
|
||||
|
||||
//实际售价:无折扣品类按原价,其余按折后价
|
||||
String get _priceText =>
|
||||
'${(_noDiscount ? _video.originCoins : _video.coins) ?? 0} 金币';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
globalStore.updateUserInfo();
|
||||
globalStore.refreshWallet();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<GlobalStore>(
|
||||
builder: (_, store, __) {
|
||||
final enough = (store.wallet?.amount ?? 0) >= (_video.realCoins ?? 0);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SheetHandleBar(),
|
||||
18.sizeBoxH,
|
||||
const Text(
|
||||
'购买作品',
|
||||
style: TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
//作品解锁价
|
||||
Row(
|
||||
children: [
|
||||
const Text('作品解锁',
|
||||
style: TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => CoinPayBottomSheet.show(),
|
||||
child: Text(
|
||||
_priceText,
|
||||
style: const TextStyle(
|
||||
color: Color(0xffFF9000),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
//图集和黄游金币解锁,没有vip折扣,不展示原价和折扣说明
|
||||
if (!_noDiscount) ..._discountRows(store),
|
||||
Divider(height: 0.5, color: Colors.white.withValues(alpha: .05)),
|
||||
18.sizeBoxH,
|
||||
//我的金币余额 + 充值入口
|
||||
Row(
|
||||
children: [
|
||||
const Text('我的金币',
|
||||
style: TextStyle(color: Color(0xE5ffffff), fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${store.wallet?.amount?.toDouble().fixed(2) ?? 0}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => CoinPayBottomSheet.show(),
|
||||
child: const Text(
|
||||
'充值',
|
||||
style: TextStyle(
|
||||
color: Color(0xffA4634D),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Color(0xffA4634D),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
//底部两个按钮:立即支付 / 开通会员
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _button(
|
||||
enough ? '$_priceText/立即支付' : '余额不足,前往充值',
|
||||
textColor: const Color(0xff7B0000),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xfff8f3ae), Color(0xffddac44)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
onTap: () => _onPay(enough),
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: _button(
|
||||
'开通会员免费看',
|
||||
textColor: Colors.white,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xfff68804),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
onTap: () => pushToWalletPage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
//原价 + 折扣说明(有 vip 折扣的品类才展示)
|
||||
List<Widget> _discountRows(GlobalStore store) {
|
||||
final isNormalUser = store.meInfo == null || store.meInfo?.vipLevel == 0;
|
||||
final isVip = store.isVIP;
|
||||
final String discountText;
|
||||
if ((_video.coins ?? 0) < (_video.originCoins ?? 0) && isVip) {
|
||||
discountText = '您当前享受${store.meInfo?.payVidDiscount}折优惠';
|
||||
} else if (isVip) {
|
||||
discountText = '您当前不享受免费观看';
|
||||
} else {
|
||||
discountText = '您当前不享受折扣优惠';
|
||||
}
|
||||
return [
|
||||
Row(
|
||||
children: [
|
||||
const Text('作品原价',
|
||||
style: TextStyle(color: Color(0xE5FFFFFF), fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_video.originCoins ?? 0} 金币',
|
||||
style: TextStyle(
|
||||
color: const Color(0x8CFFFFFF),
|
||||
fontSize: 14,
|
||||
decoration: isNormalUser ? null : TextDecoration.lineThrough,
|
||||
decorationColor: const Color(0x8CFFFFFF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
GradientText(
|
||||
discountText,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xffAC7E6E), Color(0xffA4634D)]),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => pushToWalletPage(),
|
||||
child: GradientText(
|
||||
isVip ? '升级会员免费看' : '购买VIP免费看',
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xffAC7E6E), Color(0xffA4634D)]),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Color(0xffA4634D),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
];
|
||||
}
|
||||
|
||||
Widget _button(String text,
|
||||
{required Color textColor,
|
||||
required BoxDecoration decoration,
|
||||
required VoidCallback onTap}) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: decoration,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPay(bool enough) async {
|
||||
if (!enough) {
|
||||
await CoinPayBottomSheet.show();
|
||||
return;
|
||||
}
|
||||
//黄游(SEED_LINK)和普通视频都按单片算,动漫按整部算
|
||||
final type =
|
||||
(!_isSeedLink && _isCartoon) ? ProductType.mediaAll : ProductType.media;
|
||||
await PayManager().buy(
|
||||
_video.id,
|
||||
type,
|
||||
source: 'video_buy',
|
||||
goldVideoCouponNum: 0,
|
||||
jumpWalletOnInsufficient: false,
|
||||
onSuccess: (data) {
|
||||
_video.vidStatus?.hasPaid = true;
|
||||
Get.back(result: true);
|
||||
},
|
||||
onFailure: (data) {
|
||||
if (data?.code == 8000) {
|
||||
CoinPayBottomSheet.show();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
|
||||
import '../../config/address.dart';
|
||||
import '../../hj_model/splash/domain_source_model.dart';
|
||||
import '../../tools_base/global_store/store.dart';
|
||||
import '../mine/vip_level_dialog.dart';
|
||||
|
||||
class VideoLineMenuAlert extends StatelessWidget {
|
||||
/// 返回 true 表示切换了线路,调用方据此重新起播
|
||||
static Future<bool> show() async {
|
||||
return await Get.bottomSheet<bool>(VideoLineMenuAlert()) ?? false;
|
||||
}
|
||||
|
||||
VideoLineMenuAlert({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: screen.screenWidth,
|
||||
padding: EdgeInsets.only(bottom: screen.paddingBottom),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xff0F0F0F),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
18.sizeBoxH,
|
||||
const SheetHandleBar(),
|
||||
4.sizeBoxH,
|
||||
...Address.cdnAddressLists.map(_lineItem),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _lineItem(Domain domain) {
|
||||
final isSelected = domain.url == Address.cdnAddress;
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
if (!globalStore.isVIP) {
|
||||
showVipLevelDialog("该线路仅限会员使用\n\n"
|
||||
"开通会员立享 极速观影体验");
|
||||
} else if (isSelected) {
|
||||
Get.back();
|
||||
} else {
|
||||
Address.cdnAddress = domain.url;
|
||||
Get.back(result: true); // 带回切换结果,由调用方重新起播
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
domain.desc ?? "",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isSelected
|
||||
? Color(0xfff68804)
|
||||
: Colors.white.withValues(alpha: 0.45),
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
height: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_dialog.dart';
|
||||
|
||||
/// 动漫/图集的 VIP 门槛提示弹窗:只有文案和一个开通按钮
|
||||
class VipACGDialog extends StatelessWidget {
|
||||
const VipACGDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonDialog(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"温馨提示",
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
24.sizeBoxH,
|
||||
Text(
|
||||
"本内容需要开通VIP会员\n开通会员 即可解锁查看完整版",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: .45),
|
||||
height: 2),
|
||||
),
|
||||
24.sizeBoxH,
|
||||
//开通按钮。margin 留在 InkWell 外,否则左右各 12 的空白会被算进点击热区
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => pushToWalletPage(),
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3)),
|
||||
child: const Text(
|
||||
"立即开通",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
|
||||
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
|
||||
import '../../hj_page/mine/widgets/gradient_text.dart';
|
||||
import 'guide_common.dart';
|
||||
import 'guide_config.dart';
|
||||
import 'guide_manager.dart';
|
||||
|
||||
/// 吸底型付费引导弹窗(通用壳):横条 + 标题 + [badge] + 主图 + 副文案 + 开通按钮,
|
||||
/// 首页/播放页各自传 title/subtitle/buttonText 组装
|
||||
class GuideBottomSheet extends StatelessWidget {
|
||||
final VipProductModel card;
|
||||
final Widget title; // 各场景自定义
|
||||
final Widget? badge; // 标题下方的小标签(免费次数场景用),不传不占位
|
||||
final Widget subtitle; // 主图下方文案
|
||||
final String buttonText;
|
||||
final PaySourcePage sourcePage;
|
||||
final String cover; // 场景下发的主图,相对路径由 ImageCacheManager 拼域名
|
||||
const GuideBottomSheet({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.title,
|
||||
this.badge,
|
||||
required this.subtitle,
|
||||
required this.buttonText,
|
||||
required this.sourcePage,
|
||||
required this.cover,
|
||||
});
|
||||
|
||||
/// [scene] 决定主图/卡/下单来源,调用方须先用开关判过能弹
|
||||
/// 返回 true = 点了开通并已跳会员页(调用方据此别恢复播放)
|
||||
static Future<bool> show({
|
||||
required GuideScene scene,
|
||||
required Widget title,
|
||||
required Widget subtitle,
|
||||
Widget? badge,
|
||||
String? buttonText,
|
||||
}) async {
|
||||
final card = await loadGuideCard(scene);
|
||||
if (card == null) return false; // 配置有误不弹
|
||||
final goVip = await Get.bottomSheet<bool>(
|
||||
GuideBottomSheet(
|
||||
card: card,
|
||||
title: title,
|
||||
badge: badge,
|
||||
subtitle: subtitle,
|
||||
buttonText: buttonText ?? '立即开通 · 仅需¥${card.discountedPriceUI}',
|
||||
sourcePage: guideSourcePage(scene),
|
||||
cover: GuideManager().configOf(scene)?.cover ?? '', //已校验非空
|
||||
),
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
return goVip ?? false;
|
||||
}
|
||||
|
||||
/// 播放页引导(长短视频共用文案):文案优先用下发的,没配走默认。
|
||||
/// [buttonText] 短视频传无价文案,长视频走默认带价文案
|
||||
static Future<bool> showVideo({String? buttonText}) {
|
||||
final config = GuideManager().configOf(GuideScene.videoPreviewEnd);
|
||||
return show(
|
||||
scene: GuideScene.videoPreviewEnd,
|
||||
title: Text(
|
||||
config?.title?.isNotEmpty == true
|
||||
? config!.title!
|
||||
: '该视频为VIP内容\n只能观看试看内容哦~',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: GradientText(
|
||||
config?.description?.isNotEmpty == true
|
||||
? config!.description!
|
||||
: '想要查看完整内容,需要开通会员',
|
||||
gradient:
|
||||
const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]),
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
),
|
||||
buttonText: buttonText,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.fromLTRB(16, 18, 16, 18 + screen.paddingBottom),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xff0F0F0F), Colors.black]),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Color(0x4DE4BB6D))), // rgba(228,187,109,0.3)
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SheetHandleBar(),
|
||||
18.sizeBoxH,
|
||||
title,
|
||||
if (badge != null) ...[18.sizeBoxH, badge!],
|
||||
18.sizeBoxH,
|
||||
// 主图(场景下发)
|
||||
SizedBox(
|
||||
width: 270,
|
||||
height: 122,
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: cover, fit: BoxFit.fill, borderRadius: 8),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
subtitle,
|
||||
18.sizeBoxH,
|
||||
// 开通按钮
|
||||
GestureDetector(
|
||||
onTap: () => goVipDetail(card, sourcePage: sourcePage),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xffFFB03F), Color(0xffF2680C)]),
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x73F2680C),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6))
|
||||
], // rgba(242,104,12,0.45)
|
||||
),
|
||||
child: Text(buttonText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
|
||||
import '../../hj_page/main_page/provider/msg_provider.dart';
|
||||
import '../../hj_page/mine/mine_vip/mine_charge_vip_page.dart';
|
||||
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_product_manager.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
|
||||
import '../../tools_base/debug_log.dart';
|
||||
import 'guide_config.dart';
|
||||
import 'guide_manager.dart';
|
||||
|
||||
/// 付费引导弹窗的共用逻辑:取卡 / 场景来源 / 跳会员页
|
||||
|
||||
/// 弹窗前置校验都收在这里,返回 null 即不弹:productId、cover 缺任一或卡不在列表里
|
||||
/// 都算配置有误,一律不弹、不做兜底回落。
|
||||
/// 顺带备好卡数据(有缓存走缓存),播放页可提前调一次,别暂停后才拉数据白停几百毫秒
|
||||
Future<VipProductModel?> loadGuideCard(GuideScene scene) async {
|
||||
final config = GuideManager().configOf(scene);
|
||||
if (config == null) return null; // 开关关着
|
||||
if (config.productId?.isNotEmpty != true ||
|
||||
config.cover?.isNotEmpty != true) {
|
||||
debugLog('paymentGuide', '${scene.value} 缺 productId 或 cover,配置有误不弹');
|
||||
return null;
|
||||
}
|
||||
if (vipProductManager.vipCards.isEmpty)
|
||||
await vipProductManager.loadVipCards();
|
||||
final card = vipProductManager.vipCards
|
||||
.firstWhereOrNull((e) => e.productID == config.productId);
|
||||
if (card == null)
|
||||
debugLog(
|
||||
'paymentGuide', '${scene.value} 配置的会员卡不在列表里,不弹: ${config.productId}');
|
||||
return card;
|
||||
}
|
||||
|
||||
/// 分层门槛:非 VIP + 未付费的 under7/over7 两档。只剩上新横幅在用,其余场景走开关
|
||||
bool isUnpayGuideUser() {
|
||||
if (globalStore.isVIP) return false;
|
||||
final status = MineMsgProvider().payTier?.status;
|
||||
return status == PayTier.under7DayUnpay || status == PayTier.over7DayUnpay;
|
||||
}
|
||||
|
||||
/// 场景 → 下单来源页(透传 /mine/topay 的 sourcePage)
|
||||
PaySourcePage guideSourcePage(GuideScene scene) => switch (scene) {
|
||||
GuideScene.homeNewUser ||
|
||||
GuideScene.homeOldUser ||
|
||||
GuideScene.homeNewUserFreeTrial =>
|
||||
PaySourcePage.homeUserSegment,
|
||||
GuideScene.vipCenter => PaySourcePage.vipCenter,
|
||||
_ => PaySourcePage.videoBottomSheet, //播放页起播引导 / 返回挽留
|
||||
};
|
||||
|
||||
/// 开通 → 关弹窗 + 进会员页并选中该卡(落地页 VIP_CENTER 开着还会再弹一次,不去重)
|
||||
void goVipDetail(VipProductModel card, {required PaySourcePage sourcePage}) {
|
||||
Get.back(result: true); // 带回"去开通了",调用方据此不恢复播放
|
||||
Get.to(
|
||||
() => MineChargeVipPage(vipID: card.productID, sourcePage: sourcePage));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// 付费引导场景(ping 下发 paymentGuide.scenes 的 key)
|
||||
enum GuideScene {
|
||||
homeNewUser('HOME_NEW_USER'), //首页新用户引导(吸底)
|
||||
homeOldUser('HOME_OLD_USER'), //首页老用户引导(吸底)
|
||||
//新用户免费次数将尽引导(GuideFreeTrialSheet)。key 是 HOME_ 开头,但实际触发在播放页:
|
||||
//newUnpay 用免费次数看视频且停留满5分钟
|
||||
homeNewUserFreeTrial('HOME_NEW_USER_FREE_TRIAL'),
|
||||
//文档叫「试看结束引导」,实际是需付费视频起播3秒的前置引导(真试看结束是 BuyVipAlert 硬拦截,不受开关管)
|
||||
videoPreviewEnd('VIDEO_PREVIEW_END'),
|
||||
videoBack('VIDEO_BACK'), //退出视频挽留
|
||||
vipCenter('VIP_CENTER'), //会员中心引导
|
||||
//会员内容上新:前端不读这个开关,横幅按分层 + 独立接口判断(见 GuidePushBanner),此项只为枚举完整
|
||||
vipContentUpdate('VIP_CONTENT_UPDATE');
|
||||
|
||||
final String value;
|
||||
const GuideScene(this.value);
|
||||
|
||||
/// 后端 key → 枚举,不认识的返回 null
|
||||
static GuideScene? from(String? value) => values.firstWhereOrNull((e) => e.value == value);
|
||||
}
|
||||
|
||||
/// 付费引导配置(/ping/domain 的 paymentGuide 字段)
|
||||
/// 场景弹不弹全看这里的开关,触发时不再另发查询请求
|
||||
class GuideConfig {
|
||||
bool enabled = false; //全局开关,false 时所有场景都不展示
|
||||
final scenes = <GuideScene, GuideSceneConfig>{};
|
||||
|
||||
GuideConfig.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
enabled = json['enabled'] ?? false;
|
||||
final map = json['scenes'];
|
||||
if (map is! Map) return;
|
||||
map.forEach((key, value) {
|
||||
final scene = GuideScene.from('$key'); //前端不认识的场景直接丢
|
||||
if (scene != null && value is Map) {
|
||||
scenes[scene] = GuideSceneConfig.fromJson(Map<String, dynamic>.from(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 单场景配置:enabled && show 都为 true 才弹,触发几次弹几次,前端不改这两个值。
|
||||
/// 后端另下发的 configId / style / durationSeconds 前端用不上,没解析
|
||||
class GuideSceneConfig {
|
||||
bool enabled = false;
|
||||
bool show = false;
|
||||
String? title;
|
||||
String? description;
|
||||
String? cover; //弹窗主图(设计稿那张「限时永久卡」),不是会员卡图
|
||||
String? productId; //会员商品 id:按它取卡,跳会员页也选中它
|
||||
|
||||
GuideSceneConfig.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
enabled = json['enabled'] ?? false;
|
||||
show = json['show'] ?? false;
|
||||
title = json['title'];
|
||||
description = json['description'];
|
||||
cover = json['cover'];
|
||||
productId = json['productId'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../../assets_tool/images.dart';
|
||||
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
|
||||
import '../../hj_page/mine/widgets/gradient_text.dart';
|
||||
import 'guide_common.dart';
|
||||
import 'guide_config.dart';
|
||||
import 'guide_manager.dart';
|
||||
|
||||
/// 优惠倒计时:时长前端写死,起点由 app 启动时 reset 一次,回前台不重置
|
||||
class PromoCountdown {
|
||||
static const Duration _duration = Duration(hours: 2);
|
||||
static DateTime? _deadline;
|
||||
|
||||
static DateTime get _end => _deadline ??= DateTime.now().add(_duration);
|
||||
|
||||
/// 剩余时长,归零停在 0
|
||||
static Duration get remain {
|
||||
final d = _end.difference(DateTime.now());
|
||||
return d.isNegative ? Duration.zero : d;
|
||||
}
|
||||
|
||||
/// app 启动时调一次(见 main),进程存活期间自然倒数
|
||||
static void reset() => _deadline = DateTime.now().add(_duration);
|
||||
}
|
||||
|
||||
/// 优惠倒计时弹窗(居中):主图骑顶 + 优惠文案 + 倒计时 + 开通按钮。
|
||||
/// 长视频返回挽留 / 进会员页时触发
|
||||
class GuideCountdownDialog extends StatelessWidget {
|
||||
final VipProductModel card;
|
||||
final ValueChanged<VipProductModel>?
|
||||
onConfirm; // 默认跳会员页选卡;已在会员页的场景自己处理(关弹窗+就地选中该卡)
|
||||
final PaySourcePage sourcePage;
|
||||
final String cover; // 场景下发的主图,相对路径由 ImageCacheManager 拼域名
|
||||
const GuideCountdownDialog({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.sourcePage,
|
||||
required this.cover,
|
||||
this.onConfirm,
|
||||
});
|
||||
|
||||
/// [scene] 决定主图/卡/下单来源,调用方须先用开关判过能弹
|
||||
/// 返回 true = 点了「立即开通」(点X/点蒙层/没弹成都是 false),返回挽留场景据此决定要不要放行返回
|
||||
static Future<bool> show(
|
||||
{required GuideScene scene,
|
||||
ValueChanged<VipProductModel>? onConfirm}) async {
|
||||
final card = await loadGuideCard(scene);
|
||||
if (card == null) return false; // 配置有误不弹
|
||||
final confirmed = await Get.dialog<bool>(
|
||||
GuideCountdownDialog(
|
||||
card: card,
|
||||
sourcePage: guideSourcePage(scene),
|
||||
cover: GuideManager().configOf(scene)?.cover ?? '', //已校验非空
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 311,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
// 主体(顶部留 61 给主图骑顶溢出)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 61),
|
||||
padding: const EdgeInsets.fromLTRB(24, 80, 24, 24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xff0F0F0F),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 现在下单获得[优惠]福利:「优惠」金渐变、更大号
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
const Text('现在下单获得',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w800)),
|
||||
2.sizeBoxW,
|
||||
const GradientText(
|
||||
'优惠',
|
||||
gradient: LinearGradient(colors: [
|
||||
Color(0xffFFE3A0),
|
||||
Color(0xffF0A93C)
|
||||
]),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800),
|
||||
),
|
||||
2.sizeBoxW,
|
||||
const Text('福利',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w800)),
|
||||
],
|
||||
),
|
||||
15.sizeBoxH,
|
||||
const _CountdownRow(),
|
||||
15.sizeBoxH,
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (onConfirm != null) {
|
||||
onConfirm!(card);
|
||||
} else {
|
||||
goVipDetail(card, sourcePage: sourcePage);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 269,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [
|
||||
Color(0xffFFB03F),
|
||||
Color(0xffF2680C)
|
||||
]),
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x73F2680C),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6))
|
||||
], // rgba(242,104,12,0.45)
|
||||
),
|
||||
child: Text(
|
||||
'立即开通 · 仅需¥${card.discountedPriceUI}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 主图骑在顶部
|
||||
Positioned(
|
||||
top: 0,
|
||||
child: SizedBox(
|
||||
width: 270,
|
||||
height: 122,
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: cover, fit: BoxFit.fill, borderRadius: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
20.sizeBoxH,
|
||||
GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Image.asset("close_button.png".commonImgPath, width: 32),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 距优惠结束 HH:MM:SS:每秒只重建这一行,归零后停表
|
||||
class _CountdownRow extends StatefulWidget {
|
||||
const _CountdownRow();
|
||||
|
||||
@override
|
||||
State<_CountdownRow> createState() => _CountdownRowState();
|
||||
}
|
||||
|
||||
class _CountdownRowState extends State<_CountdownRow> {
|
||||
final _remain = ValueNotifier<Duration>(PromoCountdown.remain);
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
final r = PromoCountdown.remain;
|
||||
if (r == Duration.zero) t.cancel(); //归零停表(含弹窗打开时就已过期)
|
||||
if (r.inSeconds == _remain.value.inSeconds) return; //秒数没变不通知
|
||||
_remain.value = r;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_remain.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('距优惠结束',
|
||||
style: TextStyle(
|
||||
color: Color(0xffB8AE9C),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)),
|
||||
8.sizeBoxW,
|
||||
ValueListenableBuilder<Duration>(
|
||||
valueListenable: _remain,
|
||||
builder: (_, r, __) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_timeCell(r.inHours),
|
||||
_colon,
|
||||
_timeCell(r.inMinutes % 60),
|
||||
_colon,
|
||||
_timeCell(r.inSeconds % 60),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 时/分/秒块
|
||||
Widget _timeCell(int v) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffE23B2E),
|
||||
borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(
|
||||
v.toString().padLeft(2, '0'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700),
|
||||
),
|
||||
);
|
||||
|
||||
Widget get _colon => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 3),
|
||||
child: Text(':',
|
||||
style: TextStyle(
|
||||
color: Color(0xffE85B4F),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../hj_page/mine/widgets/gradient_text.dart';
|
||||
import '../../hj_utils/free_play_manager.dart';
|
||||
import 'guide_bottom_sheet.dart';
|
||||
import 'guide_config.dart';
|
||||
import 'guide_manager.dart';
|
||||
|
||||
/// 新用户免费次数将尽的付费引导(吸底,复用 [GuideBottomSheet] 通用壳,多一条剩余次数红标)。
|
||||
/// 触发:newUnpay 用免费次数看视频、次数将尽且停留满5分钟;卡和主图都取 HOME_NEW_USER_FREE_TRIAL 场景配置
|
||||
class GuideFreeTrialSheet {
|
||||
GuideFreeTrialSheet._();
|
||||
|
||||
static const scene = GuideScene.homeNewUserFreeTrial;
|
||||
|
||||
/// 剩余免费次数。+1 是为了和右下角「免费视频剩余N次」角标同口径——都含正在看的这一次
|
||||
/// (remain 在 _initPlayer 起手就被 useFreePlay 扣过一次了,不 +1 会比角标少 1)
|
||||
static int get remainCount => (FreePlayManager().remain?.watchCount ?? 0) + 1;
|
||||
|
||||
/// 次数将尽:剩 2 次及以内才值得打扰
|
||||
static bool get isCountLow => remainCount <= 2;
|
||||
|
||||
/// 返回 true = 点了开通并已跳会员页
|
||||
static Future<bool> show() {
|
||||
final remain = remainCount;
|
||||
final total = FreePlayManager().remain?.total;
|
||||
final description = GuideManager().configOf(scene)?.description; //副文案走下发,没配用默认
|
||||
return GuideBottomSheet.show(
|
||||
scene: scene,
|
||||
title: EasyRichText(
|
||||
'免费观看次数 即将用尽!',
|
||||
textAlign: TextAlign.center,
|
||||
defaultStyle: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w500),
|
||||
patternList: [
|
||||
EasyRichTextPattern(
|
||||
targetString: '即将用尽!',
|
||||
matchWordBoundaries: false, // 纯中文无 \b 词边界,关掉才能匹配上
|
||||
style: const TextStyle(color: Color(0xffE94335), fontSize: 17, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 剩余免费次数红标
|
||||
badge: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1AE94335), // rgba(233,67,53,0.1)
|
||||
border: Border.all(color: const Color(0x33E7472A)), // rgba(231,71,42,0.2)
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
total != null ? '剩余免费次数 $remain/$total次' : '剩余免费次数 $remain次',
|
||||
style: const TextStyle(color: Color(0xffE94335), fontSize: 15),
|
||||
),
|
||||
),
|
||||
subtitle: GradientText(
|
||||
description?.isNotEmpty == true ? description! : '开通会员即可 畅看 "VIP全部视频"',
|
||||
gradient: const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 19, fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
|
||||
import 'package:hgdj/hj_page/home/activity_float/home_activity_float.dart';
|
||||
import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/fly_to_overlay.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
/// 支付分层首页弹窗:展示后端 homePage 图,点击跳会员页并选中 config.vipCard。
|
||||
/// 倒计时读 config.lastDiscountTime,秒级刷新由 MineMsgProvider().tick 驱动,过期自动停
|
||||
class GuideHomeDialog extends StatefulWidget {
|
||||
final PayTierConfig config;
|
||||
|
||||
const GuideHomeDialog({super.key, required this.config});
|
||||
|
||||
@override
|
||||
State<GuideHomeDialog> createState() => _GuideHomeDialogState();
|
||||
}
|
||||
|
||||
class _GuideHomeDialogState extends State<GuideHomeDialog> {
|
||||
final _posterKey = GlobalKey(); // 关闭动画的起飞位置
|
||||
|
||||
/// 关闭中:① 防连点,pop 过渡期弹窗还在树上,再点会把首页也 pop 掉
|
||||
/// ② 藏掉原海报,否则它随弹窗淡出、和飞行副本重影
|
||||
bool _closing = false;
|
||||
|
||||
PayTierConfig get config => widget.config;
|
||||
|
||||
/// 关闭:海报缩小飞进右下角浮窗,告诉用户同款入口收在哪
|
||||
void _close() {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
final target = _floatRect();
|
||||
if (target != null) {
|
||||
FlyToOverlay.play(
|
||||
context: context,
|
||||
sourceKey: _posterKey,
|
||||
target: target,
|
||||
// 副本只放海报,倒计时胶囊不跟着飞(缩到 60 宽也看不清)
|
||||
// 占位给透明:没命中内存缓存时别飞一个灰块过去
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: config.homePage ?? "",
|
||||
fit: BoxFit.fill,
|
||||
borderRadius: 0,
|
||||
placeHolderWidget: const SizedBox(),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() {}); // 原海报立刻消失,只留副本在飞
|
||||
Get.back();
|
||||
}
|
||||
|
||||
/// 落点:容器右下角一块浮窗大小的区域(浮窗都 bottomRight 对齐)。
|
||||
/// 认容器不认轮播子项——子项会轮到屏幕外,容器位置才稳定。
|
||||
/// 浮窗没展示时容器不存在,返回 null → 直接关不飞
|
||||
Rect? _floatRect() {
|
||||
final box = homeFloatKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return null;
|
||||
final origin = box.localToGlobal(Offset.zero);
|
||||
const w = 60.0, h = 66.0; // 与 _LayeredFloatView 的尺寸一致
|
||||
return Rect.fromLTWH(
|
||||
origin.dx + box.size.width - w, origin.dy + box.size.height - h, w, h);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 点蒙层、返回键都走 _close,保证任何关闭方式都有飞入动画
|
||||
// (canPop:false 只拦 maybePop,_close 里的 Get.back() 是强制 pop,不会被自己拦住)
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _close();
|
||||
},
|
||||
child: Center(
|
||||
// 关闭中内容交给飞行副本。必须同时 IgnorePointer:
|
||||
// Opacity(0) 照样吃手势,退出动画那百来毫秒点到海报会再 pop 一次并跳会员页
|
||||
child: IgnorePointer(
|
||||
ignoring: _closing,
|
||||
child: Opacity(
|
||||
opacity: _closing ? 0 : 1,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30),
|
||||
child: GestureDetector(
|
||||
// 只回传"去开通",跳会员页交给调用方(_showPayDialog)——它要 await 到从会员页
|
||||
// 返回才继续弹窗链,否则后面的首页吸底引导会弹在会员页上
|
||||
onTap: () {
|
||||
if (_closing) return; // 防连点:pop 过渡期弹窗还在树上,再点会把首页也 pop 掉
|
||||
_closing = true;
|
||||
Get.back(result: true);
|
||||
},
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: Stack(
|
||||
key: _posterKey,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
NetworkImageLoader(
|
||||
imageUrl: config.homePage ?? "",
|
||||
fit: BoxFit.fill,
|
||||
borderRadius: 0,
|
||||
placeHolderWidget:
|
||||
Container(color: const Color(0x00000000)),
|
||||
),
|
||||
// 倒计时胶囊:海报右上角,听 tick 每秒刷新,过期自动收起
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: MineMsgProvider().tick,
|
||||
builder: (_, __, ___) =>
|
||||
config.hasDiscountCountdown
|
||||
? _buildCountdownPill()
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
20.sizeBoxH,
|
||||
GestureDetector(
|
||||
onTap: _close,
|
||||
child: Image.asset("close_button.png".commonImgPath,
|
||||
width: 32),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 倒计时胶囊:红渐变 + 奶白边,HH:MM:SS
|
||||
Widget _buildCountdownPill() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xffE03017), Color(0xffF27952)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xffFFFAEE), width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
'${config.discountHour}:${config.discountMin}:${config.discountSec}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xffFFF0D1),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
|
||||
|
||||
import '../../hj_page/main_page/provider/msg_provider.dart';
|
||||
import 'guide_config.dart';
|
||||
|
||||
/// 付费引导开关(全站唯一数据源)
|
||||
///
|
||||
/// 配置随 /ping/domain 及 /ping/domain/refresh?keys=paymentGuide 下发后缓存在内存,
|
||||
/// 首页/播放页/会员中心触发时直接读,不再临时请求接口。
|
||||
///
|
||||
/// 弹不弹只认后端下发的 enabled && show:本地不去重、不改这两个值、也不上报曝光,
|
||||
/// 触发几次弹几次,要收敛得由后端把 show 改成 false;没下发配置则一律不弹。
|
||||
/// (VIP_CONTENT_UPDATE 不归这里管,它走独立接口 + 曝光回执,见 GuidePushBanner)
|
||||
class GuideManager {
|
||||
static final GuideManager _instance = GuideManager._();
|
||||
factory GuideManager() => _instance;
|
||||
GuideManager._();
|
||||
|
||||
GuideConfig? _config;
|
||||
|
||||
/// ping / ping refresh 下发时写入。字段缺失(后端未下发)时保留上一次配置,不清空
|
||||
void update(dynamic json) {
|
||||
if (json is! Map) return;
|
||||
_config = GuideConfig.fromJson(Map<String, dynamic>.from(json));
|
||||
}
|
||||
|
||||
/// 场景配置:全局开关关掉、场景没配或没启用都返回 null
|
||||
GuideSceneConfig? configOf(GuideScene scene) {
|
||||
final all = _config;
|
||||
if (all == null || !all.enabled) return null;
|
||||
final config = all.scenes[scene];
|
||||
return config?.enabled == true ? config : null;
|
||||
}
|
||||
|
||||
/// 能不能弹:全局开关 + 场景启用([configOf])之外,还要后端本次放行
|
||||
bool canShow(GuideScene scene) => configOf(scene)?.show == true;
|
||||
|
||||
/// 首页新/老用户二选一:**按本地分层选**,newUnpay 走新用户场景,其余走老用户;
|
||||
/// 选中的那个不可展示就不弹,不回退到另一个——分层不匹配的文案给错人比不弹更糟。
|
||||
/// (以前只按 show 挑,后端两个都下发 true 时老用户会命中新人场景)
|
||||
///
|
||||
/// ⚠️ 分层为 null 时必须退回按 show 挑,不能当成老用户:payTier 由 MainLogic.onInit 的
|
||||
/// refreshPayPopup 异步拉,而本 getter 在首帧后的弹窗链里就被读了——首页没别的弹窗时
|
||||
/// 请求还没回来,硬判成老用户会让新用户永远错过新人引导,且 _adShown 保证不会再有第二次
|
||||
GuideScene? get homeScene {
|
||||
final tier = MineMsgProvider().payTier?.status;
|
||||
if (tier == null)
|
||||
return [GuideScene.homeNewUser, GuideScene.homeOldUser]
|
||||
.firstWhereOrNull(canShow);
|
||||
final scene = tier == PayTier.newUnpay
|
||||
? GuideScene.homeNewUser
|
||||
: GuideScene.homeOldUser;
|
||||
return canShow(scene) ? scene : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../assets_tool/images.dart';
|
||||
import '../../hj_page/home/home_cell_style/video_simple_cell.dart';
|
||||
import '../../hj_page/main_page/main_page.dart';
|
||||
import '../../hj_page/mine/widgets/gradient_text.dart';
|
||||
import '../../hj_utils/api_service/common_service.dart';
|
||||
import '../../hj_utils/light_model.dart';
|
||||
import '../../hj_utils/screen.dart';
|
||||
import '../../hj_utils/store_keys.dart';
|
||||
import '../../routers/jump_router.dart';
|
||||
import '../../tools_base/debug_log.dart';
|
||||
import '../../tools_base/global_store/store.dart';
|
||||
import 'guide_common.dart';
|
||||
import 'guide_push_model.dart';
|
||||
import 'timed_popup_manager.dart';
|
||||
|
||||
/// VIP「特享内容」顶部推送横幅(下滑入,上滑收起)
|
||||
///
|
||||
/// 用 app 满 [_delay] 后,每次回到 MainPage 拉一次接口,show=true 才弹。
|
||||
/// [start] 的定时任务兜住「到点时一直停在 MainPage、没有 pop 事件」这种边界,
|
||||
/// 其余靠 [onBackToMainPage]。调度走 [TimedPopupManager],与其它引导互斥
|
||||
class GuidePushBanner extends StatelessWidget {
|
||||
final GuidePushModel model;
|
||||
const GuidePushBanner({super.key, required this.model});
|
||||
|
||||
static const _scene = 'VIP_CONTENT_UPDATE'; //接口场景标识
|
||||
static const _delay = Duration(minutes: 3); //进 app 多久后才开始推
|
||||
|
||||
//进 app 的时刻,MainPage 每次创建都重置。null = start() 还没跑
|
||||
static DateTime? _enterAt;
|
||||
static bool _showing = false; //显示中:自身关闭引发的 didPopNext 不能又弹一次
|
||||
static bool _fetching = false; //拉取中:快速进出页面会连续触发,防并发重复请求
|
||||
|
||||
//够不够时长。算墙上时间,切后台那段也算(要的是用满3分钟,不是前台累积)
|
||||
static bool get _overDelay => _enterAt != null && DateTime.now().difference(_enterAt!) >= _delay;
|
||||
|
||||
/// 只对 under7/over7 未付费、在 MainPage 栈顶、自己没在显示时弹
|
||||
static bool get _canShow => !_showing && isUnpayGuideUser() && Get.currentRoute == MainPage.routeName;
|
||||
|
||||
/// MainPage 创建时调一次
|
||||
static void start() {
|
||||
_enterAt = DateTime.now();
|
||||
TimedPopupManager().schedule(
|
||||
type: TimedPopupType.guidePushBanner,
|
||||
delay: _delay,
|
||||
//计时无条件起(见 schedule 注释),条件到点才判,和 onBackToMainPage 走同一套
|
||||
canShow: () => _canShow,
|
||||
onShow: _fetchThenShow,
|
||||
);
|
||||
}
|
||||
|
||||
/// MainPage 销毁时调:定时任务不留着空跑
|
||||
static void stop() => TimedPopupManager().cancel(TimedPopupType.guidePushBanner);
|
||||
|
||||
/// 回到一级页面:满 [_delay] 后每次都拉一次接口
|
||||
static Future<void> onBackToMainPage() {
|
||||
if (!_overDelay) return Future.value();
|
||||
return TimedPopupManager().trigger(canShow: () => _canShow, onShow: _fetchThenShow);
|
||||
}
|
||||
|
||||
/// 拉配置 → show=true 且本地没展示过这批才弹。调用方已在互斥区内,不再嵌套 trigger
|
||||
static Future<void> _fetchThenShow() async {
|
||||
if (_fetching || !_canShow) return;
|
||||
_fetching = true;
|
||||
try {
|
||||
final model = await CommonService.fetchPaymentGuide(_scene);
|
||||
if (model?.show != true || model!.videos.isEmpty) return;
|
||||
|
||||
// 回执没落库时服务端会重复下发同一批:挡掉别重复弹,顺便补报那次没成功的曝光
|
||||
// (只挡不补的话服务端永远认为没展示过,就卡死在「本地挡着、服务端一直发」)
|
||||
final record = await _readRecord();
|
||||
if (record != null && record.isSameAs(model)) {
|
||||
if (!record.reported) await _report(record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_canShow) return; //拉取期间可能已经跳去别的页面
|
||||
_showing = true;
|
||||
final newRecord = ShownRecord.from(model, const Uuid().v4());
|
||||
await _saveRecord(newRecord); //先落本地再弹,并发的后一次才挡得住
|
||||
final closed = _showBanner(model); //generalDialog 一调用就已入栈 = 展示成功
|
||||
_report(newRecord); //不 await:上报不挡展示
|
||||
//等关闭,期间 _showing 保持 true:横幅自己是 MainPage 上的一个 route,
|
||||
//关闭会触发 didPopNext,不挡住就立刻又弹一次
|
||||
await closed;
|
||||
} catch (e) {
|
||||
debugLog('guidePushBanner', e.toString());
|
||||
} finally {
|
||||
_showing = false;
|
||||
_fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 上报已展示。失败不改 reported,留到下次拉到同一批时重报(幂等,必须用同一个 requestId)
|
||||
static Future<void> _report(ShownRecord record) async {
|
||||
try {
|
||||
final success = await CommonService.reportPaymentGuideImpression(
|
||||
configId: record.configId,
|
||||
scene: _scene,
|
||||
contentVersion: record.version,
|
||||
requestId: record.requestId,
|
||||
);
|
||||
if (!success) return;
|
||||
record.reported = true;
|
||||
await _saveRecord(record);
|
||||
} catch (e) {
|
||||
debugLog('guidePushBanner', '曝光上报失败,留待下次重报: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录 key 带 uid,换号互不影响。只留最近一条,更早的版本服务端不会再下发
|
||||
static String get _recordKey => '${StoreKeys.VIP_PUSH_SHOWN_VERSION_PREFIX}${globalStore.meInfo?.uid ?? 0}';
|
||||
|
||||
static Future<ShownRecord?> _readRecord() async {
|
||||
final raw = await lightKV.getString(_recordKey);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return ShownRecord.fromJson(jsonDecode(raw));
|
||||
} catch (_) {
|
||||
return null; //旧格式/脏数据,当没记录处理
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _saveRecord(ShownRecord record) => lightKV.setString(_recordKey, jsonEncode(record.toJson()));
|
||||
|
||||
static Future<void> _showBanner(GuidePushModel model) {
|
||||
return Get.generalDialog(
|
||||
barrierDismissible: false, //点遮罩不关,只认上滑和横幅里的按钮
|
||||
barrierLabel: 'GuidePushBanner',
|
||||
barrierColor: Colors.black.withValues(alpha: .5),
|
||||
transitionDuration: const Duration(milliseconds: 320),
|
||||
pageBuilder: (_, __, ___) => GuidePushBanner(model: model),
|
||||
transitionBuilder: (_, animation, __, child) => SlideTransition(
|
||||
position: Tween(begin: const Offset(0, -1), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _maxCount = 4; //最多展示几个封面
|
||||
|
||||
List<GuidePushVideo> get videos => model.videos.take(_maxCount).toList();
|
||||
|
||||
// ===== 尺寸按比例算,不写死高度,任何屏宽都不溢出 =====
|
||||
static const _cellRatio = 76 / 55; //单卡整体比例(设计稿 76×55,含底部标题)
|
||||
static const _maxContentWidth = 500.0; //超宽屏限宽居中,不让封面无限拉大
|
||||
|
||||
double get _contentWidth => Get.width < _maxContentWidth ? Get.width : _maxContentWidth;
|
||||
|
||||
//以下几个常量要和 _buildVideoCard 的实际布局一致,改一处就同步改
|
||||
static const _cardMargin = 6.0; //卡片左右外边距
|
||||
static const _cardPadding = 6.0; //卡片内左右边距
|
||||
static const _itemSpacing = 4.0; //封面之间的间距
|
||||
static const _headerSpace = 36.0; //卡片内顶部留给标签/标题的高度
|
||||
|
||||
double get _itemWidth => (_contentWidth - _cardMargin * 2 - _cardPadding * 2 - _itemSpacing * (_maxCount - 1)) / _maxCount;
|
||||
|
||||
double get _itemHeight => _itemWidth / _cellRatio;
|
||||
|
||||
/// 卡片高 = 顶部让位 + 单卡高 + 底部留白
|
||||
double get _cardHeight => _headerSpace + _itemHeight + 16;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//Material 只包横幅本体,铺满全屏没必要还白吃一层点击
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: _SwipeUpToDismiss(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.only(top: Get.mediaQuery.padding.top),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)),
|
||||
border: const Border(bottom: BorderSide(color: Color(0xffB71E03))),
|
||||
),
|
||||
child: Center(
|
||||
heightFactor: 1, //不加会撑满全屏高度,横幅变成一整块黑屏
|
||||
child: SizedBox(
|
||||
width: _contentWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
//标签/标题压在卡片上部,故同层叠
|
||||
Stack(children: [_buildVideoCard(), _buildHeader()]),
|
||||
19.sizeBoxH,
|
||||
_buildVipButton(),
|
||||
21.sizeBoxH,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 视频卡片:标签/标题压在上半部,视频排下半部。
|
||||
/// GridView 固定 4 列,不足 4 条时靠左排,封面不会被拉大撑破卡片
|
||||
Widget _buildVideoCard() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: _cardMargin, right: _cardMargin, top: 19),
|
||||
child: Container(
|
||||
height: _cardHeight,
|
||||
padding: const EdgeInsets.only(left: _cardPadding, right: _cardPadding, top: _headerSpace),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffB71E03).withValues(alpha: .1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xffB71E03)),
|
||||
),
|
||||
child: GridView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _maxCount,
|
||||
crossAxisSpacing: _itemSpacing,
|
||||
childAspectRatio: _cellRatio,
|
||||
),
|
||||
itemCount: videos.length,
|
||||
itemBuilder: (_, index) => _buildVideoItem(videos[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 复用首页视频 cell:不传 imgAspectRatio,封面撑满格子剩余高度,比例随格子走(裁切显示)
|
||||
Widget _buildVideoItem(GuidePushVideo video) {
|
||||
return VideoSimpleCell(
|
||||
videoModel: video.toVideoModel(), //freeArea=true 顺带去掉右上角 VIP 角标
|
||||
textLines: 1,
|
||||
isShowBottom: false,
|
||||
titleFontSize: 8,
|
||||
coverFontSize: 7,
|
||||
onTap: () {
|
||||
Get.back();
|
||||
//只传 id:转出来的 model 带 freeArea=true 会被播放页当成免费区,
|
||||
//且 playTime<300 会被判成短视频跳到抖音页
|
||||
pushToVideoPage(videoId: video.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 贴纸 + 标题底部对齐一行,标题走接口下发的 description,没配用默认文案
|
||||
Widget _buildHeader() {
|
||||
return Positioned(
|
||||
left: 25,
|
||||
right: 27,
|
||||
top: 4,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Image.asset('update_video.webp'.videoPath, width: 113),
|
||||
//用 Expanded 不用 Spacer:Row 给非 flex 子节点的是无界约束,scaleDown 缩不动会溢出
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: GradientText(
|
||||
model.description?.isNotEmpty == true ? model.description! : 'VIP会员“特享内容”更新上架啦~',
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xffFFE8BE), Color(0xffE6B764)],
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVipButton() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 39),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
final action = model.action; //VIP_PRODUCT 带商品 id 进会员页,没配走默认
|
||||
pushToWalletPage(vipId: action?.type == 'VIP_PRODUCT' ? action?.value : null);
|
||||
},
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
gradient: const LinearGradient(colors: [Color(0xffFFB03F), Color(0xffF2680C)]),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xffF2680C).withValues(alpha: .45),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'开通会员 · 查看劲爆视频',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 上滑关闭:跟手位移 + 下拉阻尼 + 松手回弹,滑得够远或甩得够快直接 pop。
|
||||
/// 不用 Dismissible:它要等自己那套滑出动画跑完才回调,横幅都滑没了遮罩还杵在那
|
||||
class _SwipeUpToDismiss extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _SwipeUpToDismiss({required this.child});
|
||||
|
||||
@override
|
||||
State<_SwipeUpToDismiss> createState() => _SwipeUpToDismissState();
|
||||
}
|
||||
|
||||
class _SwipeUpToDismissState extends State<_SwipeUpToDismiss> with SingleTickerProviderStateMixin {
|
||||
static const _dismissRatio = 0.2; //上滑超过自身高度 20% 松手即关
|
||||
static const _flingVelocity = 700.0; //甩得够快就关,不管滑了多远
|
||||
static const _downDamping = 0.18; //往下拖的阻尼:横幅本来就贴着顶,只留一点点余量
|
||||
|
||||
late final AnimationController _springCtr = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 240),
|
||||
);
|
||||
late final CurvedAnimation _spring = CurvedAnimation(parent: _springCtr, curve: Curves.easeOutCubic);
|
||||
|
||||
double _dragY = 0; //手指累计位移,负 = 往上
|
||||
|
||||
//回弹就是把当前位移按 _spring 收回 0
|
||||
double get _translateY => (_dragY < 0 ? _dragY : _dragY * _downDamping) * (1 - _spring.value);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_spring.dispose();
|
||||
_springCtr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
//回弹途中又按下:接住当前位置继续跟手,别手指按着它还自顾自往回收
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_dragY = _translateY;
|
||||
_springCtr.value = 0;
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) => setState(() => _dragY += details.delta.dy);
|
||||
|
||||
void _onDragEnd(DragEndDetails details) {
|
||||
final velocity = details.primaryVelocity ?? 0; //上滑为负
|
||||
if (-_dragY > (context.size?.height ?? Get.height) * _dismissRatio || velocity < -_flingVelocity) {
|
||||
//位移不复位就 pop,路由的反向动画从当前位置接着走,横幅和遮罩一块收走
|
||||
Get.back();
|
||||
} else {
|
||||
_springCtr.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _spring,
|
||||
builder: (_, child) => Transform.translate(offset: Offset(0, _translateY), child: child),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地展示记录:弹过哪批内容 + 曝光有没有报成功,存 lightKV(重启不丢)
|
||||
class ShownRecord {
|
||||
final String version; //contentVersion
|
||||
final String configId;
|
||||
final String requestId; //幂等 id,重报要用同一个
|
||||
final List<String> videoIds;
|
||||
bool reported;
|
||||
|
||||
ShownRecord({
|
||||
required this.version,
|
||||
required this.configId,
|
||||
required this.requestId,
|
||||
required this.videoIds,
|
||||
this.reported = false,
|
||||
});
|
||||
|
||||
factory ShownRecord.from(GuidePushModel model, String requestId) => ShownRecord(
|
||||
version: model.contentVersion ?? '',
|
||||
configId: model.configId ?? '',
|
||||
requestId: requestId,
|
||||
videoIds: model.videos.map((e) => e.id ?? '').toList(),
|
||||
);
|
||||
|
||||
factory ShownRecord.fromJson(Map<String, dynamic> json) => ShownRecord(
|
||||
version: json['version'] ?? '',
|
||||
configId: json['configId'] ?? '',
|
||||
requestId: json['requestId'] ?? '',
|
||||
videoIds: (json['videoIds'] as List?)?.map((e) => '$e').toList() ?? [],
|
||||
reported: json['reported'] ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'version': version,
|
||||
'configId': configId,
|
||||
'requestId': requestId,
|
||||
'videoIds': videoIds,
|
||||
'reported': reported,
|
||||
};
|
||||
|
||||
/// 同一批:版本号一致,或版本号变了但视频还是那几条(前后端不同步)
|
||||
bool isSameAs(GuidePushModel model) {
|
||||
if (version.isNotEmpty && version == model.contentVersion) return true;
|
||||
final ids = model.videos.map((e) => e.id ?? '').toList();
|
||||
return ids.isNotEmpty && ids.join(',') == videoIds.join(',');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import '../../hj_model/video_model.dart';
|
||||
|
||||
/// 付费引导弹窗配置(场景 VIP_CONTENT_UPDATE:VIP 内容上新顶部推送横幅)
|
||||
/// 只解析前端用得到的字段;title/segment/style/cover/durationSeconds/videoIds 暂未使用
|
||||
class GuidePushModel {
|
||||
bool? show; // false = 本次不展示(已展示过 / 无可用配置 / 无符合条件内容)
|
||||
String? configId;
|
||||
String? contentVersion; // 内容版本,上报回执时原样带回;也是本地去重的依据
|
||||
String? description; // 横幅标题文案
|
||||
String? productId;
|
||||
GuidePushAction? action; // 按钮跳转配置
|
||||
List<GuidePushVideo> videos = []; // 最新 VIP 内容,最多 4 条
|
||||
|
||||
GuidePushModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
show = json['show'];
|
||||
configId = json['configId'];
|
||||
contentVersion = json['contentVersion'];
|
||||
description = json['description'];
|
||||
productId = json['productId'];
|
||||
if (json['action'] is Map) {
|
||||
action = GuidePushAction.fromJson(json['action']);
|
||||
}
|
||||
if (json['videos'] is List) {
|
||||
videos = (json['videos'] as List).map((e) => GuidePushVideo.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹窗按钮动作:type=VIP_PRODUCT 时 value 为会员商品 id
|
||||
class GuidePushAction {
|
||||
String? type;
|
||||
String? value;
|
||||
|
||||
GuidePushAction.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
type = json['type'];
|
||||
value = json['value'];
|
||||
}
|
||||
}
|
||||
|
||||
class GuidePushVideo {
|
||||
String? id;
|
||||
String? title;
|
||||
String? cover;
|
||||
String? coverThumb; // 缩略图,列表展示优先用它
|
||||
int? playTime; // 时长(秒)
|
||||
int? playCount; // 播放量
|
||||
|
||||
GuidePushVideo.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
title = json['title'];
|
||||
cover = json['cover'];
|
||||
coverThumb = json['coverThumb'];
|
||||
playTime = json['playTime'];
|
||||
playCount = json['playCount'];
|
||||
}
|
||||
|
||||
/// 列表用封面:缩略图为空时回落大图
|
||||
String get showCover => coverThumb?.isNotEmpty == true ? coverThumb! : cover ?? '';
|
||||
|
||||
/// 转成 VideoModel,复用项目现成的视频 cell。
|
||||
/// freeArea=true 是为了让 cell 不画右上角 VIP 角标(见 VideoSimpleCell._buildLevelIcon),
|
||||
/// ⚠️ 因此这个对象只能喂给 cell 渲染,不能传给播放页——那边会当成免费区、放过付费拦截
|
||||
VideoModel toVideoModel() => VideoModel()
|
||||
..id = id
|
||||
..title = title
|
||||
..cover = showCover
|
||||
..playTime = playTime
|
||||
..playCount = playCount
|
||||
..freeArea = true;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// 定时弹窗的任务标识:只服务 [TimedPopupManager.schedule] / [TimedPopupManager.cancel] 的去重与取消。
|
||||
/// 立即触发的场景(首页吸底、返回挽留、会员页倒计时等)走 trigger,不占枚举值。
|
||||
enum TimedPopupType {
|
||||
freeWatchPay, // 新用户(newUnpay)免费次数观看满5分钟付费引导
|
||||
videoPayAhead, // 需付费视频起播3秒的前置提示(VIDEO_PREVIEW_END 开关控,一次进页面只弹一次)
|
||||
guidePushBanner, // 进 app 满3分钟的 VIP「特享内容」顶部推送横幅
|
||||
}
|
||||
|
||||
/// 引导弹窗统一调度管理器(全站唯一)
|
||||
///
|
||||
/// 所有付费引导弹窗都经这里触发,统一「多久/什么条件/弹什么」,并保证同一时刻只弹一个(互斥,防叠弹)。
|
||||
/// - [schedule] 定时触发:停留 N 时长后弹(如试看/停留计时)。
|
||||
/// - [trigger] 立即触发:事件驱动即弹(如进页面/点返回)。
|
||||
/// 具体触发条件与弹窗动作由调用方传入(canShow/onShow),本类只负责调度、去重、互斥、生命周期。
|
||||
class TimedPopupManager {
|
||||
static final TimedPopupManager _instance = TimedPopupManager._();
|
||||
factory TimedPopupManager() => _instance;
|
||||
TimedPopupManager._();
|
||||
|
||||
/// 进行中的计时任务(按类型去重,同类型再次 schedule 会重启计时)
|
||||
final Map<TimedPopupType, Timer> _timers = {};
|
||||
|
||||
/// 互斥:同一时刻只显示一个引导弹窗,避免多场景叠弹
|
||||
bool _showing = false;
|
||||
|
||||
/// 定时触发:停留 [delay] 后满足条件弹。同 [type] 重复调用会取消上一个、重新计时。
|
||||
///
|
||||
/// ⚠️ 计时无条件起,[canShow] 只在到点时判一次。别在这里加"起步先判一次不满足就不计时"的优化:
|
||||
/// 这些条件(分层、免费次数、登录态)本来就是等着这 N 分钟里变的,起步挡掉就再也起不来了
|
||||
/// ——切号后分层/次数要等接口回来才对,起步判死会让弹窗永久不弹。
|
||||
void schedule({
|
||||
required TimedPopupType type,
|
||||
required Duration delay,
|
||||
required bool Function() canShow,
|
||||
required Future<void> Function() onShow,
|
||||
}) {
|
||||
cancel(type);
|
||||
_timers[type] = Timer(delay, () {
|
||||
_timers.remove(type);
|
||||
trigger(canShow: canShow, onShow: onShow); // 到点走统一出口;被互斥挡下则本次作废
|
||||
});
|
||||
}
|
||||
|
||||
/// 立即触发:事件驱动(进页面/返回等)满足条件即弹,也是 [schedule] 到点后的统一出口。
|
||||
/// 返回的 Future 在弹窗关闭时完成,调用方可 await 以串起后续逻辑(如试看结束链路)。
|
||||
/// ⚠️ [onShow] 的 Future 必须最终完成,否则互斥标记不复位、全站引导弹窗会静默失效。
|
||||
Future<void> trigger({
|
||||
required bool Function() canShow,
|
||||
required Future<void> Function() onShow,
|
||||
}) async {
|
||||
if (_showing) return; // 已有引导弹窗在显示
|
||||
if (!canShow()) return; // 弹前二次校验(定时场景期间状态可能变)
|
||||
_showing = true;
|
||||
try {
|
||||
await onShow(); // 弹窗 show 的 Future 在关闭时 resolve
|
||||
} finally {
|
||||
_showing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消指定类型的计时任务(页面退出时调用)
|
||||
void cancel(TimedPopupType type) => _timers.remove(type)?.cancel();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局色值
|
||||
class AppColors {
|
||||
/// 无背景的一级字体颜色 #333333
|
||||
static const Color mainTextColor33 = Color(0xFF333333);
|
||||
|
||||
/// 提示颜色,一般是输入框 hint #999999
|
||||
static const Color tipTextColor99 = Color(0xFF999999);
|
||||
|
||||
/// 主背景色(暗色主题)#0F0F0F
|
||||
static const Color primaryColor = Color(0xFF0F0F0F);
|
||||
|
||||
/// 链接/高亮色 #3E64EA
|
||||
static const Color primaryHighColor = Color(0xFF3E64EA);
|
||||
|
||||
/// 主题强调色,按钮/选中态用(本项目为橙色)#F68804
|
||||
static const Color actionRed = Color(0xFFF68804);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 图片资源路径扩展:'ic_home.png'.homePath => 'assets/images/home/ic_home.png'
|
||||
extension ImageLoad on String {
|
||||
//通用
|
||||
String get commonImgPath => 'assets/images/common/$this';
|
||||
//首页
|
||||
String get homePath => 'assets/images/home/$this';
|
||||
//视频
|
||||
String get videoPath => 'assets/images/video/$this';
|
||||
//acg(漫画/小说)
|
||||
String get acgImgPath => 'assets/images/acg/$this';
|
||||
//社区
|
||||
String get communityPath => 'assets/images/community/$this';
|
||||
//评论
|
||||
String get commentPath => 'assets/images/comment/$this';
|
||||
//直播
|
||||
String get livePath => 'assets/images/live_images/$this';
|
||||
//ai
|
||||
String get aiPath => 'assets/images/ai/$this';
|
||||
//我的
|
||||
String get mineImgPath => 'assets/images/mine/$this';
|
||||
//启动页
|
||||
String get launchPath => 'assets/images/launch/$this';
|
||||
//app icon
|
||||
String get appImgPath => 'assets/images/app_icon/$this';
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
import '../hj_model/splash/domain_source_model.dart';
|
||||
|
||||
/// 地址数据:接口路径常量 + 启动选线后下发的各类域名
|
||||
class Address {
|
||||
// ===== 路径常量 =====
|
||||
/// 接口路径前缀,拼在 baseHost 后面
|
||||
static const apiPrefix = 'api/app';
|
||||
|
||||
/// 视频上传
|
||||
static const uploadVideo = '/vid/uploadDotJson';
|
||||
|
||||
/// 图片上传
|
||||
static const uploadImg = '/vid/uploadStatic';
|
||||
|
||||
/// 通过支付宝API解析银行卡号发卡行和银行卡类别、获取银行LOGO
|
||||
static const aliCcdApi = 'https://ccdcapi.alipay.com/validateAndCacheCardInfo.json?_input_charset=utf-8&cardBinCheck=true&cardNo=';
|
||||
|
||||
// ===== 域名 / 线路(启动时赋值)=====
|
||||
/// 选线后的域名 host
|
||||
static String? baseHost;
|
||||
|
||||
/// 接口基础路径 = baseHost + apiPrefix
|
||||
static String? baseApiPath;
|
||||
|
||||
/// cdn地址,后台返回
|
||||
static String? cdnAddress;
|
||||
|
||||
/// 有声小说 cdn
|
||||
static String? audioCdnAddress;
|
||||
|
||||
/// 图片加载地址
|
||||
static String? baseImagePath;
|
||||
|
||||
/// 落地页地址
|
||||
static String? groundUrl;
|
||||
|
||||
/// cdn 线路列表
|
||||
static List<Domain> cdnAddressLists = [];
|
||||
|
||||
/// 当前生效的 cdn 线路
|
||||
static Domain? get currentDomain {
|
||||
final index = cdnAddressLists.indexWhere((e) => e.url == cdnAddress);
|
||||
return index < 0 ? null : cdnAddressLists[index];
|
||||
}
|
||||
|
||||
// ===== 用户态 =====
|
||||
static String? token;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import '../hj_model/home/plate_model.dart';
|
||||
import '../hj_model/splash/domain_source_model.dart';
|
||||
|
||||
/// 全局配置:常量 + 启动时由 /ping/domain 下发并缓存的运营配置
|
||||
class Config {
|
||||
// ===== 应用标识 =====
|
||||
static const appName = '黄果短剧';
|
||||
static const innerVersion = '1.0.0';
|
||||
static const isDebug = false;
|
||||
|
||||
/// 播放失败诊断开关:失败时把现场信息复制到剪贴板,供用户回传排查。
|
||||
/// ⚠️ 测试包专用,**发正式包前必须改回 false**
|
||||
static const playDiagnose = false;
|
||||
|
||||
// ===== 密钥 =====
|
||||
/// 加密密钥
|
||||
static const encryptKey = '65dc07d1b7915c6b2937432b091837a7';
|
||||
|
||||
/// 签名密钥(防重放攻击)
|
||||
static const signKey = 'kaFtkDJRcchRMTI9';
|
||||
|
||||
// ===== 域名 / 线路 =====
|
||||
static String dns = '91porn.zmnchs.com'; // 运维提供,返回配置域名
|
||||
|
||||
/// 本地兜底线路(无本地缓存线路时用)
|
||||
static const lineList = isDebug
|
||||
? [
|
||||
'https://91pornht.remoces.com',
|
||||
]
|
||||
: [
|
||||
'https://d1s84171319hi1.cloudfront.net',
|
||||
'https://d2j97wqe01j5pa.cloudfront.net',
|
||||
];
|
||||
|
||||
// ===== AI 功能价格 =====
|
||||
static String aiUndressPrice = ''; // 脱衣价格
|
||||
static String aiVideoPrice = ''; // 图生视频价格
|
||||
static String aiDrawPrice = ''; // 绘图价格
|
||||
static String aiNovelPrice = ''; // 小说价格
|
||||
static List<AISwitchConf>? aiTypes; // ai功能类型(含排序)
|
||||
|
||||
// ===== 首页模块数据 =====
|
||||
static HomePlateModel? plateModule; // 亚模块数据
|
||||
static List<JGAreaModel>? jgArea; // 金刚区
|
||||
static List<MarqueeModel>? marquees; // 新版跑马灯数据
|
||||
static List<BannerJumpEntity> bannerJumps = [];
|
||||
static BannerJump? proxyBanner;
|
||||
|
||||
// ===== 搜索 =====
|
||||
static List<String> hotWords = []; // 搜索热门词
|
||||
static List<String> searchHints = []; // 搜索提示词
|
||||
|
||||
// ===== 暗网 =====
|
||||
static bool darkWebEnable = true; // 是否开启
|
||||
static String? darkWebVipName;
|
||||
static String? darkWebVipId;
|
||||
static String? darkWebImg; // 暗网图片
|
||||
static String? darkWebIcon; // 暗网图标
|
||||
static String? darkWebIconName; // 暗网图标名称
|
||||
|
||||
// ===== 短剧 =====
|
||||
static String? shortDramaCardId; // 短剧卡 id,付费墙拉起会员弹窗时默认选中它
|
||||
static bool entryPopupEnabled = true; // 「AI爽剧来袭」引导气泡开关,后台置 false 即永久不出现
|
||||
|
||||
// ===== 开关 / 展示配置 =====
|
||||
static bool isStoreOpen = false; // 商店模块开关
|
||||
static bool vipMark = true; // vip角标是否展示
|
||||
static bool coinMark = true; // 金币角标是否展示
|
||||
static bool freeMark = true; // 免费类角标总开关(如「免费试看」)
|
||||
static String? signIcon; // 签到
|
||||
static String? mineBg; // 我的背景配置
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
extension DateTimeNullableX on DateTime? {
|
||||
String yyyyMMDDFormat({String gap = "-"}) {
|
||||
if (this == null) return "";
|
||||
return DateFormat("yyyy${gap}MM${gap}dd HH:mm:ss").format(this!);
|
||||
}
|
||||
|
||||
String ymdSMFormat({String gap = "-"}) {
|
||||
if (this == null) return "";
|
||||
return DateFormat("yyyy${gap}MM${gap}dd HH:mm").format(this!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// 可空 double 扩展
|
||||
extension DoubleNullableX on double? {
|
||||
/// 防止字段为空报错
|
||||
double em() => this ?? 0.0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// 通用基础类型扩展统一入口
|
||||
export 'datetime_extension.dart';
|
||||
export 'double_extension.dart';
|
||||
export 'int_extension.dart';
|
||||
export 'list_extension.dart';
|
||||
export 'map_extension.dart';
|
||||
export 'num_extension.dart';
|
||||
export 'string_extension.dart';
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../hj_utils/date_time_util.dart';
|
||||
|
||||
/// 可空 int 扩展
|
||||
extension IntNullableX on int? {
|
||||
/// 人性化数量:1234 / 1.2w / 1.3亿;0、负数、null 一律 '0'
|
||||
String get countStr {
|
||||
final v = this ?? 0;
|
||||
if (v <= 0) return '0';
|
||||
if (v < 10000) return '$v';
|
||||
if (v < 100000000) return '${(v / 10000).toStringAsFixed(1)}w';
|
||||
return '${(v / 100000000).toStringAsFixed(1)}亿';
|
||||
}
|
||||
|
||||
/// 同 [countStr],但 0/null 返回 [def](按钮上显示「点赞」而不是「0」)
|
||||
String countOr(String? def) => (this ?? 0) <= 0 ? (def ?? '0') : countStr;
|
||||
|
||||
/// 秒数 → mm:ss 或 HH:mm:ss
|
||||
String get hmsStr => DateTimeUtil.formatHMS(this ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// 可空 List 扩展
|
||||
extension ListNullableX on List? {
|
||||
/// 长度,null 视为 0
|
||||
int em() => this?.length ?? 0;
|
||||
|
||||
/// 是否为空(null 也视为空)
|
||||
bool empty() => this == null || this!.isEmpty;
|
||||
|
||||
/// 是否非空
|
||||
bool notEmpty() => this != null && this!.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// 可空 Map 扩展
|
||||
extension MapNullableX on Map? {
|
||||
/// 长度,null 视为 0
|
||||
int em() => this?.length ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 可空 num 扩展
|
||||
extension NumNullableX on num? {
|
||||
/// 防止字段为空报错
|
||||
num em() => this ?? 0;
|
||||
|
||||
/// 数字转金额保留两位小数
|
||||
String toAmount() => em().toStringAsFixed(2);
|
||||
}
|
||||
|
||||
/// 非空 num 扩展
|
||||
extension NumX on num {
|
||||
/// 小数点截取 [fractionDigits] 位(直接截断,不四舍五入)
|
||||
String fixed(int fractionDigits) {
|
||||
final fixString = toStringAsFixed(fractionDigits + 1);
|
||||
return fixString.substring(0, fixString.length - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'datetime_extension.dart';
|
||||
|
||||
/// DateFormat 构造要解析 pattern,列表里高频调用,按 pattern 缓存
|
||||
final _formatters = <String, DateFormat>{};
|
||||
|
||||
DateFormat _formatterOf(String pattern) => _formatters.putIfAbsent(pattern, () => DateFormat(pattern));
|
||||
|
||||
/// 可空字符串扩展
|
||||
extension StringNullableX on String? {
|
||||
DateTime? get _localDateTime {
|
||||
final value = this?.trim();
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return DateTime.tryParse(value)?.toLocal();
|
||||
}
|
||||
|
||||
String _format(String pattern) {
|
||||
final date = _localDateTime;
|
||||
return date == null ? '' : _formatterOf(pattern).format(date);
|
||||
}
|
||||
|
||||
// ============= utc 字符串 → 各种本地格式 =============
|
||||
|
||||
/// yyyy-MM-dd HH:mm:ss
|
||||
String utcToYMDHMS() => _format('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
/// yyyy-MM-dd HH:mm
|
||||
String utcToYMDHM({String gap = '-'}) => _format('yyyy${gap}MM${gap}dd HH:mm');
|
||||
|
||||
/// yyyy-MM-dd
|
||||
String utcToYMD({String gap = '-'}) => _format('yyyy${gap}MM${gap}dd');
|
||||
|
||||
/// MM月dd日
|
||||
String utcToCnMD() {
|
||||
final date = _localDateTime;
|
||||
return date == null ? '' : '${date.month}月${date.day}日';
|
||||
}
|
||||
|
||||
/// 相对时间:"刚刚 / N分钟前 / N小时前 / N天前 / yyyy-MM-dd HH:mm"
|
||||
String utcToAgo({String gap = '-'}) {
|
||||
final date = _localDateTime;
|
||||
//解析不出:本来就是空值兜底“近期”,脏数据给空串
|
||||
if (date == null) return this?.trim().isNotEmpty == true ? '' : '近期';
|
||||
|
||||
final diff = DateTime.now().difference(date);
|
||||
final s = diff.inSeconds;
|
||||
if (s >= 0 && s < 60) return '刚刚';
|
||||
final m = diff.inMinutes;
|
||||
if (m > 0 && m < 60) return '${m + 1}分钟前';
|
||||
final h = diff.inHours;
|
||||
if (h > 0 && h < 24) return '${h + 1}小时前';
|
||||
final d = diff.inDays;
|
||||
if (d > 0 && d < 3) return '${d + 1}天前';
|
||||
return date.ymdSMFormat(gap: gap);
|
||||
}
|
||||
}
|
||||
|
||||
/// 非空字符串扩展
|
||||
extension StringX on String {
|
||||
/// 截断到最大长度
|
||||
String limit(int max) {
|
||||
if (max <= 0) return '';
|
||||
return length <= max ? this : substring(0, max);
|
||||
}
|
||||
|
||||
/// 是否含 16 个以上的 0,用于识别全零设备号(如全零 UUID)
|
||||
bool hasManyZeros() {
|
||||
const zero = 0x30; //'0'
|
||||
var count = 0;
|
||||
for (var i = 0; i < length; i++) {
|
||||
if (codeUnitAt(i) == zero && ++count >= 16) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'package:hgdj/hj_model/actress_info.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
|
||||
import '../cartoon_media_info.dart';
|
||||
|
||||
class AllMediaInfo {
|
||||
List<Content>? contents;
|
||||
int? countBrowse;
|
||||
int? countCollect;
|
||||
int? countComment;
|
||||
int? countDisLike;
|
||||
int? countLike;
|
||||
int? countPurchases;
|
||||
int? countView;
|
||||
String? createdAt;
|
||||
int? direction;
|
||||
int? freeTime;
|
||||
bool? hasFollow;
|
||||
String? horizontalCover;
|
||||
List<ActressInfo>? allActress;
|
||||
|
||||
String? get coverH {
|
||||
return (horizontalCover?.isNotEmpty == true)
|
||||
? horizontalCover
|
||||
: verticalCover;
|
||||
}
|
||||
|
||||
String? get coverV {
|
||||
return (verticalCover?.isNotEmpty == true)
|
||||
? verticalCover
|
||||
: horizontalCover;
|
||||
}
|
||||
|
||||
String? id;
|
||||
int? kind;
|
||||
String? mId;
|
||||
VidStatus? mediaStatus;
|
||||
String? mediaType;
|
||||
String? moduleName;
|
||||
int? number;
|
||||
int? permission;
|
||||
bool? permissionIconHide;
|
||||
int? price;
|
||||
String? sId;
|
||||
String? sectionName;
|
||||
int? sectionSort;
|
||||
int? sellType;
|
||||
int? sortCode;
|
||||
int? status;
|
||||
String? summary;
|
||||
List<TagsBean>? tagDetails;
|
||||
List<String>? tags;
|
||||
|
||||
String? title;
|
||||
int? totalEpisode; //总集数
|
||||
int? currentEpisode; //当前集数
|
||||
|
||||
int? updateStatus; //0 默认 1、更新中 2、已完结
|
||||
String? updateTime;
|
||||
String? verticalCover;
|
||||
|
||||
AllMediaInfo({
|
||||
this.contents,
|
||||
this.countBrowse,
|
||||
this.countCollect,
|
||||
this.countComment,
|
||||
this.countDisLike,
|
||||
this.countLike,
|
||||
this.countPurchases,
|
||||
this.countView,
|
||||
this.allActress,
|
||||
this.createdAt,
|
||||
this.direction,
|
||||
this.freeTime,
|
||||
this.hasFollow,
|
||||
this.horizontalCover,
|
||||
this.id,
|
||||
this.kind,
|
||||
this.mId,
|
||||
this.mediaStatus,
|
||||
this.mediaType,
|
||||
this.moduleName,
|
||||
this.number,
|
||||
this.permission,
|
||||
this.permissionIconHide,
|
||||
this.price,
|
||||
this.sId,
|
||||
this.sectionName,
|
||||
this.sectionSort,
|
||||
this.sellType,
|
||||
this.sortCode,
|
||||
this.status,
|
||||
this.summary,
|
||||
this.tagDetails,
|
||||
this.tags,
|
||||
this.title,
|
||||
this.totalEpisode,
|
||||
this.currentEpisode,
|
||||
this.updateStatus,
|
||||
this.updateTime,
|
||||
this.verticalCover,
|
||||
});
|
||||
|
||||
//集数状态描述
|
||||
String get episodeNumberStatus {
|
||||
if (updateStatus == 2) {
|
||||
return '共$totalEpisode话';
|
||||
} else {
|
||||
return '更新${currentEpisode != 0 ? currentEpisode : totalEpisode}话';
|
||||
}
|
||||
}
|
||||
|
||||
//更新状态
|
||||
String get updateDesc {
|
||||
if (updateStatus == 2) {
|
||||
return '已完结';
|
||||
} else {
|
||||
return '连载中';
|
||||
}
|
||||
}
|
||||
|
||||
AllMediaInfo.fromJson(Map<String, dynamic> json) {
|
||||
contents = json["contents"] == null
|
||||
? []
|
||||
: List<Content>.from(json["contents"]!.map((x) => Content.fromJson(x)));
|
||||
countBrowse = json["countBrowse"];
|
||||
countCollect = json["countCollect"];
|
||||
countComment = json["countComment"];
|
||||
countDisLike = json["countDisLike"];
|
||||
countLike = json["countLike"];
|
||||
countPurchases = json["countPurchases"];
|
||||
countView = json["countView"];
|
||||
createdAt = json["createdAt"];
|
||||
direction = json["direction"];
|
||||
freeTime = json["freeTime"];
|
||||
hasFollow = json["hasFollow"];
|
||||
horizontalCover = json["horizontalCover"];
|
||||
allActress = json["actresses"] == null
|
||||
? []
|
||||
: List<ActressInfo>.from(
|
||||
json["actresses"]!.map((x) => ActressInfo.fromJson(x)));
|
||||
id = json["id"];
|
||||
kind = json["kind"];
|
||||
mId = json["mId"];
|
||||
mediaStatus = json["mediaStatus"] == null
|
||||
? null
|
||||
: VidStatus.fromMap(json["mediaStatus"]);
|
||||
mediaType = json["mediaType"];
|
||||
moduleName = json["moduleName"];
|
||||
number = json["number"];
|
||||
permission = json["permission"];
|
||||
permissionIconHide = json["permissionIconHide"];
|
||||
price = json["price"];
|
||||
sId = json["sId"];
|
||||
sectionName = json["sectionName"];
|
||||
sectionSort = json["sectionSort"];
|
||||
sellType = json["sellType"];
|
||||
sortCode = json["sortCode"];
|
||||
status = json["status"];
|
||||
summary = json["summary"];
|
||||
tagDetails = json["tagDetails"] == null
|
||||
? []
|
||||
: List<TagsBean>.from(
|
||||
json["tagDetails"]!.map((x) => TagsBean.fromMap(x)));
|
||||
tags = parseStringList(json["tags"]);
|
||||
title = json["title"];
|
||||
totalEpisode = json["totalEpisode"];
|
||||
currentEpisode = json['currentEpisode'];
|
||||
updateStatus = json["updateStatus"];
|
||||
updateTime = json["updateTime"];
|
||||
verticalCover = json["verticalCover"];
|
||||
}
|
||||
}
|
||||
|
||||
class Content {
|
||||
String? audioUrl;
|
||||
int? countBrowse;
|
||||
int? countCollect;
|
||||
int? countComment;
|
||||
int? countDisLike;
|
||||
int? countLike;
|
||||
int? countPurchases;
|
||||
String? createdAt;
|
||||
int? episodeNumber;
|
||||
int? height;
|
||||
String? id;
|
||||
int? listenPermission;
|
||||
String? md5;
|
||||
String? mediaId;
|
||||
int? mediaSize;
|
||||
VidStatus? mediaStatus;
|
||||
String? name;
|
||||
int? playTime;
|
||||
int? price;
|
||||
double? ratio;
|
||||
String? text;
|
||||
String? updateTime;
|
||||
List<String>? urlSet;
|
||||
String? videoUrl;
|
||||
int? weight;
|
||||
|
||||
Content({
|
||||
this.audioUrl,
|
||||
this.countBrowse,
|
||||
this.countCollect,
|
||||
this.countComment,
|
||||
this.countDisLike,
|
||||
this.countLike,
|
||||
this.countPurchases,
|
||||
this.createdAt,
|
||||
this.episodeNumber,
|
||||
this.height,
|
||||
this.id,
|
||||
this.listenPermission,
|
||||
this.md5,
|
||||
this.mediaId,
|
||||
this.mediaSize,
|
||||
this.mediaStatus,
|
||||
this.name,
|
||||
this.playTime,
|
||||
this.price,
|
||||
this.ratio,
|
||||
this.text,
|
||||
this.updateTime,
|
||||
this.urlSet,
|
||||
this.videoUrl,
|
||||
this.weight,
|
||||
});
|
||||
|
||||
Content.fromJson(Map<String, dynamic> json) {
|
||||
audioUrl = json["audioUrl"];
|
||||
countBrowse = json["countBrowse"];
|
||||
countCollect = json["countCollect"];
|
||||
countComment = json["countComment"];
|
||||
countDisLike = json["countDisLike"];
|
||||
countLike = json["countLike"];
|
||||
countPurchases = json["countPurchases"];
|
||||
createdAt = json["createdAt"];
|
||||
episodeNumber = json["episodeNumber"];
|
||||
height = json["height"];
|
||||
id = json["id"];
|
||||
listenPermission = json["listenPermission"];
|
||||
md5 = json["md5"];
|
||||
mediaId = json["mediaId"];
|
||||
mediaSize = json["mediaSize"];
|
||||
mediaStatus = json["mediaStatus"] == null
|
||||
? null
|
||||
: VidStatus.fromMap(json["mediaStatus"]);
|
||||
name = json["name"];
|
||||
playTime = json["playTime"];
|
||||
price = json["price"];
|
||||
ratio = double.tryParse(json['ratio']?.toString() ?? '');
|
||||
text = json["text"];
|
||||
updateTime = json["updateTime"];
|
||||
urlSet = parseStringList(json["urlSet"]);
|
||||
videoUrl = json["videoUrl"];
|
||||
weight = json["weight"];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"audioUrl": audioUrl,
|
||||
"countBrowse": countBrowse,
|
||||
"countCollect": countCollect,
|
||||
"countComment": countComment,
|
||||
"countDisLike": countDisLike,
|
||||
"countLike": countLike,
|
||||
"countPurchases": countPurchases,
|
||||
"createdAt": createdAt,
|
||||
"episodeNumber": episodeNumber,
|
||||
"height": height,
|
||||
"id": id,
|
||||
"listenPermission": listenPermission,
|
||||
"md5": md5,
|
||||
"mediaId": mediaId,
|
||||
"mediaSize": mediaSize,
|
||||
"mediaStatus": mediaStatus?.toJson(),
|
||||
"name": name,
|
||||
"playTime": playTime,
|
||||
"price": price,
|
||||
"ratio": ratio,
|
||||
"text": text,
|
||||
"updateTime": updateTime,
|
||||
"urlSet":
|
||||
urlSet == null ? [] : List<dynamic>.from(urlSet!.map((x) => x)),
|
||||
"videoUrl": videoUrl,
|
||||
"weight": weight,
|
||||
};
|
||||
}
|
||||
|
||||
class MediaSearchListModel {
|
||||
bool? hasNext;
|
||||
List<CartoonMediaInfo>? list;
|
||||
List<CartoonMediaInfo>? tagMedia;
|
||||
String? tagId;
|
||||
int? total;
|
||||
|
||||
MediaSearchListModel({this.hasNext, this.list, this.total, this.tagId});
|
||||
|
||||
MediaSearchListModel.fromJson(dynamic json) {
|
||||
hasNext = json['hasNext'];
|
||||
list = (json['list'] as List?)
|
||||
?.map((v) => CartoonMediaInfo.fromJson(v))
|
||||
.toList();
|
||||
tagMedia = (json['tagMedia'] as List?)
|
||||
?.map((v) => CartoonMediaInfo.fromJson(v))
|
||||
.toList();
|
||||
tagId = json['tagId'];
|
||||
total = json['total'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['hasNext'] = hasNext;
|
||||
|
||||
map['list'] = list?.map((v) => v.toJson()).toList();
|
||||
|
||||
map['total'] = total;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//漫画章节
|
||||
import 'package:hgdj/config/address.dart' as address;
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../media_content.dart';
|
||||
|
||||
class ComicChapterInfo {
|
||||
int? episodeNumber;
|
||||
bool? hasBuy;
|
||||
String? id;
|
||||
int? listenPermission; // 0:会员 1:金币购买 2:免费
|
||||
String? mediaId;
|
||||
String? name;
|
||||
int? price;
|
||||
String? createdAt;
|
||||
String? cover;
|
||||
String? audioUrl;
|
||||
|
||||
bool inFreeEpisode = false; //是否属于免费集数(前 freeEpisode 集)
|
||||
|
||||
MediaContent? mediaContent; //(另外的接口数据返回,外部手动赋值)
|
||||
|
||||
// 有声小说
|
||||
String get getRealAudioUrl {
|
||||
if (audioUrl == null || audioUrl!.isEmpty) {
|
||||
return "";
|
||||
}
|
||||
if (!audioUrl!.startsWith("http") && !audioUrl!.startsWith("https")) {
|
||||
return path.join(address.Address.audioCdnAddress ?? '', audioUrl);
|
||||
}
|
||||
return audioUrl!;
|
||||
}
|
||||
|
||||
ComicChapterInfo({
|
||||
this.episodeNumber,
|
||||
this.hasBuy,
|
||||
this.id,
|
||||
this.listenPermission,
|
||||
this.mediaId,
|
||||
this.name,
|
||||
this.price,
|
||||
this.createdAt,
|
||||
this.cover,
|
||||
this.audioUrl,
|
||||
});
|
||||
|
||||
ComicChapterInfo.fromJson(Map<String, dynamic> json) {
|
||||
episodeNumber = json['episodeNumber'];
|
||||
hasBuy = json['hasBuy'];
|
||||
id = json['id'];
|
||||
listenPermission = json['listenPermission'];
|
||||
mediaId = json['mediaId'];
|
||||
name = json['name'];
|
||||
price = json['price'];
|
||||
createdAt = json['createdAt'];
|
||||
cover = json['cover'];
|
||||
audioUrl = json['audioUrl'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['episodeNumber'] = episodeNumber;
|
||||
data['hasBuy'] = hasBuy;
|
||||
data['id'] = id;
|
||||
data['listenPermission'] = listenPermission;
|
||||
data['mediaId'] = mediaId;
|
||||
data['name'] = name;
|
||||
data['price'] = price;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
|
||||
class ActressInfo {
|
||||
String? age;
|
||||
String? alias;
|
||||
String? birthday;
|
||||
String? bloodType;
|
||||
String? chineseName;
|
||||
int? collectCount;
|
||||
num? commentCount;
|
||||
String? cupSize;
|
||||
String? debutTime;
|
||||
String? englishName;
|
||||
num? galleryCount;
|
||||
String? gender;
|
||||
num? growCount;
|
||||
String? height;
|
||||
String? id;
|
||||
int? likeCount;
|
||||
String? name;
|
||||
String? portrait;
|
||||
String? region;
|
||||
String? score;
|
||||
List<String>? seriesCover;
|
||||
String? summary;
|
||||
String? threeDimensions;
|
||||
int? type; // //1、女优 2、网黄 3、博主 4、声优
|
||||
int? videoCount;
|
||||
String? weight;
|
||||
String? office;
|
||||
int? picsCount;
|
||||
int? dislikeCount;
|
||||
bool? hasLiked;
|
||||
bool? hasDisliked;
|
||||
bool? hasCollected;
|
||||
int? viewCount;
|
||||
bool? hasSubscribe;
|
||||
|
||||
String get heightThreeDimen {
|
||||
if (threeDimensions?.isNotEmpty == true && height?.isNotEmpty == true) {
|
||||
return "身高三围: T$height $threeDimensions";
|
||||
}
|
||||
if (height?.isNotEmpty == true) {
|
||||
return "身高: T$height";
|
||||
}
|
||||
if (threeDimensions?.isNotEmpty == true) {
|
||||
return "三围: $threeDimensions";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
ActressInfo();
|
||||
|
||||
int get workCount {
|
||||
return (videoCount ?? 0) + (picsCount ?? 0);
|
||||
}
|
||||
|
||||
String get actressName {
|
||||
return name ?? '';
|
||||
}
|
||||
|
||||
ActressInfo.fromJson(dynamic json) {
|
||||
age = json['age'];
|
||||
alias = json['alias'];
|
||||
birthday = json['birthday'];
|
||||
bloodType = json['bloodType'];
|
||||
chineseName = json['chineseName'];
|
||||
collectCount = json['collectCount'];
|
||||
commentCount = json['commentCount'];
|
||||
cupSize = json['cupSize'].toString();
|
||||
debutTime = json['debutTime'];
|
||||
englishName = json['englishName'];
|
||||
galleryCount = json['galleryCount'];
|
||||
gender = json['gender'];
|
||||
growCount = json['growCount'];
|
||||
height = json['height'];
|
||||
id = json['id'];
|
||||
likeCount = json['likeCount'];
|
||||
name = json['name'];
|
||||
portrait = json['portrait'];
|
||||
region = json['region'];
|
||||
score = json['score'];
|
||||
seriesCover = parseStringList(json['seriesCover']);
|
||||
summary = json['summary'];
|
||||
threeDimensions = json['threeDimensions'];
|
||||
type = json['type'];
|
||||
weight = json['weight'];
|
||||
videoCount = json['videoCount'];
|
||||
office = json['office'];
|
||||
picsCount = json['picsCount'];
|
||||
dislikeCount = json['dislikeCount'];
|
||||
hasLiked = json['hasLiked'];
|
||||
hasDisliked = json['hasDisliked'];
|
||||
hasCollected = json['hasCollected'];
|
||||
viewCount = json['viewCount'];
|
||||
hasSubscribe = json['hasSubscribe'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['age'] = age;
|
||||
map['alias'] = alias;
|
||||
map['birthday'] = birthday;
|
||||
map['bloodType'] = bloodType;
|
||||
map['chineseName'] = chineseName;
|
||||
map['collectCount'] = collectCount;
|
||||
map['commentCount'] = commentCount;
|
||||
map['cupSize'] = cupSize.toString();
|
||||
map['debutTime'] = debutTime;
|
||||
map['englishName'] = englishName;
|
||||
map['galleryCount'] = galleryCount;
|
||||
map['gender'] = gender;
|
||||
map['growCount'] = growCount;
|
||||
map['height'] = height;
|
||||
map['id'] = id;
|
||||
map['likeCount'] = likeCount;
|
||||
map['name'] = name;
|
||||
map['portrait'] = portrait;
|
||||
map['region'] = region;
|
||||
map['score'] = score;
|
||||
map['seriesCover'] = seriesCover;
|
||||
map['summary'] = summary;
|
||||
map['threeDimensions'] = threeDimensions;
|
||||
map['type'] = type;
|
||||
map['videoCount'] = videoCount;
|
||||
map['weight'] = weight;
|
||||
map['office'] = office;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:hgdj/hj_model/actress_info.dart';
|
||||
|
||||
class ActressModel {
|
||||
ActressModel({
|
||||
this.hasFollow,
|
||||
this.info,
|
||||
});
|
||||
|
||||
ActressModel.fromJson(dynamic json) {
|
||||
hasFollow = json['hasFollow'];
|
||||
if (json['info'] != null) {
|
||||
info = ActressInfo.fromJson(json['info']);
|
||||
}
|
||||
if (json['desc'] is List) {
|
||||
desc = (json['desc'] as List).map((e) => e.toString()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
bool? hasFollow;
|
||||
ActressInfo? info;
|
||||
List<String>? desc;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// 评论区置顶 Banner(GET /banner/list?scene=COMMENT_TOP)
|
||||
class CommentTopBannerModel {
|
||||
String? id;
|
||||
String? imageUrl;
|
||||
String? mediaType; // IMAGE | GIF
|
||||
String? linkType; // INTERNAL | EXTERNAL | NONE
|
||||
String? linkValue;
|
||||
int? sort;
|
||||
|
||||
CommentTopBannerModel({
|
||||
this.id,
|
||||
this.imageUrl,
|
||||
this.mediaType,
|
||||
this.linkType,
|
||||
this.linkValue,
|
||||
this.sort,
|
||||
});
|
||||
|
||||
bool get hasLink {
|
||||
final t = linkType?.toUpperCase();
|
||||
return t == 'INTERNAL' || t == 'EXTERNAL';
|
||||
}
|
||||
|
||||
bool get isGif =>
|
||||
mediaType?.toUpperCase() == 'GIF' || (imageUrl?.toLowerCase().contains('.gif') ?? false);
|
||||
|
||||
factory CommentTopBannerModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
return CommentTopBannerModel(
|
||||
id: json['id']?.toString(),
|
||||
imageUrl: json['imageUrl']?.toString(),
|
||||
mediaType: json['mediaType']?.toString(),
|
||||
linkType: json['linkType']?.toString(),
|
||||
linkValue: json['linkValue']?.toString(),
|
||||
sort: json['sort'] is int ? json['sort'] as int : int.tryParse('${json['sort'] ?? ''}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_model/splash/ads_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
|
||||
import 'acg/comic_chapters_model.dart';
|
||||
import 'actress_info.dart';
|
||||
import 'media_content.dart';
|
||||
|
||||
class CartoonMediaInfo {
|
||||
// —— 基础信息 ——
|
||||
String? id; //文档id
|
||||
String? title; //标题
|
||||
String? author; //作者
|
||||
String? summary; //简介
|
||||
String? mediaType; //媒体类型 "video":动漫,"image":漫画 "text":小说
|
||||
int? mediaSubType; //子类型 ,暂时只有小说使用 0-默认文本小说 1-有声小说
|
||||
int? kind; //种类(1、哩番,2、3D,3、同人动画,4、单行本,5、连载漫画,6、韩漫 )
|
||||
int? status; //状态
|
||||
VidStatus? mediaStatus; //媒体状态
|
||||
String? lsjId; // 老司机媒体资源ID
|
||||
|
||||
// —— 封面 ——
|
||||
String? horizontalCover; //横版封面
|
||||
String? verticalCover; //竖版封面
|
||||
|
||||
// —— 集数 / 更新 ——
|
||||
int? totalEpisode; //总集数
|
||||
int? currentEpisode; //当前集数
|
||||
int? freeEpisode; //免费集数
|
||||
bool isCartoonFreeEpisode =
|
||||
false; //当前播放集是否属于免费集(前 freeEpisode 集):运行时按当前集号计算,命中则不拦播、进度可全程拖
|
||||
int? updateStatus; //更新状态 0 默认 1、更新中 2、已完结
|
||||
String? updateTime; //文档更新时间
|
||||
String? createdAt; //文档创建时间
|
||||
|
||||
// —— 价格 / 权限 ——
|
||||
int? price; //价格(整部购买的类型 小说 cos 写真)
|
||||
int? discountPrice; // 折扣价(后端字段,App 未使用)
|
||||
int? contentsPrice; //所有子集的价格
|
||||
int? permission; // 0:会员 1:金币购买 2:免费
|
||||
bool? permissionIconHide; //售卖类型标识隐藏
|
||||
|
||||
// —— 专题 / 模块 ——
|
||||
String? mId; //模块ID
|
||||
String? moduleName; //模块名称
|
||||
String? sId; //专题id
|
||||
String? sectionName; //专题名称
|
||||
int? sectionSort; //专题排序
|
||||
int? sortCode; //排序
|
||||
|
||||
// —— 统计 ——
|
||||
int? countBrowse; //浏览数
|
||||
int? countCollect; //收藏数
|
||||
int? countDisLike; //不喜欢数
|
||||
int? countComment; //评论数
|
||||
int? countLike; //喜欢数
|
||||
int? countPurchases; //购买数
|
||||
int? countView; //展现数
|
||||
bool? hasFollow; //是否关注
|
||||
|
||||
// —— 标签 / 演员 / 内容 ——
|
||||
List<TagsBean>? tagDetails; //标签对象
|
||||
List<ActressInfo>? allActress; // 演员列表
|
||||
ActressInfo? actress; // 主演员
|
||||
List<MediaContent>? contents; // 子集内容列表
|
||||
AdsInfoModel? randomAdsInfo; // 随机广告
|
||||
|
||||
// —— 其他后端字段 ——
|
||||
int? direction; // 方向(横/竖)
|
||||
int? freeTime; // 免费试看时长
|
||||
bool? isDelete; // 是否已删除
|
||||
int? choiceSort; // 后端字段,App 未使用
|
||||
int? countPurchasesRate; // 后端字段,App 未使用
|
||||
String? updatedAct; // 后端字段,App 未使用
|
||||
|
||||
// —— 本地 / UI 字段(非后端返回) ——
|
||||
List<ComicChapterInfo>? episodeList; // 通过接口获取,外部赋值(改版了,不是同一个接口返回)
|
||||
int? episodeCurPage; // 当前页码
|
||||
int episodePageSize = 40; // 每页集数
|
||||
int isUpSort = 0; //0-正序 1-倒序
|
||||
bool haxNextEpisode = true; // 是否还有下一页
|
||||
int? episodeNumber; //本地添加字段。用于判断,跳转到播放详情的index
|
||||
bool isSelected = false; // 是否选中
|
||||
String? heroTag; //本地字段,用于传递hero动画tag 随机数
|
||||
|
||||
String? get coverH =>
|
||||
horizontalCover?.isNotEmpty == true ? horizontalCover : verticalCover;
|
||||
|
||||
String? get coverV =>
|
||||
verticalCover?.isNotEmpty == true ? verticalCover : horizontalCover;
|
||||
|
||||
String get unit => mediaType == 'image' ? '话' : '章';
|
||||
//更新状态
|
||||
String get updateDesc => updateStatus == 1 ? '连载中' : '已完结';
|
||||
|
||||
String get tagIds => tagDetails?.map((e) => e.id).toList().join(',') ?? '';
|
||||
String get tagNames =>
|
||||
tagDetails?.map((e) => e.name).toList().join(',') ?? '';
|
||||
|
||||
//更新状态
|
||||
String get getDetailUpdateString {
|
||||
if (updateStatus == 2 || updateStatus == 0) {
|
||||
return '已完结';
|
||||
} else {
|
||||
return '连载中';
|
||||
}
|
||||
}
|
||||
|
||||
Color get getDetailUpdateColor {
|
||||
if (updateStatus == 2 || updateStatus == 0) {
|
||||
return Color(0xff989898);
|
||||
} else {
|
||||
return AppColors.actionRed;
|
||||
}
|
||||
}
|
||||
|
||||
//是否是有声小说
|
||||
bool get isAudiobooks => mediaType == 'text' && mediaSubType == 1;
|
||||
|
||||
//集数状态描述
|
||||
String get episodeNumberStatus {
|
||||
if (updateStatus == 1) {
|
||||
if (currentEpisode == 0) {
|
||||
return '共$totalEpisode$unit';
|
||||
} else {
|
||||
return '更新$currentEpisode$unit';
|
||||
}
|
||||
} else {
|
||||
return '共$totalEpisode$unit';
|
||||
}
|
||||
}
|
||||
|
||||
//评论类型,传参数类型
|
||||
String get commentType => 'cartoon';
|
||||
|
||||
/// 该集是否落在前 N 集免费区间内。
|
||||
/// 集号缺失兜底为 1(与项目其他处 `episodeNumber ?? 1` 口径一致);服务端真给 0 或负数时判为非免费
|
||||
bool isFreeEpisodeNumber(int? episodeNumber) {
|
||||
final ep = episodeNumber ?? 1;
|
||||
return ep >= 1 && (freeEpisode ?? 0) >= ep;
|
||||
}
|
||||
|
||||
/// 转成播放器用的 [VideoModel],[episode] 为当前集
|
||||
/// 顺带把 [isCartoonFreeEpisode] 按当前集号刷新一次(有副作用,别当纯转换用)
|
||||
VideoModel toVideoModel(ComicChapterInfo? episode) {
|
||||
final model = VideoModel()
|
||||
..videoType = 1
|
||||
..id = id
|
||||
..totalEpisode = totalEpisode
|
||||
..updateStatus = updateStatus
|
||||
..mediaType = mediaType
|
||||
..mediaInfo = this
|
||||
..title = title
|
||||
..tags = tagDetails
|
||||
..cover = coverH
|
||||
..likeCount = countCollect // 视频likecount是收藏数
|
||||
..freeTime = freeTime ?? 0
|
||||
..collectCount = countCollect
|
||||
..commentCount = countComment
|
||||
..playCount = countBrowse
|
||||
..freeArea = permission == 2
|
||||
..coins = price
|
||||
..originCoins = price
|
||||
..vidStatus = (VidStatus()
|
||||
..hasPaid = mediaStatus?.hasPaid == true
|
||||
..hasCollected = mediaStatus?.hasCollected
|
||||
..hasLiked = mediaStatus?.hasLiked
|
||||
..hasDisliked = mediaStatus?.hasDisliked);
|
||||
|
||||
// 动漫免费集:当前集号在前 freeEpisode 集内 → 播放不拦、进度可全程拖(与 video_logic 免费章节判定同一口径)
|
||||
isCartoonFreeEpisode = isFreeEpisodeNumber(episode?.episodeNumber);
|
||||
|
||||
final list = episodeList ?? [];
|
||||
if (list.isNotEmpty) {
|
||||
//在列表里找不到当前集就退回第一集,别让播放信息整块空掉
|
||||
final index = list.indexWhere((e) => e.id == episode?.id);
|
||||
final content = list[index < 0 ? 0 : index].mediaContent;
|
||||
model
|
||||
..subid = content?.id
|
||||
..name = content?.name
|
||||
..sourceURL = content?.videoUrl
|
||||
..playTime = content?.playTime;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
//acg子集角标显示逻辑,0:会员 1:金币购买 2:免费。为 false(无任何权限)时才展示角标
|
||||
bool get hasPermission {
|
||||
return (mediaStatus?.hasPaid == true && permission == 1) ||
|
||||
permission == 2 ||
|
||||
(permission == 0 && globalStore.isVIP) ||
|
||||
globalStore.isSuperUp;
|
||||
}
|
||||
|
||||
CartoonMediaInfo(
|
||||
{this.contents,
|
||||
this.choiceSort,
|
||||
this.countBrowse,
|
||||
this.countCollect,
|
||||
this.countComment,
|
||||
this.countLike,
|
||||
this.countPurchases,
|
||||
this.countPurchasesRate,
|
||||
this.countView,
|
||||
this.createdAt,
|
||||
this.direction,
|
||||
this.freeTime,
|
||||
this.horizontalCover,
|
||||
this.id,
|
||||
this.isDelete,
|
||||
this.kind,
|
||||
this.mId,
|
||||
this.mediaType,
|
||||
this.moduleName,
|
||||
this.permission,
|
||||
this.permissionIconHide,
|
||||
this.price,
|
||||
this.sId,
|
||||
this.discountPrice,
|
||||
this.sectionName,
|
||||
this.sectionSort,
|
||||
this.sortCode,
|
||||
this.status,
|
||||
this.summary,
|
||||
this.tagDetails,
|
||||
this.title,
|
||||
this.totalEpisode,
|
||||
this.currentEpisode,
|
||||
this.updateStatus,
|
||||
this.updateTime,
|
||||
this.updatedAct,
|
||||
this.verticalCover,
|
||||
this.author,
|
||||
this.freeEpisode,
|
||||
this.contentsPrice,
|
||||
this.mediaSubType,
|
||||
this.actress});
|
||||
|
||||
CartoonMediaInfo.fromJson(dynamic json) {
|
||||
freeEpisode = json['freeEpisode'];
|
||||
mediaSubType = json['mediaSubType'];
|
||||
author = json['author'];
|
||||
|
||||
choiceSort = json['choiceSort'];
|
||||
totalEpisode = json['totalEpisode'];
|
||||
currentEpisode = json['currentEpisode'];
|
||||
countBrowse = json['countBrowse'];
|
||||
countCollect = json['countCollect'];
|
||||
countComment = json['countComment'];
|
||||
countLike = json['countLike'];
|
||||
countDisLike = json['countDisLike'];
|
||||
countPurchases = json['countPurchases'];
|
||||
countPurchasesRate = json['countPurchasesRate'];
|
||||
discountPrice = json['discountPrice'];
|
||||
countView = json['countView'];
|
||||
createdAt = json['createdAt'];
|
||||
direction = json['direction'];
|
||||
freeTime = json['freeTime'];
|
||||
horizontalCover = json['horizontalCover'];
|
||||
id = json['id'];
|
||||
isDelete = json['isDelete'];
|
||||
kind = json['kind'];
|
||||
mId = json['mId'];
|
||||
mediaType = json['mediaType'];
|
||||
moduleName = json['moduleName'];
|
||||
|
||||
permission = json['permission'];
|
||||
permissionIconHide = json['permissionIconHide'];
|
||||
price = json['price'];
|
||||
sId = json['sId'];
|
||||
sectionName = json['sectionName'];
|
||||
sectionSort = json['sectionSort'];
|
||||
|
||||
sortCode = json['sortCode'];
|
||||
status = json['status'];
|
||||
summary = json['summary'];
|
||||
lsjId = json['lsjId'];
|
||||
actress =
|
||||
json["actress"] == null ? null : ActressInfo.fromJson(json["actress"]);
|
||||
allActress = json["actresses"] == null
|
||||
? []
|
||||
: List<ActressInfo>.from(
|
||||
json["actresses"]!.map((x) => ActressInfo.fromJson(x)));
|
||||
hasFollow = json['hasFollow'];
|
||||
if (json['tagDetails'] != null) {
|
||||
tagDetails = [];
|
||||
json['tagDetails'].forEach((v) {
|
||||
tagDetails?.add(TagsBean.fromMap(v));
|
||||
});
|
||||
}
|
||||
if (json['contents'] != null) {
|
||||
contents = [];
|
||||
json['contents'].forEach((v) {
|
||||
contents?.add(MediaContent.fromJson(v));
|
||||
});
|
||||
}
|
||||
|
||||
if (json['title'] == null) {
|
||||
title = json['name'];
|
||||
} else {
|
||||
title = json['title'];
|
||||
}
|
||||
updateStatus = json['updateStatus'];
|
||||
updateTime = json['updateTime'];
|
||||
updatedAct = json['updatedAct'];
|
||||
verticalCover = json['verticalCover'];
|
||||
mediaStatus = VidStatus.fromMap(json['mediaStatus']);
|
||||
contentsPrice = json['contentsPrice'];
|
||||
}
|
||||
|
||||
///videoModel是否随机广告
|
||||
bool isRandomAd() {
|
||||
return randomAdsInfo != null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['mediaSubType'] = mediaSubType;
|
||||
map['choiceSort'] = choiceSort;
|
||||
map['countBrowse'] = countBrowse;
|
||||
map['countCollect'] = countCollect;
|
||||
map['countComment'] = countComment;
|
||||
map['countLike'] = countLike;
|
||||
map['countDisLike'] = countDisLike;
|
||||
map['countPurchases'] = countPurchases;
|
||||
map['countPurchasesRate'] = countPurchasesRate;
|
||||
map['countView'] = countView;
|
||||
map['createdAt'] = createdAt;
|
||||
map['direction'] = direction;
|
||||
map['freeTime'] = freeTime;
|
||||
map['horizontalCover'] = horizontalCover;
|
||||
map['id'] = id;
|
||||
map['isDelete'] = isDelete;
|
||||
map['kind'] = kind;
|
||||
map['mId'] = mId;
|
||||
map['mediaType'] = mediaType;
|
||||
map['moduleName'] = moduleName;
|
||||
|
||||
map['permission'] = permission;
|
||||
map['permissionIconHide'] = permissionIconHide;
|
||||
map['price'] = price;
|
||||
map['sId'] = sId;
|
||||
map['sectionName'] = sectionName;
|
||||
map['sectionSort'] = sectionSort;
|
||||
|
||||
map['sortCode'] = sortCode;
|
||||
map['status'] = status;
|
||||
map['summary'] = summary;
|
||||
map['lsjId'] = lsjId;
|
||||
if (tagDetails != null) {
|
||||
map['tagDetails'] = tagDetails?.map((v) => v.toJson()).toList();
|
||||
}
|
||||
if (contents != null) {
|
||||
map['contents'] = contents?.map((v) => v.toJson()).toList();
|
||||
}
|
||||
map['hasFollow'] = hasFollow;
|
||||
map['title'] = title;
|
||||
map['totalEpisode'] = totalEpisode;
|
||||
map['currentEpisode'] = currentEpisode;
|
||||
map['updateTime'] = updateTime;
|
||||
map['updateStatus'] = updateStatus;
|
||||
map['updatedAct'] = updatedAct;
|
||||
map['verticalCover'] = verticalCover;
|
||||
map['mediaStatus'] = mediaStatus;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:hgdj/hj_model/comment/comment_model.dart';
|
||||
|
||||
///评论列表返回信息
|
||||
class CommentListRes {
|
||||
List<CommentModel>? list;
|
||||
int? total;
|
||||
bool? hasNext = false;
|
||||
List<CommentLink>? quickSearchList;
|
||||
|
||||
CommentListRes.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
list =
|
||||
(json['list'] as List?)?.map((e) => CommentModel.fromJson(e)).toList();
|
||||
total = json['total'];
|
||||
hasNext = json['hasNext'];
|
||||
quickSearchList = (json['quickSearchList'] as List?)
|
||||
?.map((e) => CommentLink.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
///评论区快捷入口
|
||||
class CommentLink {
|
||||
String? id;
|
||||
String? title;
|
||||
String? searchKeyword;
|
||||
String? link;
|
||||
int? type; //1:评论置顶 2:评论大家都在搜
|
||||
|
||||
CommentLink.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
title = json['title'];
|
||||
searchKeyword = json['searchKeyword'];
|
||||
link = json['link'];
|
||||
type = json['type'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:hgdj/hj_model/comment/reply_model.dart';
|
||||
|
||||
class CommentModel {
|
||||
String? id;
|
||||
|
||||
//用户信息
|
||||
int? userID;
|
||||
String? userName;
|
||||
String? userPortrait;
|
||||
bool? superUser;
|
||||
int? vipLevel;
|
||||
|
||||
//评论内容
|
||||
String? content;
|
||||
String? image;
|
||||
String? createdAt;
|
||||
bool? isGodComment; //神评论
|
||||
bool? official; //官方评论,读取用 isOfficial
|
||||
|
||||
//互动数据
|
||||
int? likeCount;
|
||||
int? commCount;
|
||||
bool? isLike;
|
||||
|
||||
//超链接评论(点评论跳搜索/外链)
|
||||
String? searchKeyword;
|
||||
int? linkType; //1:内链跳转 2:外链跳转
|
||||
String? linkStr;
|
||||
|
||||
//子回复,分页在本地维护
|
||||
List<ReplyModel>? replies;
|
||||
bool? hasMoreReply = false; //还有更多子回复
|
||||
int? replyPage = 1;
|
||||
|
||||
bool get isOfficial => official == true;
|
||||
|
||||
//超链接评论隐藏点赞/回复入口 true 隐藏 false 显示
|
||||
bool get isHideLikeOrComment =>
|
||||
(linkType == 1 || linkType == 2) &&
|
||||
linkStr?.isNotEmpty == true &&
|
||||
searchKeyword?.isNotEmpty == true;
|
||||
|
||||
CommentModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
userID = json['userID'];
|
||||
userName = json['userName'];
|
||||
userPortrait = json['userPortrait'];
|
||||
superUser = json['superUser'];
|
||||
vipLevel = json['vipLevel'];
|
||||
content = json['content'];
|
||||
image = json['image'];
|
||||
createdAt = json['createdAt'];
|
||||
isGodComment = json['isGodComment'];
|
||||
official = json['isOfficial'];
|
||||
likeCount = json['likeCount'];
|
||||
commCount = json['commCount'];
|
||||
isLike = json['isLike'];
|
||||
searchKeyword = json['searchKeyword'];
|
||||
linkType = json['linkType'];
|
||||
linkStr = json['linkStr'];
|
||||
replies =
|
||||
(json['Info'] as List?)?.map((e) => ReplyModel.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
class ReplyModel {
|
||||
String? id;
|
||||
|
||||
//用户信息
|
||||
int? userID;
|
||||
String? userName;
|
||||
String? userPortrait;
|
||||
|
||||
//被回复人
|
||||
int? toUserID;
|
||||
String? toUserName;
|
||||
|
||||
//回复内容
|
||||
String? content;
|
||||
String? image;
|
||||
String? createdAt;
|
||||
|
||||
//互动数据
|
||||
int? likeCount;
|
||||
bool? isLike;
|
||||
|
||||
ReplyModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
userID = json['userID'];
|
||||
userName = json['userName'];
|
||||
userPortrait = json['userPortrait'];
|
||||
toUserID = json['toUserID'];
|
||||
toUserName = json['toUserName'];
|
||||
content = json['content'];
|
||||
image = json['image'];
|
||||
createdAt = json['createdAt'];
|
||||
likeCount = json['likeCount'];
|
||||
isLike = json['isLike'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
///我的评论列表项
|
||||
class UserCommentItem {
|
||||
String? id;
|
||||
String? objID;
|
||||
String? content;
|
||||
int? likeCount;
|
||||
bool? isGodComment;
|
||||
String? vidTitle;
|
||||
String? vidCover;
|
||||
String? createdAt;
|
||||
|
||||
UserCommentItem.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
objID = json['objID'];
|
||||
content = json['content'];
|
||||
likeCount = json['likeCount'];
|
||||
isGodComment = json['isGodComment'];
|
||||
vidTitle = json['vidTitle'];
|
||||
vidCover = json['vidCover'];
|
||||
createdAt = json['createdAt'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:hgdj/hj_model/drama_media_info.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
|
||||
/// 一条短剧观看记录:续播位置 + 列表展示用的剧信息快照。本地记录,不走后端,见 [DramaResumeStore]。
|
||||
/// 落库时整条存成 model_data,所以 [mediaId] 必须写进 json——
|
||||
/// 基类只回 model_data 一列,读盘时全靠它认领是哪部剧
|
||||
class DramaResume {
|
||||
final String mediaId; //哪部剧,同时是行主键
|
||||
final String contentId; //上次看的分集。定位只认它,别再存「第几集」——分页深度靠边翻边找就够了
|
||||
final int progressSeconds; //看到第几秒
|
||||
|
||||
/// 剧信息快照(封面/剧名/集数)。续播定位用不到,是历史记录页拿它画卡片——
|
||||
/// 只存 id 的话列表除了 id 什么都渲染不出来
|
||||
final DramaMediaInfo? drama;
|
||||
|
||||
/// 本条写入时刻(毫秒)。历史记录页按它倒序,不看 db 的 create_time——
|
||||
/// 老数据没这个字段,读出来是 0,排在最后
|
||||
final int watchedAt;
|
||||
|
||||
const DramaResume({
|
||||
required this.mediaId,
|
||||
required this.contentId,
|
||||
required this.progressSeconds,
|
||||
this.drama,
|
||||
this.watchedAt = 0,
|
||||
});
|
||||
|
||||
DramaResume.fromJson(dynamic json)
|
||||
: mediaId = json?['mediaId'] ?? '',
|
||||
contentId = json?['contentId'] ?? '',
|
||||
progressSeconds = json?['progressSeconds'] ?? 0,
|
||||
drama = json?['drama'] == null
|
||||
? null
|
||||
: DramaMediaInfo.fromJson(json['drama']),
|
||||
watchedAt = json?['watchedAt'] ?? 0;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mediaId': mediaId,
|
||||
'contentId': contentId,
|
||||
'progressSeconds': progressSeconds,
|
||||
'drama': drama?.toJson(),
|
||||
'watchedAt': watchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/// 付费墙:文案/价格/余额全部服务端下发,前端不自己拼
|
||||
class DramaPaywall {
|
||||
String? checkoutContextId; //本次付费墙上下文,充值/开卡归因和下单都要带
|
||||
String? title;
|
||||
int? coinBalance;
|
||||
int? unlockCoin;
|
||||
String? coinButtonText;
|
||||
String? cardButtonText;
|
||||
|
||||
DramaPaywall.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
checkoutContextId = json['checkoutContextId'];
|
||||
title = json['title'];
|
||||
coinBalance = json['coinBalance'];
|
||||
unlockCoin = json['unlockCoin'];
|
||||
coinButtonText = json['coinButtonText'];
|
||||
cardButtonText = json['cardButtonText'];
|
||||
}
|
||||
|
||||
String get titleUI => title?.isNotEmpty == true ? title! : '更多精彩解锁即享';
|
||||
|
||||
String get coinButtonUI => coinButtonText?.isNotEmpty == true
|
||||
? coinButtonText!
|
||||
: '${unlockCoin ?? 0}金币解锁';
|
||||
|
||||
String get cardButtonUI =>
|
||||
cardButtonText?.isNotEmpty == true ? cardButtonText! : '开通短剧卡免费看';
|
||||
}
|
||||
|
||||
/// AI短剧 Feed 的一条:某部剧 + 该剧第一集。整页走 ListBaseModel<DramaFeedItem>
|
||||
class DramaFeedItem {
|
||||
DramaMediaInfo? media;
|
||||
MediaContent? content;
|
||||
|
||||
DramaFeedItem.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
//缺 media 就不造空模型,上层按 null 过滤掉这条,别让信息流多出一个没剧名没地址的空位
|
||||
if (json['media'] != null) media = DramaMediaInfo.fromJson(json['media']);
|
||||
if (json['content'] != null)
|
||||
content = MediaContent.fromJson(json['content']);
|
||||
}
|
||||
}
|
||||
|
||||
/// 短剧专题(热门短剧 Tab)
|
||||
class DramaTopic {
|
||||
String? topicId;
|
||||
String? name;
|
||||
String? topicType;
|
||||
String? systemKey;
|
||||
int? sort;
|
||||
int? workCount;
|
||||
|
||||
DramaTopic({
|
||||
this.topicId,
|
||||
this.name,
|
||||
this.topicType,
|
||||
this.systemKey,
|
||||
this.sort,
|
||||
this.workCount,
|
||||
});
|
||||
|
||||
DramaTopic.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
topicId = json['topicId']?.toString();
|
||||
name = json['name']?.toString();
|
||||
topicType = json['topicType']?.toString();
|
||||
systemKey = json['systemKey']?.toString();
|
||||
sort = json['sort'] is int
|
||||
? json['sort'] as int
|
||||
: int.tryParse('${json['sort'] ?? ''}');
|
||||
workCount = json['workCount'] is int
|
||||
? json['workCount'] as int
|
||||
: int.tryParse('${json['workCount'] ?? ''}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 短剧搜索结果(`/media/search` kind=4)
|
||||
class DramaSearchResult {
|
||||
List<DramaMediaInfo> list = [];
|
||||
List<DramaMediaInfo> tagMediaList = [];
|
||||
String? tagID;
|
||||
bool hasNext = false;
|
||||
|
||||
DramaSearchResult.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
hasNext = json['hasNext'] == true;
|
||||
tagID = json['tagID']?.toString();
|
||||
list = (json['list'] as List?)
|
||||
?.map((e) => DramaMediaInfo.fromJson(e))
|
||||
.toList() ??
|
||||
[];
|
||||
tagMediaList = (json['tagMediaList'] as List?)
|
||||
?.map((e) => DramaMediaInfo.fromJson(e))
|
||||
.toList() ??
|
||||
[];
|
||||
}
|
||||
}
|
||||
|
||||
/// 单集金币解锁的下单结果
|
||||
class DramaUnlockResult {
|
||||
String? orderId;
|
||||
String? mediaId;
|
||||
String? contentId;
|
||||
int? paidCoin;
|
||||
int? coinBalance;
|
||||
|
||||
DramaUnlockResult.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
orderId = json['orderId'];
|
||||
mediaId = json['mediaId'];
|
||||
contentId = json['contentId'];
|
||||
paidCoin = json['paidCoin'];
|
||||
coinBalance = json['coinBalance'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 短剧单集下载授权结果。权益/上下架/次数校验和扣次都在服务端一次做完,
|
||||
/// 客户端只负责拿地址开下载,不再自己算次数
|
||||
class DramaDownloadAuth {
|
||||
String? mediaId;
|
||||
String? contentId;
|
||||
int? episodeNumber;
|
||||
String? name;
|
||||
String? cover;
|
||||
String? downloadUrl; // H.264 m3u8,可能是相对地址
|
||||
String? h265DownloadUrl; // H.265 m3u8,没有时为空串
|
||||
int? mediaSize; // 字节
|
||||
String? expiresAt; // 本次地址失效时间(签发后 6 小时)
|
||||
int? remainingDownloadCount; // 扣完之后钱包剩余下载次数,直接用它顶掉本地值
|
||||
bool? charged; // false = 幂等重试命中,没有重复扣次
|
||||
|
||||
DramaDownloadAuth.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
mediaId = json['mediaId'];
|
||||
contentId = json['contentId'];
|
||||
episodeNumber = json['episodeNumber'];
|
||||
name = json['name'];
|
||||
cover = json['cover'];
|
||||
downloadUrl = json['downloadUrl'];
|
||||
h265DownloadUrl = json['h265DownloadUrl'];
|
||||
mediaSize = json['mediaSize'];
|
||||
expiresAt = json['expiresAt'];
|
||||
remainingDownloadCount = json['remainingDownloadCount'];
|
||||
charged = json['charged'] == true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
|
||||
/// 短剧作品信息(接口 DramaInfo:一部剧 + 一堆分集)
|
||||
/// 单集播放数据用 [toVideoModel] 转成 VideoModel 交给短视频播放器
|
||||
class DramaMediaInfo {
|
||||
// —— 基础信息 ——
|
||||
String? id; //短剧id
|
||||
String? title; //剧名
|
||||
String? author; //作者/出品方
|
||||
String? summary; //简介
|
||||
int? kind; //种类
|
||||
int? style; //样式
|
||||
int? status; //0 下架 1 上架
|
||||
int? number; //序号
|
||||
VidStatus? mediaStatus; //媒体状态(已购/已收藏/已点赞)
|
||||
String? lsjId; // 老司机媒体资源ID
|
||||
|
||||
// —— 封面 ——
|
||||
String? horizontalCover; //横版封面
|
||||
String? verticalCover; //竖版封面
|
||||
|
||||
// —— 集数 / 更新 ——
|
||||
int? totalEpisode; //总集数
|
||||
int? currentEpisode; //已更新到第几集
|
||||
int? freeEpisode; //默认免费集数,仅后台初始化用;能不能播一律看分集的 canPlay
|
||||
int? updateStatus; //1 连载中 2 已完结
|
||||
String? updateTime; //更新时间
|
||||
String? createdAt; //创建时间
|
||||
|
||||
// —— 价格 / 权限 ——
|
||||
int? price; //整部价格
|
||||
int? contentsPrice; //所有分集的价格
|
||||
int? permission; // 0:会员/短剧卡 1:金币购买 2:免费
|
||||
bool? permissionIconHide; //售卖类型标识隐藏
|
||||
|
||||
// —— 专题 / 模块 ——
|
||||
String? mId; //模块ID
|
||||
String? moduleName; //模块名称
|
||||
String? sId; //专题id
|
||||
String? sectionName; //专题名称
|
||||
int? sectionSort; //专题排序
|
||||
int? sortCode; //排序
|
||||
|
||||
// —— 统计 ——
|
||||
int? countBrowse; //浏览数
|
||||
int? countCollect; //收藏数
|
||||
int? countDisLike; //不喜欢数
|
||||
int? countComment; //评论数
|
||||
int? countLike; //喜欢数
|
||||
int? countPurchases; //购买数
|
||||
int? countView; //展现数
|
||||
bool? hasFollow; //是否关注
|
||||
|
||||
// —— 标签 ——
|
||||
List<TagsBean>? tagDetails; //标签对象
|
||||
|
||||
// —— 其他后端字段 ——
|
||||
int? direction; //方向(横/竖)
|
||||
int? freeTime; //免费试看时长
|
||||
|
||||
// —— 本地 / UI 字段(非后端返回) ——
|
||||
List<MediaContent>? episodeList; //分集列表,走 /media_content/list 单独拉,外部赋值
|
||||
int? episodeCurPage; //当前页码
|
||||
//每页集数:必须与选集面板的分段大小(DramaEpisodeSheet._segmentSize)一致,
|
||||
//这样第 i 页正好是第 i 段,点哪段补哪页,不会出现「点进去先 10 个再变 30 个」
|
||||
int episodePageSize = 30;
|
||||
int isUpSort = 0; //0-正序 1-倒序
|
||||
bool haxNextEpisode = true; //是否还有下一页
|
||||
bool isSelected = false; //是否选中
|
||||
|
||||
String? get coverH =>
|
||||
horizontalCover?.isNotEmpty == true ? horizontalCover : verticalCover;
|
||||
|
||||
String? get coverV =>
|
||||
verticalCover?.isNotEmpty == true ? verticalCover : horizontalCover;
|
||||
|
||||
String get unit => '集';
|
||||
|
||||
//更新状态
|
||||
String get updateDesc => updateStatus == 1 ? '连载中' : '已完结';
|
||||
|
||||
String get tagIds => tagDetails?.map((e) => e.id).toList().join(',') ?? '';
|
||||
|
||||
String get tagNames =>
|
||||
tagDetails?.map((e) => e.name).toList().join(',') ?? '';
|
||||
|
||||
Color get updateDescColor =>
|
||||
updateStatus == 1 ? AppColors.actionRed : const Color(0xff989898);
|
||||
|
||||
//集数状态描述
|
||||
String get episodeNumberStatus {
|
||||
if (updateStatus == 1 && (currentEpisode ?? 0) > 0) {
|
||||
return '更新$currentEpisode$unit';
|
||||
}
|
||||
//没有集数就别出这行——直接插值会渲染成「共null集」
|
||||
return (totalEpisode ?? 0) > 0 ? '共$totalEpisode$unit' : '';
|
||||
}
|
||||
|
||||
//点赞/评论/分享统一传这个类型
|
||||
String get commentType => 'drama';
|
||||
|
||||
/// 转成播放器用的 [VideoModel],[episode] 为当前集;列表卡片场景传 null
|
||||
VideoModel toVideoModel(MediaContent? episode) {
|
||||
return VideoModel()
|
||||
..id = id
|
||||
..dramaInfo = this
|
||||
..dramaEpisode = episode
|
||||
..subid = episode?.id
|
||||
..episodeNo = episode?.episodeNumber
|
||||
..totalEpisode = totalEpisode
|
||||
..updateStatus = updateStatus
|
||||
..title = title
|
||||
..tags = tagDetails
|
||||
..name = episode?.name
|
||||
..cover = episode?.cover?.isNotEmpty == true ? episode?.cover : coverV
|
||||
..sourceURL = episode?.videoUrl
|
||||
..h265Url = episode?.h265Url
|
||||
..playTime = episode?.playTime
|
||||
..likeCount = countLike // 短剧点赞走 thumbsUp,操作台右侧显示的就是这个数
|
||||
..freeTime = freeTime ?? 0
|
||||
..collectCount = countCollect
|
||||
..commentCount = countComment
|
||||
..playCount = countBrowse
|
||||
..freeArea = episode?.isFree == true
|
||||
..coins = price
|
||||
..originCoins = price
|
||||
..vidStatus = (VidStatus()
|
||||
..hasPaid = mediaStatus?.hasPaid == true
|
||||
..hasCollected = mediaStatus?.hasCollected
|
||||
..hasLiked = mediaStatus?.hasLiked
|
||||
..hasDisliked = mediaStatus?.hasDisliked);
|
||||
}
|
||||
|
||||
DramaMediaInfo();
|
||||
|
||||
DramaMediaInfo.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
title = json['title'] ?? json['name'];
|
||||
author = json['author'];
|
||||
summary = json['summary'];
|
||||
kind = json['kind'];
|
||||
style = json['style'];
|
||||
status = json['status'];
|
||||
number = json['number'];
|
||||
lsjId = json['lsjId'];
|
||||
horizontalCover = json['horizontalCover'];
|
||||
verticalCover = json['verticalCover'];
|
||||
totalEpisode = json['totalEpisode'];
|
||||
currentEpisode = json['currentEpisode'];
|
||||
freeEpisode = json['freeEpisode'];
|
||||
updateStatus = json['updateStatus'];
|
||||
updateTime = json['updateTime'];
|
||||
createdAt = json['createdAt'];
|
||||
price = json['price'];
|
||||
contentsPrice = json['contentsPrice'];
|
||||
permission = json['permission'];
|
||||
permissionIconHide = json['permissionIconHide'];
|
||||
mId = json['mId'];
|
||||
moduleName = json['moduleName'];
|
||||
sId = json['sId'];
|
||||
sectionName = json['sectionName'];
|
||||
sectionSort = json['sectionSort'];
|
||||
sortCode = json['sortCode'];
|
||||
countBrowse = json['countBrowse'];
|
||||
countCollect = json['countCollect'];
|
||||
countDisLike = json['countDisLike'];
|
||||
countComment = json['countComment'];
|
||||
countLike = json['countLike'];
|
||||
countPurchases = json['countPurchases'];
|
||||
countView = json['countView'];
|
||||
hasFollow = json['hasFollow'];
|
||||
direction = json['direction'];
|
||||
freeTime = json['freeTime'];
|
||||
mediaStatus = VidStatus.fromMap(json['mediaStatus']);
|
||||
tagDetails =
|
||||
(json['tagDetails'] as List?)?.map((e) => TagsBean.fromMap(e)).toList();
|
||||
}
|
||||
|
||||
/// 落本地观看记录用(见 [DramaResume.drama])。只序列化后端字段,
|
||||
/// episodeList / 分页游标那些是运行期状态,进二级页会重新拉,存了也没意义
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'author': author,
|
||||
'summary': summary,
|
||||
'kind': kind,
|
||||
'style': style,
|
||||
'status': status,
|
||||
'number': number,
|
||||
'lsjId': lsjId,
|
||||
'horizontalCover': horizontalCover,
|
||||
'verticalCover': verticalCover,
|
||||
'totalEpisode': totalEpisode,
|
||||
'currentEpisode': currentEpisode,
|
||||
'freeEpisode': freeEpisode,
|
||||
'updateStatus': updateStatus,
|
||||
'updateTime': updateTime,
|
||||
'createdAt': createdAt,
|
||||
'price': price,
|
||||
'contentsPrice': contentsPrice,
|
||||
'permission': permission,
|
||||
'permissionIconHide': permissionIconHide,
|
||||
'mId': mId,
|
||||
'moduleName': moduleName,
|
||||
'sId': sId,
|
||||
'sectionName': sectionName,
|
||||
'sectionSort': sectionSort,
|
||||
'sortCode': sortCode,
|
||||
'countBrowse': countBrowse,
|
||||
'countCollect': countCollect,
|
||||
'countDisLike': countDisLike,
|
||||
'countComment': countComment,
|
||||
'countLike': countLike,
|
||||
'countPurchases': countPurchases,
|
||||
'countView': countView,
|
||||
'hasFollow': hasFollow,
|
||||
'direction': direction,
|
||||
'freeTime': freeTime,
|
||||
'mediaStatus': mediaStatus?.toJson(),
|
||||
'tagDetails': tagDetails?.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// 合集(专辑)信息,会被 history_util 序列化进本地观看历史
|
||||
class CollectionModel {
|
||||
int? collectCount;
|
||||
int? commentCount;
|
||||
String? cover;
|
||||
String? createdAt;
|
||||
bool? hasBought;
|
||||
String? id;
|
||||
bool? isRecommend;
|
||||
int? likeCount;
|
||||
int? mediaCount;
|
||||
String? name;
|
||||
int? price;
|
||||
String? updateTime;
|
||||
int? viewCount;
|
||||
|
||||
CollectionModel.fromJson(Map<String, dynamic> json) {
|
||||
collectCount = json['collectCount'];
|
||||
commentCount = json['commentCount'];
|
||||
cover = json['cover'];
|
||||
createdAt = json['createdAt'];
|
||||
hasBought = json['hasBought'];
|
||||
id = json['id'];
|
||||
isRecommend = json['isRecommend'];
|
||||
likeCount = json['likeCount'];
|
||||
mediaCount = json['mediaCount'];
|
||||
name = json['name'];
|
||||
price = json['price'];
|
||||
updateTime = json['updateTime'];
|
||||
viewCount = json['viewCount'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'collectCount': collectCount,
|
||||
'commentCount': commentCount,
|
||||
'cover': cover,
|
||||
'createdAt': createdAt,
|
||||
'hasBought': hasBought,
|
||||
'id': id,
|
||||
'isRecommend': isRecommend,
|
||||
'likeCount': likeCount,
|
||||
'mediaCount': mediaCount,
|
||||
'name': name,
|
||||
'price': price,
|
||||
'updateTime': updateTime,
|
||||
'viewCount': viewCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import '../actress_info.dart';
|
||||
import '../cartoon_media_info.dart';
|
||||
import '../splash/ads_model.dart';
|
||||
import '../video_model.dart';
|
||||
|
||||
/// 模块详情:一个首页 tab 拉回来的全部内容
|
||||
class ModuleDetailModel {
|
||||
List<AllSection>? allSection; //所有专题
|
||||
List<VideoModel>? allVideoInfo; //所有视频
|
||||
List<ActressInfo>? allActress;
|
||||
List<CartoonMediaInfo>? allMediaInfo;
|
||||
List<VideoModel>? chosenVideoInfo; // 精选视频
|
||||
bool? hasNext;
|
||||
|
||||
//只有热门随机刷新那条路径会手工造一个,只带 allVideoInfo
|
||||
ModuleDetailModel({this.allVideoInfo});
|
||||
|
||||
ModuleDetailModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
chosenVideoInfo = _list(json['chosenVideoInfo'], (e) => VideoModel.fromJson(e));
|
||||
allSection = _list(json['allSection'], (e) => AllSection.fromJson(e));
|
||||
allVideoInfo = _list(json['allVideoInfo'], (e) => VideoModel.fromJson(e));
|
||||
allActress = _list(json['allActress'], (e) => ActressInfo.fromJson(e));
|
||||
allMediaInfo = _list(json['allMediaInfo'], (e) => CartoonMediaInfo.fromJson(e));
|
||||
hasNext = json['hasNext'];
|
||||
}
|
||||
|
||||
//某个字段下发成非数组时只丢这一项,别让整个模块的解析失败
|
||||
static List<T>? _list<T>(dynamic v, T Function(dynamic) fromJson) => v is List ? v.map(fromJson).toList() : null;
|
||||
}
|
||||
|
||||
/// 专题:既是首页楼层,也被 AdManager 拿来当广告占位插进列表
|
||||
class AllSection {
|
||||
String? sectionID;
|
||||
String? sectionName;
|
||||
String? sectionTitle;
|
||||
String? sectionDesc;
|
||||
String? sectionCover;
|
||||
String? desc;
|
||||
|
||||
List<TagsBean>? allTags;
|
||||
List<VideoModel>? allVideoInfo;
|
||||
List<CartoonMediaInfo>? allMediaInfo;
|
||||
List<ActressInfo>? allActress;
|
||||
|
||||
int? linkType;
|
||||
String? linkUrl;
|
||||
|
||||
/// 横版(长视频):101 一大四小 / 102 四宫格 / 103 六宫格 / 104 1.5滑动 / 105 2.5滑动 / 106 横屏列表 / 107 横屏大图
|
||||
/// 竖版(短视频、ACG):201 四宫格 / 202 六宫格 / 203 九宫格 / 204 1.5滑动 / 205 2.5滑动
|
||||
/// 301 猜你喜欢
|
||||
int? showType;
|
||||
int? type;
|
||||
int? voteCount; //大胸投票
|
||||
int? disVoteCount; //贫胸投票
|
||||
int? collCount;
|
||||
int? viewCounts;
|
||||
bool? collected;
|
||||
bool? hasNext;
|
||||
bool? hot;
|
||||
|
||||
///videoModel是否随机广告
|
||||
AdsInfoModel? randomAdsInfo;
|
||||
List<AdsInfoModel>? adsInfoArr;
|
||||
|
||||
bool get isGuessLike => showType == 301; // 猜你喜欢
|
||||
|
||||
bool isRandomAd() {
|
||||
return randomAdsInfo != null;
|
||||
}
|
||||
|
||||
bool isAdsArr() {
|
||||
return adsInfoArr != null;
|
||||
}
|
||||
|
||||
AllSection();
|
||||
|
||||
AllSection.fromJson(dynamic json) {
|
||||
allActress = (json['allActress'] as List?)?.map((e) => ActressInfo.fromJson(e)).toList();
|
||||
allVideoInfo = (json['allVideoInfo'] as List?)?.map((e) => VideoModel.fromJson(e)).toList();
|
||||
allMediaInfo = (json['allMediaInfo'] as List?)?.map((e) => CartoonMediaInfo.fromJson(e)).toList();
|
||||
allTags = (json['allTags'] as List?)?.map((e) => TagsBean.fromMap(e)).toList();
|
||||
hot = json['hot'];
|
||||
desc = json['desc'];
|
||||
sectionDesc = json['sectionDesc'];
|
||||
collCount = json['collCount'];
|
||||
viewCounts = json['viewCounts'];
|
||||
linkType = json['linkType'];
|
||||
linkUrl = json['linkUrl'];
|
||||
sectionID = json['sectionID'] ?? json['id'];
|
||||
sectionName = json['sectionName'];
|
||||
showType = json['showType'];
|
||||
type = json['type'];
|
||||
voteCount = json['voteCount'];
|
||||
disVoteCount = json['disVoteCount'];
|
||||
collected = json['collected'];
|
||||
sectionCover = json['sectionCover'];
|
||||
sectionTitle = json['sectionTitle'];
|
||||
hasNext = json['hasNext'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 片库筛选用的标签,会随 Keyword 一起回传给接口
|
||||
class Tags {
|
||||
String? coverImg;
|
||||
String? description;
|
||||
bool? hasCollected;
|
||||
String? id;
|
||||
String? name;
|
||||
int? playCount;
|
||||
|
||||
Tags.fromJson(Map<String, dynamic> json) {
|
||||
coverImg = json['coverImg'];
|
||||
description = json['description'];
|
||||
hasCollected = json['hasCollected'];
|
||||
id = json['id'];
|
||||
name = json['name'];
|
||||
playCount = json['playCount'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'coverImg': coverImg,
|
||||
'description': description,
|
||||
'hasCollected': hasCollected,
|
||||
'id': id,
|
||||
'name': name,
|
||||
'playCount': playCount,
|
||||
}..removeWhere((key, value) => value == null);
|
||||
}
|
||||
|
||||
/// 福利页的限免专区,只用 id 去拉列表、title 做标题
|
||||
class WareDiscountAreaData {
|
||||
String? id;
|
||||
String? title;
|
||||
|
||||
WareDiscountAreaData.fromJson(Map<String, dynamic> json) {
|
||||
id = json["id"];
|
||||
title = json["title"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
|
||||
//亚模块模型
|
||||
class ModuleData {
|
||||
String? id;
|
||||
String? moduleName;
|
||||
//1首页(原首页视频) 2社区 3暗网 4动漫 5漫画 6小说 7黄油 8图集 9短视频 10私密圈 11AI广场 12短剧
|
||||
int? type;
|
||||
//1 模块普通海角系样式(HJShowType) 2 全专题组合样式(AllSectionShowType)
|
||||
int? showType;
|
||||
bool? pureVersion; //true:广告纯净模式
|
||||
String? defaultTagId; //社区默认选中
|
||||
HaiJiaoStyle? haiJiaoStyle;
|
||||
|
||||
ModuleData({this.moduleName, this.type, this.id, this.showType});
|
||||
|
||||
ModuleData.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
moduleName = json['moduleName'];
|
||||
type = json['type'];
|
||||
showType = json['showType'];
|
||||
pureVersion = json['pureVersion'];
|
||||
defaultTagId = json['defaultTagId'];
|
||||
haiJiaoStyle = HaiJiaoStyle.fromJson(json['haiJiaoStyle']);
|
||||
}
|
||||
|
||||
//只给 ModuleSortManager 存本地排序用,读回来只按 id 跟后端最新数据配对,故不存 haiJiaoStyle 等内容字段
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"id": id,
|
||||
"moduleName": moduleName,
|
||||
"type": type,
|
||||
"showType": showType,
|
||||
'defaultTagId': defaultTagId,
|
||||
};
|
||||
}
|
||||
|
||||
//动漫/漫画/小说
|
||||
bool get isACG => type == 4 || type == 5 || type == 6;
|
||||
|
||||
/// 展示用的排序项:top=true 的置顶,最多 4 个。
|
||||
/// 标题、sort 值、随机刷新配置一律从这份派生——分头各排一次,遇到同名/同值 remove 会删错元素,两边就对不上了。
|
||||
/// haiJiaoStyle 解析完就不再变,算一次存起来,别每个 getter 各构造一遍
|
||||
late final List<SortRuleModel> _sortedRules = () {
|
||||
final rules = [...?haiJiaoStyle?.sortRules];
|
||||
final topIndex = rules.indexWhere((e) => e.top == true);
|
||||
if (topIndex > 0) rules.insert(0, rules.removeAt(topIndex));
|
||||
return rules.length > 4 ? rules.sublist(0, 4) : rules;
|
||||
}();
|
||||
|
||||
/// 排序 tab(标题 + sort 值),为空返回 null 让调用方退回默认值。
|
||||
/// 标题和 sort 值必须一起产出:拆成两个并行数组时,后端只下发其中一个就会静默错位
|
||||
List<SortTab<int>>? get sortTabs => _sortedRules.isEmpty
|
||||
? null
|
||||
: _sortedRules.map((e) => SortTab(e.name ?? '', e.val ?? -1)).toList();
|
||||
|
||||
/// 按 sort 值取对应的排序规则(用于判断该排序项是否走随机刷新接口)
|
||||
SortRuleModel? sortRuleOf(int? val) =>
|
||||
_sortedRules.firstWhereOrNull((e) => e.val == val);
|
||||
}
|
||||
|
||||
class HaiJiaoStyle {
|
||||
int? defaultShow; //默认展示 0-一排两个 1-一排一个
|
||||
int? sectionStyle; //专题展示样式 0-不展示 1-十六岁专题 2-女优专题 3-网黄专题 4-普通专题
|
||||
int? showChosenVideo; //0-不展示精选视频 1-展示精选视频
|
||||
int? sortShow; //排序规则展示样式 0-不展示 1-展示
|
||||
List<SortRuleModel>? sortRules; //排序规则
|
||||
|
||||
HaiJiaoStyle.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
defaultShow = json['defaultShow'];
|
||||
sectionStyle = json['sectionStyle'];
|
||||
showChosenVideo = json['showChosenVideo'];
|
||||
sortShow = json['sortShow'];
|
||||
final rules = json['sortRules'];
|
||||
if (rules is List)
|
||||
sortRules = rules.map((e) => SortRuleModel.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class SortRuleModel {
|
||||
int? val;
|
||||
bool? top; //是否置顶
|
||||
String? name;
|
||||
//刷新模式 DEFAULT / RANDOM_TOP_N,判断随机刷新只认这个字段,不许匹配 name 或硬编码 val
|
||||
String? refreshMode;
|
||||
|
||||
SortRuleModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
val = json['val'];
|
||||
top = json['top'];
|
||||
name = json['name'];
|
||||
refreshMode = json['refreshMode'];
|
||||
}
|
||||
|
||||
bool get isRandomRefresh => refreshMode == 'RANDOM_TOP_N';
|
||||
}
|
||||
|
||||
class HomePlateModel {
|
||||
//解析一律走 _modules,字段缺失也退化成空列表,所以都是非空的,调用方不用判空
|
||||
List<ModuleData> homePage = []; //首页-视频
|
||||
List<ModuleData> deepWeb = []; //暗网
|
||||
List<ModuleData> community = []; //社区
|
||||
List<ModuleData> novel = []; //小说
|
||||
List<ModuleData> pics = []; //图集:后端原始模块,展示要用 picsUi
|
||||
List<ModuleData> game = []; //黄游:后端原始模块,展示要用 gameUi
|
||||
List<ModuleData> dramaPage = []; //短剧专题(与 update-markers.modules 按 id 对齐)
|
||||
|
||||
//图集:固定注入「热门推荐」(type -100 对应 PIC 数据源) 再接后端模块
|
||||
List<ModuleData> get picsUi => [
|
||||
ModuleData(moduleName: '热门推荐', type: -100, showType: 1),
|
||||
...pics,
|
||||
];
|
||||
|
||||
//黄游:固定注入「热门推荐」(type -101 对应 SEED_LINK 数据源) 再接后端模块,showType 2 走列表样式
|
||||
List<ModuleData> get gameUi => [
|
||||
ModuleData(moduleName: '热门推荐', type: -101, showType: 2),
|
||||
...game,
|
||||
];
|
||||
|
||||
HomePlateModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
homePage = _modules(json['homePage']);
|
||||
deepWeb = _modules(json['deepWeb']);
|
||||
community = _modules(json['community']);
|
||||
novel = _modules(json['novel']);
|
||||
pics = _modules(json['pics']);
|
||||
game = _modules(json['game']);
|
||||
dramaPage = _modules(json['dramaPage']);
|
||||
}
|
||||
|
||||
//字段缺失或类型不对(后端偶发下发非数组)都退化成空列表,别让首页解析整个抛掉
|
||||
static List<ModuleData> _modules(dynamic list) =>
|
||||
(list is List ? list : const [])
|
||||
.map((e) => ModuleData.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../video_model.dart';
|
||||
|
||||
///推荐界面信息返回
|
||||
class RecommendListRes {
|
||||
List<VideoModel>? vInfo;
|
||||
bool? hasNext;
|
||||
|
||||
/// 推荐队列版本,仅用于日志排查
|
||||
String? queueVersion;
|
||||
|
||||
/// 视频列表字段各接口叫法不一,按 list → videos → vInfos 依次取
|
||||
RecommendListRes.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
final list = json['list'] ?? json['videos'] ?? json['vInfos'];
|
||||
vInfo = (list as List?)?.map((e) => VideoModel.fromJson(e)).toList();
|
||||
hasNext = json['hasNext'];
|
||||
queueVersion = json['queueVersion']?.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// 首页内容更新红点标记(GET /content/update-markers)
|
||||
class HomeUpdateMarkersResp {
|
||||
String? homeLatestAt;
|
||||
String? todayLatestAt;
|
||||
List<HomeModuleUpdateMarker>? modules;
|
||||
|
||||
HomeUpdateMarkersResp({
|
||||
this.homeLatestAt,
|
||||
this.todayLatestAt,
|
||||
this.modules,
|
||||
});
|
||||
|
||||
factory HomeUpdateMarkersResp.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
return HomeUpdateMarkersResp(
|
||||
homeLatestAt: json['homeLatestAt']?.toString(),
|
||||
todayLatestAt: json['todayLatestAt']?.toString(),
|
||||
modules: (json['modules'] as List?)
|
||||
?.whereType<Map>()
|
||||
.map((e) => HomeModuleUpdateMarker.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeModuleUpdateMarker {
|
||||
String? moduleId;
|
||||
String? latestAt;
|
||||
|
||||
HomeModuleUpdateMarker({this.moduleId, this.latestAt});
|
||||
|
||||
factory HomeModuleUpdateMarker.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
return HomeModuleUpdateMarker(
|
||||
moduleId: json['moduleId']?.toString(),
|
||||
latestAt: json['latestAt']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import '../cartoon_media_info.dart';
|
||||
import '../video_model.dart';
|
||||
import 'module_detail_model.dart';
|
||||
|
||||
/// 片库筛选面板的全部可选项(分类 / 排序 / 付费 / 标签 / 时间)
|
||||
class HomeVideoLibrary {
|
||||
List<TimeType>? canvas;
|
||||
List<TimeType>? orderBy;
|
||||
List<TimeType>? paymentType;
|
||||
List<Tags>? vidTags;
|
||||
List<Tags>? acgTags;
|
||||
List<TimeType>? timeType;
|
||||
|
||||
HomeVideoLibrary();
|
||||
|
||||
HomeVideoLibrary.fromJson(dynamic json) {
|
||||
canvas = (json['canvas'] as List?)?.map((e) => TimeType.fromJson(e)).toList();
|
||||
orderBy = (json['orderBy'] as List?)?.map((e) => TimeType.fromJson(e)).toList();
|
||||
paymentType = (json['paymentType'] as List?)?.map((e) => TimeType.fromJson(e)).toList();
|
||||
vidTags = (json['vidTags'] as List?)?.map((e) => Tags.fromJson(e)).toList();
|
||||
acgTags = (json['acgTags'] as List?)?.map((e) => Tags.fromJson(e)).toList();
|
||||
timeType = (json['timeType'] as List?)?.map((e) => TimeType.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
//一项都没有就别渲染筛选面板了;timeType 不算,它单独有默认值
|
||||
bool get isNotEmpty =>
|
||||
canvas?.isNotEmpty == true ||
|
||||
orderBy?.isNotEmpty == true ||
|
||||
paymentType?.isNotEmpty == true ||
|
||||
acgTags?.isNotEmpty == true ||
|
||||
vidTags?.isNotEmpty == true;
|
||||
}
|
||||
|
||||
/// 筛选面板里的一个选项,key 传给接口、name 给用户看
|
||||
class TimeType {
|
||||
String? key;
|
||||
String? name;
|
||||
|
||||
TimeType.fromJson(dynamic json) {
|
||||
key = json['key'];
|
||||
name = json['name'];
|
||||
}
|
||||
|
||||
bool get isACG => name?.contains("动漫") == true || name?.contains("漫画") == true;
|
||||
|
||||
Map<String, dynamic> toJson() => {'key': key, 'name': name};
|
||||
}
|
||||
|
||||
/// 用户当前选中的筛选组合,整个塞进请求参数
|
||||
class Keyword {
|
||||
TimeType? canvas;
|
||||
TimeType? orderBy;
|
||||
Tags? tags;
|
||||
TimeType? paymentType;
|
||||
TimeType? timeType;
|
||||
|
||||
Keyword();
|
||||
|
||||
Keyword.fromJson(dynamic json) {
|
||||
canvas = json['canvas'] != null ? TimeType.fromJson(json['canvas']) : null;
|
||||
orderBy = json['orderBy'] != null ? TimeType.fromJson(json['orderBy']) : null;
|
||||
tags = json['tags'] != null ? Tags.fromJson(json['tags']) : null;
|
||||
paymentType = json['paymentType'] != null ? TimeType.fromJson(json['paymentType']) : null;
|
||||
timeType = json['timeType'] != null ? TimeType.fromJson(json['timeType']) : null;
|
||||
}
|
||||
|
||||
//拼给调用方比对「筛选条件有没有变」,不作他用
|
||||
String get modelKey => "${canvas?.name}${orderBy?.name}${tags?.name}${paymentType?.name}${timeType?.name}";
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'canvas': canvas?.toJson(),
|
||||
'orderBy': orderBy?.toJson(),
|
||||
'tags': tags?.toJson(),
|
||||
'paymentType': paymentType?.toJson(),
|
||||
'timeType': timeType?.toJson(),
|
||||
}..removeWhere((key, value) => value == null);
|
||||
}
|
||||
|
||||
/// 片库筛选结果:影视走 list,ACG 走 allMediaList
|
||||
class HomeVideoLibraryResult {
|
||||
bool? hasNext;
|
||||
List<VideoModel>? list;
|
||||
List<CartoonMediaInfo>? allMediaList;
|
||||
|
||||
HomeVideoLibraryResult.fromJson(dynamic json) {
|
||||
hasNext = json['hasNext'];
|
||||
list = (json['list'] as List?)?.map((e) => VideoModel.fromJson(e)).toList();
|
||||
allMediaList = (json['allMediaList'] as List?)?.map((e) => CartoonMediaInfo.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:hgdj/hj_model/home/module_detail_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
|
||||
//注意⚠️ VideoListResp 只用于兼容不同业务服务端返回的视频列表 key 不一致的情况
|
||||
//注意⚠️ 请勿添加其他类型的数据list,如需添加请单独另外创建新的类
|
||||
class VideoListResp {
|
||||
List<VideoModel>? videos;
|
||||
bool? hasNext;
|
||||
|
||||
/// 搜索返回的标签视频
|
||||
List<VideoModel>? tagVidList;
|
||||
String? tagID;
|
||||
|
||||
VideoListResp.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
hasNext = json['hasNext'];
|
||||
//各业务的视频列表 key 不一样,逐个匹配(后命中的覆盖先命中的)
|
||||
for (final key in [
|
||||
'videos',
|
||||
'vInfos',
|
||||
'searchVideo',
|
||||
'list',
|
||||
'data',
|
||||
'searchTag'
|
||||
]) {
|
||||
if (json[key] is List) {
|
||||
videos =
|
||||
(json[key] as List).map((e) => VideoModel.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
tagID = json['tagID'] ?? json['libraryTag']?['id'];
|
||||
if (json['tagVidList'] is List) {
|
||||
tagVidList = (json['tagVidList'] as List)
|
||||
.map((e) => VideoModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//注意⚠️ 别并进 ListBaseModel:调用方(SectionAllLogic)靠 hasNext 的 null 区分「后端没下发」和「明确没有更多」,
|
||||
//而 ListBaseModel 会把 null 压成 false,专题全部页会误判成没有下一页、停在第一页
|
||||
class SectionListResp {
|
||||
List<AllSection>? list;
|
||||
bool? hasNext;
|
||||
|
||||
SectionListResp.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
hasNext = json['hasNext'];
|
||||
if (json['list'] is List) {
|
||||
list = (json['list'] as List).map((e) => AllSection.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:hgdj/hj_model/acg/comic_chapters_model.dart';
|
||||
import 'package:hgdj/hj_model/actress_model.dart';
|
||||
import 'package:hgdj/hj_model/cartoon_media_info.dart';
|
||||
import 'package:hgdj/hj_model/comment/reply_model.dart';
|
||||
import 'package:hgdj/hj_model/comment/user_comment_res.dart';
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/drama_media_info.dart';
|
||||
import 'package:hgdj/hj_model/home/collection_model.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/hj_model/message/message_dynamic_list.dart';
|
||||
import 'package:hgdj/hj_model/mine/exchange/bill_item_model.dart';
|
||||
import 'package:hgdj/hj_model/mine/follow_user_list_model.dart';
|
||||
import 'package:hgdj/hj_model/mine/promotion_record.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
|
||||
import '../hj_page/ai/models/ai_change_face_video_model.dart';
|
||||
import '../hj_page/ai/models/ai_record_model.dart';
|
||||
import '../hj_page/ai/models/ai_square_model.dart';
|
||||
import '../hj_page/live/live_model.dart';
|
||||
import '../hj_page/mine/make_money/withdraw_details_model.dart';
|
||||
|
||||
/// 分页列表通用返回:{ hasNext, total, list }
|
||||
/// 元素类型由 [T] 决定,解析器在 [_parsers] 里按 Type 注册——**新增 T 记得补一行,漏了 list 就是空的**
|
||||
class ListBaseModel<T> {
|
||||
bool? hasNext;
|
||||
List<T>? list;
|
||||
int? total;
|
||||
|
||||
// 用 Type 对象做 key 而非 T.toString():release 混淆下类名字符串会被改写,
|
||||
// 字符串匹配失败会导致 list 不被赋值 → 静默“没数据”
|
||||
static final Map<Type, dynamic Function(dynamic)> _parsers = {
|
||||
VideoModel: (e) => VideoModel.fromJson(e),
|
||||
CartoonMediaInfo: (e) => CartoonMediaInfo.fromJson(e),
|
||||
DramaMediaInfo: (e) => DramaMediaInfo.fromJson(e),
|
||||
DramaFeedItem: (e) => DramaFeedItem.fromJson(e),
|
||||
CollectionModel: (e) => CollectionModel.fromJson(e),
|
||||
ComicChapterInfo: (e) => ComicChapterInfo.fromJson(e),
|
||||
MediaContent: (e) => MediaContent.fromJson(e),
|
||||
UserCommentItem: (e) => UserCommentItem.fromJson(e),
|
||||
Promotion: (e) => Promotion.fromJson(e),
|
||||
BillItemModel: (e) => BillItemModel.fromJson(e),
|
||||
AiRecordModel: (e) => AiRecordModel.fromJson(e),
|
||||
AiChangeFaceVideoMod: (e) => AiChangeFaceVideoMod.fromJson(e),
|
||||
TagsBean: (e) => TagsBean.fromMap(e),
|
||||
FollowUserModel: (e) => FollowUserModel.fromJson(e),
|
||||
ActressModel: (e) => ActressModel.fromJson(e),
|
||||
MessageDynamicList: (e) => MessageDynamicList.fromJson(e),
|
||||
ReplyModel: (e) => ReplyModel.fromJson(e),
|
||||
AISquareItemModel: (e) => AISquareItemModel.fromJson(e),
|
||||
LiveAnchor: (e) => LiveAnchor.fromJson(e),
|
||||
IncomeModel: (e) => IncomeModel.fromJson(e),
|
||||
};
|
||||
|
||||
ListBaseModel.fromJson(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
total = map['total'];
|
||||
hasNext = map['hasNext'] ?? false;
|
||||
final parse = _parsers[T];
|
||||
if (parse == null) return;
|
||||
final raw = map['list'];
|
||||
list = raw is List ? raw.map(parse).cast<T>().toList() : <T>[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:hgdj/config/address.dart' as address;
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
/// 作品的「一集」:漫画一话 / 小说一章 / 有声一集 / 短剧一集 共用这一个模型,
|
||||
/// 所以字段是几种业务的并集,取值时按自己的业务挑(如短剧只关心视频与解锁那几个)
|
||||
class MediaContent {
|
||||
// ===== 标识 =====
|
||||
String? id; //本集 id
|
||||
String? mediaId; //所属作品(剧/漫画/小说)的 id
|
||||
int? episodeNumber; //第几集/第几话,从 1 开始
|
||||
String? name; //集名,如「第1集」
|
||||
String? cover; //本集封面,短剧多为空、回退用剧封面
|
||||
|
||||
// ===== 资源 =====
|
||||
String? videoUrl; //视频相对路径(m3u8),要拼 baseApiPath 才能播
|
||||
String? h265Url; //H.265 源,为空表示这一集没有 265
|
||||
String? audioUrl; //有声书音频,见 realAudioUrl
|
||||
List<String>? urlSet; //多清晰度地址集合,短剧下发空数组
|
||||
String? text; //小说正文
|
||||
String? md5;
|
||||
int? playTime; //时长(秒)
|
||||
int? mediaSize; //文件字节数
|
||||
int? height; //视频高
|
||||
int? weight; //视频宽(后端字段就叫 weight,不是重量)
|
||||
double? ratio; //宽高比,0 表示后端没算
|
||||
|
||||
// ===== 权限 / 付费 =====
|
||||
//0会员 1金币购买 2免费。**只用于展示**,能不能播看 canPlay
|
||||
int? listenPermission;
|
||||
int? price; //单集金币价,0 为免费
|
||||
bool? hasBuy; //本集是否已单独购买过
|
||||
bool? isFree; //本集是否免费,别再用整部的 freeEpisode 自己算
|
||||
bool? canPlay; //能不能播只认这个字段
|
||||
String? accessType; // free 免费 / bought 已购 / card 短剧卡 / coin 未解锁
|
||||
DramaPaywall? paywall; //付费墙文案与价格,canPlay=false 时必有,能播时为 null
|
||||
bool? isActive; //是否上架,下架的集不该出现在选集面板
|
||||
|
||||
// ===== 试看(feed / list / info 三个接口都下发)=====
|
||||
bool? previewEnabled; //这一集配没配试看
|
||||
int? previewStart; //试看起播秒数,0 = 从头
|
||||
int? previewSeconds; //试看时长(秒),从 previewStart 起算,到点停下挂付费墙
|
||||
String? previewVideoUrl; //试看片地址,与 videoUrl 同格式
|
||||
String? previewH265Url; //试看片的 265 源,为空表示没有
|
||||
|
||||
// ===== 统计 =====
|
||||
int? countBrowse;
|
||||
int? countCollect;
|
||||
int? countComment;
|
||||
int? countDisLike;
|
||||
int? countLike;
|
||||
int? countPurchases;
|
||||
VidStatus? mediaStatus; //本集的已购/已收藏/已点赞状态
|
||||
|
||||
// ===== 时间 =====
|
||||
String? createdAt;
|
||||
String? updateTime;
|
||||
|
||||
//只用来造空 model 占位(拉详情失败时页面据此走错误态),字段一律走 fromJson 赋值
|
||||
MediaContent();
|
||||
|
||||
MediaContent.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
mediaId = json['mediaId'];
|
||||
episodeNumber = json['episodeNumber'];
|
||||
name = json['name'];
|
||||
cover = json['cover'];
|
||||
|
||||
videoUrl = json['videoUrl'];
|
||||
h265Url = json['h265Url'];
|
||||
audioUrl = json['audioUrl'];
|
||||
//用 is List 判而不是 as List:下发 null 或非数组时硬 cast 会抛,整条分集就解析不出来
|
||||
final rawUrlSet = json['urlSet'];
|
||||
urlSet =
|
||||
rawUrlSet is List ? rawUrlSet.map((e) => e.toString()).toList() : [];
|
||||
text = json['text'];
|
||||
md5 = json['md5'];
|
||||
playTime = json['playTime'];
|
||||
mediaSize = json['mediaSize'];
|
||||
height = json['height'];
|
||||
weight = json['weight'];
|
||||
//数字直接转;后端也可能下发字符串("1.5"),那时才走 parse,脏值落 null
|
||||
final rawRatio = json['ratio'];
|
||||
ratio = rawRatio is num
|
||||
? rawRatio.toDouble()
|
||||
: double.tryParse(rawRatio?.toString() ?? '');
|
||||
|
||||
listenPermission = json['listenPermission'];
|
||||
price = json['price'];
|
||||
hasBuy = json['hasBuy'];
|
||||
isFree = json['isFree'];
|
||||
canPlay = json['canPlay'];
|
||||
accessType = json['accessType'];
|
||||
paywall =
|
||||
json['paywall'] != null ? DramaPaywall.fromJson(json['paywall']) : null;
|
||||
isActive = json['isActive'];
|
||||
|
||||
previewEnabled = json['previewEnabled'];
|
||||
previewStart = json['previewStart'];
|
||||
previewSeconds = json['previewSeconds'];
|
||||
previewVideoUrl = json['previewVideoUrl'];
|
||||
previewH265Url = json['previewH265Url'];
|
||||
|
||||
countBrowse = json['countBrowse'];
|
||||
countCollect = json['countCollect'];
|
||||
countComment = json['countComment'];
|
||||
countDisLike = json['countDisLike'];
|
||||
countLike = json['countLike'];
|
||||
countPurchases = json['countPurchases'];
|
||||
mediaStatus = json['mediaStatus'] != null
|
||||
? VidStatus.fromMap(json['mediaStatus'])
|
||||
: null;
|
||||
|
||||
createdAt = json['createdAt'];
|
||||
updateTime = json['updateTime'];
|
||||
}
|
||||
|
||||
/// 只给浏览历史落库用(经 CartoonMediaInfo.toJson → HistoryUtil)。
|
||||
/// paywall 和 preview* 都是这次请求的临时状态、下次进来要重新拉,故意不存
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'mediaId': mediaId,
|
||||
'episodeNumber': episodeNumber,
|
||||
'name': name,
|
||||
'cover': cover,
|
||||
'videoUrl': videoUrl,
|
||||
'h265Url': h265Url,
|
||||
'audioUrl': audioUrl,
|
||||
'urlSet': urlSet,
|
||||
'text': text,
|
||||
'md5': md5,
|
||||
'playTime': playTime,
|
||||
'mediaSize': mediaSize,
|
||||
'height': height,
|
||||
'weight': weight,
|
||||
'ratio': ratio,
|
||||
'listenPermission': listenPermission,
|
||||
'price': price,
|
||||
'hasBuy': hasBuy,
|
||||
'isFree': isFree,
|
||||
'canPlay': canPlay,
|
||||
'accessType': accessType,
|
||||
'isActive': isActive,
|
||||
'countBrowse': countBrowse,
|
||||
'countCollect': countCollect,
|
||||
'countComment': countComment,
|
||||
'countDisLike': countDisLike,
|
||||
'countLike': countLike,
|
||||
'countPurchases': countPurchases,
|
||||
if (mediaStatus != null) 'mediaStatus': mediaStatus?.toJson(),
|
||||
'createdAt': createdAt,
|
||||
'updateTime': updateTime,
|
||||
};
|
||||
}
|
||||
|
||||
/// 有声书音频完整地址:相对路径要拼音频 CDN,已是 http(s) 的直接用。
|
||||
/// 目前有声书播的是 ComicChapterInfo 里的同名实现,这里留着与 audioUrl 字段配套
|
||||
String get realAudioUrl {
|
||||
final url = audioUrl;
|
||||
if (url?.isNotEmpty != true) return '';
|
||||
if (url!.startsWith('http')) return url;
|
||||
return path.join(address.Address.audioCdnAddress ?? '', url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
|
||||
class MessageDynamicList {
|
||||
MessageDynamicList({
|
||||
this.createdAt,
|
||||
this.id,
|
||||
this.sendGender,
|
||||
this.content,
|
||||
this.sendUid,
|
||||
this.objId,
|
||||
this.sendAvatar,
|
||||
this.vipLevel,
|
||||
this.msgType,
|
||||
this.sendName,
|
||||
this.objName,
|
||||
});
|
||||
|
||||
MessageDynamicList.fromJson(Map<String, dynamic> json) {
|
||||
createdAt = json['createdAt'];
|
||||
id = json['id'];
|
||||
sendGender = json['sendGender'];
|
||||
content = json['content'];
|
||||
sendUid = json['sendUid'];
|
||||
createdAt = json['createdAt'];
|
||||
objId = json['objId'];
|
||||
sendAvatar = json['sendAvatar'];
|
||||
vipLevel = json['vipLevel'];
|
||||
msgType = json['msgType'] ?? "";
|
||||
objName = json['objName'];
|
||||
isRead = json['isRead'];
|
||||
superUser = json['superUser'];
|
||||
awards = List<int>.from((json['awards'] as List?) ?? []);
|
||||
imgUrl = json['imgUrl'];
|
||||
vipExpireDateOmitempty = json['vipExpireDateOmitempty'];
|
||||
objCover = json['objCover'];
|
||||
sendName = json['sendName'];
|
||||
likeCount = json['likeCount'];
|
||||
}
|
||||
String? id;
|
||||
int? sendUid;
|
||||
String? sendName;
|
||||
String? sendAvatar;
|
||||
String? sendGender;
|
||||
int? superUser;
|
||||
List<int>? awards;
|
||||
String? msgType;
|
||||
String? content;
|
||||
String? imgUrl;
|
||||
bool? isRead;
|
||||
String? objId;
|
||||
String? objName;
|
||||
String? createdAt;
|
||||
int? likeCount;
|
||||
String? vipExpireDateOmitempty;
|
||||
int? vipLevel;
|
||||
String? objCover;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['createdAt'] = createdAt;
|
||||
map['id'] = id;
|
||||
map['sendGender'] = sendGender;
|
||||
map['content'] = content;
|
||||
map['sendUid'] = sendUid;
|
||||
|
||||
map['objId'] = objId;
|
||||
map['sendAvatar'] = sendAvatar;
|
||||
map['vipLevel'] = vipLevel;
|
||||
map['msgType'] = msgType;
|
||||
map['objName'] = objName;
|
||||
map['isRead'] = isRead;
|
||||
|
||||
map['sendName'] = sendName;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// 积分记录
|
||||
class CreditRecordModel {
|
||||
String? desc;
|
||||
num? integral;
|
||||
String? createdAt;
|
||||
|
||||
CreditRecordModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
desc = json['desc'];
|
||||
integral = json['integral'];
|
||||
createdAt = json['createdAt'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/// 单条账单
|
||||
class BillItemModel {
|
||||
final String? createdAt;
|
||||
final String? desc;
|
||||
final String? tranType;
|
||||
final int? tranTypeInt;
|
||||
|
||||
final int? actualAmount; //实际变动金币
|
||||
final int? actualIntegral; //实际变动积分
|
||||
final int? integral;
|
||||
|
||||
BillItemModel({
|
||||
this.createdAt,
|
||||
this.desc,
|
||||
this.tranType,
|
||||
this.tranTypeInt,
|
||||
this.actualAmount,
|
||||
this.actualIntegral,
|
||||
this.integral,
|
||||
});
|
||||
|
||||
/// 数量单位:有积分变动记积分,特定交易类型记次数,其余记金币
|
||||
String get unit {
|
||||
if ((integral ?? 0) != 0) return '积分';
|
||||
if (const {103, 104, 109, 110, 111}.contains(tranTypeInt)) return '次';
|
||||
return '金币';
|
||||
}
|
||||
|
||||
/// 展示用数量:金币和积分只会变动其中一种,同时变动时不展示
|
||||
int get realCount {
|
||||
if (actualAmount == 0) return actualIntegral ?? 0;
|
||||
if (actualIntegral == 0) return actualAmount ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory BillItemModel.fromJson(Map<String, dynamic> json) => BillItemModel(
|
||||
createdAt: json["createdAt"],
|
||||
desc: json["desc"],
|
||||
tranType: json["tranType"],
|
||||
tranTypeInt: json["tranTypeInt"],
|
||||
actualAmount: json["actualAmount"],
|
||||
actualIntegral: json["actualIntegral"],
|
||||
integral: json["integral"],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/// 代充商人
|
||||
class PayForModel {
|
||||
int? imId; //商人在聊天系统中的ID
|
||||
String? userId; //商人在代充系统中的ID
|
||||
String? avatar; //头像
|
||||
String? nickName; //昵称
|
||||
String? welcomeMsg; //商人欢迎语
|
||||
List<PayInfoModel>? payInfos; //支持的支付方式
|
||||
|
||||
PayForModel();
|
||||
|
||||
PayForModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
imId = json['imId'];
|
||||
userId = json['userId'];
|
||||
avatar = json['avatar'];
|
||||
nickName = json['nickName'];
|
||||
welcomeMsg = json['welcomeMsg'];
|
||||
payInfos = (json['payInfos'] as List?)?.map((e) => PayInfoModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"imId": imId,
|
||||
"userId": userId,
|
||||
"avatar": avatar,
|
||||
"welcomeMsg": welcomeMsg,
|
||||
"nickName": nickName,
|
||||
"payInfos": payInfos,
|
||||
};
|
||||
|
||||
PayForModel clone() => PayForModel()
|
||||
..imId = imId
|
||||
..userId = userId
|
||||
..avatar = avatar
|
||||
..nickName = nickName
|
||||
..welcomeMsg = welcomeMsg
|
||||
..payInfos = payInfos?.map((e) => e.clone()).toList() ?? [];
|
||||
}
|
||||
|
||||
/// 一种支付方式
|
||||
class PayInfoModel {
|
||||
int? payMethod;
|
||||
List<int>? payType;
|
||||
|
||||
PayInfoModel();
|
||||
|
||||
PayInfoModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
payMethod = json['payMethod'];
|
||||
payType = (json['payType'] as List?)?.cast<int>().toList() ?? [];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"payMethod": payMethod,
|
||||
"payType": payType,
|
||||
};
|
||||
|
||||
PayInfoModel clone() => PayInfoModel()
|
||||
..payMethod = payMethod
|
||||
..payType = [...?payType];
|
||||
}
|
||||
|
||||
/// 代充配置,整体 base64 后透传给代充 H5
|
||||
class DCModel {
|
||||
bool? isReconnect;
|
||||
|
||||
/// 商人列表 为空则不展示代充
|
||||
List<PayForModel>? traders;
|
||||
String? url;
|
||||
String? ordUrl;
|
||||
String? traderUrl;
|
||||
|
||||
int? chargeMoney;
|
||||
|
||||
///大额支付 小额支付
|
||||
int? limit;
|
||||
String? userAgent;
|
||||
|
||||
String? wsUrl;
|
||||
|
||||
String? picUrl;
|
||||
|
||||
/// 商品id,客户端添加,非后端返回,代充值给h5的时候使用
|
||||
String? productInfo;
|
||||
|
||||
DcUserInfo? userInfo;
|
||||
|
||||
/// 支付渠道,客户端下单时赋值,非后端返回
|
||||
String? channel;
|
||||
|
||||
DCModel();
|
||||
|
||||
DCModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
isReconnect = json['isReconnect'];
|
||||
//只取第一个商人,后续逻辑都按 traders[0] 走
|
||||
final trader = (json['traders'] as List?)?.firstOrNull;
|
||||
if (trader != null) traders = [PayForModel.fromJson(trader)];
|
||||
url = json['url'];
|
||||
wsUrl = json['wsUrl'];
|
||||
picUrl = json['picUrl'];
|
||||
ordUrl = json['ordUrl'];
|
||||
traderUrl = json['traderUrl'];
|
||||
userInfo = DcUserInfo.fromJson(json['userInfo']);
|
||||
chargeMoney = json['chargeMoney'];
|
||||
limit = json['limit'];
|
||||
userAgent = json['userAgent'];
|
||||
productInfo = json['productInfo'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"isReconnect": isReconnect,
|
||||
"traders": traders,
|
||||
"url": url,
|
||||
"ordUrl": ordUrl,
|
||||
"traderUrl": traderUrl,
|
||||
"userInfo": userInfo,
|
||||
"chargeMoney": chargeMoney,
|
||||
"limit": limit,
|
||||
"wsUrl": wsUrl,
|
||||
"picUrl": picUrl,
|
||||
"userAgent": userAgent,
|
||||
"productInfo": productInfo,
|
||||
"channel": channel,
|
||||
};
|
||||
|
||||
DCModel clone() => DCModel()
|
||||
..isReconnect = isReconnect
|
||||
..traders = traders?.map((e) => e.clone()).toList() ?? []
|
||||
..url = url
|
||||
..ordUrl = ordUrl
|
||||
..traderUrl = traderUrl
|
||||
..userInfo = userInfo?.clone()
|
||||
..chargeMoney = chargeMoney
|
||||
..limit = limit
|
||||
..userAgent = userAgent
|
||||
..wsUrl = wsUrl
|
||||
..picUrl = picUrl
|
||||
..productInfo = productInfo
|
||||
..channel = channel;
|
||||
}
|
||||
|
||||
/// 代充里透传给 H5 的用户信息
|
||||
class DcUserInfo {
|
||||
int? uid;
|
||||
String? gender;
|
||||
String? name;
|
||||
String? portrait;
|
||||
|
||||
DcUserInfo();
|
||||
|
||||
DcUserInfo.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
uid = json['uid'];
|
||||
if (json['gender'] is String) gender = json['gender'];
|
||||
name = json['name'];
|
||||
portrait = json['portrait'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"uid": uid,
|
||||
"gender": gender,
|
||||
"name": name,
|
||||
"portrait": portrait,
|
||||
};
|
||||
|
||||
DcUserInfo clone() => DcUserInfo()
|
||||
..uid = uid
|
||||
..gender = gender
|
||||
..name = name
|
||||
..portrait = portrait;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:hgdj/hj_model/mine/exchange/recharge_type_list_model.dart';
|
||||
|
||||
import 'dc_model.dart';
|
||||
|
||||
/// 可供充值的金币列表
|
||||
class RechargeListModel {
|
||||
DCModel? daichong;
|
||||
List<RechargeTypeModel>? list;
|
||||
|
||||
RechargeListModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
daichong = DCModel.fromJson(json['daichong']);
|
||||
//代充数据挂在上级,逐条塞给档位,档位内部才能拼出代充支付方式
|
||||
list = (json['list'] as List?)
|
||||
?.map((e) => RechargeTypeModel.fromJson(e)..daichong = daichong)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import '../../../hj_page/mine/mine_vip/vip_support_model.dart';
|
||||
import 'dc_model.dart';
|
||||
|
||||
/// 代充的 payMethod → (type, 展示名)
|
||||
const _dcPayTypes = {
|
||||
101: ('alipy', '支付寶(人工充值)'),
|
||||
102: ('wechat', '微信(人工充值)'),
|
||||
103: ('union', '银联(人工充值)'),
|
||||
104: ('credit', '信用卡(人工充值)'),
|
||||
105: ('huabei', '花呗(人工充值)'),
|
||||
106: ('yunSanPay', '云闪付(人工充值)'),
|
||||
107: ('qqWallet', 'QQ錢包(人工充值)'),
|
||||
108: ('jindongPay', '京东支付(人工充值)'),
|
||||
};
|
||||
|
||||
/// 一个充值档位
|
||||
class RechargeTypeModel {
|
||||
String? id;
|
||||
int? amount;
|
||||
int? money;
|
||||
String? couponDesc;
|
||||
DCModel? daichong; // 从上级数据结构手动赋值过来
|
||||
List<RchgType>? rechargeTypeList;
|
||||
|
||||
/// 展示用的支付方式:代充那条要按商人支持的 payMethod 展开成多条
|
||||
List<RchgType> get rechargeTypeListUI {
|
||||
final payList = <RchgType>[];
|
||||
for (final rchg in (rechargeTypeList ?? [])) {
|
||||
if (rchg.type != 'daichong') {
|
||||
payList.add(rchg);
|
||||
continue;
|
||||
}
|
||||
final payInfos = daichong?.traders?.firstOrNull?.payInfos ?? [];
|
||||
for (final info in payInfos) {
|
||||
final named = _dcPayTypes[info.payMethod];
|
||||
payList.add(RchgType()
|
||||
..isOfficial = true
|
||||
..channel = rchg.channel
|
||||
..incrAmount = rchg.incrAmount
|
||||
..incTax = rchg.incTax
|
||||
..payMethod = info.payMethod
|
||||
..type = named?.$1
|
||||
..typeName = named?.$2);
|
||||
}
|
||||
}
|
||||
return payList;
|
||||
}
|
||||
|
||||
//money是分单位 需要/100
|
||||
int get moneyYuan => ((money ?? 0) / 100).round();
|
||||
|
||||
RechargeTypeModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
amount = json['amount'];
|
||||
money = json['money'];
|
||||
couponDesc = json['couponDesc'];
|
||||
rechargeTypeList = (json['rchgType'] as List?)?.map((o) => RchgType.fromJson(o)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// 下单返回的支付链接
|
||||
class RechargeUrlModel {
|
||||
String? payUrl;
|
||||
|
||||
/// url-打开外部支付链接;sdk-走 sdk
|
||||
String? mode;
|
||||
|
||||
RechargeUrlModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
payUrl = json['payUrl'];
|
||||
mode = json['mode'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 兑换码记录列表
|
||||
class ExchangeRecordList {
|
||||
int? total;
|
||||
List<ExchangeRecordModel>? data;
|
||||
|
||||
ExchangeRecordList.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
total = json['total'];
|
||||
data = (json['data'] as List?)?.map((e) => ExchangeRecordModel.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条兑换记录
|
||||
class ExchangeRecordModel {
|
||||
String? code;
|
||||
String? desc;
|
||||
String? createdAt;
|
||||
|
||||
ExchangeRecordModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
code = json['code'];
|
||||
desc = json['desc'];
|
||||
createdAt = json['createdAt'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// 关注 / 粉丝列表里的用户
|
||||
class FollowUserModel {
|
||||
String? id;
|
||||
int? uid;
|
||||
String? objcId;
|
||||
String? name;
|
||||
String? portrait;
|
||||
|
||||
int? fans;
|
||||
int? totalWorks;
|
||||
int? videoCount;
|
||||
|
||||
bool? hasFollow;
|
||||
bool? hasCollected;
|
||||
|
||||
FollowUserModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
uid = json['uid'];
|
||||
objcId = json['objcId'];
|
||||
name = json['name'];
|
||||
portrait = json['portrait'];
|
||||
|
||||
fans = json['fans'];
|
||||
totalWorks = json['totalWorks'];
|
||||
videoCount = json['videoCount'];
|
||||
|
||||
hasFollow = json['hasFollow'];
|
||||
hasCollected = json['hasCollected'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:hgdj/hj_model/splash/ads_model.dart';
|
||||
|
||||
class AdGroup {
|
||||
final List<AdsInfoModel>? items;
|
||||
final String? title;
|
||||
AdGroup({this.title, this.items});
|
||||
}
|
||||
|
||||
class AdTabConfig {
|
||||
/// 对应着列表数据
|
||||
final AdGroup? shuAds;
|
||||
final List<AdsInfoModel>? bannerAds;
|
||||
|
||||
/// 对应着网格排版
|
||||
final AdGroup? hengAds;
|
||||
AdTabConfig({this.shuAds, this.bannerAds, this.hengAds});
|
||||
}
|
||||
|
||||
class HappyModel {
|
||||
List<AdsInfoModel>? shuApp;
|
||||
List<AdsInfoModel>? hengApp;
|
||||
List<AdsInfoModel>? adv;
|
||||
List<AdsInfoModel>? gameApp;
|
||||
List<AdsInfoModel>? ypApp;
|
||||
List<AdsInfoModel>? zbApp;
|
||||
List<AdsInfoModel>? qpApp;
|
||||
|
||||
HappyModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
shuApp = _ads(json['shuApp']);
|
||||
hengApp = _ads(json['hengApp']);
|
||||
adv = _ads(json['adv']);
|
||||
gameApp = _ads(json['gameApp']);
|
||||
ypApp = _ads(json['ypApp']);
|
||||
zbApp = _ads(json['zbApp']);
|
||||
qpApp = _ads(json['qpApp']);
|
||||
}
|
||||
|
||||
static List<AdsInfoModel>? _ads(dynamic raw) =>
|
||||
raw is! List ? null : raw.map((e) => AdsInfoModel.fromJson(e)).toList();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 官方渠道列表项
|
||||
class OfficialListItemModel {
|
||||
String? officialName;
|
||||
String? officialDesc;
|
||||
String? officialImg;
|
||||
String? officialUrl;
|
||||
num? position;
|
||||
|
||||
OfficialListItemModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
officialName = json['officialName'];
|
||||
officialDesc = json['officialDesc'];
|
||||
officialImg = json['officialImg'];
|
||||
officialUrl = json['officialUrl'];
|
||||
position = json['position'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// 推广记录里的下线用户
|
||||
class Promotion {
|
||||
String? name;
|
||||
String? portrait;
|
||||
String? createAt;
|
||||
|
||||
Promotion.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
name = json['name'];
|
||||
portrait = json['portrait'];
|
||||
createAt = json['createAt'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// 积分兑换商品
|
||||
class IntegralExchangeModel {
|
||||
String? id;
|
||||
int? type; //5_实物,兑换后要联系客服填地址
|
||||
String? img;
|
||||
int? price;
|
||||
|
||||
IntegralExchangeModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
type = json['type'];
|
||||
img = json['img'];
|
||||
price = json['price'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务中心:按类型分组的任务列表
|
||||
class TaskCenterData {
|
||||
List<DailyTask>? dailyTask; //每日任务
|
||||
List<DailyTask>? onceTask; //一次性任务
|
||||
List<DailyTask>? growthTasks; //成长任务
|
||||
|
||||
TaskCenterData.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
dailyTask = _tasks(json['dailyTask']);
|
||||
onceTask = _tasks(json['onceTask']);
|
||||
growthTasks = _tasks(json['growthTasks']);
|
||||
}
|
||||
|
||||
static List<DailyTask>? _tasks(dynamic raw) => (raw as List?)?.map((e) => DailyTask.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
/// 单个任务
|
||||
class DailyTask {
|
||||
String? id;
|
||||
String? title;
|
||||
String? subTitle;
|
||||
String? desc;
|
||||
String? img;
|
||||
String? link;
|
||||
int? type;
|
||||
int? prizesIntegral; //完成可得积分
|
||||
int? countdownType;
|
||||
String? startAt;
|
||||
String? endAt;
|
||||
int? status;
|
||||
|
||||
/// 任务所属分组,后端不下发,合并列表时由客户端标记:1_每日 2_一次性 3_成长
|
||||
int? doType;
|
||||
|
||||
DailyTask.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
title = json['title'];
|
||||
subTitle = json['subTitle'];
|
||||
desc = json['desc'];
|
||||
img = json['img'];
|
||||
link = json['link'];
|
||||
type = json['type'];
|
||||
prizesIntegral = json['prizesIntegral'];
|
||||
countdownType = json['countdownType'];
|
||||
startAt = json['startAt'];
|
||||
endAt = json['endAt'];
|
||||
status = json['status'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/// VIP 卡片统计事件(POST /analytics/events)
|
||||
class VipCardAnalyticsEvent {
|
||||
final String eventId;
|
||||
final String eventName;
|
||||
final String sessionId;
|
||||
final String occurredAt;
|
||||
final String? experimentId;
|
||||
final String? variant;
|
||||
final String? productId;
|
||||
final int? price; // 分;下单相关扩展字段,后端可忽略未知字段
|
||||
|
||||
VipCardAnalyticsEvent({
|
||||
required this.eventId,
|
||||
required this.eventName,
|
||||
required this.sessionId,
|
||||
required this.occurredAt,
|
||||
this.experimentId,
|
||||
this.variant,
|
||||
this.productId,
|
||||
this.price,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'eventId': eventId,
|
||||
'eventName': eventName,
|
||||
'sessionId': sessionId,
|
||||
'occurredAt': occurredAt,
|
||||
if (experimentId != null) 'experimentId': experimentId,
|
||||
if (variant != null) 'variant': variant,
|
||||
if (productId != null) 'productId': productId,
|
||||
if (price != null) 'price': price,
|
||||
};
|
||||
}
|
||||
|
||||
/// 事件名
|
||||
abstract class VipCardAnalyticsEventName {
|
||||
/// 卡皮页展示(A/B)
|
||||
static const pageView = 'VIP_CARD_PAGE_VIEW';
|
||||
|
||||
/// 套餐曝光(选中 / 默认选中)
|
||||
static const productImpression = 'VIP_PRODUCT_IMPRESSION';
|
||||
|
||||
/// 无购买直接关闭
|
||||
static const closeWithoutPurchase = 'VIP_CARD_CLOSE_WITHOUT_PURCHASE';
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import '../../tools_base/banner/ads_grid_view_widget.dart';
|
||||
|
||||
/// 广告信息
|
||||
class AdsInfoModel {
|
||||
// ----- 基础信息 -----
|
||||
String? id;
|
||||
String? title;
|
||||
String? cover; //封面图
|
||||
String? href; //跳转地址
|
||||
String? newUrl;
|
||||
|
||||
// ----- 官方/来源信息 -----
|
||||
String? officialImg;
|
||||
String? officialName;
|
||||
String? officialDesc;
|
||||
String? officialUrl;
|
||||
String? officialType;
|
||||
|
||||
// ----- 展示位置与排序 -----
|
||||
String? positionName;
|
||||
String? imPosition; //IM 广告位标识(IM_LIST_BANNER 等字符串 key)
|
||||
/// 广告位置
|
||||
/// 0 - 启动页广告
|
||||
/// 1 - 首页-推荐显示小广告
|
||||
/// 2 - 首页-推荐公告展示
|
||||
/// 3 - 消息界面广告
|
||||
/// 4 - 首页-热点 banner广告
|
||||
int? position;
|
||||
|
||||
/// 同一位置使用此进行排序
|
||||
int? sortCode;
|
||||
|
||||
/// 广告样式
|
||||
/// full-screen 全屏(启动页、短视频)
|
||||
/// banner-720300 Banner720300
|
||||
/// banner-720150 Banner720150
|
||||
/// banner-750700 Banner750700
|
||||
/// banner-800450 Banner800450
|
||||
/// pop-ups-600800 弹窗600800
|
||||
/// pop-ups-512512 弹窗512512
|
||||
/// small-cube 小方块
|
||||
/// vertical-cube 竖方块
|
||||
String? coverImgSize;
|
||||
|
||||
// ----- 业务控制 -----
|
||||
int? moduleType; //楼凤广告模块过滤
|
||||
int? watchTime; //广告关闭时长
|
||||
|
||||
AdsInfoModel({this.id, this.cover, this.href, this.title});
|
||||
|
||||
/// 是否有广告倒计时
|
||||
bool get hasAdTime => (watchTime ?? 0) > 0;
|
||||
|
||||
/// 根据 coverImgSize 映射广告布局样式(small-cube 与未知值同走默认)
|
||||
AdStyle get adStyle => switch (coverImgSize) {
|
||||
'vertical-cube' => AdStyle.oneScrollBig,
|
||||
'banner-720300' => AdStyle.banner,
|
||||
_ => AdStyle.twoAutoScroll,
|
||||
};
|
||||
|
||||
//同时兼容 Adv(image/name/url) 与 ShuApp(icon/name/url) 字段
|
||||
AdsInfoModel.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
title = json['title'] ?? json['name'];
|
||||
cover = json['cover'] ?? json['image'] ?? json['icon'];
|
||||
href = json['href'] ?? json['url'];
|
||||
newUrl = json['newUrl'];
|
||||
officialImg = json['officialImg'];
|
||||
officialName = json['officialName'];
|
||||
officialDesc = json['officialDesc'];
|
||||
officialUrl = json['officialUrl'];
|
||||
officialType = json['officialType'];
|
||||
positionName = json['positionName'];
|
||||
position = json['position'];
|
||||
sortCode = json['sortCode'];
|
||||
coverImgSize = json['coverImgSize'];
|
||||
moduleType = json['moduleType'];
|
||||
watchTime = json['watchTime'];
|
||||
}
|
||||
|
||||
//和 fromJson 同一套解析,别再各写一份
|
||||
static AdsInfoModel fromMap(Map<dynamic, dynamic>? map) => AdsInfoModel.fromJson(map);
|
||||
|
||||
Map toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"cover": cover,
|
||||
"href": href,
|
||||
"position": position,
|
||||
"sortCode": sortCode,
|
||||
"positionName": positionName,
|
||||
};
|
||||
}
|
||||
|
||||
/// 公告信息
|
||||
class AnnounceInfoBean {
|
||||
String? content;
|
||||
String? cover;
|
||||
String? href; //图片公告点击时跳转的地址
|
||||
int? type; //0:系统公告 1:图片公告 3:活动公告
|
||||
String? title;
|
||||
|
||||
static AnnounceInfoBean fromJson(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return AnnounceInfoBean()
|
||||
..content = map['content']
|
||||
..cover = map['cover']
|
||||
..href = map['href']
|
||||
..type = map['type']
|
||||
..title = map['title'];
|
||||
}
|
||||
|
||||
Map toJson() => {
|
||||
"content": content,
|
||||
"cover": cover,
|
||||
"href": href,
|
||||
"type": type,
|
||||
"title": title,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
class BannerJumpEntity {
|
||||
String? id;
|
||||
int? position;
|
||||
String? url;
|
||||
String? title;
|
||||
String? startAt;
|
||||
String? banner;
|
||||
String? endAt;
|
||||
int? countdownType;
|
||||
|
||||
BannerJumpEntity({this.title, this.id, this.position, this.url, this.startAt, this.banner, this.endAt, this.countdownType});
|
||||
|
||||
BannerJumpEntity.fromJson(dynamic json) {
|
||||
id = json['id'];
|
||||
position = json['position'];
|
||||
url = json['url'];
|
||||
title = json['title'];
|
||||
startAt = json['startAt'];
|
||||
endAt = json['endAt'];
|
||||
banner = json['banner'];
|
||||
countdownType = json['countdownType'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// 粘贴版信息
|
||||
class CutInfo {
|
||||
// pc是邀请码promoteCode
|
||||
String? pc;
|
||||
|
||||
/// dc是渠道 distinctCode
|
||||
String? dc;
|
||||
String? tid;
|
||||
|
||||
static CutInfo fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return CutInfo()
|
||||
..pc = map['pc']
|
||||
..dc = map['dc']
|
||||
..tid = map['tid'];
|
||||
}
|
||||
|
||||
Map toJson() => {
|
||||
"pc": pc,
|
||||
"dc": dc,
|
||||
"tid": tid,
|
||||
}..removeWhere((key, value) => value == null || (value as String?)?.isEmpty == true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
|
||||
import '../../alert/vip_guide/guide_manager.dart';
|
||||
import '../../config/config.dart';
|
||||
import '../../hj_page/ai/ai_sub_type/ai_function_logic.dart';
|
||||
import '../../hj_page/main_page/provider/msg_provider.dart';
|
||||
import '../../hj_page/pre_sale/pre_sale_model.dart';
|
||||
import 'ads_model.dart';
|
||||
|
||||
/// 预售/活动状态
|
||||
class AdvanceStatus {
|
||||
bool? activityPopUp; //预售活动是否首页弹窗
|
||||
bool? activityStatus; //是否开启
|
||||
bool? balancePayment; //是否有尾款支付
|
||||
String? oid;
|
||||
PrivilegeLimit? privilegeLimit; //预付权益
|
||||
|
||||
AdvanceStatus.fromJson(dynamic json) {
|
||||
activityStatus = json['activityStatus'];
|
||||
balancePayment = json['balancePayment'];
|
||||
activityPopUp = json['activityPopUp'];
|
||||
oid = json['oid'];
|
||||
privilegeLimit = json['privilegeLimit'] != null
|
||||
? PrivilegeLimit.fromJson(json['privilegeLimit'])
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 预付权益限制与剩余次数
|
||||
class PrivilegeLimit {
|
||||
bool? hasLimit; //是否有权益限制
|
||||
Remain? remain;
|
||||
|
||||
PrivilegeLimit({this.hasLimit, this.remain});
|
||||
|
||||
PrivilegeLimit.fromJson(Map<String, dynamic> json) {
|
||||
hasLimit = json['hasLimit'];
|
||||
remain = json['remain'] != null ? Remain.fromJson(json['remain']) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 今日各项功能剩余次数
|
||||
class Remain {
|
||||
int? todayAiUndressCount; //今日剩余ai脱衣次数
|
||||
int? todayCoinVideoCount; //今日剩余金币视频观看次数
|
||||
int? todayDownloadCount; //今日剩余下载次数
|
||||
|
||||
Remain(
|
||||
{this.todayAiUndressCount,
|
||||
this.todayCoinVideoCount,
|
||||
this.todayDownloadCount});
|
||||
|
||||
Remain.fromJson(Map<String, dynamic> json) {
|
||||
todayAiUndressCount = json['todayAiUndressCount'];
|
||||
todayCoinVideoCount = json['todayCoinVideoCount'];
|
||||
todayDownloadCount = json['todayDownloadCount'];
|
||||
}
|
||||
|
||||
void reduceVideoCoinCount() {
|
||||
todayCoinVideoCount = (todayCoinVideoCount ?? 0) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一份 bannerJump 下按位置取:key '1' 播放页,'2' 全民代理页
|
||||
class BannerJump {
|
||||
AdditionalProp? additionalProp;
|
||||
|
||||
BannerJump({this.additionalProp});
|
||||
|
||||
BannerJump.fromJson(Map<String, dynamic> json, {String key = '1'}) {
|
||||
additionalProp =
|
||||
json[key] != null ? AdditionalProp.fromJson(json[key]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
class AdditionalProp {
|
||||
String? id;
|
||||
String? title; //标题
|
||||
String? banner; //BANNER图片
|
||||
String? url; //跳转地址
|
||||
int? position; //位置 1:视频播放器下方
|
||||
String? startAt; //开始时间
|
||||
String? endAt; //结束时间
|
||||
int? countdownType;
|
||||
|
||||
AdditionalProp(
|
||||
{this.id,
|
||||
this.banner,
|
||||
this.endAt,
|
||||
this.position,
|
||||
this.startAt,
|
||||
this.title,
|
||||
this.url,
|
||||
this.countdownType});
|
||||
|
||||
AdditionalProp.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
title = json['title'];
|
||||
banner = json['banner'];
|
||||
url = json['url'];
|
||||
position = json['position'];
|
||||
startAt = json['startAt'];
|
||||
endAt = json['endAt'];
|
||||
countdownType = json['countdownType'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'banner': banner,
|
||||
'url': url,
|
||||
'position': position,
|
||||
'startAt': startAt,
|
||||
'endAt': endAt,
|
||||
'countdownType': countdownType,
|
||||
};
|
||||
}
|
||||
|
||||
/// 全局配置:域名、广告、AI价格、活动、搜索等
|
||||
class DomainSourceModel {
|
||||
// ----- 域名 / 线路 / 版本 -----
|
||||
List<String>? domain; //接口域名
|
||||
List<Sourcelist>? sourceList;
|
||||
List<CheckVersionInfo>? ver;
|
||||
|
||||
// ----- 广告 / 公告 -----
|
||||
AdsBean? ads;
|
||||
|
||||
// ----- AI 功能价格 -----
|
||||
String? aiUndressPrice; //AI脱衣价格
|
||||
String? aiImageToVideoPrice; //图生视频价格
|
||||
String? aiTextToImagePrice; //绘图价格
|
||||
String? aiTextToNovelPrice; //小说价格
|
||||
|
||||
// ----- 活动 / 预售 / Banner -----
|
||||
AdvanceStatus? advanceStatus;
|
||||
AdvancePage? advancePage;
|
||||
BannerJump? bannerJump; //限时Banner活动
|
||||
BannerJump? proxyBannerJump;
|
||||
|
||||
// ----- 抽奖 -----
|
||||
String? luckyDrawH5; //抽奖H5链接
|
||||
String? luckyDrawIcon; //抽奖入口浮动图标
|
||||
|
||||
// ----- 搜索 -----
|
||||
List<String>? hotSearchTerms; //搜索热门词
|
||||
List<String>? searchHintWord; //搜索提示词
|
||||
|
||||
// ----- 暗网 VIP -----
|
||||
String? darkWebVipId; //暗网vip卡id
|
||||
String? darkWebVipName; //暗网vip卡名
|
||||
String? shortDramaCardId; //短剧卡id(付费墙默认选中的会员卡)
|
||||
List<String>? recommendVipIds;
|
||||
|
||||
// ----- 其他 -----
|
||||
bool? broadcast; //是否展示直播模块
|
||||
bool? storeIsOpen; //原味商店开关
|
||||
List<JGAreaModel>? jgArea; //金刚区配置
|
||||
|
||||
static DomainSourceModel fromJson(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
final info = DomainSourceModel();
|
||||
|
||||
// 直接写入全局 Config 的配置项
|
||||
Config.vipMark = map['vipMark'] ?? true;
|
||||
Config.coinMark = map['coinMark'] ?? true;
|
||||
Config.freeMark = map['freeMark'] ?? true;
|
||||
Config.signIcon = map['signIcon'];
|
||||
Config.darkWebEnable = map['darkWebEnable'] ?? true;
|
||||
Config.darkWebImg = map['darkWebImg'];
|
||||
Config.darkWebIcon = map['darkWebIcon'];
|
||||
Config.darkWebIconName = map['darkWebIconName'];
|
||||
Config.mineBg = map['personalCenterBackground'];
|
||||
Config.entryPopupEnabled =
|
||||
map['shortDramaEntryPopupEnabled'] ?? true; //短剧引导气泡,字段缺失当开启
|
||||
MineMsgProvider().payTier =
|
||||
PayTierModel.fromJson(map); //支付状态分层(各展示位图片/卡ID/倒计时)
|
||||
GuideManager().update(map['paymentGuide']); //付费引导各场景开关(弹窗一律读缓存,不再临时查接口)
|
||||
if (map['aiSwitchConf'] is List) {
|
||||
Config.aiTypes = (map['aiSwitchConf'] as List)
|
||||
.map((e) => AISwitchConf.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
if (map.containsKey('bannerJumpList')) {
|
||||
Config.bannerJumps = (map['bannerJumpList'] as List?)
|
||||
?.map((o) => BannerJumpEntity.fromJson(o))
|
||||
.toList() ??
|
||||
[];
|
||||
}
|
||||
|
||||
// 域名 / 线路 / 版本
|
||||
if (map.containsKey('domain')) {
|
||||
info.domain = (map['domain'] as List?)?.map((o) => o.toString()).toList();
|
||||
}
|
||||
if (map.containsKey('sourceList')) {
|
||||
info.sourceList = (map['sourceList'] as List?)
|
||||
?.map((o) => Sourcelist.fromJson(o))
|
||||
.toList();
|
||||
}
|
||||
if (map.containsKey('ver')) {
|
||||
info.ver = (map['ver'] as List?)
|
||||
?.map((o) => CheckVersionInfo.fromMap(o))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 广告
|
||||
if (map.containsKey('ads')) {
|
||||
info.ads = AdsBean.fromMap(map['ads']);
|
||||
}
|
||||
|
||||
// AI 价格
|
||||
info.aiUndressPrice = map['aiUndressPrice']?.toString();
|
||||
info.aiImageToVideoPrice = map['aiImageToVideoPrice']?.toString();
|
||||
info.aiTextToImagePrice = map['aiTextToImagePrice']?.toString();
|
||||
info.aiTextToNovelPrice = map['aiTextToNovelPrice']?.toString();
|
||||
|
||||
// 活动 / 预售 / Banner
|
||||
if (map.containsKey('advancePage')) {
|
||||
info.advancePage = AdvancePage.fromJson(map['advancePage']);
|
||||
}
|
||||
if (map.containsKey('advanceStatus')) {
|
||||
info.advanceStatus = AdvanceStatus.fromJson(map['advanceStatus']);
|
||||
}
|
||||
info.bannerJump = map['bannerJump'] != null
|
||||
? BannerJump.fromJson(map['bannerJump'])
|
||||
: null;
|
||||
info.proxyBannerJump = map['bannerJump'] != null
|
||||
? BannerJump.fromJson(map['bannerJump'], key: '2')
|
||||
: null;
|
||||
|
||||
// 抽奖
|
||||
info.luckyDrawH5 = map['luckyDrawH5'];
|
||||
info.luckyDrawIcon = map['luckyDrawIcon'];
|
||||
|
||||
// 搜索
|
||||
if (map['hotSearchTerms'] is List) {
|
||||
info.hotSearchTerms =
|
||||
(map['hotSearchTerms'] as List).map((e) => e.toString()).toList();
|
||||
}
|
||||
if (map['searchHintWord'] is List) {
|
||||
info.searchHintWord =
|
||||
(map['searchHintWord'] as List).map((e) => e.toString()).toList();
|
||||
}
|
||||
|
||||
// 暗网 VIP
|
||||
info.darkWebVipId = map['darkWebVipId'];
|
||||
info.darkWebVipName = map['darkWebVipName'];
|
||||
info.shortDramaCardId = map['shortDramaCardId'];
|
||||
if (map['recommendVipIds'] is List) {
|
||||
info.recommendVipIds =
|
||||
(map['recommendVipIds'] as List).map((e) => e.toString()).toList();
|
||||
}
|
||||
|
||||
// 其他
|
||||
info.broadcast = map['broadcast'] ?? false;
|
||||
info.storeIsOpen = map['storeIsOpen'];
|
||||
if (map['jgArea'] is List) {
|
||||
info.jgArea = (map['jgArea'] as List?)
|
||||
?.map((e) => JGAreaModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑马灯公告
|
||||
class MarqueeModel {
|
||||
String? content; //跑马灯内容
|
||||
String? url; //跳转连接
|
||||
bool? active; //激活状态
|
||||
String? id; //公告id
|
||||
int? type; //跑马灯类型,0:会员中心
|
||||
int? position;
|
||||
String? positionDesc;
|
||||
|
||||
static MarqueeModel fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return MarqueeModel()
|
||||
..content = map['content']
|
||||
..url = map['url']
|
||||
..active = map['active']
|
||||
..id = map['id']
|
||||
..type = map['type']
|
||||
..position = map['position']
|
||||
..positionDesc = map['positionDesc'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本检测信息
|
||||
class CheckVersionInfo {
|
||||
String? description;
|
||||
bool? forcedUpdate; //是否强制更新
|
||||
String? platform;
|
||||
String? url; //Android 的 apk 下载地址
|
||||
String? iosUrl; //iOS 的 App Store 地址,点升级直接跳商店
|
||||
String? verName;
|
||||
int? code;
|
||||
|
||||
static CheckVersionInfo fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return CheckVersionInfo()
|
||||
..description = map['description']
|
||||
..forcedUpdate = map['forcedUpdate']
|
||||
..platform = map['platform']
|
||||
..url = map['url']
|
||||
..iosUrl = map['iosUrl']
|
||||
..verName = map['verName']
|
||||
..code = map['Code'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 广告聚合:图片广告列表 + 文本公告
|
||||
class AdsBean {
|
||||
List<AdsInfoModel>? adsInfoList;
|
||||
List<AnnounceInfoBean>? announInfo; //文本公告
|
||||
|
||||
///多个公告
|
||||
List<AnnounceInfoBean>? announList;
|
||||
|
||||
static AdsBean fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return AdsBean()
|
||||
..announInfo = (map['announInfo'] as List?)
|
||||
?.map((o) => AnnounceInfoBean.fromJson(o))
|
||||
.toList()
|
||||
..announList = (map['announList'] as List?)
|
||||
?.map((o) => AnnounceInfoBean.fromJson(o))
|
||||
.toList()
|
||||
..adsInfoList = (map['adsInfoList'] as List?)
|
||||
?.map((o) => AdsInfoModel.fromMap(o))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map toJson() => {
|
||||
"announInfo": announInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/// 金刚区单项
|
||||
class JGAreaModel {
|
||||
String? id;
|
||||
String? name;
|
||||
int? type;
|
||||
String? img;
|
||||
String? desc;
|
||||
int? linkType;
|
||||
String? linkUrl;
|
||||
String? mid;
|
||||
|
||||
JGAreaModel(
|
||||
{this.id,
|
||||
this.name,
|
||||
this.img,
|
||||
this.desc,
|
||||
this.linkType,
|
||||
this.linkUrl,
|
||||
this.type,
|
||||
this.mid});
|
||||
|
||||
factory JGAreaModel.fromJson(Map<String, dynamic> json) => JGAreaModel(
|
||||
id: json["id"],
|
||||
name: json["name"],
|
||||
img: json["img"],
|
||||
desc: json["desc"],
|
||||
linkType: json["link_type"],
|
||||
linkUrl: json["link_url"],
|
||||
type: json["type"],
|
||||
mid: json["mid"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"img": img,
|
||||
"desc": desc,
|
||||
"link_type": linkType,
|
||||
"link_url": linkUrl,
|
||||
'mid': mid,
|
||||
};
|
||||
}
|
||||
|
||||
/// 线路源
|
||||
class Sourcelist {
|
||||
String? id;
|
||||
List<Domain>? domain;
|
||||
bool? isActive;
|
||||
String? type;
|
||||
|
||||
Sourcelist({this.id, this.domain, this.isActive, this.type});
|
||||
|
||||
factory Sourcelist.fromJson(Map<String, dynamic> json) => Sourcelist(
|
||||
id: json["id"],
|
||||
domain:
|
||||
List<Domain>.from(json["domain"].map((x) => Domain.fromJson(x))),
|
||||
isActive: json["isActive"],
|
||||
type: json["type"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"domain": List<dynamic>.from(domain!.map((x) => x.toJson())),
|
||||
"isActive": isActive,
|
||||
"type": type,
|
||||
};
|
||||
}
|
||||
|
||||
/// 域名节点(带权重)
|
||||
class Domain {
|
||||
int? weight;
|
||||
String? url;
|
||||
String? desc;
|
||||
int? status;
|
||||
|
||||
Domain({this.weight, this.url, this.desc, this.status});
|
||||
|
||||
factory Domain.fromJson(Map<String, dynamic> json) => Domain(
|
||||
weight: json["weight"],
|
||||
url: json["url"],
|
||||
desc: json["desc"],
|
||||
status: json["status"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"weight": weight,
|
||||
"url": url,
|
||||
"desc": desc,
|
||||
"status": status,
|
||||
};
|
||||
}
|
||||
|
||||
//AI 类型配置表:type → 名称/功能枚举/图标(存图标名,读取时拼 .aiPath)
|
||||
class _AiTypeConf {
|
||||
final String name;
|
||||
final AiType type;
|
||||
final String img;
|
||||
const _AiTypeConf(this.name, this.type, this.img);
|
||||
}
|
||||
|
||||
const _aiTypeConfMap = <int, _AiTypeConf>{
|
||||
1: _AiTypeConf('AI脱衣', AiType.autoStrip, 'ai_address.png'),
|
||||
2: _AiTypeConf('视频换脸', AiType.videoChangeFace, 'ai_video_face.png'),
|
||||
3: _AiTypeConf('图片换脸', AiType.imageChangeFace, 'ai_pic_face.png'),
|
||||
4: _AiTypeConf('图生视频', AiType.imageToVideo, 'ai_itv.png'),
|
||||
5: _AiTypeConf('AI绘画', AiType.aiPaint, 'ai_draw.png'),
|
||||
6: _AiTypeConf('AI小说', AiType.aiNovel, 'ai_novel_enter.png'),
|
||||
7: _AiTypeConf('AI女友', AiType.aiMate, 'ai_mate_enter.png'),
|
||||
};
|
||||
|
||||
/// AI 功能开关配置
|
||||
class AISwitchConf {
|
||||
int? type; //AI类型 1:AI脱衣 2:AI视频换脸 3:AI图片换脸 4:图生视频 5:文生图 6:AI小说
|
||||
int? sort; //排序,越小越靠前
|
||||
bool? isOpen; //是否开启
|
||||
String? aiTypeName;
|
||||
String? img; //图标(选中/未选中同一张,渲染时统一染白)
|
||||
AiType? aiType;
|
||||
|
||||
AISwitchConf.fromJson(Map<String, dynamic> json) {
|
||||
type = json['type'];
|
||||
sort = json['sort'];
|
||||
isOpen = json['isOpen'];
|
||||
final conf = _aiTypeConfMap[type];
|
||||
if (conf != null) {
|
||||
aiTypeName = conf.name;
|
||||
aiType = conf.type;
|
||||
img = conf.img.aiPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 限时 Banner 跳转配置
|
||||
class BannerJumpEntity {
|
||||
String? id;
|
||||
int? position;
|
||||
String? url;
|
||||
String? title;
|
||||
String? startAt;
|
||||
String? banner;
|
||||
String? endAt;
|
||||
int? countdownType;
|
||||
|
||||
BannerJumpEntity(
|
||||
{this.title,
|
||||
this.id,
|
||||
this.position,
|
||||
this.url,
|
||||
this.startAt,
|
||||
this.banner,
|
||||
this.endAt,
|
||||
this.countdownType});
|
||||
|
||||
BannerJumpEntity.fromJson(dynamic json) {
|
||||
id = json['id'];
|
||||
position = json['position'];
|
||||
url = json['url'];
|
||||
title = json['title'];
|
||||
startAt = json['startAt'];
|
||||
endAt = json['endAt'];
|
||||
banner = json['banner'];
|
||||
countdownType = json['countdownType'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付状态分层(ping/domain 的 paymentStatusPopup 字段),用于付费流程的用户分层
|
||||
enum PayTier {
|
||||
newUnpay('new_unpay'), //新用户:注册≤1天且从未付费 → 推新人200卡(新人价+首购引导+紧迫感)
|
||||
under7DayUnpay('under_7_day_unpay'), //未付费·注册1~7天 → 推300卡(限时优惠+紧迫感)
|
||||
over7DayUnpay('over_7_day_unpay'), //未付费·注册>7天 → 推300卡(永久卡唤醒需求+信任背书)
|
||||
over7DayNeedUpgrade(
|
||||
'over_7_day_need_upgrade'), //低级会员需续费:注册>7天且已付费、未达最高等级(<5) → 推500至尊卡
|
||||
normal('normal'), //正常用户:无特殊弹窗
|
||||
unregistered('unregistered'); //未注册(未登录)
|
||||
|
||||
final String value;
|
||||
const PayTier(this.value);
|
||||
|
||||
static PayTier? from(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
for (final e in PayTier.values) {
|
||||
if (e.value == value) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付状态弹窗配置(收敛 ping/domain 支付分层相关字段)
|
||||
class PayTierModel {
|
||||
PayTier? status; //用户分层
|
||||
PayTierConfig? config; //各展示位配置(图片/卡ID/倒计时)
|
||||
|
||||
PayTierModel({this.status, this.config});
|
||||
|
||||
PayTierModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
status = PayTier.from(json['paymentStatusPopup']);
|
||||
// /ping/domain 配置在 paymentStatusPopupConfig 下;/ping/domain/refresh 平铺在外层,回退用 json 本身
|
||||
config = PayTierConfig.fromJson(json['paymentStatusPopupConfig'] ?? json);
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付状态弹窗各展示位配置(meTab/playPage 为图片链接;vipCard 卡ID;lastDiscountTime 优惠截止时间)
|
||||
/// 全站新人卡/分层优惠倒计时的唯一数据源,各展示位一律读这里,不再另开接口取时间
|
||||
class PayTierConfig {
|
||||
String? homePage; //首页弹窗图片链接
|
||||
String? homePageFlot; //首页浮窗图片链接
|
||||
String? playPage; //播放页图片链接
|
||||
String? meTab; //me tab 图片链接
|
||||
String? vipCard; //点击跳转的会员卡 ID
|
||||
int? lastDiscountTime; //优惠截止 Unix 时间戳(秒),早于当前则不展示倒计时
|
||||
|
||||
PayTierConfig(
|
||||
{this.homePage,
|
||||
this.homePageFlot,
|
||||
this.playPage,
|
||||
this.meTab,
|
||||
this.vipCard,
|
||||
this.lastDiscountTime});
|
||||
|
||||
PayTierConfig.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
homePage = json['homepage'];
|
||||
homePageFlot = json['homepageFlot'];
|
||||
playPage = json['playPage'];
|
||||
meTab = json['meTab'];
|
||||
vipCard = json['vipCard'];
|
||||
lastDiscountTime = _parseEpochSec(json['lastDiscountTime']);
|
||||
}
|
||||
|
||||
static const _minValidEpochSec =
|
||||
946684800; //2000-01-01,用于挡掉后端零值时间 0001-01-01T00:00:00Z
|
||||
|
||||
//兼容 Unix 秒(int) / 数字字符串 / ISO 字符串(带 Z 或 +08:00 时区);零值哨兵一律归 null,避免负 epoch 流入业务
|
||||
static int? _parseEpochSec(dynamic v) {
|
||||
if (v == null) return null;
|
||||
int? sec;
|
||||
if (v is num) {
|
||||
sec = v.toInt();
|
||||
} else if (v is String) {
|
||||
final s = v.trim();
|
||||
if (s.isEmpty) return null;
|
||||
sec = int.tryParse(s) ??
|
||||
(DateTime.tryParse(s)?.millisecondsSinceEpoch ?? 0) ~/ 1000;
|
||||
}
|
||||
return (sec != null && sec >= _minValidEpochSec) ? sec : null;
|
||||
}
|
||||
|
||||
/// 优惠剩余秒数(截止时刻 - 当前),≤0 表示已过期/不展示倒计时
|
||||
int get discountRemainSec {
|
||||
final end = lastDiscountTime ?? 0;
|
||||
if (end <= 0) return 0;
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final r = end - now;
|
||||
return r > 0 ? r : 0;
|
||||
}
|
||||
|
||||
bool get hasDiscountCountdown => discountRemainSec > 0;
|
||||
|
||||
String get discountHour =>
|
||||
(discountRemainSec ~/ 3600).toString().padLeft(2, '0');
|
||||
|
||||
String get discountMin =>
|
||||
((discountRemainSec ~/ 60) % 60).toString().padLeft(2, '0');
|
||||
|
||||
String get discountSec => (discountRemainSec % 60).toString().padLeft(2, '0');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
class WatchCount {
|
||||
int? watchCount; // 剩余免费观看次数
|
||||
int? total; // 免费观看总次数(后端下发,用于"剩余 x/y 次"展示)
|
||||
bool? isCan; // 金币视频不再免费次数内
|
||||
|
||||
//⚠️注意: coinWatchCount: 活动权益金币视频免费观看次数, 需要手动赋值,
|
||||
int coinWatchCount = -1; //短视频业务专用, 因为短视频业务提前预加载数据,需要copy对象,保存当前对应的count值
|
||||
|
||||
static WatchCount fromJson(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return WatchCount()
|
||||
..watchCount = map['watchCount']
|
||||
..total = map['total']
|
||||
..isCan = map['isCan'];
|
||||
}
|
||||
|
||||
//只复制展示用的次数;coinWatchCount 按注释由调用方自行赋值
|
||||
WatchCount copy() => WatchCount()
|
||||
..watchCount = watchCount
|
||||
..total = total;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// 推广收益概览
|
||||
class UserIncomeModel {
|
||||
String? totalAmount; // 可提现收益
|
||||
String? totalIncomeAmount; // 累计收益总额
|
||||
String? totalPayUserCount; // 累计付费用户
|
||||
String? totalInviteUserCount; // 累计推广用户
|
||||
String? todayInviteUserCount; // 今日推广用户
|
||||
String? monthInviteUserCount; // 当月推广用户
|
||||
String? monthIncomeAmount; // 当月推广收益金币
|
||||
String? todayIncomeAmount; // 今日推广收益金币
|
||||
|
||||
UserIncomeModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
totalAmount = json['totalAmount'];
|
||||
totalIncomeAmount = json['totalIncomeAmount'];
|
||||
totalPayUserCount = json['totalPayUserCount'];
|
||||
totalInviteUserCount = json['totalInviteUserCount'];
|
||||
todayInviteUserCount = json['todayInviteUserCount'];
|
||||
monthInviteUserCount = json['monthInviteUserCount'];
|
||||
monthIncomeAmount = json['monthIncomeAmount'];
|
||||
todayIncomeAmount = json['todayIncomeAmount'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 推广收益列表(分页)
|
||||
class InviteIncomeModel {
|
||||
bool? hasNext;
|
||||
List<InviteItem>? items;
|
||||
|
||||
InviteIncomeModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
hasNext = json['hasNext'];
|
||||
items = (json['list'] as List?)?.map((e) => InviteItem.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条推广记录
|
||||
class InviteItem {
|
||||
String? userName; // 被邀请人昵称
|
||||
int? incomeAmount; // 该用户带来的收益金币
|
||||
|
||||
InviteItem.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
userName = json['userName'];
|
||||
incomeAmount = json['incomeAmount'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
|
||||
/// 用户信息
|
||||
class UserInfoModel {
|
||||
// —— 基础资料 ——
|
||||
int? uid; // 用户ID
|
||||
String? name; // 昵称
|
||||
String? portrait; // 头像
|
||||
String? gender; // 性别
|
||||
String? summary; // 个性签名
|
||||
String? mobile; // 手机号
|
||||
int? urrPortraitStatus; // 头像审核状态
|
||||
|
||||
// —— 账号 / 登录 ——
|
||||
String? token; // 登录 token
|
||||
String? devID; // 设备 ID
|
||||
String? createdAt; // 注册时间
|
||||
String? districtCode; // 渠道码
|
||||
|
||||
// —— 社交统计 ——
|
||||
int? fans; // 粉丝数
|
||||
int? likeCount; // 获赞数
|
||||
bool? isFollow; // 是否已关注
|
||||
|
||||
// —— 会员 / VIP ——
|
||||
bool? isVip; // 是否会员
|
||||
int? vipLevel; // 会员等级 1-影片会员 2-ACG会员 3-超级会员(一卡通)
|
||||
String? vipName; // 会员名称
|
||||
String? vipExpireDate; // 会员到期时间
|
||||
bool? isUpgrade; // 是否可升级
|
||||
|
||||
// —— 推广 / 邀请 ——
|
||||
String? promoteURL; // 推广链接
|
||||
String? promotionCode; // 我的邀请码
|
||||
String? inviterCode; // 邀请人邀请码
|
||||
|
||||
// —— 金币 / 观影券 / 权益 ——
|
||||
bool allGoldVideoFree = false; // 是否全场金币视频免费
|
||||
String? goldVideoFreeExpire; // 金币视频免费权益到期时间
|
||||
List<MyCoupon>? goldVideoCoupon; // 观影券列表
|
||||
String? broadcastExpire; // 直播观看权益有效期
|
||||
String? dramaExpire; // 短剧卡到期时间,晚于当前时间就是有短剧权益
|
||||
int? payVidDiscount; // 付费视频折扣
|
||||
|
||||
// —— 创作 / 等级 ——
|
||||
int? vidUploadCount; // 视频上传数
|
||||
int? coverUploadCount; // 封面上传数
|
||||
int? level; // 当前等级
|
||||
|
||||
// —— 状态 / 权限 ——
|
||||
int? adverAbTestShowType; // 广告AB -1-首次进app免广告 0-全部展示 其他N-注册后N分钟内免广告(分层后固定不变)
|
||||
|
||||
// ============ 计算属性 ============
|
||||
|
||||
/// 观影券总数量
|
||||
int get couponCount =>
|
||||
(goldVideoCoupon ?? []).fold(0, (sum, e) => sum + (e.count ?? 0));
|
||||
|
||||
/// 可解锁该金币视频的观影券面额:券面额需 >= 视频金币,都不够就退回第一张
|
||||
int? couponGold(int? originCoins) {
|
||||
final coupons = goldVideoCoupon ?? [];
|
||||
for (final coupon in coupons) {
|
||||
if ((coupon.gold ?? 0) >= (originCoins ?? 0)) return coupon.gold ?? 0;
|
||||
}
|
||||
return coupons.isEmpty ? null : coupons.first.gold;
|
||||
}
|
||||
|
||||
/// 作品总数(视频上传 + 封面上传)
|
||||
int get totalWorkCount => (vidUploadCount ?? 0) + (coverUploadCount ?? 0);
|
||||
|
||||
/// 会员等级对应的角标图片;等级 1~4,0 级(含推广会员)无角标
|
||||
String get vipImageName {
|
||||
final level = vipLevel ?? 0;
|
||||
if (level < 1) return "";
|
||||
return "vip_${level.clamp(1, 4)}.webp".mineImgPath;
|
||||
}
|
||||
|
||||
/// 是否充值会员
|
||||
bool get isRechargeVIP => (isVip ?? false) && (vipLevel ?? 0) > 0;
|
||||
|
||||
// ============ 序列化 ============
|
||||
|
||||
UserInfoModel.fromJson(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
uid = map['uid'];
|
||||
name = map['name'];
|
||||
portrait = map['portrait'];
|
||||
gender = map['gender'];
|
||||
summary = map['summary'];
|
||||
mobile = map['mobile'];
|
||||
urrPortraitStatus = map['urrPortraitStatus'];
|
||||
|
||||
token = map['token'];
|
||||
devID = map['devID'];
|
||||
createdAt = map['createdAt'];
|
||||
districtCode = map['districtCode'];
|
||||
|
||||
fans = map['fans'];
|
||||
likeCount = map['likeCount'];
|
||||
isFollow = map['isFollow'];
|
||||
|
||||
isVip = map['isVip'];
|
||||
vipLevel = map['vipLevel'];
|
||||
vipName = map['vipName'];
|
||||
vipExpireDate = map['vipExpireDate'];
|
||||
isUpgrade = map['isUpgrade'];
|
||||
|
||||
promoteURL = map['promoteURL'];
|
||||
promotionCode = map['promotionCode'];
|
||||
inviterCode = map['inviterCode'];
|
||||
|
||||
allGoldVideoFree = map['allGoldVideoFree'] ?? false;
|
||||
goldVideoFreeExpire = map['goldVideoFreeExpire'];
|
||||
goldVideoCoupon = (map['goldVideoCoupon'] as List?)
|
||||
?.map((o) => MyCoupon.fromJson(o))
|
||||
.toList();
|
||||
broadcastExpire = map['broadcastExpire'];
|
||||
dramaExpire = map['dramaExpire'];
|
||||
payVidDiscount = map['payVidDiscount'];
|
||||
|
||||
vidUploadCount = map['vidUploadCount'];
|
||||
coverUploadCount = map['coverUploadCount'];
|
||||
level = map['level'];
|
||||
|
||||
adverAbTestShowType = map['adverAbTestShowType'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 观影券
|
||||
class MyCoupon {
|
||||
int? gold; // 券面额(金币)
|
||||
int? count; // 数量
|
||||
|
||||
MyCoupon.fromJson(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
gold = map['gold'];
|
||||
count = map['count'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 钱包信息,来自 /mine/wallet
|
||||
class WalletModel {
|
||||
// ===== 收益 =====
|
||||
int? income; //收益金币的整数部分
|
||||
double? incomePot; //收益金币的零头,和 income 一起算总收益
|
||||
num? vidIncome; //视频收益
|
||||
|
||||
// ===== 余额 =====
|
||||
int? amount; //充值金币余额,购买后会被写回
|
||||
int? integral; //积分
|
||||
|
||||
// ===== 剩余次数 =====
|
||||
int? downloadCount; //视频下载次数
|
||||
int? aiUndressFreeTimes; //AI 脱衣免费次数
|
||||
int? todayAiFreeTimes; //今日 AI 免费次数
|
||||
|
||||
//字段顺序与上方声明一致,便于比对
|
||||
WalletModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
income = json['income'];
|
||||
//后端可能下发 double / int / 数字字符串,统一按字符串解析。
|
||||
//解析不了、或拿到 NaN/Infinity 都留 null 当没零头——balance 的 `~/` 吃到它们会抛 UnsupportedError
|
||||
final pot = double.tryParse('${json['incomePot'] ?? 0}');
|
||||
incomePot = pot != null && pot.isFinite ? pot : null;
|
||||
vidIncome = json['vidIncome'];
|
||||
amount = json['amount'];
|
||||
integral = json['integral'];
|
||||
downloadCount = json['downloadCount'];
|
||||
aiUndressFreeTimes = json['aiUndressFreeTimes'];
|
||||
todayAiFreeTimes = json['todayAiFreeTimes'];
|
||||
}
|
||||
|
||||
//收益总额:先放大 100 倍算再取整,避免 double 直接相加的精度误差
|
||||
int get balance => ((income ?? 0) * 100.0 + (incomePot ?? 0) * 100) ~/ 100;
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import 'package:hgdj/hj_model/splash/ads_model.dart';
|
||||
import 'package:hgdj/hj_utils/date_time_util.dart';
|
||||
|
||||
import '../config/address.dart';
|
||||
import '../tools_base/global_store/store.dart';
|
||||
import 'cartoon_media_info.dart';
|
||||
import 'drama_media_info.dart';
|
||||
import 'media_content.dart';
|
||||
|
||||
/// 通用视频/帖子/图集/动漫/广告等多业务复合模型
|
||||
/// 后端用同一个 model 承载多种业务,按 [newsType]、[mediaType]、[videoType] 区分具体形态
|
||||
class VideoModel {
|
||||
// ===== 标识 / 类型 =====
|
||||
String? id; // 视频/帖子ID
|
||||
String? vid; // 视频 vid
|
||||
String? subid; // 动漫集数id
|
||||
String? lsjId; // 老司机媒体资源ID
|
||||
int? uid; // 发布者UID
|
||||
String? newsType; // 帖子类型 SP/CSP/COVER_SP/COVER/AD_SP/AD_COVER
|
||||
int? type; // 类型
|
||||
int? videoType; // 0视频, 1 动漫
|
||||
String? mediaType; // 媒体子类型:'image' 表示漫画(单位为"话"),其他视为视频/动漫(单位为"集")
|
||||
int? showType; // 0-所有的人都可以看 1-奇数可看 2-偶数可看
|
||||
//0 未审核 1通过 2审核失败 3视为免费 5、已下架
|
||||
int? status;
|
||||
String? reason; //审核拒绝理由
|
||||
|
||||
// ===== 内容 / 展示 =====
|
||||
String? title; // 标题
|
||||
String? name; // 动漫番号
|
||||
String? desc; // 描述
|
||||
String? content; // 正文内容
|
||||
String? richText; //图文html
|
||||
String? linkStr; // 富文本
|
||||
String? cover; // 封面
|
||||
String? coverThumb; // 封面缩略图
|
||||
List<String>? seriesCover; // 多封面列表
|
||||
List<TagsBean>? tags; // 标签列表
|
||||
String? via; // 来源渠道
|
||||
String? createdAt; // 创建时间
|
||||
String? reviewAt; // 审核通过时间
|
||||
bool? isTopping; // 是否置顶
|
||||
bool? chosen; // 置精
|
||||
Publisher? publisher; // 发布者
|
||||
CommentBean? comment; // 热评
|
||||
|
||||
// ===== 播放源 =====
|
||||
String? sourceURL; // 源视频地址(H.264,兜底)
|
||||
String? h265Url; // H.265 播放地址(可空/空串表示无 265 资源)
|
||||
String? chineseLink; // 中文字幕
|
||||
String? codelessLink; // 无码链接 马赛克
|
||||
String? previewURL; // 预览片段地址
|
||||
int playContentStyle = 1; // 1:源视频,2:中文字幕,3:破解版(无码)
|
||||
int? playTime; // 时长(秒)
|
||||
double? ratio; //视频宽高比,没返回或者0,强制设置16/9
|
||||
String? resolution; // 分辨率
|
||||
int? size; // 文件大小
|
||||
int? downloadAllow; // 0表示不允许下载 1表示VIP 2表示免费
|
||||
|
||||
// ===== 计价 / 权限 =====
|
||||
int? coins; // 折扣价格
|
||||
int? originCoins; // 原价
|
||||
int? permission; //动漫权限 0:会员 1:金币购买 2:免费
|
||||
///是否免费观看
|
||||
bool? freeArea;
|
||||
int? freeTime; // 免费试看时长(秒)
|
||||
int? previewStart; //预览时间起始
|
||||
|
||||
/// 是否展示「免费试看」角标(需同时满足 ping 的 freeMark 总开关)
|
||||
bool? showFreeTrialBadge;
|
||||
|
||||
/// 剩余试看次数
|
||||
int? freeTrialRemaining;
|
||||
|
||||
/// 当前视频是否可用免费试看
|
||||
bool? canUseFreeTrial;
|
||||
VidStatus? vidStatus; // 当前用户对该视频的状态
|
||||
|
||||
// ===== 统计 =====
|
||||
int? hot; // 热度
|
||||
int? likeCount; // 点赞数
|
||||
int? collectCount; // 收藏数
|
||||
int? commentCount; // 评论数
|
||||
int? playCount; // 播放数
|
||||
int? pageViewCount; // 浏览数
|
||||
int? videoCount; // 视频数
|
||||
int? purchaseCount; // 购买数
|
||||
|
||||
// ===== 种子 =====
|
||||
String? seedLink; // 种子链接
|
||||
String? seedLinkUrl; // 种子下载链接
|
||||
int? seedSize; //种子大小
|
||||
int? seedPlayTime; // 种子时长(秒)
|
||||
|
||||
// ===== 动漫 / 漫画 =====
|
||||
CartoonMediaInfo? mediaInfo; // 动漫信息
|
||||
int? totalEpisode; // 动漫集数
|
||||
int? updateStatus; //更新状态 0 默认(已完结) 1、更新中 2、已完结
|
||||
|
||||
// ===== 短剧 =====
|
||||
// 只由 DramaMediaInfo.toVideoModel 组装,不参与 fromJson——短剧走自己的接口,不从视频列表里解析
|
||||
DramaMediaInfo? dramaInfo; // 所属短剧(剧名/总集数/分集列表)
|
||||
MediaContent? dramaEpisode; // 当前分集(权限/付费墙/播放地址以它为准)
|
||||
int? episodeNo; // 当前是第几集
|
||||
|
||||
// ===== 广告 =====
|
||||
AdsInfoModel? randomAdsInfo; // 随机广告
|
||||
List<AdsInfoModel>? adsInfoArr; // 广告列表
|
||||
|
||||
/// 广告链接
|
||||
String? linkUrl;
|
||||
|
||||
// ===== 客户端本地态(不参与接口解析)=====
|
||||
// 短视频业务用到,二级短视频列表数据没有获取权限,需要通过详情接口获取,边播放边刷新数据。
|
||||
VideoModel? detailVModel; // 短视频二级列表详情(无权限,边播边刷)
|
||||
bool isLoadingDetail = false; // 详情是否加载中
|
||||
String? localPath; // 下载到本地的路径
|
||||
String? loadProgress; // 下载进度(百分比字符串)
|
||||
String? isLoaderRunning; // 1 下载中 0 暂停
|
||||
bool isSelected = false; // 列表编辑态是否勾选
|
||||
String? videoTypeId; // 视频分类 id
|
||||
String? videoTypeName; // 视频分类名
|
||||
|
||||
VideoModel({this.id});
|
||||
|
||||
VideoModel.fromJson(Map<dynamic, dynamic>? map) {
|
||||
map ??= {};
|
||||
showType = map['showType'];
|
||||
richText = map['richText'];
|
||||
previewStart = map['previewStart'];
|
||||
// hot 后端可能下发 int / double / 字符串
|
||||
final hotVal = map['hot'];
|
||||
hot = hotVal is num ? hotVal.toInt() : int.tryParse('$hotVal');
|
||||
if (map['permission'] is int) {
|
||||
permission = map['permission'];
|
||||
}
|
||||
chineseLink = map['chineseLink'];
|
||||
codelessLink = map["codelessLink"];
|
||||
chosen = map['chosen'];
|
||||
uid = map["uid"];
|
||||
isTopping = map['isTopping'];
|
||||
seedPlayTime = map['seedPlayTime'];
|
||||
seedLinkUrl = map['seedLinkUrl'];
|
||||
|
||||
seedLink = map['seedLink'];
|
||||
seedSize = map["seedSize"];
|
||||
subid = map["subid"];
|
||||
content = map["content"];
|
||||
coins = map['coins'];
|
||||
purchaseCount = map['purchaseCount'];
|
||||
originCoins = map['originCoins'];
|
||||
commentCount = map['commentCount'];
|
||||
cover = map['cover'];
|
||||
desc = map['desc'];
|
||||
coverThumb = map['coverThumb'];
|
||||
createdAt = map['createdAt'];
|
||||
reviewAt = map['reviewAt'];
|
||||
freeTime = map['freeTime'];
|
||||
id = map['id'];
|
||||
likeCount = map['likeCount'];
|
||||
collectCount = map['collectCount'];
|
||||
playCount = map['playCount'];
|
||||
pageViewCount = map['pageViewCount'];
|
||||
videoCount = map['videoCount'] ?? 0;
|
||||
playTime = map['playTime'];
|
||||
publisher = Publisher.fromMap(map['publisher']);
|
||||
// 宽高比没返回或为 0 时兜底 16/9
|
||||
final r = double.tryParse('${map['ratio'] ?? ''}') ?? 0;
|
||||
ratio = r == 0 ? 16 / 9 : r;
|
||||
resolution = map['resolution'];
|
||||
size = map['size'];
|
||||
type = map['type'];
|
||||
sourceURL = map['sourceURL'];
|
||||
h265Url = map['h265Url'];
|
||||
previewURL = map['previewURL'];
|
||||
status = map['status'];
|
||||
freeArea = map['freeArea'];
|
||||
showFreeTrialBadge = map['showFreeTrialBadge'];
|
||||
freeTrialRemaining = map['freeTrialRemaining'];
|
||||
canUseFreeTrial = map['canUseFreeTrial'];
|
||||
name = map['name'];
|
||||
// String 元素生成空 TagsBean,连同空名标签一起被 where 过滤掉
|
||||
tags = (map['tags'] as List?)
|
||||
?.map((o) => o is String ? TagsBean() : TagsBean.fromMap(o))
|
||||
.where((e) => e.mergeName?.isNotEmpty == true) //过滤空字符串/空名标签
|
||||
.toList();
|
||||
title = map['title'];
|
||||
via = map['via'];
|
||||
comment =
|
||||
map.containsKey("comment") ? CommentBean.fromMap(map['comment']) : null;
|
||||
vidStatus = VidStatus.fromMap(map['vidStatus']);
|
||||
if (map['seriesCover'] is List) {
|
||||
seriesCover =
|
||||
(map['seriesCover'] as List).map((e) => e.toString()).toList();
|
||||
}
|
||||
newsType = map['newsType'];
|
||||
reason = map['reason'];
|
||||
linkUrl = map['linkUrl'];
|
||||
linkStr = map['linkStr'];
|
||||
totalEpisode = map['totalEpisode'];
|
||||
updateStatus = map['updateStatus'];
|
||||
episodeNo = map['episodeNo'] ?? map['episodeNumber'];
|
||||
mediaType = map['mediaType'];
|
||||
vid = map['vid'];
|
||||
videoType = map['videoType'];
|
||||
downloadAllow = map['downloadAllow'];
|
||||
videoTypeId = map['videoTypeId'];
|
||||
videoTypeName = map['videoTypeName'];
|
||||
lsjId = map['lsjId'];
|
||||
// 刻意不解析 mediaInfo:video_detail_bottom_menu 用 `mediaInfo != null` 判定「漫画 / 长视频」,
|
||||
// 一旦后端下发该字段就会把长视频误判成漫画(收藏走到 deleteBookshelf 分支)。
|
||||
// 它只由客户端在 ACG 流程里显式赋值(见 video_logic 与 CartoonMediaInfo.toVideoModel),故两边都不参与序列化
|
||||
}
|
||||
|
||||
/// 与 [VideoModel.fromJson] 严格对称:key 集合一致、嵌套对象也展开成 Map,
|
||||
/// 存进本地(浏览历史 / 下载缓存记录)再读回来不丢字段。分组顺序与上方字段声明一致,便于比对。
|
||||
/// 嵌套的那几个必须自己调 toJson()——只放对象时 jsonEncode 那条路能递归转,
|
||||
/// 但 fromJson(toJson()) 这种内存直接往返会拿对象去喂 fromMap,抛 not a subtype
|
||||
/// 两边都不参与序列化的:客户端本地态(playContentStyle / localPath / loadProgress /
|
||||
/// isLoaderRunning / isSelected / detailVModel,由运行时查询回填)
|
||||
/// 以及 mediaInfo(原因见 fromJson 末尾注释)。
|
||||
Map<String, dynamic> toJson() => {
|
||||
// 标识 / 类型
|
||||
'id': id,
|
||||
'vid': vid,
|
||||
'subid': subid,
|
||||
'lsjId': lsjId,
|
||||
'uid': uid,
|
||||
'newsType': newsType,
|
||||
'type': type,
|
||||
'videoType': videoType,
|
||||
'mediaType': mediaType,
|
||||
'showType': showType,
|
||||
'status': status,
|
||||
'reason': reason,
|
||||
// 内容 / 展示
|
||||
'title': title,
|
||||
'name': name,
|
||||
'desc': desc,
|
||||
'content': content,
|
||||
'richText': richText,
|
||||
'linkStr': linkStr,
|
||||
'cover': cover,
|
||||
'coverThumb': coverThumb,
|
||||
'seriesCover': seriesCover,
|
||||
'tags': tags?.map((e) => e.toJson()).toList(),
|
||||
'via': via,
|
||||
'createdAt': createdAt,
|
||||
'reviewAt': reviewAt,
|
||||
'isTopping': isTopping,
|
||||
'chosen': chosen,
|
||||
'publisher': publisher?.toJson(),
|
||||
'comment': comment?.toJson(),
|
||||
// 播放源
|
||||
'sourceURL': sourceURL,
|
||||
'h265Url': h265Url,
|
||||
'chineseLink': chineseLink,
|
||||
'codelessLink': codelessLink,
|
||||
'previewURL': previewURL,
|
||||
'playTime': playTime,
|
||||
'ratio': ratio,
|
||||
'resolution': resolution,
|
||||
'size': size,
|
||||
'downloadAllow': downloadAllow,
|
||||
// 计价 / 权限
|
||||
'coins': coins,
|
||||
'originCoins': originCoins,
|
||||
'permission': permission,
|
||||
'freeArea': freeArea,
|
||||
'freeTime': freeTime,
|
||||
'previewStart': previewStart,
|
||||
'showFreeTrialBadge': showFreeTrialBadge,
|
||||
'freeTrialRemaining': freeTrialRemaining,
|
||||
'canUseFreeTrial': canUseFreeTrial,
|
||||
'vidStatus': vidStatus?.toJson(),
|
||||
// 统计
|
||||
'hot': hot,
|
||||
'likeCount': likeCount,
|
||||
'collectCount': collectCount,
|
||||
'commentCount': commentCount,
|
||||
'playCount': playCount,
|
||||
'pageViewCount': pageViewCount,
|
||||
'videoCount': videoCount,
|
||||
'purchaseCount': purchaseCount,
|
||||
// 种子
|
||||
'seedLink': seedLink,
|
||||
'seedLinkUrl': seedLinkUrl,
|
||||
'seedSize': seedSize,
|
||||
'seedPlayTime': seedPlayTime,
|
||||
// 动漫 / 漫画 / 短剧
|
||||
'totalEpisode': totalEpisode,
|
||||
'updateStatus': updateStatus,
|
||||
//短剧缓存记录靠它显示「第N集」,不写回读盘后集数就丢了
|
||||
'episodeNo': episodeNo,
|
||||
// 广告
|
||||
'linkUrl': linkUrl,
|
||||
// 视频分类
|
||||
'videoTypeId': videoTypeId,
|
||||
'videoTypeName': videoTypeName,
|
||||
};
|
||||
|
||||
// ===== 播放地址 =====
|
||||
|
||||
/// 拼 m3u8 完整播放链接:各播放源共用同一套 host / token / cdn 参数。
|
||||
/// 后端偶尔直接下发完整链接(265、短剧下载授权地址),那种别再往前面拼一遍 host
|
||||
String _m3u8(String? path) {
|
||||
if (path != null &&
|
||||
(path.startsWith('http://') || path.startsWith('https://')))
|
||||
return path;
|
||||
return "${Address.baseApiPath}/vid/h5/m3u8/$path?token=${Address.token}&c=${Address.cdnAddress}";
|
||||
}
|
||||
|
||||
/// 源视频播放 m3u8 完整链接(H.264,兜底)
|
||||
String get realVideoUrl => _m3u8(sourceURL);
|
||||
|
||||
/// 无码(破解版)m3u8 完整链接
|
||||
String get realUncodeUrl => _m3u8(codelessLink);
|
||||
|
||||
/// 中文字幕版 m3u8 完整链接
|
||||
String get realChineseUrl => _m3u8(chineseLink);
|
||||
|
||||
/// 预览片段 m3u8 完整链接
|
||||
String get realPreviewUrl => _m3u8(previewURL);
|
||||
|
||||
/// H.265 播放 m3u8 完整链接。h265Url 已是完整 http 链接则直接用
|
||||
String get realH265Url {
|
||||
final u = h265Url ?? '';
|
||||
if (u.isEmpty) return '';
|
||||
return u.startsWith('http') ? u : _m3u8(u);
|
||||
}
|
||||
|
||||
/// 详情页最终播放 URL(整合各分支,优先级从上到下):
|
||||
/// - playContentStyle 2 且有中字链接 → 中字源
|
||||
/// - playContentStyle 3 且有无码链接 → 无码源
|
||||
/// - 命中预览权 [canPlayPreview] → 预览片段
|
||||
/// - 普通源:[useH265] 且有 265 地址 → 265(省带宽),否则 264(兜底)
|
||||
/// [useH265] 由调用方算好传入(设备硬解能力 + 本条是否已运行时回退),model 不依赖 CodecSupport
|
||||
String realPlayUrl({required bool canPlayPreview, required bool useH265}) {
|
||||
if (playContentStyle == 2 && chineseLink?.isNotEmpty == true)
|
||||
return realChineseUrl;
|
||||
if (playContentStyle == 3 && codelessLink?.isNotEmpty == true)
|
||||
return realUncodeUrl;
|
||||
if (canPlayPreview) return realPreviewUrl;
|
||||
if (useH265 && realH265Url.isNotEmpty) return realH265Url;
|
||||
return realVideoUrl;
|
||||
}
|
||||
|
||||
// ===== 派生取值 =====
|
||||
|
||||
/// 展示用创建时间:createdAt 为空或后端占位值时退回审核通过时间
|
||||
String? get realCreateAt {
|
||||
if (createdAt?.contains("0001-01-01") == true ||
|
||||
createdAt?.isNotEmpty != true) {
|
||||
return reviewAt;
|
||||
}
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/// 当前播放秒数是否处于免费试看区间 [previewStart, previewStart + freeTime]
|
||||
bool isInFreeTime(int sec) {
|
||||
final start = previewStart ?? 0;
|
||||
final end = start + (freeTime ?? 0);
|
||||
return sec >= start && sec < end;
|
||||
}
|
||||
|
||||
bool get isDarkTag =>
|
||||
tags?.any((e) => e.name?.contains("暗网") == true) ?? false;
|
||||
|
||||
String get allTags => tags?.map((e) => e.name).join(' ') ?? '';
|
||||
|
||||
/// 是否已点赞(vidStatus 为 null 时视为 false)
|
||||
bool get hasLiked => vidStatus?.hasLiked ?? false;
|
||||
|
||||
/// 是否已收藏(vidStatus 为 null 时视为 false)
|
||||
bool get hasCollected => vidStatus?.hasCollected ?? false;
|
||||
|
||||
/// 下载进度 0~1
|
||||
double get progress => (double.tryParse(loadProgress ?? '') ?? 0) / 100;
|
||||
|
||||
/// 下载任务是否正在跑("2" 是已完成,不算跑)
|
||||
bool get isDownloading => isLoaderRunning == "1";
|
||||
|
||||
/// 免费下载(downloadAllow 2):不校验 VIP / 次数,图标也换免费那套
|
||||
bool get isFreeDownload => downloadAllow == 2;
|
||||
|
||||
String get seedSizeDesc => "${seedSize}Mb";
|
||||
|
||||
String get seedPlayTimeDesc {
|
||||
final count = seedPlayTime ?? 0;
|
||||
return count > 0 ? DateTimeUtil.formatHMS(count) : "";
|
||||
}
|
||||
|
||||
String get seedRealLink {
|
||||
if (seedLinkUrl?.isNotEmpty == true) {
|
||||
var linkArr = seedLinkUrl?.split("[种子链接]:") ?? [];
|
||||
if (linkArr.length == 2) {
|
||||
return linkArr.last;
|
||||
}
|
||||
linkArr = seedLinkUrl?.split("[下载链接]:") ?? [];
|
||||
if (linkArr.length == 2) {
|
||||
return linkArr.last;
|
||||
}
|
||||
}
|
||||
return seedLinkUrl ?? "";
|
||||
}
|
||||
|
||||
bool isRandomAd() => randomAdsInfo != null;
|
||||
|
||||
bool isAdsArr() => adsInfoArr != null;
|
||||
|
||||
/// 是否需要金币购买(coins 或 originCoins 大于 0)
|
||||
bool isCoinVideo() => (coins ?? 0) > 0 || (originCoins ?? 0) > 0;
|
||||
|
||||
/// 实际售价:VIP 走折扣价,非 VIP 走原价
|
||||
int? get realCoins => globalStore.isVIP ? coins : originCoins;
|
||||
|
||||
int videoCoin() => realCoins ?? 0;
|
||||
|
||||
//评论类型,传参数类型
|
||||
String get commentType => 'video';
|
||||
|
||||
//集数状态描述
|
||||
String get episodeNumberStatus => mediaInfo?.episodeNumberStatus ?? '';
|
||||
|
||||
/// 动漫/漫画的更新状态描述:updateStatus==1 显示"更新N集/话",其他状态一律"已完结"
|
||||
String get updateDesc {
|
||||
final unit = mediaType == 'image' ? '话' : '集';
|
||||
if (updateStatus == 1) return '更新$totalEpisode$unit';
|
||||
return '已完结';
|
||||
}
|
||||
}
|
||||
|
||||
/// 图集与黄游内容的解锁规则扩展
|
||||
extension LockType on VideoModel {
|
||||
/// 解锁样式(图集与黄游共用同一套规则):0-不需要解锁 1-金币解锁 2-会员解锁
|
||||
int get lockType {
|
||||
//1.免费
|
||||
if (freeArea == true) return 0;
|
||||
if (globalStore.isSuperUp) return 0;
|
||||
//2.自己发布
|
||||
if (globalStore.isMe(publisher?.uid)) return 0;
|
||||
//3.金币解锁
|
||||
if ((originCoins ?? 0) > 0) {
|
||||
//是否购买
|
||||
if (vidStatus?.hasPaid == true) return 0;
|
||||
//是否免费
|
||||
if (coins == 0) return 0;
|
||||
return 1;
|
||||
}
|
||||
//vip解锁
|
||||
if (globalStore.isRechargeVIP) return 0;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/// 帖子流相关的判断与封面取值扩展(社区/个人主页等场景使用)
|
||||
extension Post on VideoModel {
|
||||
bool isVideo() {
|
||||
return newsType == 'SP' ||
|
||||
newsType == 'MOVIE' ||
|
||||
newsType == 'SHORT' ||
|
||||
newsType == 'COVER';
|
||||
}
|
||||
|
||||
bool isSeedPost() {
|
||||
return newsType == 'ADULT_GAME' ||
|
||||
(seedLink?.isNotEmpty ?? false) ||
|
||||
newsType == 'SEED_LINK';
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前用户对该视频的状态:是否点赞/收藏/购买/已读等
|
||||
class VidStatus {
|
||||
bool? hasCollected;
|
||||
bool? hasLiked;
|
||||
bool? hasPaid;
|
||||
int? todayPlayCnt;
|
||||
int? todayRank;
|
||||
bool? hasDisliked;
|
||||
bool? hasPaidSeed;
|
||||
bool? hasGraded;
|
||||
bool? hasAddBookshelf;
|
||||
bool? hasRead;
|
||||
|
||||
static VidStatus fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
VidStatus info = VidStatus();
|
||||
info.hasCollected = map['hasCollected'];
|
||||
info.hasLiked = map['hasLiked'];
|
||||
info.hasPaid = map['hasPaid'];
|
||||
info.todayPlayCnt = map['todayPlayCnt'];
|
||||
info.todayRank = map['todayRank'];
|
||||
info.hasDisliked = map['hasDisliked'];
|
||||
info.hasPaidSeed = map['hasPaidSeed'];
|
||||
info.hasGraded = map['hasGraded'];
|
||||
info.hasAddBookshelf = map['hasAddBookshelf'];
|
||||
info.hasRead = map['hasRead'];
|
||||
return info;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"hasDisliked": hasDisliked,
|
||||
"hasCollected": hasCollected,
|
||||
"hasLiked": hasLiked,
|
||||
"hasPaid": hasPaid,
|
||||
"todayPlayCnt": todayPlayCnt,
|
||||
"todayRank": todayRank,
|
||||
"hasPaidSeed": hasPaidSeed,
|
||||
"hasGraded": hasGraded,
|
||||
"hasAddBookshelf": hasAddBookshelf,
|
||||
"hasRead": hasRead,
|
||||
};
|
||||
}
|
||||
|
||||
/// 标签 / 话题 / 分类公共模型(视频标签、帖子话题、ACG 分类共用)
|
||||
class TagsBean {
|
||||
String? coverImg;
|
||||
String? description;
|
||||
bool? hasCollected;
|
||||
String? id;
|
||||
String? name;
|
||||
int? playCount;
|
||||
int? type;
|
||||
String? hotMark;
|
||||
String? tagName;
|
||||
int? videoCount;
|
||||
int? collCount;
|
||||
bool isSelected = false;
|
||||
int? vidCount;
|
||||
int? followCount;
|
||||
|
||||
/// 客户端本地态,故意不参与 fromMap / toJson:由跳转方设定,决定标签页拉哪类内容。
|
||||
/// CommunityTagLogic 请求到新 tag 后会把它手动搬回来(见该 logic 的 onReady),别当僵尸字段删掉
|
||||
String? newsType;
|
||||
|
||||
TagsBean({this.id, this.name});
|
||||
|
||||
//兼容name和tagname,后台返回不一致
|
||||
String? get mergeName => tagName?.isNotEmpty == true ? tagName : name;
|
||||
|
||||
TagsBean.fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
vidCount = map['vidCount'];
|
||||
followCount = map['followCount'];
|
||||
hotMark = map['hotMark'];
|
||||
tagName = (map['tagName'] as String?)?.trim();
|
||||
videoCount = map['videoCount'];
|
||||
coverImg = map['coverImg'];
|
||||
description = map['description'];
|
||||
hasCollected = map['hasCollected'];
|
||||
id = map['id'];
|
||||
name = (map['name'] as String?)?.trim();
|
||||
type = map['type'];
|
||||
playCount = map['playCount'];
|
||||
collCount = map['collCount'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"coverImg": coverImg,
|
||||
"description": description,
|
||||
"hasCollected": hasCollected,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"playCount": playCount,
|
||||
"tagName": tagName,
|
||||
};
|
||||
}
|
||||
|
||||
/// 视频/帖子发布者信息(用户基础资料 + VIP / 认证 / 活跃度等)
|
||||
class Publisher {
|
||||
int? age;
|
||||
String? gender;
|
||||
bool? hasFollowed;
|
||||
String? name;
|
||||
String? portrait;
|
||||
int? uid;
|
||||
int? vipLevel; //月卡 季卡 年卡
|
||||
bool? isVip;
|
||||
bool? superUser; //大v
|
||||
int? activeValue; //活跃值
|
||||
bool? officialCert; //官方认证
|
||||
int? rechargeLevel; //vip等级 vip1-vip8
|
||||
int? fans;
|
||||
int? merchantUser; //认证商户
|
||||
List<int> awards = [];
|
||||
String? upTag;
|
||||
int? totalWorks;
|
||||
String? vipName;
|
||||
|
||||
Publisher();
|
||||
|
||||
Publisher.fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
if (map['awards'] is List) {
|
||||
awards = [...(map['awards'] as List).map((o) => o)];
|
||||
}
|
||||
totalWorks = map['totalWorks'];
|
||||
upTag = map['upTag'];
|
||||
merchantUser = map['merchantUser'];
|
||||
age = map['age'];
|
||||
gender = map['gender'];
|
||||
hasFollowed = map['hasFollowed'];
|
||||
name = map['name'];
|
||||
portrait = map['portrait'];
|
||||
uid = map['uid'];
|
||||
vipLevel = map['vipLevel'];
|
||||
isVip = map['isVip'];
|
||||
if (map['superUser'] is bool) {
|
||||
superUser = map['superUser'];
|
||||
} else if (map['superUser'] is int) {
|
||||
superUser = map['superUser'] >= 1;
|
||||
}
|
||||
activeValue = map['activeValue'];
|
||||
officialCert = map['officialCert'];
|
||||
rechargeLevel = map['rechargeLevel'];
|
||||
fans = map['fans'];
|
||||
vipName = map['vipName'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"age": age,
|
||||
"gender": gender,
|
||||
"hasFollowed": hasFollowed,
|
||||
"name": name,
|
||||
"portrait": portrait,
|
||||
"uid": uid,
|
||||
"vipLevel": vipLevel,
|
||||
"isVip": isVip,
|
||||
"superUser": superUser,
|
||||
"activeValue": activeValue,
|
||||
"officialCert": officialCert,
|
||||
"rechargeLevel": rechargeLevel,
|
||||
};
|
||||
}
|
||||
|
||||
/// 详情页热评(取一条最热评论附带在视频/帖子主体里展示)
|
||||
class CommentBean {
|
||||
int? uid;
|
||||
String? name;
|
||||
String? portrait;
|
||||
String? cid;
|
||||
String? content;
|
||||
int? likeCount;
|
||||
bool? isAuthor;
|
||||
String? createdAt;
|
||||
|
||||
CommentBean.fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
uid = map['uid'];
|
||||
name = map['name'];
|
||||
portrait = map['portrait'];
|
||||
cid = map['cid'];
|
||||
content = map['content'];
|
||||
likeCount = map['likeCount'];
|
||||
isAuthor = map['isAuthor'];
|
||||
createdAt = map['createdAt'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"uid": uid,
|
||||
"name": name,
|
||||
"portrait": portrait,
|
||||
"cid": cid,
|
||||
"content": content,
|
||||
"likeCount": likeCount,
|
||||
"isAuthor": isAuthor,
|
||||
"createdAt": createdAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/tools_base/base_list_controller.dart';
|
||||
|
||||
import 'ai_sub_type/ai_function_logic.dart';
|
||||
import 'models/ai_mod_list_model.dart';
|
||||
|
||||
class AIFaceCategoryLogic extends ListBaseLogic<AICategoryMod> {
|
||||
final AiType type; //图片换脸 / 视频换脸
|
||||
|
||||
TabController? tabCtr;
|
||||
|
||||
AIFaceCategoryLogic({this.type = AiType.imageChangeFace});
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
tabCtr?.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
void loadData() async {
|
||||
dataList ??= [];
|
||||
final resp = await AIService.getModelListV2(type: type);
|
||||
if (resp != null) {
|
||||
dataList?.addAll(resp.categoryList ?? []);
|
||||
// 重新创建前先释放旧 controller,避免重复 loadData 时泄漏
|
||||
tabCtr?.dispose();
|
||||
tabCtr = TabController(length: dataList?.length ?? 0, vsync: this);
|
||||
}
|
||||
update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import '../../assets_tool/app_colors.dart';
|
||||
import '../../tools_base/indicator/custom_tab_indicator.dart';
|
||||
import 'ai_change_face_logic.dart';
|
||||
import 'ai_face_sub_page.dart';
|
||||
import 'ai_sub_type/ai_function_logic.dart';
|
||||
|
||||
/// AI 换脸首页:根据 type 区分图片换脸 / 视频换脸,按分类列出 Tab,每 Tab 一个子页
|
||||
class AIChangeFacePage extends StatelessWidget {
|
||||
final AiType type; //图片换脸 / 视频换脸
|
||||
const AIChangeFacePage({super.key, this.type = AiType.imageChangeFace});
|
||||
|
||||
// 图片/视频换脸两个子页作为 AI 首页并存 Tab 同时存活,用 type 做 tag 隔离两个 logic 实例
|
||||
String get _tag => 'ai_change_face_${type.name}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AIFaceCategoryLogic>(
|
||||
tag: _tag,
|
||||
init: AIFaceCategoryLogic(type: type),
|
||||
builder: (logic) {
|
||||
if (logic.isLoading) return LoadingCenterWidget();
|
||||
if (logic.isEmptyData) return CErrorWidget();
|
||||
return Column(
|
||||
children: [
|
||||
12.sizeBoxH,
|
||||
_buildTabbar(logic),
|
||||
12.sizeBoxH,
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: logic.tabCtr,
|
||||
children: List.generate(
|
||||
logic.dataList?.length ?? 0,
|
||||
(index) => AIFaceChangeSubPage(
|
||||
type: type, //图片换脸 / 视频换脸
|
||||
mod: logic.dataList?[index],
|
||||
).keepAlive,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabbar(AIFaceCategoryLogic logic) {
|
||||
// 内层 GetBuilder 仅做局部刷新,复用同 tag 的 logic 实例
|
||||
return GetBuilder<AIFaceCategoryLogic>(
|
||||
tag: _tag,
|
||||
id: 'tab',
|
||||
builder: (_) {
|
||||
return TabBar(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
tabAlignment: TabAlignment.start,
|
||||
controller: logic.tabCtr,
|
||||
isScrollable: true,
|
||||
onTap: (value) => logic.update(['tab']),
|
||||
tabs: List.generate(
|
||||
logic.dataList?.length ?? 0,
|
||||
(index) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(logic.dataList?[index].name ?? ''),
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 14),
|
||||
width: 1,
|
||||
height: 8,
|
||||
color: (index != ((logic.dataList?.length ?? 0) - 1))
|
||||
? Colors.white.withValues(alpha: .3)
|
||||
: Colors.transparent,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
labelPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 4),
|
||||
labelColor: Colors.white,
|
||||
labelStyle: const TextStyle(fontSize: 12),
|
||||
unselectedLabelColor: Colors.white.withValues(alpha: 0.70),
|
||||
unselectedLabelStyle: const TextStyle(fontSize: 12),
|
||||
indicatorPadding: EdgeInsets.only(right: 30),
|
||||
indicator: CustomIndicator(
|
||||
width: 16,
|
||||
height: 4,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: AppColors.actionRed,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../../alert/mine/vip_level_dialog.dart';
|
||||
import '../../../config/config.dart';
|
||||
import '../../../hj_utils/api_service/ai_service.dart';
|
||||
import '../../../tools_base/global_store/store.dart';
|
||||
import '../../../tools_base/loading/loading_helper.dart';
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
import '../models/ai_change_face_video_model.dart';
|
||||
import '../widgets/ai_mod_cell.dart';
|
||||
|
||||
/// ai绘画
|
||||
class AiPaintLogic extends AiFunctionBaseLogic {
|
||||
late final TextEditingController titleCtr = TextEditingController();
|
||||
final aspectRatios = ["1:1", "4:3", "3:4", "16:9", "9:16"];
|
||||
int selectIndex = 0; //比例选择
|
||||
bool isFold = false;
|
||||
int chooseItemIndex = 0; //图片选择
|
||||
|
||||
List<AiChangeFaceVideoMod> models = [];
|
||||
|
||||
AiPaintLogic(super.modList);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
models = modList?.aiTextToImgMod ?? [];
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
titleCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submit() async {
|
||||
if ((globalStore.wallet?.amount ?? 0) <
|
||||
(int.tryParse(Config.aiDrawPrice) ?? 0)) {
|
||||
showVipLevelDialog(
|
||||
"当前金币不足",
|
||||
buttonTitle: '我知道了',
|
||||
desc: '充值金币 即可继续生成',
|
||||
vipEvent: () => Get.back(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (titleCtr.text.isEmpty) {
|
||||
showToast('请输入绘画描述~');
|
||||
return;
|
||||
}
|
||||
if (models.isEmpty) {
|
||||
showToast('暂无模版,请退出重试');
|
||||
return;
|
||||
}
|
||||
|
||||
LoadingHelper.showLoading();
|
||||
final result = await AIService.generateTextToImage(
|
||||
aspectRatios[selectIndex],
|
||||
models[chooseItemIndex].styleType ?? 0,
|
||||
titleCtr.text,
|
||||
shareStatus: shareToAiSquare,
|
||||
shareTitle: titleCtr.text,
|
||||
);
|
||||
LoadingHelper.dismissLoading();
|
||||
if (result) {
|
||||
showToast("提交成功~");
|
||||
selectIndex = 0;
|
||||
chooseItemIndex = 0;
|
||||
isFold = false;
|
||||
titleCtr.text = '';
|
||||
update();
|
||||
} else {
|
||||
showToast("提交失败");
|
||||
}
|
||||
}
|
||||
|
||||
Widget instanceChild(int index) {
|
||||
return AIModCell(
|
||||
mod: models[index],
|
||||
isSelected: chooseItemIndex == index,
|
||||
onTap: () {
|
||||
chooseItemIndex = index;
|
||||
update();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:math';
|
||||
|
||||
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/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
|
||||
import '../../../tools_base/widget/net_image_widget.dart';
|
||||
import '../models/ai_mod_list_model.dart';
|
||||
import '../widgets/ai_draw_text_field.dart';
|
||||
import 'ai_paint_logic.dart';
|
||||
|
||||
//ai绘画页面
|
||||
class AiPaintPage extends StatelessWidget {
|
||||
final AiModList? aiModList;
|
||||
|
||||
const AiPaintPage({super.key, this.aiModList});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Color(0xff030F18),
|
||||
child: GetBuilder<AiPaintLogic>(
|
||||
init: AiPaintLogic(aiModList),
|
||||
builder: (logic) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
12.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"形象描述(必填)",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
10.sizeBoxH,
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
blurRadius: 1, //阴影模糊程度
|
||||
spreadRadius: 1.0, //阴影扩散程度
|
||||
)
|
||||
],
|
||||
),
|
||||
child: AIDrawTextField(
|
||||
controller: logic.titleCtr,
|
||||
hintText: "示例:“女,大学生,身高165cm,体重50kg,穿着JK制服.”",
|
||||
maxLength: 100,
|
||||
height: 112,
|
||||
),
|
||||
),
|
||||
14.sizeBoxH,
|
||||
Text(
|
||||
"选择长宽比例",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
20.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: SingleChildScrollView(
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
logic.aspectRatios.length,
|
||||
(index) => Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
logic.selectIndex = index;
|
||||
logic.update();
|
||||
},
|
||||
child: Container(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: logic.selectIndex == index
|
||||
? [
|
||||
AppColors.actionRed,
|
||||
AppColors.actionRed
|
||||
]
|
||||
: [
|
||||
Colors.white
|
||||
.withValues(alpha: .05),
|
||||
Colors.white
|
||||
.withValues(alpha: .05),
|
||||
]),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: logic.selectIndex == index
|
||||
? null
|
||||
: Color(0xFF7C7C7C),
|
||||
),
|
||||
height: 28,
|
||||
child: Text(
|
||||
logic.aspectRatios[index],
|
||||
style: TextStyle(
|
||||
color: logic.selectIndex == index
|
||||
? Colors.white
|
||||
: Colors.white
|
||||
.withValues(alpha: .5),
|
||||
fontSize: logic.selectIndex == index
|
||||
? 14
|
||||
: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
0.5.line,
|
||||
18.sizeBoxH,
|
||||
Container(
|
||||
padding: EdgeInsets.only(bottom: logic.isFold ? 0 : 10),
|
||||
margin: EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
blurRadius: 1, //阴影模糊程度
|
||||
spreadRadius: 1.0, //阴影扩散程度
|
||||
)
|
||||
],
|
||||
),
|
||||
child: _getStyleView(logic),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"处理一张照片的费用是 ",
|
||||
style: TextStyle(
|
||||
color: Color(0xff999999),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(int.tryParse(Config.aiDrawPrice) ?? 0)}金币',
|
||||
style: TextStyle(
|
||||
color: AppColors.actionRed,
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.submit(),
|
||||
child: Container(
|
||||
height: 44,
|
||||
margin: EdgeInsets.symmetric(vertical: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
"立即提交",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getStyleView(AiPaintLogic logic) {
|
||||
return logic.isFold
|
||||
? InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
logic.isFold = !logic.isFold;
|
||||
logic.update();
|
||||
},
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildStyleTitle(),
|
||||
Spacer(),
|
||||
NetworkImageLoader(
|
||||
imageUrl: logic.models.length > 1
|
||||
? logic.models[logic.chooseItemIndex].cover ?? ""
|
||||
: '',
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
10.sizeBoxW,
|
||||
Transform.rotate(
|
||||
angle: pi,
|
||||
child: Image.asset('ai_draw_narrow.png'.aiPath, width: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
logic.isFold = !logic.isFold;
|
||||
logic.update();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildStyleTitle(),
|
||||
Spacer(),
|
||||
Image.asset('ai_draw_narrow.png'.aiPath, width: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
GridView.builder(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 105 / 131,
|
||||
),
|
||||
itemCount: logic.models.length,
|
||||
itemBuilder: (context, index) {
|
||||
return logic.instanceChild(index);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
_buildStyleTitle() {
|
||||
return Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'ai_draw_style.png'.aiPath,
|
||||
width: 15,
|
||||
color: Colors.white,
|
||||
),
|
||||
5.sizeBoxW,
|
||||
Text(
|
||||
"风格",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:hgdj/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/tools_base/base_list_controller.dart';
|
||||
|
||||
import 'ai_sub_type/ai_function_logic.dart';
|
||||
import 'models/ai_mod_list_model.dart';
|
||||
|
||||
class AIFaceTemplateLogic extends ListBaseLogic<TemplateModel> {
|
||||
final AICategoryMod? mod;
|
||||
final AiType type; //图片换脸 / 视频换脸
|
||||
List<String> sortType = ['上架时间', '使用次数', '价格排序']; //排序
|
||||
|
||||
int sortIndex = 0; //排序 0-上架时间, 1-使用次数, 2-价格排序
|
||||
bool ascending = true; //是否是升序
|
||||
|
||||
int get crossAxisCount => type.isVideoFace ? 2 : 3;
|
||||
|
||||
double get spacing => type.isVideoFace ? 7 : 5;
|
||||
|
||||
double get ratio => type.isVideoFace ? 168 / 95 : 111 / 148;
|
||||
|
||||
AIFaceTemplateLogic({this.mod, this.type = AiType.imageChangeFace});
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
void loadData() async {
|
||||
dataList ??= [];
|
||||
final resp =
|
||||
await AIService.getModelListV2(type: type, categoryId: mod?.id ?? '');
|
||||
if (resp != null) {
|
||||
dataList?.addAll(resp.templateList ?? []);
|
||||
//默认上架时间升序
|
||||
dataList?.sort((obj1, obj2) => obj1.timestamp.compareTo(obj2.timestamp));
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
//排序
|
||||
void changeSortAction(int index) {
|
||||
if (index == sortIndex) {
|
||||
ascending = !ascending;
|
||||
} else {
|
||||
sortIndex = index;
|
||||
ascending = true;
|
||||
}
|
||||
//本地排序
|
||||
switch (sortIndex) {
|
||||
case 0:
|
||||
dataList?.sort(
|
||||
(obj1, obj2) => ascending
|
||||
? obj1.timestamp.compareTo(obj2.timestamp)
|
||||
: obj2.timestamp.compareTo(obj1.timestamp),
|
||||
);
|
||||
case 1:
|
||||
dataList?.sort(
|
||||
(obj1, obj2) => ascending
|
||||
? (obj1.usedCount ?? 0).compareTo(obj2.usedCount ?? 0)
|
||||
: (obj2.usedCount ?? 0).compareTo(obj1.usedCount ?? 0),
|
||||
);
|
||||
case 2:
|
||||
dataList?.sort(
|
||||
(obj1, obj2) => ascending
|
||||
? (obj1.coin ?? 0).compareTo(obj2.coin ?? 0)
|
||||
: (obj2.coin ?? 0).compareTo(obj1.coin ?? 0),
|
||||
);
|
||||
default:
|
||||
}
|
||||
update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../../assets_tool/app_colors.dart';
|
||||
import 'ai_face_sub_logic.dart';
|
||||
import 'ai_mod_detail_page.dart';
|
||||
import 'ai_sub_type/ai_function_logic.dart';
|
||||
import 'models/ai_mod_list_model.dart';
|
||||
|
||||
/// AI 换脸分类子页:顶部 3 个排序按钮 + 网格列表,按 type 区分图片/视频换脸
|
||||
class AIFaceChangeSubPage extends StatelessWidget {
|
||||
final AICategoryMod? mod;
|
||||
final AiType type; //图片换脸 / 视频换脸
|
||||
const AIFaceChangeSubPage(
|
||||
{super.key, this.mod, this.type = AiType.imageChangeFace});
|
||||
|
||||
// 多分类子页作为 TabBarView 的 keepAlive children 并存,用 type+分类id 隔离各自 logic 实例
|
||||
String get _tag => 'ai_face_sub_${type.name}_${mod?.id}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AIFaceTemplateLogic>(
|
||||
tag: _tag,
|
||||
init: AIFaceTemplateLogic(mod: mod, type: type),
|
||||
builder: (logic) {
|
||||
if (logic.isLoading) return LoadingCenterWidget();
|
||||
if (logic.isEmptyData) {
|
||||
return CErrorWidget(retryOnTap: () => logic.loadData());
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _buildSortView(logic),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 20),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: logic.crossAxisCount,
|
||||
mainAxisSpacing: logic.spacing,
|
||||
crossAxisSpacing: logic.spacing,
|
||||
childAspectRatio: logic.ratio,
|
||||
),
|
||||
itemCount: logic.dataList?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
TemplateModel? model = logic.dataList?[index];
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(AIModDetailPage(model!)),
|
||||
child: NetworkImageLoader(imageUrl: model?.cover ?? ''),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortView(AIFaceTemplateLogic logic) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSortItem(logic.sortType[0], logic.ascending, 0, logic),
|
||||
20.sizeBoxW,
|
||||
_buildSortItem(logic.sortType[1], logic.ascending, 1, logic),
|
||||
20.sizeBoxW,
|
||||
_buildSortItem(logic.sortType[2], logic.ascending, 2, logic),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortItem(
|
||||
String title, bool ascending, int sortIndex, AIFaceTemplateLogic logic) {
|
||||
final bool sel = logic.sortIndex == sortIndex;
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.changeSortAction(sortIndex),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? AppColors.actionRed : Color(0x1AFFFFFF),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5),
|
||||
),
|
||||
5.sizeBoxW,
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildArrow(active: sel && ascending),
|
||||
4.sizeBoxH,
|
||||
Transform.rotate(
|
||||
angle: -pi, child: _buildArrow(active: sel && !ascending)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArrow({required bool active}) {
|
||||
return Image.asset(
|
||||
'ai_narrow.png'.aiPath,
|
||||
width: 4.8,
|
||||
color: active ? Color(0xffFFD460) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../models/ai_girl_resp_model.dart';
|
||||
import '../widgets/ai_girl_sheet.dart';
|
||||
import 'ai_h5_page.dart';
|
||||
|
||||
class AIGrilFriendLogic extends GetxController {
|
||||
AIGirlFriendBalanceModel? balanceModel;
|
||||
AIGirlFriendUrlModel? urlModel;
|
||||
AIGirlFriendCurrencys? currencys;
|
||||
RxBool open = true.obs;
|
||||
RxString tips = ''.obs;
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
onFetchChargeList();
|
||||
}
|
||||
|
||||
loadData() async {
|
||||
try {
|
||||
balanceModel = await AIService.getBalance({});
|
||||
open.value = true;
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
open.value = false;
|
||||
}
|
||||
update(['balance']);
|
||||
}
|
||||
|
||||
//跳转url
|
||||
onFetchJumpUrl() async {
|
||||
try {
|
||||
LoadingAlertWidget.show();
|
||||
urlModel = await AIService.getMateUrl();
|
||||
LoadingAlertWidget.cancel();
|
||||
} catch (e) {
|
||||
LoadingAlertWidget.cancel();
|
||||
debugLog(e);
|
||||
}
|
||||
if (urlModel?.url != null) {
|
||||
await Get.to(AiH5Page(webUrl: urlModel?.url));
|
||||
loadData();
|
||||
} else {
|
||||
showToast('未知链接');
|
||||
}
|
||||
}
|
||||
|
||||
//充值操作
|
||||
onRechargeBottomSheet() async {
|
||||
if (currencys != null && currencys?.list?.isNotEmpty == true) {
|
||||
Get.bottomSheet(AIGirlSheet(list: currencys?.list),
|
||||
isScrollControlled: true);
|
||||
} else {
|
||||
await onFetchChargeList();
|
||||
if (currencys?.list?.isNotEmpty != true) {
|
||||
showToast("暂未配置充值数据");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//获取充值列表
|
||||
Future onFetchChargeList({bool showLoading = false}) async {
|
||||
if (showLoading) LoadingAlertWidget.show();
|
||||
try {
|
||||
currencys = await AIService.getMateCurrencies();
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
if (showLoading) LoadingAlertWidget.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
import '../../../tools_base/unique_tag_mixin.dart';
|
||||
import 'ai_girl_friend_logic.dart';
|
||||
|
||||
// Ai女友
|
||||
class AIGrilFriendPage extends StatefulWidget {
|
||||
const AIGrilFriendPage({super.key});
|
||||
|
||||
@override
|
||||
State<AIGrilFriendPage> createState() => _AIGrilFriendPageState();
|
||||
}
|
||||
|
||||
class _AIGrilFriendPageState extends State<AIGrilFriendPage>
|
||||
with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AIGrilFriendLogic>(
|
||||
tag: uniqueTag,
|
||||
init: AIGrilFriendLogic(),
|
||||
builder: (logic) => Scaffold(
|
||||
body: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'ai_girl_friend_bg.webp'.aiPath,
|
||||
fit: BoxFit.fill,
|
||||
width: Get.width,
|
||||
height: Get.height,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 125,
|
||||
left: 10,
|
||||
right: 10,
|
||||
child: GestureDetector(
|
||||
onTap: () => logic.onFetchJumpUrl(),
|
||||
child: Image.asset(
|
||||
'ai_girl_friend_btn.webp'.aiPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
height: 101,
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.asset(
|
||||
'ai_girl_friend_top.webp'.aiPath,
|
||||
width: Get.width,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
_buildBanlance(logic),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Obx(() => logic.open.value
|
||||
? Container()
|
||||
: Stack(
|
||||
children: [
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: .75),
|
||||
child: Center(
|
||||
child: Obx(
|
||||
() => Text(
|
||||
logic.tips.value,
|
||||
style:
|
||||
textStyle(17, Colors.white, FontWeight.w500),
|
||||
),
|
||||
)),
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
top: 60,
|
||||
child: GestureDetector(
|
||||
child: GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: const Icon(Icons.arrow_back_ios,
|
||||
size: 24, color: Colors.white),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBanlance(AIGrilFriendLogic logic) {
|
||||
return Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 14,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
child: GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: const Icon(Icons.arrow_back_ios,
|
||||
size: 24, color: Colors.white),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
border: Border.all(color: Color(0xff2A5298)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 12),
|
||||
GetBuilder<AIGrilFriendLogic>(
|
||||
id: 'balance',
|
||||
builder: (_) {
|
||||
if (_.balanceModel != null) {
|
||||
return Text(
|
||||
'积分:${_.balanceModel?.balance ?? 0}',
|
||||
style: textStyle(18, Colors.white, FontWeight.w400),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox(
|
||||
width: 22,
|
||||
child: CupertinoActivityIndicator(
|
||||
color: Colors.white,
|
||||
radius: 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
GestureDetector(
|
||||
onTap: () => logic.onRechargeBottomSheet(),
|
||||
child: Image.asset(
|
||||
'ai_girl_friend_topup.webp'.aiPath,
|
||||
width: 76,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
import '../../web_page/h5_webview_settings.dart';
|
||||
|
||||
class AiH5Page extends StatefulWidget {
|
||||
final String? webUrl;
|
||||
const AiH5Page({super.key, this.webUrl});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _YHYSH5ViewState();
|
||||
}
|
||||
}
|
||||
|
||||
class _YHYSH5ViewState extends State<AiH5Page> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
InAppWebView(
|
||||
initialUrlRequest:
|
||||
URLRequest(url: WebUri(widget.webUrl ?? "")), //h5的url
|
||||
initialSettings: h5WebViewSettings,
|
||||
initialUserScripts: UnmodifiableListView<UserScript>([]),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 70,
|
||||
right: 10,
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryHighColor,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
'退出',
|
||||
style: textStyle(14, Colors.black, FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../../hj_model/splash/domain_source_model.dart';
|
||||
import './ai_change_face_page.dart';
|
||||
import './ai_draw/ai_paint_page.dart';
|
||||
import './ai_image_to_video/ai_main_itv_page.dart';
|
||||
import './ai_novel/ai_novel_page.dart';
|
||||
import './ai_sub_type/ai_function_logic.dart';
|
||||
import './ai_sub_type/ai_strip_sub_page.dart';
|
||||
import './models/ai_mod_list_model.dart';
|
||||
import 'ai_square/ai_square_page.dart';
|
||||
|
||||
class AiHomeLogic extends GetxController
|
||||
with GetSingleTickerProviderStateMixin {
|
||||
bool isShowLoading = true;
|
||||
final AiType? aiType;
|
||||
// int tabIndex;
|
||||
|
||||
AiHomeLogic({this.aiType});
|
||||
|
||||
AiModList? aiModList;
|
||||
|
||||
late List<AISwitchConf> menus = [];
|
||||
|
||||
late TabController tabCtr;
|
||||
|
||||
// 跳转相关模块的亚模块
|
||||
void jumpSubModule(int index) {
|
||||
tabCtr.index = index;
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
List<AISwitchConf>? aiTypes = Config.aiTypes;
|
||||
//1.删除未开启的
|
||||
aiTypes?.removeWhere((model) => model.isOpen == false);
|
||||
//2.排序
|
||||
aiTypes?.sort(
|
||||
(m1, m2) => (m1.sort ?? 0).compareTo(m2.sort ?? 0),
|
||||
);
|
||||
menus.addAll(aiTypes ?? []);
|
||||
//3.获取到默认进来的排序
|
||||
int? initialIndex = aiTypes?.indexWhere((model) => model.aiType == aiType);
|
||||
initialIndex = max(0, initialIndex ?? 0);
|
||||
tabCtr = TabController(
|
||||
initialIndex: initialIndex, length: menus.length, vsync: this)
|
||||
..addListener(() {
|
||||
if (!tabCtr.indexIsChanging) {
|
||||
update(['tab']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
presaleProvider.refreshStatus(); //刷新预售权益
|
||||
globalStore.refreshWallet(); //刷新钱包
|
||||
loadData();
|
||||
}
|
||||
|
||||
void loadData() async {
|
||||
aiModList = await AIService.getModelList();
|
||||
aiModList ??= AiModList();
|
||||
isShowLoading = false;
|
||||
update();
|
||||
}
|
||||
|
||||
Widget subPage(AISwitchConf model) {
|
||||
switch (model.aiType) {
|
||||
case AiType.imageToVideo:
|
||||
return AiMainItvPage(aiModList: aiModList).keepAlive; //图生视频
|
||||
case AiType.aiPaint:
|
||||
return AiPaintPage(aiModList: aiModList).keepAlive; //ai绘画
|
||||
case AiType.autoStrip:
|
||||
return AIStripSubPage().keepAlive; //脱衣
|
||||
case AiType.videoChangeFace:
|
||||
return AIChangeFacePage(type: AiType.videoChangeFace).keepAlive; //视频换脸
|
||||
case AiType.imageChangeFace:
|
||||
return AIChangeFacePage(type: AiType.imageChangeFace).keepAlive; //图片换脸
|
||||
case AiType.aiNovel:
|
||||
return AINovelSubPage().keepAlive; //ai小说
|
||||
case AiType.aiMate:
|
||||
return AiSquarePage().keepAlive; //ai女友
|
||||
default:
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
import 'ai_home_logic.dart';
|
||||
import 'ai_record/ai_main_record_page.dart';
|
||||
|
||||
/// AI 科技首页:顶部功能 tab 网格 + 各功能子页
|
||||
class AiHomePage extends StatelessWidget {
|
||||
const AiHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AiHomeLogic>(
|
||||
init: AiHomeLogic(),
|
||||
builder: (logic) {
|
||||
return Stack(
|
||||
children: [
|
||||
Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
title: Text('AI科技'),
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
actions: [
|
||||
Center(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(
|
||||
() => AIMainRecordPage(
|
||||
aiType: logic.menus[logic.tabCtr.index].aiType,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'记录',
|
||||
style: textStyle(
|
||||
14,
|
||||
Colors.white.withValues(alpha: 0.55),
|
||||
FontWeight.w400),
|
||||
),
|
||||
),
|
||||
),
|
||||
16.sizeBoxW
|
||||
],
|
||||
),
|
||||
body: _buildContent(logic))
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 内容区:加载中 / 加载失败 / tab 网格 + 子页
|
||||
Widget _buildContent(AiHomeLogic logic) {
|
||||
if (logic.isShowLoading) return const LoadingCenterWidget();
|
||||
if (logic.aiModList == null) {
|
||||
return CErrorWidget(retryOnTap: () => logic.loadData());
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_buildTabBar(logic),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: logic.tabCtr,
|
||||
children: logic.menus
|
||||
.map<Widget>((e) => logic.subPage(e).keepAlive)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 顶部功能 tab 网格(id:'tab' 局部刷新选中态)
|
||||
Widget _buildTabBar(AiHomeLogic logic) {
|
||||
return GetBuilder<AiHomeLogic>(
|
||||
init: logic,
|
||||
id: 'tab',
|
||||
builder: (_) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 105 / 26,
|
||||
crossAxisSpacing: 14,
|
||||
mainAxisSpacing: 14,
|
||||
),
|
||||
itemCount: logic.menus.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isSelected = logic.tabCtr.index == index;
|
||||
final menu = logic.menus[index];
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
logic.tabCtr.animateTo(index);
|
||||
logic.update(['tab']);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: isSelected
|
||||
? Color(0xffF68804)
|
||||
: Colors.white.withValues(alpha: .1),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(
|
||||
width: 1, color: Colors.white.withValues(alpha: .35)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
menu.img ?? '',
|
||||
width: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
menu.aiTypeName ?? '',
|
||||
style: textStyle(12, Colors.white, FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 图片转视频
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/tools_base/file_upload/file_upload_tool.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_alert.dart';
|
||||
|
||||
import '../../../alert/mine/vip_level_dialog.dart';
|
||||
import '../../../hj_model/splash/ads_model.dart';
|
||||
|
||||
class AiItvLogic extends GetxController {
|
||||
final localPicList = <String>[]; //已选本地图片路径,与 PicPicker 共享
|
||||
final AdsInfoModel? model;
|
||||
|
||||
double get aspectRatio => 408 / 310;
|
||||
bool shareToAiSquare = true;
|
||||
|
||||
/// 图片转视频模版
|
||||
TextEditingController editingCtr = TextEditingController();
|
||||
|
||||
AiItvLogic({this.model});
|
||||
|
||||
Future<void> submit() async {
|
||||
if ((globalStore.wallet?.amount ?? 0) <
|
||||
(int.tryParse(Config.aiVideoPrice) ?? 0)) {
|
||||
showVipLevelDialog(
|
||||
"当前金币不足",
|
||||
buttonTitle: '我知道了',
|
||||
desc: '充值金币 即可继续生成',
|
||||
vipEvent: () => Get.back(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
aiImageToVideo();
|
||||
}
|
||||
|
||||
//AI 图片转视频
|
||||
aiImageToVideo() async {
|
||||
if (localPicList.isEmpty) {
|
||||
CommonAlert.show(title: "提示", content: "请选择图片", showCancel: false);
|
||||
return;
|
||||
}
|
||||
await FileUploadTool().uploadImagesWithProgress(
|
||||
localPicList,
|
||||
onFailure: () => showToast("图片上传失败"),
|
||||
onSuccess: (imageArr) async {
|
||||
LoadingAlertWidget.show(title: "正在更新数据...");
|
||||
try {
|
||||
final result = await AIService.generateImgVideo(
|
||||
imageArr.first,
|
||||
shareStatus: shareToAiSquare,
|
||||
shareTitle: editingCtr.text,
|
||||
mid: model?.id,
|
||||
);
|
||||
if (result) {
|
||||
showToast("提交成功~");
|
||||
localPicList.clear();
|
||||
globalStore.refreshWallet();
|
||||
update();
|
||||
} else {
|
||||
showToast("提交失败");
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
showToast(e.message.toString());
|
||||
} catch (e) {
|
||||
showToast(e.toString());
|
||||
} finally {
|
||||
LoadingAlertWidget.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/banner/ads_banner_widget.dart';
|
||||
|
||||
import '../../../hj_model/splash/ads_model.dart';
|
||||
import '../widgets/pic_picker.dart';
|
||||
import 'ai_itv_logic.dart';
|
||||
|
||||
class AiItvPage extends StatelessWidget {
|
||||
final AdsInfoModel? model;
|
||||
const AiItvPage({super.key, this.model});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Color(0xff030F18),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
model?.title ?? '图生视频',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
centerTitle: false,
|
||||
titleSpacing: -20,
|
||||
),
|
||||
body: GetBuilder<AiItvLogic>(
|
||||
init: AiItvLogic(model: model),
|
||||
global: false,
|
||||
builder: (logic) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 12, 0, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
PicPicker(
|
||||
width: 111,
|
||||
height: 111,
|
||||
picList: logic.localPicList,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"注意事项:",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Text(
|
||||
'''1、素材仅供AI使用,绝无外泄风险,请放心使用. \n2、素材需清晰,小于2MB,上传间隔大于60秒. \n3、本功能不支持多人图片 \n4、生成失败退回金币,若违规作废. 5、禁止使用未成年图片!''',
|
||||
style: TextStyle(
|
||||
color: Color(0xff656565),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
0.5.line,
|
||||
18.sizeBoxH,
|
||||
Text(
|
||||
"案列鉴赏",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 343 / 224,
|
||||
child: model == null
|
||||
? SizedBox.shrink()
|
||||
: AIBannerWidget(
|
||||
models: [model!],
|
||||
),
|
||||
),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
0.5.line,
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"处理一张照片的费用是 ",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .6),
|
||||
fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
"${Config.aiVideoPrice}金币",
|
||||
style: TextStyle(
|
||||
color: AppColors.actionRed,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.submit(),
|
||||
child: Container(
|
||||
height: 44,
|
||||
// margin: EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
"立即提交",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// AiShareToSquare(
|
||||
// logic.editingCtr,
|
||||
// valueChanged: (value) => logic.shareToAiSquare = value,
|
||||
// ),
|
||||
// 20.sizeBoxH,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../hj_model/splash/ads_model.dart';
|
||||
import '../../../tools_base/loading/loading_center_widget.dart';
|
||||
import '../../../tools_base/widget/net_image_widget.dart';
|
||||
import '../models/ai_mod_list_model.dart';
|
||||
import 'ai_itv_page.dart';
|
||||
|
||||
/// AI 图生视频主页:模板两列网格
|
||||
class AiMainItvPage extends StatelessWidget {
|
||||
final AiModList? aiModList;
|
||||
const AiMainItvPage({super.key, this.aiModList});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (aiModList == null) return LoadingCenterWidget(); //数据未就绪
|
||||
final list = aiModList!.aiImgToVideoMod;
|
||||
if (list == null || list.isEmpty) return CErrorWidget(); //无模板数据
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 167 / 223,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (context, index) => _buildItem(list[index]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 单个模板卡片:封面图 + 底部标题胶囊
|
||||
Widget _buildItem(AdsInfoModel item) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(AiItvPage(model: item)),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: NetworkImageLoader(imageUrl: item.newUrl ?? ''),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 6,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
gradient: LinearGradient(colors: [
|
||||
Color(0xffFF4D4D),
|
||||
Color(0xffFF6E6E),
|
||||
]),
|
||||
),
|
||||
child: Text(
|
||||
item.title ?? '',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
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_page/pre_sale/pre_sale_provider.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
import '../mine/widgets/gradient_text.dart';
|
||||
import '../video/simple_video_player_page.dart';
|
||||
import 'ai_sub_type/ai_function_logic.dart';
|
||||
import 'models/ai_mod_list_model.dart';
|
||||
import 'widgets/pic_picker.dart';
|
||||
|
||||
//ai换脸模版详情页
|
||||
class AIModDetailPage extends StatelessWidget {
|
||||
final TemplateModel modMod;
|
||||
const AIModDetailPage(this.modMod, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AiFaceDetailLogic>(
|
||||
init: AiFaceDetailLogic(null, mod: modMod),
|
||||
builder: (logic) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(logic.mod.title ?? ''),
|
||||
centerTitle: false,
|
||||
titleSpacing: -10,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(child: _buildContent(logic)),
|
||||
72.sizeBoxH,
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.submit(),
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
margin: EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'立即提交',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildContent(AiFaceDetailLogic logic) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 60),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 343 / 193,
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: logic.mod.cover ?? '',
|
||||
borderRadius: 8,
|
||||
fit: logic.isChangeVideo ? BoxFit.fitWidth : BoxFit.fitHeight,
|
||||
),
|
||||
),
|
||||
//视频换脸
|
||||
if (logic.isChangeVideo)
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
VideoModel videoModel = VideoModel()
|
||||
..sourceURL = logic.mod.m3u8Url;
|
||||
Get.to(
|
||||
SimpleVideoPlayerPage(
|
||||
videoUrl: videoModel.realVideoUrl,
|
||||
title: logic.mod.title ?? '',
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Image.asset(
|
||||
'circle_play.webp'.videoPath,
|
||||
width: 50,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
.5.line,
|
||||
18.sizeBoxH,
|
||||
Text(
|
||||
"注意事项:",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
8.sizeBoxH,
|
||||
Text(
|
||||
'''1. 选择一张人脸清晰,不得有任何遮挡的照片上传(注意:只含一个人物和脸部,图片不能过暗)
|
||||
2. 选择一个心仪的视频或图片模板,点击生成,生成时间需要3-5分钟,耐心等待。(图片模板可自行上传)
|
||||
3. 在右上角记录查看生成进度,生成成功后可以点击进行下载,也可以在线观看。
|
||||
4. 按照上方操作,有问题随时联系在线客服进行处理。
|
||||
5. 不支持多人图片,禁止未成年人图片''',
|
||||
style: TextStyle(
|
||||
color: Color(0xff656565),
|
||||
fontSize: 12,
|
||||
height: 18 / 12,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
_buildExample(),
|
||||
18.sizeBoxH,
|
||||
0.5.line,
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"上传脸部信息",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Text(
|
||||
"图片大小请低于2Mb",
|
||||
style: TextStyle(
|
||||
color: Color(0xff656565),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
12.sizeBoxH,
|
||||
PicPicker(
|
||||
width: 111,
|
||||
height: 111,
|
||||
picList: logic.localPicList,
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [_priceInfo(logic)],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 免费次数 / 处理费用 / 抵扣券(视频换脸)信息,钱包变化用 Consumer 局部刷新
|
||||
Widget _priceInfo(AiFaceDetailLogic logic) {
|
||||
return Consumer<GlobalStore>(builder: (_, provider, __) {
|
||||
final total = provider.wallet?.aiUndressFreeTimes ?? 0;
|
||||
final presaleCount = presaleProvider.remain?.todayAiUndressCount ?? 0;
|
||||
final todayFreeCount = provider.wallet?.todayAiFreeTimes ?? 0;
|
||||
//今日免费次数
|
||||
final toDaytotal = todayFreeCount + presaleCount;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!logic.isChangeVideo) ...[
|
||||
Text(
|
||||
'你当前免费体验为$total次数',
|
||||
style: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Text(
|
||||
'当日免费$toDaytotal次数',
|
||||
style: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
12.sizeBoxH,
|
||||
EasyRichText(
|
||||
'处理一张照片的费用是 ${logic.price}金币',
|
||||
defaultStyle: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400),
|
||||
patternList: [
|
||||
EasyRichTextPattern(
|
||||
targetString: '${logic.price}金币',
|
||||
style: TextStyle(color: AppColors.actionRed),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (logic.isChangeVideo) ...[
|
||||
if (logic.coupon != null) ...[
|
||||
12.sizeBoxH,
|
||||
EasyRichText(
|
||||
'已抵扣 ${logic.coupon?.goodsValue ?? 0}金币',
|
||||
defaultStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .55),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400),
|
||||
patternList: [
|
||||
EasyRichTextPattern(
|
||||
targetString: '${logic.coupon?.goodsValue ?? 0}金币',
|
||||
style: TextStyle(color: AppColors.actionRed),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
12.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.showCoupon(),
|
||||
child: GradientText(
|
||||
logic.coupon == null ? '使用抵扣券' : '更换抵扣券',
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xff35DEBC),
|
||||
Color(0xff22BB9C),
|
||||
],
|
||||
),
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
if (logic.coupon != null) ...[
|
||||
12.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
//取消优惠券
|
||||
logic.coupon = null;
|
||||
logic.update();
|
||||
},
|
||||
child: Text(
|
||||
'取消',
|
||||
style: textStyle(14, Colors.white.withValues(alpha: .55),
|
||||
FontWeight.w400),
|
||||
),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildExample() {
|
||||
return Row(children: [
|
||||
_buildExampleItem('正面无遮挡', "ai_changeface_right.webp".aiPath),
|
||||
20.sizeBoxW,
|
||||
_buildExampleItem('不遮挡脸部', "ai_changeface_wrong_1.webp".aiPath),
|
||||
20.sizeBoxW,
|
||||
_buildExampleItem('不遮挡眼睛', "ai_changeface_wrong_2.webp".aiPath),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildExampleItem(String title, String img) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Image.asset(img),
|
||||
),
|
||||
14.sizeBoxH,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/ai_record_model.dart';
|
||||
|
||||
class AiNovelDetailPage extends StatelessWidget {
|
||||
final AiRecordModel model;
|
||||
const AiNovelDetailPage(this.model, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('小说详情')),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||||
child: Text(
|
||||
model.content ?? '',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: .8), fontSize: 14, height: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//ai小说
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_helper.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../../../hj_utils/api_service/ai_service.dart';
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
|
||||
class AINovelLogic extends AiFunctionBaseLogic {
|
||||
late final personTextCtr = TextEditingController();
|
||||
late final addressTextCtr = TextEditingController();
|
||||
late final detailTextCtr = TextEditingController();
|
||||
late final storylineTextCtr = TextEditingController();
|
||||
final mods = <String>['AI小艺', 'AI小萌'];
|
||||
int selectIndex = 0;
|
||||
|
||||
AINovelLogic(super.modList);
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
personTextCtr.dispose();
|
||||
addressTextCtr.dispose();
|
||||
detailTextCtr.dispose();
|
||||
storylineTextCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submit() async {
|
||||
if (storylineTextCtr.text.isEmpty) {
|
||||
showToast('请输入故事情节描述~');
|
||||
return;
|
||||
}
|
||||
|
||||
LoadingHelper.showLoading();
|
||||
final result = await AIService.generateNovel(
|
||||
storylineTextCtr.text,
|
||||
characterSetting: personTextCtr.text,
|
||||
locationScene: addressTextCtr.text,
|
||||
details: detailTextCtr.text,
|
||||
modelType: selectIndex + 1,
|
||||
);
|
||||
LoadingHelper.dismissLoading();
|
||||
if (result) {
|
||||
showToast('提交成功~');
|
||||
storylineTextCtr.clear();
|
||||
personTextCtr.clear();
|
||||
addressTextCtr.clear();
|
||||
detailTextCtr.clear();
|
||||
selectIndex = 0;
|
||||
update();
|
||||
} else {
|
||||
showToast('提交失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
|
||||
import '../widgets/ai_draw_text_field.dart';
|
||||
import 'ai_novel_logic.dart';
|
||||
|
||||
class AINovelSubPage extends StatelessWidget {
|
||||
const AINovelSubPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Color(0xff030F18),
|
||||
// 用默认 global:global:false 下 widget 销毁不会 Get.delete,onClose 不触发,输入框 controller 释放不掉
|
||||
child: GetBuilder<AINovelLogic>(
|
||||
init: AINovelLogic(null),
|
||||
builder: (logic) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.symmetric(horizontal: 6).copyWith(top: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInputView(
|
||||
logic.personTextCtr,
|
||||
),
|
||||
12.sizeBoxH,
|
||||
_buildInputView(
|
||||
logic.addressTextCtr,
|
||||
title: '地点场景(选填)',
|
||||
hint: '示例:办公室、酒吧',
|
||||
),
|
||||
12.sizeBoxH,
|
||||
_buildInputView(
|
||||
logic.storylineTextCtr,
|
||||
title: '故事情节(必填)',
|
||||
hint: '故事大致情节,例如:35岁女强人在酒吧遇到跳钢管舞的男模后,欲火焚身、欲罢不能。',
|
||||
maxLength: 200,
|
||||
),
|
||||
12.sizeBoxH,
|
||||
_buildInputView(
|
||||
logic.detailTextCtr,
|
||||
title: '细节说明(选填)',
|
||||
hint: '例如:请详细描写女主身材美貌,与霸总的做爱过程',
|
||||
maxLength: 200,
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"选择模型",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
10.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: SingleChildScrollView(
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
logic.mods.length,
|
||||
(index) => Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
logic.selectIndex = index;
|
||||
logic.update();
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: logic.selectIndex == index
|
||||
? [
|
||||
AppColors.actionRed,
|
||||
AppColors.actionRed
|
||||
]
|
||||
: [
|
||||
Colors.white
|
||||
.withValues(alpha: .05),
|
||||
Colors.white
|
||||
.withValues(alpha: .05),
|
||||
]),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: logic.selectIndex == index
|
||||
? null
|
||||
: Color(0xFF7C7C7C),
|
||||
),
|
||||
height: 28,
|
||||
child: Text(
|
||||
logic.mods[index],
|
||||
style: TextStyle(
|
||||
color: logic.selectIndex == index
|
||||
? Colors.white
|
||||
: Colors.white.withValues(alpha: .5),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"生成一次AI小说费用是 ",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${Config.aiNovelPrice}金币',
|
||||
style: TextStyle(
|
||||
color: AppColors.actionRed,
|
||||
fontSize: 14,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.submit(),
|
||||
child: Container(
|
||||
height: 44,
|
||||
margin: EdgeInsets.symmetric(vertical: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
"立即提交",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
// 0.5.line,
|
||||
// 10.sizeBoxH,
|
||||
],
|
||||
).paddingSymmetric(horizontal: 10);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildInputView(
|
||||
TextEditingController textCtr, {
|
||||
String title = '人物设定(选填)',
|
||||
String hint = '示例:总裁、公主、小萝莉.',
|
||||
int maxLength = 20,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
10.sizeBoxH,
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
blurRadius: 1, //阴影模糊程度
|
||||
spreadRadius: 1.0, //阴影扩散程度
|
||||
)
|
||||
],
|
||||
),
|
||||
child: AIDrawTextField(
|
||||
controller: textCtr,
|
||||
hintText: hint,
|
||||
maxLength: maxLength,
|
||||
height: 112,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../config/config.dart';
|
||||
import '../../../hj_model/splash/domain_source_model.dart';
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
|
||||
class AIMainRecordLogic extends GetxController with GetTickerProviderStateMixin {
|
||||
final AiType? aiType;
|
||||
AIMainRecordLogic({this.aiType});
|
||||
|
||||
final List<AISwitchConf> menus = [];
|
||||
late TabController tabCtr;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// 拷贝一份再过滤/排序,避免原地改动全局 Config.aiTypes 影响其他页面
|
||||
final aiTypes = List<AISwitchConf>.from(Config.aiTypes ?? []);
|
||||
//1.删除未开启的和 AI 女友
|
||||
aiTypes.removeWhere((model) => model.isOpen == false || model.aiType == AiType.aiMate);
|
||||
//2.排序
|
||||
aiTypes.sort((m1, m2) => (m1.sort ?? 0).compareTo(m2.sort ?? 0));
|
||||
menus.addAll(aiTypes);
|
||||
//3.定位默认进入的 tab
|
||||
final initialIndex = max(0, aiTypes.indexWhere((model) => model.aiType == aiType));
|
||||
tabCtr = TabController(initialIndex: initialIndex, length: menus.length, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
tabCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import '../../../hj_model/splash/domain_source_model.dart';
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
import 'ai_main_record_logic.dart';
|
||||
import 'ai_record_page.dart';
|
||||
|
||||
/// AI 生成记录首页:按已开启的 AI 功能列 Tab,每 Tab 一个对应类型的记录子页
|
||||
class AIMainRecordPage extends StatelessWidget {
|
||||
final AiType? aiType;
|
||||
const AIMainRecordPage({super.key, this.aiType});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AIMainRecordLogic>(
|
||||
init: AIMainRecordLogic(aiType: aiType),
|
||||
builder: (logic) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('生成记录')),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildTabbar(logic),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: logic.tabCtr,
|
||||
children: logic.menus.map((e) => _subPage(e)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 所有 AI 功能统一用 AiRecordPage,内部按 aiType 区分接口/列表/item
|
||||
Widget _subPage(AISwitchConf model) {
|
||||
final type = model.aiType;
|
||||
if (type == null) return Container();
|
||||
return AiRecordPage(aiType: type).keepAlive;
|
||||
}
|
||||
|
||||
Widget _buildTabbar(AIMainRecordLogic logic) {
|
||||
return Container(
|
||||
height: 36,
|
||||
color: Colors.black,
|
||||
child: TabBar(
|
||||
tabAlignment: TabAlignment.center,
|
||||
controller: logic.tabCtr,
|
||||
tabs: List.generate(
|
||||
logic.tabCtr.length,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Text(logic.menus[index].aiTypeName ?? ""),
|
||||
),
|
||||
),
|
||||
isScrollable: true,
|
||||
labelPadding: EdgeInsets.zero,
|
||||
labelColor: Colors.white.withValues(alpha: .9),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
unselectedLabelColor: Colors.white.withValues(alpha: .35),
|
||||
unselectedLabelStyle:
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
indicator: CustomIndicator(
|
||||
isGradient: true,
|
||||
width: 13,
|
||||
height: 3,
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user