初始化
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_alert.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
import '../models/ai_record_model.dart';
|
||||
import '../widgets/ai_image_record.dart';
|
||||
import '../widgets/ai_novel_record.dart';
|
||||
import '../widgets/ai_paint_record.dart';
|
||||
import '../widgets/ai_video_record.dart';
|
||||
|
||||
/// AI 生成记录 logic:一个类按 aiType 区分业务(接口 / 删除 / item widget)
|
||||
/// 覆盖脱衣 / 视频换脸 / 图片换脸 / 小说 / 图生视频 / 绘画六类
|
||||
class AIRecordLogic extends GetxController {
|
||||
AIRecordLogic(this.aiType, {this.sort = 1});
|
||||
|
||||
final AiType aiType;
|
||||
final int sort; // 1排队 2成功 3失败,按状态固定,由所属状态页构造传入
|
||||
final dataSource = <AiRecordModel>[];
|
||||
int page = 1;
|
||||
RefreshController? refreshCtr;
|
||||
bool isLoading = true;
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
fetchPageData();
|
||||
}
|
||||
|
||||
Future<void> fetchPageData({bool isRefresh = true}) async {
|
||||
if (isRefresh) page = 1;
|
||||
try {
|
||||
final res = await _fetchList(page);
|
||||
isLoading = false;
|
||||
if (isRefresh) {
|
||||
dataSource.clear();
|
||||
refreshCtr?.refreshCompleted();
|
||||
}
|
||||
dataSource.addAll(res?.list ?? []);
|
||||
(res?.hasNext ?? false)
|
||||
? refreshCtr?.loadComplete()
|
||||
: refreshCtr?.loadNoData();
|
||||
page += 1;
|
||||
} catch (e) {
|
||||
isLoading = false;
|
||||
refreshCtr?.refreshCompleted();
|
||||
refreshCtr?.loadComplete();
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
// 按 aiType 调对应记录接口(status 即 sort:1排队 2成功 3失败)
|
||||
Future<ListBaseModel<AiRecordModel>?> _fetchList(int page) {
|
||||
switch (aiType) {
|
||||
case AiType.videoChangeFace: // 视频换脸:sort(1/2/3) 映射成接口状态码(0/1/-1)
|
||||
return AIService.getChangeFaceList(page, 10, [0, 1, -1][sort - 1]);
|
||||
case AiType.imageChangeFace: // 图片换脸
|
||||
return AIService.getImgList(page, 10, sort);
|
||||
case AiType.aiNovel: // 小说
|
||||
return AIService.getNovelList(page, 10, sort);
|
||||
case AiType.imageToVideo: // 图生视频
|
||||
return AIService.getImgVideoList(page, 10, sort);
|
||||
case AiType.aiPaint: // ai绘画(文生图)
|
||||
return AIService.getTextToImageList(page, 10, sort);
|
||||
default: // 脱衣
|
||||
return AIService.getUndressList(page, 10, sort);
|
||||
}
|
||||
}
|
||||
|
||||
Widget instanceChild(int index) {
|
||||
final model = dataSource[index];
|
||||
switch (aiType) {
|
||||
case AiType.aiNovel:
|
||||
return AINovelRecord(model,
|
||||
onDeleteCallback: () => deleteRecord(index));
|
||||
case AiType.imageToVideo:
|
||||
return AIVideoRecord(model,
|
||||
status: sort, onDeleteCallback: () => deleteRecord(index));
|
||||
case AiType.aiPaint:
|
||||
return AIPaintRecord(model,
|
||||
status: sort, onDeleteCallback: () => deleteRecord(index));
|
||||
default: //脱衣 / 视频换脸 / 图片换脸
|
||||
return AIImageRecord(model,
|
||||
aiType: aiType, onDeleteCallback: () => deleteRecord(index));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> deleteRecord(int index) async {
|
||||
if (!await CommonAlert.show(content: '是否删除该生成记录?')) return false;
|
||||
if (!await _deleteBill(dataSource[index].id)) return false;
|
||||
dataSource.removeAt(index);
|
||||
showToast('删除成功');
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 按 aiType 调对应删除接口
|
||||
Future<bool> _deleteBill(String? id) {
|
||||
switch (aiType) {
|
||||
case AiType.videoChangeFace:
|
||||
return AIService.deleteChangeFace(id);
|
||||
case AiType.imageChangeFace:
|
||||
return AIService.deleteImg(id);
|
||||
case AiType.aiNovel:
|
||||
return AIService.deleteNovel(id);
|
||||
case AiType.imageToVideo:
|
||||
return AIService.deleteImgVideo(id);
|
||||
case AiType.aiPaint:
|
||||
return AIService.deleteTextToImage(id);
|
||||
default:
|
||||
return AIService.deleteUndress(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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/refresh/pull_refresh.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
import 'ai_record_logic.dart';
|
||||
|
||||
const _subTypes = ['排队', '成功', '失败'];
|
||||
|
||||
/// 某个 AI 功能的生成记录子页:排队/成功/失败 3 个 keepalive 状态页,可横滑切换
|
||||
class AiRecordPage extends StatefulWidget {
|
||||
final AiType aiType; // 脱衣/视频换脸/图片换脸/小说
|
||||
|
||||
const AiRecordPage({super.key, required this.aiType});
|
||||
|
||||
@override
|
||||
State<AiRecordPage> createState() => _AiRecordPageState();
|
||||
}
|
||||
|
||||
class _AiRecordPageState extends State<AiRecordPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabCtr =
|
||||
TabController(length: _subTypes.length, vsync: this);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabCtr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabCtr,
|
||||
children: List.generate(
|
||||
_subTypes.length,
|
||||
(index) =>
|
||||
_AiRecordStatusList(aiType: widget.aiType, status: index + 1)
|
||||
.keepAlive,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 排队/成功/失败 状态 Tab
|
||||
Widget _buildTabBar() {
|
||||
return TabBar(
|
||||
controller: _tabCtr,
|
||||
tabs: List.generate(
|
||||
_subTypes.length,
|
||||
(index) => Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: index != 2
|
||||
? Border(
|
||||
right: BorderSide(
|
||||
width: .5, color: Colors.white.withValues(alpha: .1)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Text(_subTypes[index]),
|
||||
),
|
||||
),
|
||||
isScrollable: true,
|
||||
padding: EdgeInsets.only(top: 10, left: 7),
|
||||
labelPadding: EdgeInsets.zero,
|
||||
tabAlignment: TabAlignment.start,
|
||||
unselectedLabelStyle:
|
||||
TextStyle(color: Colors.white.withValues(alpha: .35), fontSize: 14),
|
||||
labelStyle:
|
||||
TextStyle(color: Colors.white.withValues(alpha: .9), fontSize: 14),
|
||||
indicator: const BoxDecoration(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个状态(排队/成功/失败)的记录列表,独立 logic 实例 + 独立分页/刷新
|
||||
class _AiRecordStatusList extends StatelessWidget {
|
||||
final AiType aiType;
|
||||
final int status; // 1排队 2成功 3失败
|
||||
|
||||
const _AiRecordStatusList({required this.aiType, required this.status});
|
||||
|
||||
String get _tag => 'airec_${aiType.name}_$status';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AIRecordLogic>(
|
||||
tag: _tag,
|
||||
init: AIRecordLogic(aiType, sort: status),
|
||||
builder: (logic) {
|
||||
return pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshCtr = ctr,
|
||||
onRefresh: (_) => logic.fetchPageData(),
|
||||
onLoading: (_) => logic.fetchPageData(isRefresh: false),
|
||||
child: _buildList(logic),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(AIRecordLogic logic) {
|
||||
if (logic.isLoading) return LoadingCenterWidget();
|
||||
if (logic.dataSource.isEmpty) return CErrorWidget();
|
||||
return ListView.separated(
|
||||
padding: EdgeInsets.only(left: 16, right: 16, top: 18),
|
||||
separatorBuilder: (_, index) => 12.sizeBoxH,
|
||||
itemCount: logic.dataSource.length,
|
||||
itemBuilder: (BuildContext context, int index) =>
|
||||
logic.instanceChild(index),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/tools_base/base_list_controller.dart';
|
||||
|
||||
import '../ai_home_logic.dart';
|
||||
import '../ai_mod_detail_page.dart';
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
import '../models/ai_square_model.dart';
|
||||
|
||||
class AiSquareLogic extends ListBaseLogic<AISquareItemModel> {
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
//isRefresh=true 下拉刷新 / false 上拉加载更多
|
||||
void loadData({bool isRefresh = true}) =>
|
||||
fetchData(isRefresh: isRefresh, fetch: _fetch);
|
||||
|
||||
Future<(List<AISquareItemModel>?, bool)> _fetch(int page) async {
|
||||
final resp = await AIService.getPlazaList(page);
|
||||
return (resp?.list, resp?.hasNext ?? false);
|
||||
}
|
||||
|
||||
//「做同款」:脱衣/图生视频/绘画直接切到对应 Tab,换脸类先查模版还在不在
|
||||
void makeTheSameStyle(AISquareItemModel model) {
|
||||
final logic = Get.find<AiHomeLogic>();
|
||||
switch (AiTypeCode.fromServerCode(model.type)) {
|
||||
case AiType.autoStrip:
|
||||
logic.jumpSubModule(3);
|
||||
case AiType.imageToVideo:
|
||||
logic.jumpSubModule(1);
|
||||
case AiType.aiPaint:
|
||||
logic.jumpSubModule(2);
|
||||
default:
|
||||
jumpToChangeFace(model);
|
||||
}
|
||||
}
|
||||
|
||||
//模版还在就进详情,被下架了退回换脸 Tab
|
||||
void jumpToChangeFace(AISquareItemModel model) async {
|
||||
final mod = await AIService.getModelInfo(model.template, model.type);
|
||||
if (mod != null) {
|
||||
Get.to(() => AIModDetailPage(mod));
|
||||
return;
|
||||
}
|
||||
final isImageFace =
|
||||
AiTypeCode.fromServerCode(model.type) == AiType.imageChangeFace;
|
||||
Get.find<AiHomeLogic>().jumpSubModule(isImageFace ? 5 : 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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/refresh/pull_refresh.dart';
|
||||
|
||||
import '../widgets/ai_square_cell.dart';
|
||||
import 'ai_square_logic.dart';
|
||||
|
||||
class AiSquarePage extends StatelessWidget {
|
||||
AiSquarePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AiSquareLogic>(
|
||||
init: AiSquareLogic(),
|
||||
builder: (logic) {
|
||||
return Scaffold(
|
||||
body: pullYsRefresh(
|
||||
onRefresh: (c) => logic.loadData(),
|
||||
onLoading: (c) => logic.loadData(isRefresh: false),
|
||||
onInit: (ctr) => logic.refreshCtr = ctr,
|
||||
child: _buildContent(logic),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(AiSquareLogic logic) {
|
||||
if (logic.isLoading) return LoadingCenterWidget();
|
||||
if (logic.isEmptyData) {
|
||||
return CErrorWidget(retryOnTap: () => logic.loadData());
|
||||
}
|
||||
final list = logic.dataList!;
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
separatorBuilder: (context, index) => 18.sizeBoxH,
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, index) => AISquareCell(
|
||||
model: list[index],
|
||||
onTap: () => logic.makeTheSameStyle(list[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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_page/pre_sale/pre_sale_provider.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 '../../../routers/jump_router.dart';
|
||||
import '../../mine/mine_vip/coupon_model.dart';
|
||||
import '../models/ai_mod_list_model.dart';
|
||||
import '../widgets/ai_coupon_sheet.dart';
|
||||
|
||||
enum AiType {
|
||||
imageToVideo, //图生视频
|
||||
autoStrip, //智能脱衣
|
||||
videoChangeFace, //视频换脸
|
||||
imageChangeFace, //图片换脸
|
||||
aiMate, //ai女友
|
||||
aiPaint, //ai绘画
|
||||
aiNovel, //ai小说
|
||||
}
|
||||
|
||||
/// 与服务端 type 编码互转,只适用于 /aiplaza/list 这套 1..6
|
||||
/// 注意:aiSwitchConf 的 type 是另一套编码(1脱衣/2视频换脸/3图片换脸,见 domain_source_model 的 _aiTypeConfMap),别混用
|
||||
/// aiMate 走独立接口、后端没给编码,故映射为 null
|
||||
extension AiTypeCode on AiType {
|
||||
//换脸模版列表按图片/视频分别取 type,网格列数与宽高比也跟着这个走
|
||||
bool get isVideoFace => this == AiType.videoChangeFace;
|
||||
|
||||
static AiType? fromServerCode(int? code) => switch (code) {
|
||||
1 => AiType.imageChangeFace,
|
||||
2 => AiType.videoChangeFace,
|
||||
3 => AiType.autoStrip,
|
||||
4 => AiType.imageToVideo,
|
||||
5 => AiType.aiPaint,
|
||||
6 => AiType.aiNovel,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// AI 各功能 logic 的公共部分:图片载体 + 计费校验 + 上传下单模板
|
||||
abstract class AiFunctionBaseLogic extends GetxController {
|
||||
AiFunctionBaseLogic(this.modList);
|
||||
|
||||
AiModList? modList; //接口下发的模版数据
|
||||
final localPicList = <String>[]; //已选本地图片路径,与 PicPicker 共享
|
||||
|
||||
bool shareToAiSquare = true;
|
||||
final editingCtr = TextEditingController();
|
||||
|
||||
//VIP 门槛文案,配合各功能里注释掉的会员校验使用
|
||||
final hint = '您还不是VIP无法使用AI脱衣';
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
editingCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
/// 提交入口,各功能自行实现校验与下单
|
||||
Future<void> submit();
|
||||
|
||||
//免费次数或金币够不够本次消费
|
||||
bool canPay(int price) {
|
||||
final freeCount = (globalStore.wallet?.todayAiFreeTimes ?? 0) +
|
||||
(presaleProvider.remain?.todayAiUndressCount ?? 0);
|
||||
if ((globalStore.wallet?.amount ?? 0) < price && freeCount <= 0) {
|
||||
showVipLevelDialog("当前免费次数不足或金币余额不足", buttonTitle: '去充值', vipEvent: () {
|
||||
Get.back();
|
||||
pushToWalletPage(tabPosition: 1);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//校验有没有选图,必须在扣免费次数之前调,否则次数会白扣
|
||||
bool checkPic() {
|
||||
if (localPicList.isNotEmpty) return true;
|
||||
CommonAlert.show(title: "提示", content: "请选择图片", showCancel: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 上传 [pics] → [generate] 下单 → 统一处理 loading / toast / 清态
|
||||
/// 提交成功后会清空 localPicList,上传途中怕被清就传副本
|
||||
Future<void> upload(
|
||||
List<String> pics,
|
||||
Future<bool> Function(List<String> urls) generate, {
|
||||
VoidCallback? onDone,
|
||||
}) async {
|
||||
await FileUploadTool().uploadImagesWithProgress(
|
||||
pics,
|
||||
onFailure: () => showToast("图片上传失败"),
|
||||
onSuccess: (urls) async {
|
||||
LoadingAlertWidget.show(title: "正在更新数据...");
|
||||
try {
|
||||
if (await generate(urls)) {
|
||||
showToast("提交成功~");
|
||||
localPicList.clear();
|
||||
onDone?.call();
|
||||
globalStore.refreshWallet();
|
||||
update();
|
||||
} else {
|
||||
showToast("提交失败");
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
showToast(e.message.toString());
|
||||
} catch (e) {
|
||||
showToast(e.toString());
|
||||
} finally {
|
||||
LoadingAlertWidget.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 智能脱衣
|
||||
class AiStripLogic extends AiFunctionBaseLogic {
|
||||
AiStripLogic(super.modList);
|
||||
|
||||
double get aspectRatio => 408 / 310;
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
//获取脱衣模版
|
||||
Future<void> loadData() async {
|
||||
modList = await AIService.getModelList();
|
||||
update();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submit() async {
|
||||
if (!checkPic()) return;
|
||||
//预售期有免费次数先走免费,否则查金币
|
||||
if (presaleProvider.isOpen &&
|
||||
presaleProvider.hasLimit &&
|
||||
(presaleProvider.remain?.todayAiUndressCount ?? 0) > 0) {
|
||||
presaleProvider.remain?.todayAiUndressCount =
|
||||
(presaleProvider.remain?.todayAiUndressCount ?? 0) - 1;
|
||||
} else {
|
||||
if (!canPay(int.tryParse(Config.aiUndressPrice) ?? 0)) return;
|
||||
}
|
||||
//传副本:提交成功回调里会清空 localPicList
|
||||
await upload(
|
||||
[...localPicList],
|
||||
(urls) =>
|
||||
AIService.generateUndress(urls, shareToAiSquare, editingCtr.text));
|
||||
}
|
||||
}
|
||||
|
||||
// 换脸详情
|
||||
class AiFaceDetailLogic extends AiFunctionBaseLogic {
|
||||
AiFaceDetailLogic(super.modList, {required this.mod});
|
||||
|
||||
final TemplateModel mod; // 换脸模版
|
||||
AICouponModel? coupon; //折扣券
|
||||
|
||||
bool get isChangeVideo => mod.moduleType == 1; // 是否为视频
|
||||
|
||||
// 视频/图片换脸都用模版里的价格
|
||||
int get price => globalStore.isVIP ? (mod.vipCoin ?? 0) : (mod.coin ?? 0);
|
||||
|
||||
@override
|
||||
String get hint => '您还不是充值VIP无法使用AI换脸';
|
||||
|
||||
@override
|
||||
Future<void> submit() async {
|
||||
if (!checkPic()) return;
|
||||
//预售期图片换脸有免费次数先走免费,否则查金币
|
||||
if (presaleProvider.isOpen &&
|
||||
presaleProvider.hasLimit &&
|
||||
(presaleProvider.remain?.todayAiUndressCount ?? 0) > 0 &&
|
||||
!isChangeVideo) {
|
||||
presaleProvider.remain?.todayAiUndressCount =
|
||||
(presaleProvider.remain?.todayAiUndressCount ?? 0) - 1;
|
||||
} else {
|
||||
if (!canPay(price)) return;
|
||||
}
|
||||
await upload(
|
||||
localPicList,
|
||||
(urls) => isChangeVideo
|
||||
? AIService.generateChangeFace(
|
||||
urls, mod.id, coupon?.id, shareToAiSquare, editingCtr.text) //视频换脸
|
||||
: AIService.generateImg(urls.firstOrNull ?? '', mod.id ?? '',
|
||||
shareToAiSquare, editingCtr.text), //图片换脸
|
||||
onDone: () => coupon = null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showCoupon() async {
|
||||
final AICouponModel? model = await Get.bottomSheet(AICouponSheet());
|
||||
if (model != null) {
|
||||
coupon = model;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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_page/pre_sale/pre_sale_provider.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/banner/ads_banner_widget.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../widgets/pic_picker.dart';
|
||||
import 'ai_function_logic.dart';
|
||||
|
||||
//ai脱衣
|
||||
class AIStripSubPage extends StatelessWidget {
|
||||
const AIStripSubPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<AiStripLogic>(
|
||||
init: AiStripLogic(null),
|
||||
builder: (logic) {
|
||||
if (logic.modList == null) return LoadingCenterWidget();
|
||||
if (logic.modList!.aiUndressMod?.isEmpty == true) return CErrorWidget();
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
12.sizeBoxH,
|
||||
_uploadHeader(logic),
|
||||
18.sizeBoxH,
|
||||
0.5.line,
|
||||
18.sizeBoxH,
|
||||
Text(
|
||||
"案例鉴赏",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
_buildBanner(logic),
|
||||
18.sizeBoxH,
|
||||
0.5.line,
|
||||
18.sizeBoxH,
|
||||
_priceView(),
|
||||
18.sizeBoxH,
|
||||
_buildBtn(logic),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 上传图片 + 注意事项
|
||||
Widget _uploadHeader(AiStripLogic logic) {
|
||||
return Row(
|
||||
children: [
|
||||
PicPicker(
|
||||
width: 110,
|
||||
height: 110,
|
||||
picList: logic.localPicList,
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"注意事项:",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Text(
|
||||
'''1、素材仅供AI使用,绝无外泄风险,请放心使用.
|
||||
2、素材需清晰,小于2MB,上传间隔大于60秒.
|
||||
3、本功能不支持多人图片
|
||||
4、生成失败退回金币,若违规作废. 5、禁止使用未成年图片!''',
|
||||
style: TextStyle(
|
||||
color: Color(0xff656565),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 费用信息:免费次数 + 单价
|
||||
Widget _priceView() {
|
||||
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(
|
||||
children: [
|
||||
Text(
|
||||
'你当前免费体验为$total次数',
|
||||
style: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Text(
|
||||
'当日免费$toDaytotal次数',
|
||||
style: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"处理一张照片的费用是 ",
|
||||
style: TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${Config.aiUndressPrice}金币",
|
||||
style: TextStyle(
|
||||
color: AppColors.actionRed,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 案例轮播
|
||||
Widget _buildBanner(AiStripLogic logic) {
|
||||
return AspectRatio(
|
||||
aspectRatio: logic.aspectRatio,
|
||||
child: AdsBannerWidget(
|
||||
logic.modList?.aiUndressMod ?? [],
|
||||
isIndicatorUnderCenter: true,
|
||||
color: AppColors.actionRed.withValues(alpha: .3),
|
||||
selectColor: AppColors.actionRed,
|
||||
isCircle: false,
|
||||
onItemClick: (index) {
|
||||
final ad = (logic.modList!.aiUndressMod ?? [])[index];
|
||||
pushToPageByLink(ad.href);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBtn(AiStripLogic logic) {
|
||||
return GestureDetector(
|
||||
onTap: () => logic.submit(),
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
"立即提交",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//AI 模版项:脱衣/换脸/绘画列表共用
|
||||
class AiChangeFaceVideoMod {
|
||||
String? id;
|
||||
String? title;
|
||||
String? sourceURL;
|
||||
int? status;
|
||||
int? playTime;
|
||||
String? cover;
|
||||
String? type;
|
||||
int? moduleType;
|
||||
int? hotValue;
|
||||
String? hotMark;
|
||||
int? coin;
|
||||
int? vipCoin;
|
||||
String? newUrl; // 图生视频结果
|
||||
int? styleType; // ai绘画
|
||||
|
||||
AiChangeFaceVideoMod.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
title = json['title'];
|
||||
sourceURL = json['sourceURL'];
|
||||
status = json['status'];
|
||||
playTime = json['playTime'];
|
||||
cover = json['cover'];
|
||||
type = json['type'];
|
||||
moduleType = json['moduleType'];
|
||||
hotValue = json['hotValue'];
|
||||
hotMark = json['hotMark'];
|
||||
coin = json['coin'];
|
||||
vipCoin = json['vipCoin'];
|
||||
newUrl = json['newUrl'];
|
||||
styleType = json['styleType'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//ai女友余额
|
||||
class AIGirlFriendBalanceModel {
|
||||
num? balance;
|
||||
|
||||
AIGirlFriendBalanceModel.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
balance = json['balance'];
|
||||
}
|
||||
}
|
||||
|
||||
//ai跳转url
|
||||
class AIGirlFriendUrlModel {
|
||||
String? url;
|
||||
|
||||
AIGirlFriendUrlModel.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
url = json['url'] ?? json['authUrl'];
|
||||
}
|
||||
}
|
||||
|
||||
//ai女友货币档位列表
|
||||
class AIGirlFriendCurrencys {
|
||||
List<AIGirlFriendCurrency>? list;
|
||||
|
||||
AIGirlFriendCurrencys.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
list = (json['list'] as List?)?.map((e) => AIGirlFriendCurrency.fromJson(e)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class AIGirlFriendCurrency {
|
||||
String? id; //货币id
|
||||
String? name; //货币名称
|
||||
num? coins; //购买货币数(对应积分数量)
|
||||
num? price; //价格(对应需要支付的金币数量),
|
||||
String? couponDesc; //优惠描述
|
||||
int? type;
|
||||
|
||||
AIGirlFriendCurrency.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
name = json['name'];
|
||||
coins = json['coins'];
|
||||
price = json['price'];
|
||||
couponDesc = json['couponDesc'];
|
||||
type = json['type'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
|
||||
import '../../../hj_model/splash/ads_model.dart';
|
||||
import 'ai_change_face_video_model.dart';
|
||||
|
||||
//ai脱衣模版
|
||||
class AiModList {
|
||||
/// 脱衣模版
|
||||
List<AdsInfoModel>? aiUndressMod;
|
||||
|
||||
/// 图片转视频模版
|
||||
List<AdsInfoModel>? aiImgToVideoMod;
|
||||
|
||||
//ai绘画模版
|
||||
List<AiChangeFaceVideoMod>? aiTextToImgMod;
|
||||
|
||||
AiModList();
|
||||
|
||||
AiModList.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
aiUndressMod = (json['aiUndressMod'] as List?)
|
||||
?.map((e) => AdsInfoModel.fromJson(e))
|
||||
.toList();
|
||||
aiImgToVideoMod = (json['aiImgToVideoMod'] as List?)
|
||||
?.map((e) => AdsInfoModel.fromJson(e))
|
||||
.toList();
|
||||
aiTextToImgMod = (json['aiTextToImgMod'] as List?)
|
||||
?.map((e) => AiChangeFaceVideoMod.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
//ai换脸模版
|
||||
class AiChangeModList {
|
||||
String? categoryId;
|
||||
List<AICategoryMod>? categoryList;
|
||||
List<TemplateModel>? templateList;
|
||||
|
||||
AiChangeModList.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
categoryId = json['categoryId'];
|
||||
categoryList = (json['categoryList'] as List?)
|
||||
?.map((e) => AICategoryMod.fromJson(e))
|
||||
.toList();
|
||||
templateList = (json['templateList'] as List?)
|
||||
?.map((e) => TemplateModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class AICategoryMod {
|
||||
int? appId;
|
||||
String? createdAt;
|
||||
String? id;
|
||||
String? name;
|
||||
int? sortCode;
|
||||
int? status;
|
||||
List<String>? templateIds;
|
||||
int? type;
|
||||
String? updatedAt;
|
||||
|
||||
AICategoryMod.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
appId = json['appId'];
|
||||
createdAt = json['createdAt'];
|
||||
id = json['id'];
|
||||
name = json['name'];
|
||||
sortCode = json['sortCode'];
|
||||
status = json['status'];
|
||||
templateIds = parseStringList(json['templateIds']);
|
||||
type = json['type'];
|
||||
updatedAt = json['updatedAt'];
|
||||
}
|
||||
}
|
||||
|
||||
class TemplateModel {
|
||||
String? categoryId; //分类id
|
||||
int? coin; //价格(金豆)
|
||||
String? cover; //封面
|
||||
String? createdAt; //创建时间
|
||||
String? id;
|
||||
int? moduleType; //换脸模版类型 0 图片 1 视频
|
||||
String? mp4Url; //视频mp4地址
|
||||
String? m3u8Url;
|
||||
String? title;
|
||||
int? usedCount; //模版使用次数
|
||||
int? vipCoin;
|
||||
|
||||
/// 上架时间戳,模版列表按它排序;时间解析不了按 0 排最前
|
||||
int get timestamp =>
|
||||
DateTime.tryParse(createdAt ?? '')?.millisecondsSinceEpoch ?? 0;
|
||||
|
||||
TemplateModel.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
categoryId = json['categoryId'];
|
||||
coin = json['coin'];
|
||||
cover = json['cover'];
|
||||
createdAt = json['createdAt'];
|
||||
id = json['id'];
|
||||
m3u8Url = json['m3u8Url'];
|
||||
moduleType = json['moduleType'];
|
||||
mp4Url = json['mp4Url'];
|
||||
title = json['title'];
|
||||
usedCount = json['usedCount'];
|
||||
vipCoin = json['vipCoin'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
ai 视频换脸的数据注释:
|
||||
modCover 模版图片
|
||||
modMp4Url 模版视频
|
||||
picture换脸图片(用户提交的)
|
||||
cover换脸后封面大图
|
||||
url换脸后视频地址
|
||||
*/
|
||||
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
|
||||
import '../../../config/address.dart';
|
||||
|
||||
class AiRecordModel {
|
||||
String? id;
|
||||
int? uid;
|
||||
String? originPic;
|
||||
String? imgUrl;
|
||||
String? newImgUrl;
|
||||
String? styleUrl;
|
||||
String? text;
|
||||
List<String>? originPics;
|
||||
List<String>? newPic;
|
||||
List<String>? picture;
|
||||
int? coin;
|
||||
int? status;
|
||||
String? remark;
|
||||
String? url;
|
||||
String? updateAct;
|
||||
String? createdAt;
|
||||
String? updatedAt;
|
||||
String? vidId;
|
||||
String? modPic;
|
||||
String? modCover;
|
||||
String? modMp4Url;
|
||||
String? cover;
|
||||
String? content;
|
||||
String? characterSetting; //小说人物设定/故事背景等
|
||||
String? description; //故事情节描述
|
||||
String? details; //细节说明/其他要求
|
||||
String? locationScene; //地点场景
|
||||
|
||||
/// 换脸结果视频地址。query 用 cdn= 而不是 VideoModel 的 c=,两边接口不同,别顺手统一
|
||||
String get realVideoUrl =>
|
||||
"${Address.baseApiPath}/vid/h5/m3u8/$url?token=${Address.token}&cdn=${Address.cdnAddress}";
|
||||
|
||||
AiRecordModel.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
id = json['id'];
|
||||
uid = json['uid'];
|
||||
originPic = json['originPic'];
|
||||
newImgUrl = json['newImgUrl'];
|
||||
imgUrl = json['imgUrl'];
|
||||
styleUrl = json['styleUrl'];
|
||||
text = json['text'];
|
||||
originPics = parseStringList(json['originPics']);
|
||||
picture = parseStringList(json['picture']);
|
||||
newPic = parseStringList(json['newPic']);
|
||||
coin = json['coin'];
|
||||
status = json['status'];
|
||||
remark = json['remark'];
|
||||
url = json['url'];
|
||||
updateAct = json['updateAct'];
|
||||
createdAt = json['createdAt'];
|
||||
updatedAt = json['updatedAt'];
|
||||
vidId = json['vidId'];
|
||||
modPic = json['modPic'];
|
||||
modCover = json['modCover'];
|
||||
modMp4Url = json['modMp4Url'];
|
||||
cover = json['cover'];
|
||||
content = json['content'];
|
||||
characterSetting = json['characterSetting'];
|
||||
description = json['description'];
|
||||
details = json['details'];
|
||||
locationScene = json['locationScene'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import '../../../hj_model/video_model.dart';
|
||||
|
||||
class AISquareItemModel {
|
||||
String? createdAt;
|
||||
String? gender;
|
||||
String? generateImage;
|
||||
String? generateVideo;
|
||||
String? generateVideoCover;
|
||||
String? id;
|
||||
String? name;
|
||||
String? originContent;
|
||||
String? originalImage;
|
||||
String? originalVideo;
|
||||
String? originalVideoCover;
|
||||
String? portrait;
|
||||
String? reason;
|
||||
String? reviewAt;
|
||||
int? sortCode;
|
||||
int? status;
|
||||
String? template;
|
||||
String? title;
|
||||
|
||||
/// 1-ai图片换脸 2-ai视频换脸 3-ai脱衣 4-ai图生视频 5-ai绘画
|
||||
int? type;
|
||||
int? uid;
|
||||
String? updatedAt;
|
||||
|
||||
/// 生成结果视频的播放地址,借 VideoModel 拼(拼接规则只在那一处维护)
|
||||
String get realGenerateVideoUrl => (VideoModel()..sourceURL = generateVideo).realVideoUrl;
|
||||
|
||||
String get typeString => switch (type) {
|
||||
1 => '图片换脸',
|
||||
2 => '视频换脸',
|
||||
3 => 'AI脱衣',
|
||||
4 => '图生视频',
|
||||
5 => 'AI绘画',
|
||||
_ => '',
|
||||
};
|
||||
|
||||
AISquareItemModel.fromJson(dynamic json) {
|
||||
json ??= {};
|
||||
createdAt = json['createdAt'];
|
||||
gender = json['gender'];
|
||||
generateImage = json['generateImage'];
|
||||
generateVideo = json['generateVideo'];
|
||||
generateVideoCover = json['generateVideoCover'];
|
||||
id = json['id'];
|
||||
name = json['name'];
|
||||
originContent = json['originContent'];
|
||||
originalImage = json['originalImage'];
|
||||
originalVideo = json['originalVideo'];
|
||||
originalVideoCover = json['originalVideoCover'];
|
||||
portrait = json['portrait'];
|
||||
reason = json['reason'];
|
||||
reviewAt = json['reviewAt'];
|
||||
sortCode = json['sortCode'];
|
||||
status = json['status'];
|
||||
template = json['template'];
|
||||
title = json['title'];
|
||||
type = json['type'];
|
||||
uid = json['uid'];
|
||||
updatedAt = json['updatedAt'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
import '../../mine/mine_vip/coupon_model.dart';
|
||||
|
||||
/// 抵扣券选择弹窗:选中一张后 [Get.back] 回传 [AICouponModel]
|
||||
class AICouponSheet extends StatefulWidget {
|
||||
const AICouponSheet({super.key});
|
||||
|
||||
@override
|
||||
State<AICouponSheet> createState() => _AICouponSheetState();
|
||||
}
|
||||
|
||||
class _AICouponSheetState extends State<AICouponSheet> {
|
||||
int curPage = 1;
|
||||
List<AICouponModel>? dataList; //null = 还在首屏加载
|
||||
RefreshController? refreshCtr;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadData();
|
||||
}
|
||||
|
||||
Future<void> loadData({int page = 1}) async {
|
||||
const size = 10; //请求条数和「还有没有下一页」的判断必须用同一个值
|
||||
final res = await MineService.backPack(page, limit: size);
|
||||
if (res != null) {
|
||||
if (page == 1) dataList = []; //刷新成功才清空,失败保留旧数据
|
||||
(dataList ??= []).addAll(res);
|
||||
curPage = page;
|
||||
}
|
||||
dataList ??= []; //首屏失败也要退出 loading,否则永远转圈
|
||||
setState(() {});
|
||||
if (page == 1) refreshCtr?.refreshCompleted();
|
||||
(res?.length ?? 0) < size
|
||||
? refreshCtr?.loadNoData()
|
||||
: refreshCtr?.loadComplete();
|
||||
}
|
||||
|
||||
Future<void> loadMore() => loadData(page: curPage + 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SheetHandleBar(),
|
||||
18.sizeBoxH,
|
||||
Text('使用抵扣券',
|
||||
style: textStyle(
|
||||
18, Colors.white.withValues(alpha: .9), FontWeight.w600)),
|
||||
18.sizeBoxH,
|
||||
Expanded(child: _list()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list() {
|
||||
if (dataList == null) return LoadingCenterWidget();
|
||||
if (dataList!.isEmpty) return CErrorWidget(retryOnTap: loadData);
|
||||
return pullYsRefresh(
|
||||
onInit: (ctr) => refreshCtr = ctr,
|
||||
onRefresh: (_) => loadData(),
|
||||
onLoading: (_) => loadMore(),
|
||||
child: ListView.separated(
|
||||
itemCount: dataList!.length,
|
||||
separatorBuilder: (_, __) => 12.sizeBoxH,
|
||||
itemBuilder: (_, index) => _couponItem(dataList![index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _couponItem(AICouponModel model) {
|
||||
//券面是暖色底图,三处文字统一用这个深棕
|
||||
const couponText = Color(0xff7E4444);
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: model),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('ai_coupon_bg.webp'.aiPath), fit: BoxFit.fill),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
18.sizeBoxW,
|
||||
Text('${model.goodsName}',
|
||||
style: textStyle(18, couponText, FontWeight.w600)),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${model.goodsDesc}',
|
||||
style: textStyle(14, couponText, FontWeight.w400),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Container(
|
||||
height: 26,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffFFDCB3),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text('立即使用',
|
||||
style: textStyle(12, couponText, FontWeight.w400)),
|
||||
),
|
||||
18.sizeBoxW,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// AI 输入框:多行 + 右下角字数统计。
|
||||
/// controller 归外部 logic 持有,本 widget 只摘自己挂的监听
|
||||
class AIDrawTextField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final int maxLength;
|
||||
final double height;
|
||||
|
||||
const AIDrawTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText = '主人来两句嘛~',
|
||||
this.maxLength = 500,
|
||||
this.height = 181,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AIDrawTextField> createState() => _AIDrawTextFieldState();
|
||||
}
|
||||
|
||||
class _AIDrawTextFieldState extends State<AIDrawTextField> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
//刷新底部字数统计
|
||||
void _onTextChanged() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: widget.height,
|
||||
padding: const EdgeInsets.only(left: 10, right: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: .8), fontSize: 12),
|
||||
maxLength: widget.maxLength,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2),
|
||||
counterText: '', //自己在下面画统计,藏掉系统那个
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .6),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
hintMaxLines: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${widget.controller.text.length}/${widget.maxLength}',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: .5), fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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/hj_utils/api_service/ai_service.dart';
|
||||
import 'package:hgdj/routers/jump_router.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 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
import '../models/ai_girl_resp_model.dart';
|
||||
|
||||
/// AI 女友充值积分弹窗
|
||||
class AIGirlSheet extends StatefulWidget {
|
||||
final List<AIGirlFriendCurrency>? list;
|
||||
|
||||
const AIGirlSheet({super.key, this.list});
|
||||
|
||||
@override
|
||||
State<AIGirlSheet> createState() => _AIGirlSheetState();
|
||||
}
|
||||
|
||||
class _AIGirlSheetState extends State<AIGirlSheet> {
|
||||
int selectIndex = 0;
|
||||
|
||||
//TapGestureRecognizer 必须自己释放,建一次复用,别在 build 里 new
|
||||
final _serviceTap = TapGestureRecognizer()..onTap = pushToCustomService;
|
||||
|
||||
List<AIGirlFriendCurrency> get _items => widget.list ?? [];
|
||||
|
||||
//列表可能为空,取不到就没得选
|
||||
AIGirlFriendCurrency? get _selected =>
|
||||
selectIndex < _items.length ? _items[selectIndex] : null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serviceTap.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xff1D2236),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
const SheetHandleBar(color: Color(0x1AFFFFFF)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 18, bottom: 12),
|
||||
child: Text('AI女友',
|
||||
style: textStyle(20, Colors.white, FontWeight.w600)),
|
||||
),
|
||||
Text('充值积分',
|
||||
style: textStyle(
|
||||
14, Colors.white.withValues(alpha: .8), FontWeight.w400)),
|
||||
GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 106 / 60,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (_, index) => _item(index),
|
||||
),
|
||||
//小贴士整块左右各留 28
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('支付小贴士:',
|
||||
style: textStyle(14, Colors.white, FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'1.因超时支付无法到账,请重新发起。\n2.连续发起且未支付,账号可能被加入黑名单\n3.充值成功后会在1~5分钟内到账,可重新刷新进入当前页面',
|
||||
style: textStyle(12, Colors.white.withValues(alpha: .6),
|
||||
FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
GestureDetector(
|
||||
onTap: _onPay,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xff1E3C72),
|
||||
Color(0xff5F40B6),
|
||||
Color(0xff1E3C72),
|
||||
Color(0xff2A5298)
|
||||
],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
'${(_selected?.price ?? 0).truncate()}/立即支付',
|
||||
style: textStyle(16, Colors.white, FontWeight.w400),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
EasyRichText(
|
||||
'支付中如有问题 请联系在线客服',
|
||||
textAlign: TextAlign.center,
|
||||
defaultStyle: textStyle(
|
||||
12, Colors.white.withValues(alpha: .6), FontWeight.w400),
|
||||
patternList: [
|
||||
EasyRichTextPattern(
|
||||
targetString: '在线客服',
|
||||
matchOption: 'first',
|
||||
style: const TextStyle(color: AppColors.primaryHighColor),
|
||||
recognizer: _serviceTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _item(int index) {
|
||||
final item = _items[index];
|
||||
final isSelected = selectIndex == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => selectIndex = index),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xff4039A1)
|
||||
: Colors.white.withValues(alpha: .05),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xff30509C)
|
||||
: Colors.white.withValues(alpha: .1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${item.coins ?? 0}积分',
|
||||
style: textStyle(
|
||||
16,
|
||||
isSelected
|
||||
? const Color(0xffAFABFF)
|
||||
: const Color(0xff7D95D5),
|
||||
FontWeight.w400),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${(item.price ?? 0).truncate()}金币',
|
||||
style: textStyle(
|
||||
12,
|
||||
isSelected
|
||||
? const Color(0x4DF3F5FF)
|
||||
: Colors.white.withValues(alpha: .3),
|
||||
FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPay() async {
|
||||
final item = _selected;
|
||||
if (item == null) return;
|
||||
try {
|
||||
LoadingAlertWidget.show();
|
||||
final value = await AIService.exchangeMate({'id': item.id ?? ''});
|
||||
LoadingAlertWidget.cancel();
|
||||
if (value is String) {
|
||||
showToast(value);
|
||||
return;
|
||||
}
|
||||
if (value.code == 200) {
|
||||
showToast('充值成功');
|
||||
Get.back();
|
||||
return;
|
||||
}
|
||||
if (value.code == 8000) {
|
||||
//金币不足
|
||||
showToast('金币不足,请充值');
|
||||
pushToWalletPage(tabPosition: 1);
|
||||
}
|
||||
} catch (e) {
|
||||
LoadingAlertWidget.cancel();
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/video_download/video_cache_store.dart';
|
||||
import 'package:hgdj/tools_base/video_download/video_download_manager.dart';
|
||||
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../video/simple_video_player_page.dart';
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
import '../models/ai_record_model.dart';
|
||||
import 'ai_record_parts.dart';
|
||||
|
||||
// StatusGenning AiChangeFaceStatus = 0 // 未完成
|
||||
// StatusComplete AiChangeFaceStatus = 1 // 已完成
|
||||
// StatusRefund AiChangeFaceStatus = -1 // 已退款
|
||||
// StatusSubmit AiChangeFaceStatus = 2 // 已提交
|
||||
|
||||
// ai图片换脸
|
||||
// Processing = 1 // 1、进行中
|
||||
// SUCCESS = 2 // 2、成功
|
||||
// FAILURE = 3 // 3、失败
|
||||
// REFUND = 4 // 4、退款
|
||||
// StatusSubmit = 5 //5,提交
|
||||
|
||||
// ai脱衣
|
||||
// Processing = 1 // 1、进行中
|
||||
// SUCCESS = 2 // 2、成功
|
||||
// FAILURE = 3 // 3、失败
|
||||
// REFUND = 4 // 4、退款
|
||||
// PartSuccess = 5 // 5、部分成功
|
||||
// SubmitOrder = 6 // 6、已提交
|
||||
|
||||
/// 脱衣 / 视频换脸 / 图片换脸 三类记录,版式都是「(模版 +) 素材 = 结果」
|
||||
class AIImageRecord extends StatelessWidget {
|
||||
final AiRecordModel model;
|
||||
final AiType aiType;
|
||||
final VoidCallback? onDeleteCallback;
|
||||
|
||||
const AIImageRecord(this.model,
|
||||
{super.key, this.aiType = AiType.autoStrip, this.onDeleteCallback});
|
||||
|
||||
bool get isVideoFace => aiType.isVideoFace;
|
||||
|
||||
bool get isImageFace => aiType == AiType.imageChangeFace;
|
||||
|
||||
//图片/视频换脸才有模版
|
||||
bool get hasTemplate => isVideoFace || isImageFace;
|
||||
|
||||
/// 各业务的 status 编码不同(见文件顶部注释),统一映射成三态,没列到的一律按生成中处理
|
||||
AIRecordState get _state {
|
||||
if (isVideoFace) {
|
||||
return switch (model.status) {
|
||||
1 => AIRecordState.success,
|
||||
-1 => AIRecordState.failed,
|
||||
_ => AIRecordState.queuing,
|
||||
};
|
||||
}
|
||||
if (isImageFace) {
|
||||
return switch (model.status) {
|
||||
2 => AIRecordState.success,
|
||||
3 || 4 => AIRecordState.failed,
|
||||
_ => AIRecordState.queuing,
|
||||
};
|
||||
}
|
||||
//脱衣,以及其余走这套 UI 的类型
|
||||
return switch (model.status) {
|
||||
2 => AIRecordState.success,
|
||||
3 || 4 || 5 => AIRecordState.failed,
|
||||
_ => AIRecordState.queuing,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _itemClickHandler,
|
||||
child: AIRecordCard(
|
||||
state: _state,
|
||||
time: model.createdAt,
|
||||
remark: model.remark,
|
||||
onDownload: _saveHandler,
|
||||
onDelete: () => onDeleteCallback?.call(),
|
||||
child: _buildItemCell(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
//失败态的「失败原因」弹窗由 AIRecordCard 统一处理,这里只管成功态
|
||||
void _itemClickHandler() {
|
||||
if (_state != AIRecordState.success) return;
|
||||
if (isVideoFace) {
|
||||
Get.to(SimpleVideoPlayerPage(videoUrl: model.realVideoUrl, title: ''));
|
||||
return;
|
||||
}
|
||||
final images = model.newPic ?? [];
|
||||
if (images.isEmpty) return;
|
||||
ImageBrowserPage.open([images.first], showSaveButton: true);
|
||||
}
|
||||
|
||||
//素材图:视频换脸取 picture、图片换脸只有 originPic、其余取 originPics
|
||||
String get _sourceCover {
|
||||
if (isVideoFace) return model.picture?.firstOrNull ?? model.originPic ?? '';
|
||||
if (isImageFace) return model.originPic ?? '';
|
||||
return model.originPics?.firstOrNull ?? model.originPic ?? '';
|
||||
}
|
||||
|
||||
//生成中/失败时结果位回退展示素材封面,成功才是真正结果图
|
||||
String get _resultCover {
|
||||
if (_state == AIRecordState.success) {
|
||||
//视频换脸有的成功订单后端不回 cover,退回素材封面,别让结果位空着露占位图
|
||||
if (isVideoFace)
|
||||
return model.cover?.isNotEmpty == true ? model.cover! : _sourceCover;
|
||||
return model.newPic?.firstOrNull ?? '';
|
||||
}
|
||||
if (isVideoFace) return model.picture?.firstOrNull ?? '';
|
||||
return model.originPics?.firstOrNull ?? model.originPic ?? '';
|
||||
}
|
||||
|
||||
Future<void> _saveHandler() async {
|
||||
if (!hasTemplate) {
|
||||
final images = model.newPic ?? [];
|
||||
if (images.isEmpty) return;
|
||||
ImageBrowserPage.open([images.first], showSaveButton: true);
|
||||
return;
|
||||
}
|
||||
final isCached = await VideoCacheStore.instance
|
||||
.isExistLoadVideoByUrl(MediaStyle.Video, model.url ?? '');
|
||||
if (isCached) {
|
||||
showToast('你已经添加过缓存了');
|
||||
return;
|
||||
}
|
||||
if (!await _checkPermission()) return;
|
||||
final result =
|
||||
await VideoDownloadManager.instance.download(url: model.url ?? '');
|
||||
if (result != null) return;
|
||||
final viewModel = VideoModel()
|
||||
..sourceURL = model.url
|
||||
..title = 'AI换脸视频'
|
||||
..coverThumb = model.newPic?.firstOrNull
|
||||
..cover = model.newPic?.firstOrNull
|
||||
..commentCount = 0
|
||||
..id = '-1';
|
||||
showToast('已加入缓存');
|
||||
await VideoCacheStore.instance.saveVideoInfo(MediaStyle.Video, viewModel);
|
||||
}
|
||||
|
||||
Future<bool> _checkPermission() async {
|
||||
var status = await Permission.storage.status;
|
||||
if (!status.isGranted) status = await Permission.storage.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
//视频换脸的结果位单独接播放器,链接由 VideoModel 拼(和 AiRecordModel.realVideoUrl 的 query 不一样)
|
||||
void _playResultVideo() {
|
||||
final videoModel = VideoModel()..sourceURL = model.url;
|
||||
Get.to(SimpleVideoPlayerPage(
|
||||
videoUrl: videoModel.realVideoUrl, title: 'AI生成视频'));
|
||||
}
|
||||
|
||||
//三张图:模版 + 素材 = 结果
|
||||
Widget _buildItemCell() {
|
||||
return SizedBox(
|
||||
height: 90,
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasTemplate) ...[
|
||||
AIRecordThumb(
|
||||
imageUrl: isImageFace ? model.modPic : model.modCover,
|
||||
badge: const AISourceBadge(),
|
||||
width: 90.w,
|
||||
),
|
||||
const AIRecordJoin(gap: 3.5),
|
||||
],
|
||||
AIRecordThumb(
|
||||
imageUrl: _sourceCover,
|
||||
badge: const AISourceBadge(isTemplate: false),
|
||||
width: 90.w),
|
||||
const AIRecordJoin(isEquals: true, gap: 3.5),
|
||||
AIResultThumb(
|
||||
imageUrl: _resultCover,
|
||||
state: _state,
|
||||
onTap: isVideoFace ? _playResultVideo : null,
|
||||
width: 90.w,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../models/ai_change_face_video_model.dart';
|
||||
|
||||
/// 换脸模版选择项:封面 + 右上角选中标 + 标题
|
||||
class AIModCell extends StatelessWidget {
|
||||
final AiChangeFaceVideoMod mod;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const AIModCell(
|
||||
{super.key, required this.mod, this.isSelected = false, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
NetworkImageLoader(imageUrl: mod.cover ?? ''),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: Image.asset(
|
||||
isSelected
|
||||
? 'radio_sel.png'.commonImgPath
|
||||
: 'ai_draw_unselected.png'.aiPath,
|
||||
width: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
mod.title ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .8),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
import '../ai_novel/ai_novel_detail_page.dart';
|
||||
import '../models/ai_record_model.dart';
|
||||
import 'ai_record_parts.dart';
|
||||
|
||||
/// AI 小说记录:内容是提示词摘要,成功后点进详情看正文
|
||||
class AINovelRecord extends StatelessWidget {
|
||||
final AiRecordModel model;
|
||||
final VoidCallback? onDeleteCallback;
|
||||
|
||||
const AINovelRecord(this.model, {super.key, this.onDeleteCallback});
|
||||
|
||||
/// 小说接口的 status → 三态,没列到的一律按生成中处理
|
||||
AIRecordState get _state => switch (model.status) {
|
||||
3 => AIRecordState.success,
|
||||
-1 || 4 || 5 => AIRecordState.failed,
|
||||
_ => AIRecordState.queuing,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AIRecordCard(
|
||||
state: _state,
|
||||
time: model.createdAt,
|
||||
remark: model.remark,
|
||||
onDelete: () => onDeleteCallback?.call(),
|
||||
child: _buildItemCell(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemCell() {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
//只有成功才拦截点击,失败要放行给卡片弹失败原因
|
||||
onTap: _state == AIRecordState.success ? () => Get.to(() => AiNovelDetailPage(model)) : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 11),
|
||||
child: Text(
|
||||
'人物设定:${model.characterSetting}\n地点场景:${model.locationScene}\n故事情节:${model.description}\n细节说明:${model.details}',
|
||||
style: textStyle(12, const Color(0xff999999), FontWeight.w400),
|
||||
maxLines: 18,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
|
||||
|
||||
import '../models/ai_record_model.dart';
|
||||
import 'ai_record_parts.dart';
|
||||
|
||||
/// 文生图(ai绘画) 单条记录(提示词 → 结果图),列表/分页/删除由 AIRecordLogic 统一管理
|
||||
class AIPaintRecord extends StatelessWidget {
|
||||
final AiRecordModel model;
|
||||
final int status; // 1 排队 2 成功 3 失败
|
||||
final VoidCallback? onDeleteCallback;
|
||||
|
||||
const AIPaintRecord(this.model,
|
||||
{super.key, required this.status, this.onDeleteCallback});
|
||||
|
||||
AIRecordState get _state => switch (status) {
|
||||
2 => AIRecordState.success,
|
||||
3 => AIRecordState.failed,
|
||||
_ => AIRecordState.queuing,
|
||||
};
|
||||
|
||||
/// 排队/失败没有结果图,退回模版封面占位。
|
||||
/// 后端在这个接口里放模版封面的字段不固定(styleUrl 常为空),挨个兜一遍,别露占位图
|
||||
String get _templateCover =>
|
||||
[model.styleUrl, model.cover, model.modCover, model.modPic]
|
||||
.firstWhere((e) => e?.isNotEmpty == true, orElse: () => '') ??
|
||||
'';
|
||||
|
||||
//只有成功才是真结果图;未知 status 按排队处理,与 _state 的口径保持一致
|
||||
String get _resultCover =>
|
||||
status == 2 ? (model.newImgUrl ?? '') : _templateCover;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AIRecordCard(
|
||||
state: _state,
|
||||
time: model.createdAt,
|
||||
remark: model.remark,
|
||||
onDownload: _showNewPicture,
|
||||
onStatusTap: _showNewPicture,
|
||||
onDelete: () => onDeleteCallback?.call(),
|
||||
child: _buildItemCell(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNewPicture() {
|
||||
final url = model.newImgUrl ?? '';
|
||||
if (url.isEmpty) {
|
||||
showToast('图片地址为空');
|
||||
return;
|
||||
}
|
||||
ImageBrowserPage.open([url], showSaveButton: true);
|
||||
}
|
||||
|
||||
Widget _buildItemCell() {
|
||||
return SizedBox(
|
||||
height: 90,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 181,
|
||||
height: 58,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
model.text ?? '',
|
||||
style: const TextStyle(color: Color(0xff999999), fontSize: 12),
|
||||
),
|
||||
),
|
||||
const AIRecordJoin(isEquals: true),
|
||||
AIResultThumb(
|
||||
imageUrl: _resultCover,
|
||||
state: _state,
|
||||
onTap: _resultCover.isEmpty
|
||||
? null
|
||||
: () =>
|
||||
ImageBrowserPage.open([_resultCover], showSaveButton: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/date_time_util.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_alert.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
|
||||
/// AI 生成记录卡片的公共小件
|
||||
|
||||
/// 记录状态。接口的 status 各业务口径不同,由各 item 自己映射进来
|
||||
enum AIRecordState { queuing, success, failed }
|
||||
|
||||
/// 失败原因:接口没给就退回通用文案。底部文案和失败弹窗共用,改一处即可
|
||||
String _failedText(String? remark) =>
|
||||
remark?.isNotEmpty == true ? remark! : '生成失败';
|
||||
|
||||
/// 记录卡:白 5% 底 + 12 圆角,版式固定为
|
||||
/// 创建时间/删除 → 内容 → 细线 → 状态胶囊/下载或失败原因。
|
||||
/// 四类记录(脱衣换脸 / 小说 / 图生视频 / 文生图)只有中间的 [child] 不一样,
|
||||
/// 头尾要调版式只改这里一处
|
||||
class AIRecordCard extends StatelessWidget {
|
||||
final AIRecordState state;
|
||||
final Widget child;
|
||||
final String? time;
|
||||
final String? remark;
|
||||
|
||||
/// 成功时右下角的「一键下载」,不传就不显示(小说记录没有下载)
|
||||
final VoidCallback? onDownload;
|
||||
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
/// 成功态状态胶囊的点击,不传胶囊就不可点
|
||||
final VoidCallback? onStatusTap;
|
||||
|
||||
const AIRecordCard({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.child,
|
||||
this.time,
|
||||
this.remark,
|
||||
this.onDownload,
|
||||
this.onDelete,
|
||||
this.onStatusTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 18),
|
||||
child: Column(
|
||||
children: [
|
||||
AIRecordHeader(time: time, onTap: onDelete),
|
||||
12.sizeBoxH,
|
||||
child,
|
||||
12.sizeBoxH,
|
||||
0.5.line,
|
||||
12.sizeBoxH,
|
||||
AIRecordFooter(
|
||||
state: state,
|
||||
remark: remark,
|
||||
onDownload: onDownload,
|
||||
onStatusTap: onStatusTap),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (state != AIRecordState.failed) return card;
|
||||
//失败原因底部只放得下两行,点卡片看全文。四类记录都走这里,别再各自实现
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque, //卡片内的空白也要能点
|
||||
onTap: () => CommonAlert.show(
|
||||
title: '失败原因', content: _failedText(remark), showCancel: false),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片头部:左边创建时间,右边删除图标
|
||||
class AIRecordHeader extends StatelessWidget {
|
||||
final String? time;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const AIRecordHeader({super.key, this.time, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'创建时间:${DateTimeUtil.utc2iso(time)}',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xffDCDCDC)),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onTap,
|
||||
child: Image.asset('ai_record_delete.png'.aiPath, height: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片底部:左边状态胶囊,右边成功给下载按钮、失败给原因
|
||||
class AIRecordFooter extends StatelessWidget {
|
||||
final AIRecordState state;
|
||||
final String? remark;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onStatusTap;
|
||||
|
||||
const AIRecordFooter(
|
||||
{super.key,
|
||||
required this.state,
|
||||
this.remark,
|
||||
this.onDownload,
|
||||
this.onStatusTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 26), //三态高度一致
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
AIStatusPill(state,
|
||||
onTap: state == AIRecordState.success ? onStatusTap : null),
|
||||
if (state == AIRecordState.success && onDownload != null)
|
||||
AIDownloadBtn(onTap: onDownload)
|
||||
else if (state == AIRecordState.failed)
|
||||
Flexible(
|
||||
child: Text(
|
||||
_failedText(remark),
|
||||
style: const TextStyle(color: Color(0xffF52C56), fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态胶囊:生成中 / 已完成(黄底)、生成失败(无底)
|
||||
class AIStatusPill extends StatelessWidget {
|
||||
final AIRecordState state;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const AIStatusPill(this.state, {super.key, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isFailed = state == AIRecordState.failed;
|
||||
final text = switch (state) {
|
||||
AIRecordState.queuing => '生成中',
|
||||
AIRecordState.success => '已完成',
|
||||
AIRecordState.failed => '生成失败',
|
||||
};
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: isFailed
|
||||
? null
|
||||
: BoxDecoration(
|
||||
color: const Color(0x1AFFDB9E),
|
||||
borderRadius: BorderRadius.circular(3)),
|
||||
child: Text(
|
||||
text,
|
||||
style: textStyle(
|
||||
13,
|
||||
isFailed
|
||||
? Colors.white.withValues(alpha: .55)
|
||||
: const Color(0xffFFDB9E),
|
||||
FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「一键下载」按钮
|
||||
class AIDownloadBtn extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const AIDownloadBtn({super.key, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 0.5, color: Colors.white.withValues(alpha: .15)),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset('icon_ai_download.png'.aiPath, width: 18),
|
||||
2.5.sizeBoxW,
|
||||
const Text('一键下载',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录里的缩略图,[badge] 盖在左上角。
|
||||
/// [width] 三图并排的卡要传 90.w 按屏宽缩,窄屏才放得下
|
||||
class AIRecordThumb extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final Widget? badge;
|
||||
final VoidCallback? onTap;
|
||||
final double width;
|
||||
|
||||
const AIRecordThumb(
|
||||
{super.key, this.imageUrl, this.badge, this.onTap, this.width = 90});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: 90,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xff262626),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
height: 90,
|
||||
borderRadius: 8),
|
||||
),
|
||||
if (badge != null) badge!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 结果图:没出图先压一层黑蒙层,失败再盖个叉,成功才可点
|
||||
class AIResultThumb extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final AIRecordState state;
|
||||
final VoidCallback? onTap;
|
||||
final double width;
|
||||
|
||||
const AIResultThumb(
|
||||
{super.key,
|
||||
this.imageUrl,
|
||||
required this.state,
|
||||
this.onTap,
|
||||
this.width = 90});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
AIRecordThumb(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
onTap: state == AIRecordState.success ? onTap : null,
|
||||
),
|
||||
if (state != AIRecordState.success)
|
||||
Container(
|
||||
width: width,
|
||||
height: 90,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
if (state == AIRecordState.failed)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child:
|
||||
Image.asset('ai_record_failed.webp'.aiPath, width: 20))),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 缩略图左上角角标:模版(黄) / 素材(青),左上右下切角
|
||||
class AISourceBadge extends StatelessWidget {
|
||||
final bool isTemplate;
|
||||
|
||||
const AISourceBadge({super.key, this.isTemplate = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isTemplate ? const Color(0xffFFD460) : const Color(0xff03FCEB),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isTemplate ? '模版' : '素材',
|
||||
style: textStyle(12, const Color(0xff141414), FontWeight.w400),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 两张图之间的连接符:+ 或 =
|
||||
class AIRecordJoin extends StatelessWidget {
|
||||
final bool isEquals;
|
||||
final double gap;
|
||||
|
||||
const AIRecordJoin({super.key, this.isEquals = false, this.gap = 3});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: gap),
|
||||
child: Image.asset(
|
||||
(isEquals ? 'ai_record_equals.png' : 'ai_record_add.webp').aiPath,
|
||||
width: 18,
|
||||
height: 18,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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/video/simple_video_player_page.dart';
|
||||
import 'package:hgdj/hj_utils/date_time_util.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/header_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
import '../ai_sub_type/ai_function_logic.dart';
|
||||
import '../models/ai_square_model.dart';
|
||||
|
||||
/// AI 广场单条内容:作者信息 + 标题 + 按业务类型展示的图/视频
|
||||
class AISquareCell extends StatelessWidget {
|
||||
final AISquareItemModel model;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const AISquareCell({super.key, required this.model, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_headerView(),
|
||||
12.sizeBoxH,
|
||||
_contentView(),
|
||||
12.sizeBoxH,
|
||||
_imgView(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imgView() {
|
||||
return switch (AiTypeCode.fromServerCode(model.type)) {
|
||||
AiType.imageChangeFace ||
|
||||
AiType.autoStrip =>
|
||||
_twoImgsView(model.originalImage, model.generateImage),
|
||||
AiType.videoChangeFace => _videoChangeFace(),
|
||||
AiType.imageToVideo => _imgToVideo(),
|
||||
AiType.aiPaint => _aiDraw(),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _twoImgsView(String? left, String? right) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () =>
|
||||
ImageBrowserPage.open([left ?? ''], showSaveButton: true),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: left,
|
||||
imgBorderRadius:
|
||||
const BorderRadius.horizontal(left: Radius.circular(9)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () =>
|
||||
ImageBrowserPage.open([right ?? ''], showSaveButton: true),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: right,
|
||||
imgBorderRadius:
|
||||
const BorderRadius.horizontal(right: Radius.circular(9)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _aiDraw() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'“${model.originContent ?? ''}”',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xff03FCEB)),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => ImageBrowserPage.open([model.generateImage ?? ''],
|
||||
showSaveButton: true),
|
||||
child: NetworkImageLoader(imageUrl: model.generateImage),
|
||||
),
|
||||
),
|
||||
10.sizeBoxW,
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imgToVideo() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: _imgItem(model.originalImage)),
|
||||
10.sizeBoxW,
|
||||
Expanded(child: _imgItem(model.generateImage)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoChangeFace() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _videoItem(
|
||||
model.generateVideoCover, model.realGenerateVideoUrl)),
|
||||
10.sizeBoxW,
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _contentView() {
|
||||
return Text.rich(TextSpan(children: [
|
||||
if ((model.sortCode ?? 0) > 0)
|
||||
WidgetSpan(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
width: 30,
|
||||
height: 15,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xffFF2264), Color(0x1AFF2264)],
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'置顶',
|
||||
style: textStyle(10, Colors.white, FontWeight.w500),
|
||||
),
|
||||
)),
|
||||
TextSpan(
|
||||
text: model.title,
|
||||
style: textStyle(
|
||||
14, Colors.white.withValues(alpha: .9), FontWeight.w500))
|
||||
]));
|
||||
}
|
||||
|
||||
Widget _headerView() {
|
||||
return Row(
|
||||
children: [
|
||||
HeaderWidget(
|
||||
headPath: model.portrait ?? '',
|
||||
level: 0,
|
||||
headWidth: 48,
|
||||
headHeight: 48,
|
||||
isCircle: false,
|
||||
radius: 24,
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
model.name ?? '',
|
||||
style: textStyle(14, Colors.white, FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
model.typeString,
|
||||
style: textStyle(12, AppColors.actionRed, FontWeight.w400),
|
||||
),
|
||||
10.sizeBoxW,
|
||||
Text(
|
||||
DateTimeUtil.utcTurnYear(model.reviewAt),
|
||||
style: textStyle(12, Colors.white.withValues(alpha: 0.55),
|
||||
FontWeight.w400),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)),
|
||||
12.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 70,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: AppColors.actionRed),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset('make_the_same.png'.aiPath, width: 14, height: 14),
|
||||
2.sizeBoxW,
|
||||
Text(
|
||||
'制作同款',
|
||||
style: textStyle(10, Colors.white, FontWeight.w400),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imgItem(String? img) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => ImageBrowserPage.open([img ?? ''], showSaveButton: true),
|
||||
child: NetworkImageLoader(imageUrl: img, borderRadius: 9),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoItem(String? img, String url) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(
|
||||
() => SimpleVideoPlayerPage(videoUrl: url, title: model.title ?? '')),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
NetworkImageLoader(imageUrl: img, borderRadius: 9),
|
||||
Image.asset('paly_btn.png'.aiPath, width: 36, height: 36),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
|
||||
|
||||
import '../models/ai_record_model.dart';
|
||||
import 'ai_record_parts.dart';
|
||||
|
||||
/// 图生视频 单条记录(原图 → 结果图),列表/分页/删除由 AIRecordLogic 统一管理
|
||||
class AIVideoRecord extends StatelessWidget {
|
||||
final AiRecordModel model;
|
||||
final int status; // 1 排队 2 成功 3 失败
|
||||
final VoidCallback? onDeleteCallback;
|
||||
|
||||
const AIVideoRecord(this.model,
|
||||
{super.key, required this.status, this.onDeleteCallback});
|
||||
|
||||
AIRecordState get _state => switch (status) {
|
||||
2 => AIRecordState.success,
|
||||
3 => AIRecordState.failed,
|
||||
_ => AIRecordState.queuing,
|
||||
};
|
||||
|
||||
String get _resultCover =>
|
||||
model.status == 3 ? (model.newImgUrl ?? '') : (model.imgUrl ?? '');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AIRecordCard(
|
||||
state: _state,
|
||||
time: model.createdAt,
|
||||
remark: model.remark,
|
||||
onDownload: _showNewPicture,
|
||||
onStatusTap: _showNewPicture,
|
||||
onDelete: () => onDeleteCallback?.call(),
|
||||
child: _buildItemCell(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNewPicture() {
|
||||
final url = model.newImgUrl ?? '';
|
||||
if (url.isEmpty) {
|
||||
showToast('图片地址为空');
|
||||
return;
|
||||
}
|
||||
ImageBrowserPage.open([url], showSaveButton: true);
|
||||
}
|
||||
|
||||
Widget _buildItemCell() {
|
||||
return SizedBox(
|
||||
height: 90,
|
||||
child: Row(
|
||||
children: [
|
||||
AIRecordThumb(
|
||||
imageUrl: model.imgUrl,
|
||||
badge:
|
||||
Image.asset('ai_record_org.png'.aiPath, width: 36, height: 18),
|
||||
),
|
||||
const AIRecordJoin(isEquals: true),
|
||||
AIResultThumb(
|
||||
imageUrl: _resultCover,
|
||||
state: _state,
|
||||
onTap: _resultCover.isEmpty
|
||||
? null
|
||||
: () =>
|
||||
ImageBrowserPage.open([_resultCover], showSaveButton: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:image_pickers/image_pickers.dart';
|
||||
import 'package:mobkit_dashed_border/mobkit_dashed_border.dart';
|
||||
|
||||
/// AI 功能的单图选择器:未选时是虚线占位框,选后是缩略图 + 右上角删除
|
||||
class PicPicker extends StatefulWidget {
|
||||
/// 与 logic 共享同一份本地图片路径,选/删直接写回
|
||||
final List<String> picList;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
const PicPicker({
|
||||
super.key,
|
||||
required this.picList,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PicPicker> createState() => _PicPickerState();
|
||||
}
|
||||
|
||||
class _PicPickerState extends State<PicPicker> {
|
||||
List<String> get picList => widget.picList;
|
||||
|
||||
void _delPic() {
|
||||
if (picList.isNotEmpty) picList.removeAt(0);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _addPic() async {
|
||||
final paths = await _pickImg();
|
||||
if (paths.isEmpty) {
|
||||
showToast("请选择图片");
|
||||
return;
|
||||
}
|
||||
picList
|
||||
..clear()
|
||||
..addAll(paths);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
//相册选图,只取 1 张;不做二次压缩,交给插件的 compressSize
|
||||
Future<List<String>> _pickImg() async {
|
||||
final medias = await ImagePickers.pickerPaths(
|
||||
uiConfig: UIConfig(uiThemeColor: AppColors.primaryColor),
|
||||
galleryMode: GalleryMode.image,
|
||||
selectCount: 1,
|
||||
showCamera: true,
|
||||
);
|
||||
final paths =
|
||||
medias.map((e) => e.path ?? "").where((e) => e.isNotEmpty).toList();
|
||||
if (paths.length < medias.length) showToast("添加图片失败");
|
||||
return paths;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: picList.isEmpty ? _addBtn() : _picItem(),
|
||||
);
|
||||
}
|
||||
|
||||
// 已选图:点图预览,点右上角叉删除
|
||||
Widget _picItem() {
|
||||
return GestureDetector(
|
||||
onTap: () => ImagePickers.previewImages(picList, 0),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(picList.first),
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _delPic,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Image.asset("close_grey.png".commonImgPath,
|
||||
width: 14, height: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 未选图:虚线占位框
|
||||
Widget _addBtn() {
|
||||
return GestureDetector(
|
||||
onTap: _addPic,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x0DFFFFFF),
|
||||
border: DashedBorder.fromBorderSide(
|
||||
dashLength: 2,
|
||||
side: const BorderSide(color: Color(0xff656565)),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('add_grey.png'.communityPath, width: 24),
|
||||
10.sizeBoxH,
|
||||
const Text("添加图片",
|
||||
style: TextStyle(fontSize: 12, color: Color(0xff999999))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
|
||||
//acg video item组件
|
||||
class AcgItemWidget extends StatelessWidget {
|
||||
final CartoonMediaInfo? info;
|
||||
final bool coverV; //竖屏封面
|
||||
final bool? heroUI; //是否需要hero动画
|
||||
final bool? showSymbol; //是否显示角标
|
||||
final Function()? tapCallback;
|
||||
|
||||
const AcgItemWidget({
|
||||
super.key,
|
||||
this.info,
|
||||
this.coverV = true,
|
||||
this.tapCallback,
|
||||
this.heroUI = false,
|
||||
this.showSymbol = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: tapCallback ?? () => pushToCartoonPage(info),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return NetworkImageLoader(
|
||||
imageUrl: coverV ? info?.coverV ?? '' : info?.coverH ?? '',
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
right: 6,
|
||||
top: 6,
|
||||
child: _buildLevelIcon(),
|
||||
),
|
||||
if (info?.isAudiobooks == true)
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: Image.asset('voice_noval_icon.png'.videoPath),
|
||||
)
|
||||
],
|
||||
)),
|
||||
6.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
info?.title ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${info?.episodeNumberStatus ?? ''} · ${info?.updateDesc}',
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _statusColor,
|
||||
fontWeight: FontWeight.w400),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelIcon() {
|
||||
if (info?.permission == 1 && Config.coinMark)
|
||||
return _levelTag('金币', const Color(0xff8B3E00));
|
||||
if (info?.permission == 0 && Config.vipMark)
|
||||
return _levelTag('VIP', const Color(0xff141414));
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 金币/VIP 角标:渐变底 + 文字,仅文字与文字色不同
|
||||
Widget _levelTag(String text, Color textColor) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(2)),
|
||||
gradient:
|
||||
LinearGradient(colors: [Color(0xffFFE580), Color(0xffFACC15)]),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: textColor, fontSize: 10, height: 1.6),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color get _statusColor {
|
||||
return info?.updateStatus == 1
|
||||
? const Color(0xffEEC76B)
|
||||
: Colors.white.withValues(alpha: .45);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/acg/comic_chapters_model.dart';
|
||||
import 'package:hgdj/hj_page/live/live_widget.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
|
||||
|
||||
import '../../assets_tool/app_colors.dart';
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
import 'widget/free_badge.dart';
|
||||
|
||||
//有声小说 选择子集
|
||||
class AudioPlayerBottomSheet extends StatefulWidget {
|
||||
final String title;
|
||||
final List<ComicChapterInfo> allAudiobooks;
|
||||
final Function(int)? indexHandler;
|
||||
final int? currentPlayIndex;
|
||||
final String? unit; //子集单位(章/集...),与详情子集 title 格式统一
|
||||
final bool hasPermission; //整本是否已解锁:false 时前 N 集显示「免费」角标
|
||||
|
||||
AudioPlayerBottomSheet(
|
||||
this.title, {
|
||||
super.key,
|
||||
required this.allAudiobooks,
|
||||
this.indexHandler,
|
||||
this.currentPlayIndex,
|
||||
this.unit,
|
||||
this.hasPermission = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioPlayerBottomSheet> createState() => _AudioPlayerBottomSheetState();
|
||||
}
|
||||
|
||||
class _AudioPlayerBottomSheetState extends State<AudioPlayerBottomSheet> {
|
||||
int sortType = 1; //0-降序,1-升序
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
constraints: BoxConstraints(maxHeight: Get.height / 2),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Center(child: SheetHandleBar(color: Color(0xff989898))),
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => setState(() => sortType = sortType == 1 ? 0 : 1),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: sortType == 1 ? 0 : 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Image.asset('text_play_sort.png'.acgImgPath,
|
||||
width: 18),
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
sortType == 1 ? '正序' : '倒序',
|
||||
style: textStyle(12, Colors.white.withValues(alpha: .45),
|
||||
FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
key: ValueKey(sortType), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 0.5,
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
),
|
||||
itemCount: widget.allAudiobooks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final realIndex = getRealIndex(index);
|
||||
final model = widget.allAudiobooks[realIndex];
|
||||
final select = widget.currentPlayIndex == realIndex;
|
||||
return StaggerInItem(
|
||||
index: index,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
Get.back(result: realIndex);
|
||||
widget.indexHandler?.call(realIndex);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
16.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
if (select) ...[
|
||||
AudioWaveView(),
|
||||
8.sizeBoxW,
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
//与详情/抽屉子集 title 格式一致:第X章
|
||||
'第${model.episodeNumber ?? 1}${widget.unit ?? ''}',
|
||||
style: TextStyle(
|
||||
color: select
|
||||
? AppColors.actionRed
|
||||
: Colors.white
|
||||
.withValues(alpha: .9),
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
//无任何权限时,前 N 集免费展示「免费」角标
|
||||
if (widget.hasPermission == false &&
|
||||
model.inFreeEpisode) ...[
|
||||
8.sizeBoxW,
|
||||
const FreeBadge(),
|
||||
],
|
||||
8.sizeBoxW,
|
||||
const Icon(
|
||||
Icons.navigate_next,
|
||||
size: 18,
|
||||
color: Color(0xffDCDCDC),
|
||||
)
|
||||
],
|
||||
),
|
||||
16.sizeBoxH,
|
||||
],
|
||||
)),
|
||||
));
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int getRealIndex(int index) {
|
||||
return (sortType == 1) ? index : widget.allAudiobooks.length - index - 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.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/hj_utils/date_time_util.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import 'audio_player_bottomsheet.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
// 播放页面
|
||||
class AudioPlayerContent extends StatefulWidget {
|
||||
final int index;
|
||||
final AudioPlayerManager manager;
|
||||
final ACGSourceManager sourceManager;
|
||||
const AudioPlayerContent(this.sourceManager,
|
||||
{super.key, required this.manager, this.index = 0});
|
||||
|
||||
@override
|
||||
State<AudioPlayerContent> createState() => _AudioPlayerContentState();
|
||||
}
|
||||
|
||||
class _AudioPlayerContentState extends State<AudioPlayerContent> {
|
||||
AudioPlayerManager get manager => widget.manager;
|
||||
AudioPlayMod playMod = AudioPlayMod.loopList;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// manager 的 initPlayer / dispose 由 VoiceNovelReadLogic 统管:
|
||||
// - onInit 已 initPlayer
|
||||
// - onClose 已 dispose
|
||||
// 此处不要再调 initPlayer,否则 4 个 stream listener 重复注册导致旧 sub 泄漏
|
||||
manager
|
||||
.play(widget.sourceManager.allEpisodes[widget.index].getRealAudioUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: manager.audioValue,
|
||||
builder: (ctx, audioValue, child) {
|
||||
double max = (audioValue.duration?.inSeconds ?? 1).toDouble();
|
||||
if (max == 0) max = 1;
|
||||
final position = (audioValue.position?.inSeconds ?? 0).toDouble();
|
||||
final progress = position / max;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
12.sizeBoxH,
|
||||
NetworkImageLoader(
|
||||
imageUrl: widget.sourceManager.mediaInfo?.coverH ?? '',
|
||||
width: 111,
|
||||
height: 148,
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'正在播放:第${(audioValue.onPlayIndex ?? 0) + 1}集',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
_progressRow(audioValue, progress, max),
|
||||
14.sizeBoxH,
|
||||
_controlRow(audioValue),
|
||||
18.sizeBoxH,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 进度条行:左右 ±10s 快进退、中间滑块、两侧时间
|
||||
Widget _progressRow(
|
||||
AudioPlayerValue audioValue, double progress, double max) {
|
||||
return Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => manager.seekTenMinsWithDirection(-1),
|
||||
child: Image.asset('noval_time_back.png'.acgImgPath, width: 24),
|
||||
),
|
||||
8.sizeBoxH,
|
||||
// 38px:原 35px 部分机型会裁掉 "MM:SS" 末位
|
||||
SizedBox(
|
||||
width: 38,
|
||||
height: 19,
|
||||
child: Text(
|
||||
buildMMSS(audioValue.position?.inSeconds ?? 0),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
10.sizeBoxW,
|
||||
Expanded(
|
||||
child: AudioSlider(
|
||||
value: progress,
|
||||
onChange: (value) => manager.seek((max * value).toInt()), //进度
|
||||
),
|
||||
),
|
||||
10.sizeBoxW,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => manager.seekTenMinsWithDirection(1),
|
||||
child:
|
||||
Image.asset('noval_time_forword.png'.acgImgPath, width: 24),
|
||||
),
|
||||
8.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 19,
|
||||
width: 38,
|
||||
child: audioValue.duration == null
|
||||
? const CupertinoActivityIndicator(color: AppColors.actionRed)
|
||||
: Text(
|
||||
buildMMSS(audioValue.duration!.inSeconds),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 控制行:循环模式、上一集/播放暂停/下一集、播放列表
|
||||
Widget _controlRow(AudioPlayerValue audioValue) {
|
||||
final isLoop = playMod == AudioPlayMod.loop;
|
||||
final isPlaying = audioValue.isPlaying ?? false;
|
||||
return Row(
|
||||
children: [
|
||||
// 循环模式切换
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => setState(() {
|
||||
playMod = isLoop ? AudioPlayMod.loopList : AudioPlayMod.loop;
|
||||
manager.setPlayMod(playMod);
|
||||
}),
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, anim) =>
|
||||
ScaleTransition(scale: anim, child: child),
|
||||
child: Image.asset(
|
||||
isLoop
|
||||
? 'single_cycle.png'.acgImgPath
|
||||
: 'list_cycle.png'.acgImgPath,
|
||||
// key 必须按状态区分,AnimatedSwitcher 才会把它当成新 child 触发动画
|
||||
key: ValueKey(isLoop ? 'loop' : 'loop_list'),
|
||||
width: 24,
|
||||
),
|
||||
),
|
||||
2.sizeBoxH,
|
||||
Text(
|
||||
isLoop ? '单曲循环' : '多集循环',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => manager.playForward(),
|
||||
child: Image.asset('forward.png'.acgImgPath, width: 24),
|
||||
),
|
||||
40.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => isPlaying ? manager.pause() : manager.resume(),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, anim) =>
|
||||
ScaleTransition(scale: anim, child: child),
|
||||
child: Image.asset(
|
||||
isPlaying ? 'pause.png'.acgImgPath : 'play.png'.acgImgPath,
|
||||
key: ValueKey(isPlaying ? 'pause' : 'play'),
|
||||
width: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
40.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => manager.playNext(),
|
||||
child: Image.asset('next.png'.acgImgPath, width: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _showPlaylist,
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset('play_list.png'.acgImgPath, width: 24),
|
||||
2.sizeBoxH,
|
||||
Text(
|
||||
'播放列表',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 弹出播放列表,点击切集
|
||||
void _showPlaylist() {
|
||||
Get.bottomSheet(AudioPlayerBottomSheet(
|
||||
manager.sourceManger?.mediaInfo?.title ?? '',
|
||||
currentPlayIndex: widget.sourceManager.index,
|
||||
allAudiobooks: manager.sourceManger?.allEpisodes ?? [],
|
||||
indexHandler: (index) => manager.playWithIndex(index),
|
||||
unit: manager.sourceManger?.mediaInfo?.unit,
|
||||
hasPermission: manager.sourceManger?.mediaInfo?.hasPermission ?? false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放器进度条:内置 Material Slider,拖动/点击由框架处理;
|
||||
/// 轨道和 thumb 用自定义 shape 套 hgdj 的橙红配色。
|
||||
class AudioSlider extends StatelessWidget {
|
||||
final double? value;
|
||||
final Function(double value) onChange;
|
||||
|
||||
const AudioSlider({super.key, required this.value, required this.onChange});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 24,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
trackShape: GradientTrackShape(),
|
||||
thumbShape: const GradientThumbShape(radius: 8),
|
||||
),
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: (value ?? 0).clamp(0.0, 1.0),
|
||||
onChanged: onChange,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度轨道:左侧(已播放)填 actionRed,右侧(未播放)填灰色
|
||||
class GradientTrackShape extends SliderTrackShape {
|
||||
@override
|
||||
Rect getPreferredRect({
|
||||
bool isDiscrete = false,
|
||||
bool isEnabled = false,
|
||||
Offset offset = Offset.zero,
|
||||
required RenderBox parentBox,
|
||||
required SliderThemeData sliderTheme,
|
||||
}) {
|
||||
final trackHeight = sliderTheme.trackHeight ?? 2;
|
||||
final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
|
||||
return Rect.fromLTWH(
|
||||
offset.dx, trackTop, parentBox.size.width, trackHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(
|
||||
PaintingContext context,
|
||||
Offset offset, {
|
||||
required Animation<double> enableAnimation,
|
||||
bool isDiscrete = false,
|
||||
bool isEnabled = false,
|
||||
required RenderBox parentBox,
|
||||
Offset? secondaryOffset,
|
||||
required SliderThemeData sliderTheme,
|
||||
required TextDirection textDirection,
|
||||
required Offset thumbCenter,
|
||||
}) {
|
||||
final trackRect = getPreferredRect(
|
||||
parentBox: parentBox,
|
||||
sliderTheme: sliderTheme,
|
||||
offset: offset,
|
||||
);
|
||||
final activePaint = Paint()..color = AppColors.actionRed;
|
||||
final inactivePaint = Paint()..color = const Color(0xff3D3D3D);
|
||||
// 左侧已播放
|
||||
context.canvas.drawRect(
|
||||
Rect.fromLTRB(
|
||||
trackRect.left, trackRect.top, thumbCenter.dx, trackRect.bottom),
|
||||
activePaint,
|
||||
);
|
||||
// 右侧未播放
|
||||
context.canvas.drawRect(
|
||||
Rect.fromLTRB(
|
||||
thumbCenter.dx, trackRect.top, trackRect.right, trackRect.bottom),
|
||||
inactivePaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆形 thumb:外圈半透明 actionRed,内圈白
|
||||
class GradientThumbShape extends SliderComponentShape {
|
||||
final double radius;
|
||||
|
||||
const GradientThumbShape({this.radius = 8});
|
||||
|
||||
@override
|
||||
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
|
||||
Size.fromRadius(radius);
|
||||
|
||||
@override
|
||||
void paint(
|
||||
PaintingContext context,
|
||||
Offset center, {
|
||||
required Animation<double> activationAnimation,
|
||||
required Animation<double> enableAnimation,
|
||||
required bool isDiscrete,
|
||||
required TextPainter labelPainter,
|
||||
required RenderBox parentBox,
|
||||
required Size sizeWithOverflow,
|
||||
required SliderThemeData sliderTheme,
|
||||
required TextDirection textDirection,
|
||||
required double textScaleFactor,
|
||||
required double value,
|
||||
}) {
|
||||
final canvas = context.canvas;
|
||||
final outerPaint = Paint()
|
||||
..color = AppColors.actionRed.withValues(alpha: 0.7);
|
||||
final innerPaint = Paint()..color = Colors.white;
|
||||
canvas.drawCircle(center, radius, outerPaint);
|
||||
canvas.drawCircle(center, radius - 3, innerPaint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart';
|
||||
|
||||
import '../../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../../hj_model/cartoon_media_info.dart';
|
||||
import '../../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../hj_utils/history_util.dart';
|
||||
import '../../tools_base/event_bus/event_bus_util.dart';
|
||||
import 'cartoon_read_page.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
import 'widget/cartoon_subset_alert.dart';
|
||||
|
||||
class CartoonDetailLogic extends GetxController
|
||||
with GetTickerProviderStateMixin {
|
||||
String? id;
|
||||
CartoonMediaInfo? mediaInfo;
|
||||
|
||||
bool showHeader = true;
|
||||
double showOffset = 245;
|
||||
bool get isCartoonType => manager.mediaInfo?.mediaType == 'image'; //是否是动画
|
||||
|
||||
// 推荐 tab 固定 3 个(漫画/视频/动漫),顺序由页面按当前内容类型动态排
|
||||
late TabController tabCtr = TabController(length: 3, vsync: this);
|
||||
|
||||
late ScrollController scrollCtr = ScrollController()
|
||||
..addListener(() {
|
||||
//滚过阈值切换顶部沉浸 header,状态变了才刷新
|
||||
final show = scrollCtr.offset <= showOffset;
|
||||
if (show != showHeader) {
|
||||
showHeader = show;
|
||||
update(['header']);
|
||||
}
|
||||
});
|
||||
final ItemScrollController itemSCtrl = ItemScrollController();
|
||||
late ACGSourceManager manager;
|
||||
late StreamSubscription subscription;
|
||||
|
||||
CartoonDetailLogic({this.id})
|
||||
: manager = ACGSourceManager(id ?? ''); //初始化,加载子集
|
||||
|
||||
@override
|
||||
void onReady() async {
|
||||
super.onReady();
|
||||
// subscription 是 late,必须在任何 await 前初始化,
|
||||
// 否则详情加载途中退出会让 onClose 的 cancel() 打到未初始化字段而崩
|
||||
subscription = eventBus.on<ACGMenuChanged>((event) {
|
||||
//列表未 attach(未 build/布局完)时 jumpTo 内部会 ! 取空崩溃,加保护
|
||||
if (itemSCtrl.isAttached) {
|
||||
itemSCtrl.jumpTo(index: event.index ?? 0);
|
||||
}
|
||||
});
|
||||
// 先拿详情(含 freeEpisode),再拉子集,才能正确标记前 N 集免费
|
||||
await loadData();
|
||||
manager.fetchAllEpisodes();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
subscription.cancel();
|
||||
eventBus.off(subscription);
|
||||
tabCtr.dispose(); //修复泄漏
|
||||
scrollCtr.dispose(); //修复泄漏
|
||||
// manager 由页面的 ChangeNotifierProvider(create:) 在卸载时 dispose,本类不再重复释放
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
//获取媒体详情
|
||||
Future loadData() async {
|
||||
mediaInfo = await ACGService.getMediaInfo(id ?? '');
|
||||
if (mediaInfo != null) {
|
||||
HistoryUtil.insert(mediaInfo!, MediaStyle.Comics);
|
||||
manager.mediaInfo = mediaInfo;
|
||||
showOffset = mediaInfo?.mediaType == 'text' ? 300 : 320;
|
||||
} else {
|
||||
mediaInfo = CartoonMediaInfo();
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
//切换集数
|
||||
changeEpisode(int episode, bool jump) async {
|
||||
manager.changeEpisodesIndex(episode, jump: jump);
|
||||
play(); //播放
|
||||
}
|
||||
|
||||
play() async {
|
||||
//判断权限
|
||||
ComicChapterInfo? info =
|
||||
await manager.getEpisodeHasPermisson(manager.index);
|
||||
if (info == null) return;
|
||||
Get.to(() => CartoonReadPage(manager: manager..index = manager.index));
|
||||
}
|
||||
|
||||
//集数弹窗
|
||||
onMenuAction() async {
|
||||
int episode = await Get.bottomSheet(
|
||||
CartoonSubSetAlert(sourceManager: manager),
|
||||
barrierColor: Colors.black.withValues(alpha: .2),
|
||||
);
|
||||
if (episode > -1) {
|
||||
changeEpisode(episode, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.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/extension/extensions.dart';
|
||||
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
import 'package:hgdj/tools_base/widget/follow_button.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../hj_utils/const.dart';
|
||||
import '../../../hj_utils/screen.dart';
|
||||
import '../../../tools_base/loading/loading_center_widget.dart';
|
||||
import '../../alert/video/share_media_dialog.dart';
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
import '../home/tag/cartoon_tag_page.dart';
|
||||
import 'cartoon_detail_logic.dart';
|
||||
import 'cartoon_recommend_page.dart';
|
||||
import 'widget/acg_expand_text.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
import 'widget/free_badge.dart';
|
||||
|
||||
//acg详情页,小说和漫画 合并
|
||||
class CartoonDetailPage extends StatefulWidget {
|
||||
final String id;
|
||||
final String? heroTag;
|
||||
const CartoonDetailPage({super.key, this.id = '', this.heroTag});
|
||||
|
||||
@override
|
||||
State<CartoonDetailPage> createState() => _CartoonDetailPageState();
|
||||
}
|
||||
|
||||
// 详情页可经「推荐页 → 再开详情页」成环并存,用 UniqueTagMixin 给每个实例唯一 tag
|
||||
class _CartoonDetailPageState extends State<CartoonDetailPage>
|
||||
with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Color(0xff05000E),
|
||||
body: GetBuilder<CartoonDetailLogic>(
|
||||
tag: uniqueTag,
|
||||
init: CartoonDetailLogic(id: widget.id),
|
||||
builder: (logic) {
|
||||
// manager 作为 ChangeNotifier 供子树 Consumer 监听;
|
||||
// 用 create: 让 Provider 在页面卸载时自动 dispose manager(释放音频播放器+订阅),Flutter 层保证释放
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => logic.manager,
|
||||
child: Builder(builder: (_) {
|
||||
if (logic.mediaInfo == null) return LoadingCenterWidget();
|
||||
if (logic.mediaInfo?.id == null) {
|
||||
return CErrorWidget(retryOnTap: () => logic.loadData());
|
||||
}
|
||||
return _buildContent(logic);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 当前内容对应的推荐类型(小说等无对应 tab 时为 null)
|
||||
MediaStyle? _selfStyle(CartoonDetailLogic logic) =>
|
||||
switch (logic.mediaInfo?.mediaType) {
|
||||
'video' => MediaStyle.Cartoon,
|
||||
'image' => MediaStyle.Comics,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// 推荐 tab 排序:与当前内容同类的排最前,其余按默认顺序
|
||||
List<MediaStyle> _orderedStyles(CartoonDetailLogic logic) {
|
||||
const base = [MediaStyle.Comics, MediaStyle.Video, MediaStyle.Cartoon];
|
||||
final self = _selfStyle(logic);
|
||||
if (self == null) return base;
|
||||
return [self, ...base.where((s) => s != self)];
|
||||
}
|
||||
|
||||
String _recTitle(MediaStyle s) => switch (s) {
|
||||
MediaStyle.Video => '视频推荐',
|
||||
MediaStyle.Cartoon => '动漫推荐',
|
||||
_ => '漫画推荐',
|
||||
};
|
||||
|
||||
// 单个推荐 tab:同类才传 tagId(拉相似),视频 cell 用大图 2 列
|
||||
Widget _recPage(CartoonDetailLogic logic, MediaStyle s) {
|
||||
final isMatch = s == _selfStyle(logic);
|
||||
final isVideo = s == MediaStyle.Video;
|
||||
return CartoonRecommendPage(
|
||||
mediaStyle: s,
|
||||
mediaId: isMatch ? logic.mediaInfo?.tagDetails?.firstOrNull?.id : null,
|
||||
crossAxisCount: isVideo ? 2 : 3,
|
||||
childAspectRatio: isVideo ? 168 / 142 : 111 / 188,
|
||||
).keepAlive;
|
||||
}
|
||||
|
||||
Widget _buildContent(CartoonDetailLogic logic) {
|
||||
final styles = _orderedStyles(logic);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
ExtendedNestedScrollView(
|
||||
controller: logic.scrollCtr,
|
||||
onlyOneScrollInBody: true,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return [
|
||||
SliverToBoxAdapter(child: _buildImgHeader(logic)), //header
|
||||
SliverToBoxAdapter(child: _buildMenu(logic)), //目录
|
||||
SliverToBoxAdapter(
|
||||
child: _buildTabbar(logic, styles)) //推荐 tabbar
|
||||
];
|
||||
},
|
||||
//推荐
|
||||
body: TabBarView(
|
||||
controller: logic.tabCtr,
|
||||
children: styles.map((s) => _recPage(logic, s)).toList(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildTopHeader(logic),
|
||||
),
|
||||
],
|
||||
)),
|
||||
Padding(
|
||||
// 底部垫上虚拟导航栏高度,避免按钮被遮挡(edge-to-edge)
|
||||
padding: EdgeInsets.only(top: 10, bottom: 10 + screen.paddingBottom),
|
||||
child: Row(
|
||||
children: [
|
||||
16.sizeBoxW,
|
||||
if (logic.isCartoonType) ...[
|
||||
Consumer<ACGSourceManager>(
|
||||
builder: (context, manager, child) {
|
||||
bool liked =
|
||||
manager.mediaInfo?.mediaStatus?.hasLiked == true;
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => manager.onLikeAction(),
|
||||
child: Container(
|
||||
width: 108,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .2),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
liked
|
||||
? 'acg_like_sel.png'.acgImgPath
|
||||
: 'acg_like_nor.png'.acgImgPath,
|
||||
width: 16,
|
||||
),
|
||||
10.sizeBoxW,
|
||||
Text(
|
||||
'喜欢',
|
||||
style: textStyle(
|
||||
16, Color(0xff989898), FontWeight.w500),
|
||||
),
|
||||
],
|
||||
)),
|
||||
);
|
||||
},
|
||||
),
|
||||
12.sizeBoxW,
|
||||
],
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.play(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'开始阅读',
|
||||
style: textStyle(16, Colors.white, FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
16.sizeBoxW,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImgHeader(CartoonDetailLogic logic) {
|
||||
return Stack(
|
||||
children: [
|
||||
//背景图
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: logic.mediaInfo?.verticalCover ?? "",
|
||||
borderRadius: 0,
|
||||
height: 350,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
(10 + screen.paddingTop).sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
_buildBackView(),
|
||||
Spacer(),
|
||||
_buildCollAction(),
|
||||
12.sizeBoxW,
|
||||
_buildShareView(logic),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 16),
|
||||
154.sizeBoxH,
|
||||
Text(
|
||||
logic.mediaInfo?.title ?? "",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
).paddingSymmetric(horizontal: 16),
|
||||
12.sizeBoxH,
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xff05000E),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
_buildStateItem(
|
||||
'view_count.png'.communityPath,
|
||||
logic.mediaInfo?.countBrowse?.countStr ?? '',
|
||||
),
|
||||
12.sizeBoxW,
|
||||
_buildSpe(),
|
||||
12.sizeBoxW,
|
||||
// 点赞状态/数量随 manager.notifyListeners 刷新(onLikeAction 不会触发 GetX update)
|
||||
Consumer<ACGSourceManager>(
|
||||
builder: (_, manager, __) => _buildStateItem(
|
||||
manager.mediaInfo?.mediaStatus?.hasLiked == true
|
||||
? 'community_thumb_sel.png'.communityPath
|
||||
: 'like_count.png'.communityPath,
|
||||
manager.mediaInfo?.countLike?.countStr ?? '',
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
_buildSpe(),
|
||||
12.sizeBoxW,
|
||||
// 0:会员 1:金币购买 2:免费
|
||||
if (logic.mediaInfo?.permission == 0) ...[
|
||||
Text(
|
||||
'VIP',
|
||||
style:
|
||||
textStyle(12, Color(0xff989898), FontWeight.w400),
|
||||
),
|
||||
] else if (logic.mediaInfo?.permission == 1) ...[
|
||||
Text(
|
||||
'${logic.mediaInfo?.price ?? 0}金币',
|
||||
style:
|
||||
textStyle(12, Color(0xff989898), FontWeight.w400),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
//详情
|
||||
if (logic.mediaInfo?.summary?.isNotEmpty == true) ...[
|
||||
12.sizeBoxH,
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 12, 0, 0),
|
||||
child: AcgShowMoreTextWidget(
|
||||
summary: logic.mediaInfo?.summary ?? '',
|
||||
maxWidth: Get.width - 10,
|
||||
),
|
||||
)
|
||||
],
|
||||
//标签
|
||||
if ((logic.mediaInfo?.tagDetails?.length ?? 0) != 0) ...[
|
||||
12.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 26,
|
||||
child: ListView.separated(
|
||||
separatorBuilder: (context, index) => 6.sizeBoxW,
|
||||
itemCount: logic.mediaInfo?.tagDetails?.length ?? 0,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
TagsBean? tag = logic.mediaInfo?.tagDetails?[index];
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
Get.to(CartoonTagPage(
|
||||
title: tag?.name ?? '',
|
||||
sId: tag?.id ?? '',
|
||||
));
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
borderRadius: BorderRadius.circular(3)),
|
||||
height: 26,
|
||||
child: Text(
|
||||
tag?.name ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .55),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
12.sizeBoxH,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpe() {
|
||||
return Container(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
height: 18,
|
||||
width: 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStateItem(String img, String name) {
|
||||
return Row(
|
||||
children: [
|
||||
Image.asset(img, width: 16),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
name,
|
||||
style: textStyle(12, Color(0xff989898), FontWeight.w400),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabbar(CartoonDetailLogic logic, List<MediaStyle> styles) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TabBar(
|
||||
controller: logic.tabCtr,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
tabs: styles
|
||||
.map((s) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 5.0),
|
||||
child: Text(_recTitle(s)),
|
||||
))
|
||||
.toList(),
|
||||
tabAlignment: TabAlignment.center,
|
||||
isScrollable: true,
|
||||
labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
labelColor: Colors.white.withValues(alpha: 0.9),
|
||||
unselectedLabelColor: Colors.white.withValues(alpha: .55),
|
||||
unselectedLabelStyle:
|
||||
TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
indicator: CustomIndicator(
|
||||
isGradient: true,
|
||||
width: 13,
|
||||
height: 3,
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenu(CartoonDetailLogic logic) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
logic.isCartoonType
|
||||
? '选集'
|
||||
: '共${logic.mediaInfo?.totalEpisode ?? 0}${logic.mediaInfo?.unit}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.onMenuAction,
|
||||
child: Text(
|
||||
logic.isCartoonType
|
||||
? '全${logic.mediaInfo?.totalEpisode ?? 0}${logic.mediaInfo?.unit}'
|
||||
: '目录',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFA7A7A7),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
2.sizeBoxW,
|
||||
Image.asset(
|
||||
'arrow_right_grey.webp'.commonImgPath,
|
||||
color: Color(0xff989898),
|
||||
width: 24,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Consumer<ACGSourceManager>(
|
||||
builder: (context, provider, child) {
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final epm = provider.allEpisodes[index];
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.changeEpisode(index, false),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: logic.isCartoonType ? 6 : 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'第${epm.episodeNumber ?? ''}${provider.mediaInfo?.unit}${logic.isCartoonType ? '' : ' ${epm.name}'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textStyle(
|
||||
16,
|
||||
provider.index == index
|
||||
? Colors.white.withValues(alpha: .9)
|
||||
: Colors.white.withValues(alpha: .45),
|
||||
provider.index == index
|
||||
? FontWeight.w500
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
//无任何权限时,前 N 集免费展示「免费」角标
|
||||
if (provider.mediaInfo?.hasPermission == false &&
|
||||
epm.inFreeEpisode) ...[
|
||||
8.sizeBoxW,
|
||||
const FreeBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: min(4, provider.allEpisodes.length),
|
||||
);
|
||||
},
|
||||
),
|
||||
18.sizeBoxH,
|
||||
if (logic.isCartoonType) 0.5.line,
|
||||
18.sizeBoxH,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackView() {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
child: Image.asset(
|
||||
'back_circle_grey.png'.commonImgPath,
|
||||
width: 24,
|
||||
),
|
||||
onTap: () => Get.back(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShareView(CartoonDetailLogic logic) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
Get.dialog(
|
||||
ShareMediaDialog(
|
||||
videoModel: VideoModel()
|
||||
..id = logic.mediaInfo?.id
|
||||
..cover = logic.mediaInfo?.coverH,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Image.asset("acg_share.png".acgImgPath, width: 24),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopHeader(CartoonDetailLogic logic) {
|
||||
return GetBuilder<CartoonDetailLogic>(
|
||||
tag: uniqueTag,
|
||||
id: 'header',
|
||||
builder: (_) {
|
||||
return Offstage(
|
||||
offstage: logic.showHeader,
|
||||
child: Container(
|
||||
padding:
|
||||
EdgeInsets.only(left: 16, right: 16, top: screen.paddingTop),
|
||||
height: 44 + screen.paddingTop,
|
||||
color: Color(0xff05000E),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child:
|
||||
Icon(Icons.arrow_back_ios, size: 24, color: Colors.white),
|
||||
),
|
||||
8.sizeBoxW,
|
||||
Expanded(
|
||||
child: Text(
|
||||
logic.mediaInfo?.title ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
_buildCollAction(),
|
||||
12.sizeBoxW,
|
||||
_buildShareView(logic),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCollAction() {
|
||||
return Consumer<ACGSourceManager>(
|
||||
builder: (context, manager, child) {
|
||||
bool col = manager.mediaInfo?.mediaStatus?.hasCollected ?? false;
|
||||
return FollowButton(
|
||||
mediaId: manager.mediaInfo?.id ?? '',
|
||||
isFollow: col,
|
||||
followType: FollowEnum.cartoon,
|
||||
successsAction: (isSuccess) {
|
||||
manager.updateCollectState(isSuccess);
|
||||
if (isSuccess) {
|
||||
showToast('收藏成功');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart';
|
||||
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart';
|
||||
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
class CartoonReadLogic extends GetxController {
|
||||
final ACGSourceManager manager;
|
||||
|
||||
MediaContent? detailInfo;
|
||||
int currentEpisode = 0; // 当前子集数,从 0 开始
|
||||
bool showMenu = false;
|
||||
int readIndex = 1; // 当前在第几张图
|
||||
bool showProgress = false; // 展示进度
|
||||
bool sortType = true; // 排序类型 true-正序 false-倒序
|
||||
bool autoPlay = false; // 是否自动播放
|
||||
double readScale = 0; // 播放比例
|
||||
double bottomHeight = 155;
|
||||
int animationTime = 250; // 显示时间(ms)
|
||||
Timer? _timer;
|
||||
bool _isSeeking = false; // 拖进度条中:此时的滚动由 slider 主导,别让滚动回调反过来改 slider/菜单
|
||||
int _seekTarget = -1; // 本次拖动已跳到的图,-1 表示没在拖
|
||||
|
||||
final ItemScrollController itemScrollCtr = ItemScrollController();
|
||||
final ScrollOffsetController scrollOffsetCtr = ScrollOffsetController();
|
||||
final ItemPositionsListener itemListener = ItemPositionsListener.create();
|
||||
|
||||
int get imgsLength => detailInfo?.urlSet?.length ?? 1;
|
||||
|
||||
// 最后一张图的下标,无图时给 0,避免 clamp 传入负数上界
|
||||
int get _maxImgIndex => imgsLength > 0 ? imgsLength - 1 : 0;
|
||||
|
||||
CartoonReadLogic(this.manager) : currentEpisode = manager.index;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
itemListener.itemPositions.addListener(_onItemPositionsChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_stopTimer();
|
||||
itemListener.itemPositions.removeListener(_onItemPositionsChange);
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// ========== 公开方法 ==========
|
||||
// 首次进入加载当前章(也用于错误页重试,返回 Future 才有 loading + 防连点)
|
||||
Future<void> loadData() => _openEpisode(currentEpisode, isFirstLoad: true);
|
||||
|
||||
// true 下一集 / false 上一集
|
||||
void loadOtherEpisode(bool next) {
|
||||
final index = next ? currentEpisode + 1 : currentEpisode - 1;
|
||||
if (index < 0) {
|
||||
showToast('这是第一${manager.mediaInfo?.unit}了喔~');
|
||||
return;
|
||||
}
|
||||
if (index > manager.allEpisodes.length - 1) {
|
||||
showToast('这是最后一${manager.mediaInfo?.unit}了喔~');
|
||||
return;
|
||||
}
|
||||
_openEpisode(index);
|
||||
}
|
||||
|
||||
// 目录点击切章
|
||||
void loadDataWithIndex(String id) {
|
||||
// 目录列表可能倒序,按 id 定位 manager 中真实下标(不依赖"集号==下标+1",也天然不会越界)
|
||||
final realIndex = manager.allEpisodes.indexWhere((e) => e.id == id);
|
||||
if (realIndex < 0) {
|
||||
showToast('数据错误,请联系客服');
|
||||
return;
|
||||
}
|
||||
_openEpisode(realIndex);
|
||||
}
|
||||
|
||||
// 拖动进度条跳图:跨图才跳,同一张图内的细微拖动不重复 jumpTo
|
||||
void updateReadPicIndex(double value) {
|
||||
_isSeeking = true;
|
||||
readScale = value;
|
||||
update(['slider']);
|
||||
// 和 _seekTarget 比而不是和 readIndex 比:readIndex 是「最后一张可见图」,
|
||||
// 这里的 target 是「要对齐到顶部的图」,两者语义不同,混用会漏跳
|
||||
final target = (_maxImgIndex * value).round(); // 按最大下标折算,天然不越界
|
||||
if (target == _seekTarget) return;
|
||||
_seekTarget = target;
|
||||
readIndex = target;
|
||||
if (itemScrollCtr.isAttached) itemScrollCtr.jumpTo(index: target);
|
||||
}
|
||||
|
||||
// 松手:jumpTo 的位置回调下一帧才到,延后一帧再放开
|
||||
void onSeekEnd(double value) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// 拖动期间位置回调被屏蔽,松手补一次触底结算(停自动播放);
|
||||
// 放在解锁之前,免得这一步引起的滚动又被 onScrollAction 收了菜单
|
||||
_checkReachBottom();
|
||||
_isSeeking = false;
|
||||
_seekTarget = -1;
|
||||
});
|
||||
// 没有待渲染的帧时 postFrame 回调会一直挂着,这里确保有一帧
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
void changeShowMenu() {
|
||||
showMenu = !showMenu;
|
||||
update(['header', 'footer', 'like']);
|
||||
}
|
||||
|
||||
// 用户点「自动」按钮:开启的同时收起菜单
|
||||
void onChangeAutoPlay(bool play) {
|
||||
showMenu = !play;
|
||||
if (play) {
|
||||
update(['header', 'footer', 'autoplay', 'like']);
|
||||
Future.delayed(const Duration(milliseconds: 300)).then((_) {
|
||||
autoPlay = true;
|
||||
update(['autoplay']);
|
||||
});
|
||||
_startTimer();
|
||||
} else {
|
||||
_stopTimer();
|
||||
autoPlay = false;
|
||||
update(['header', 'footer', 'autoplay', 'like']);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleProgress() {
|
||||
showProgress = !showProgress;
|
||||
update(['progress']);
|
||||
}
|
||||
|
||||
void toggleSort() {
|
||||
sortType = !sortType;
|
||||
update(['menu']);
|
||||
}
|
||||
|
||||
void onScrollAction(ScrollController scrollController) {
|
||||
// 拖进度条引起的滚动不能收菜单,否则进度条自己滑出屏幕
|
||||
if (_isSeeking || !showMenu) return;
|
||||
showMenu = false;
|
||||
update(['header', 'footer', 'like']);
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
/// 切章统一入口:权限/缓存都在 manager 里,这里只管状态重置和刷新
|
||||
/// [isFirstLoad] 失败时才进错误页
|
||||
Future<void> _openEpisode(int index, {bool isFirstLoad = false}) async {
|
||||
// 首次进入页面已有全屏 loading,不再叠遮罩
|
||||
final model = await manager.getComicsMediaContent(
|
||||
index: index, showLoading: !isFirstLoad);
|
||||
if (model == null) {
|
||||
if (isFirstLoad)
|
||||
detailInfo = MediaContent(); // 空 model → id 为 null → 页面走错误态
|
||||
update();
|
||||
return;
|
||||
}
|
||||
currentEpisode = index;
|
||||
detailInfo = model;
|
||||
readIndex = 0;
|
||||
readScale = 0;
|
||||
update();
|
||||
// 首次加载时列表还没 build,controller 未 attach,跳转会空断言崩
|
||||
if (itemScrollCtr.isAttached) itemScrollCtr.jumpTo(index: 0);
|
||||
}
|
||||
|
||||
void _onItemPositionsChange() {
|
||||
if (_isSeeking) return; // 拖动中:slider 值已由手指决定,再反写会和 jumpTo 来回抖
|
||||
final positions = itemListener.itemPositions.value;
|
||||
if (positions.isEmpty) return; // 无图/列表重建时为空,取 first 会抛 StateError
|
||||
readIndex = positions.last.index;
|
||||
_checkReachBottom();
|
||||
// 分母用最大下标,滚到底才是满进度
|
||||
final count = detailInfo?.urlSet?.length ?? 0;
|
||||
readScale = count > 1 ? (readIndex / (count - 1)).clamp(0.0, 1.0) : 0;
|
||||
update(['slider']);
|
||||
}
|
||||
|
||||
// 触底:停自动播放(顺带弹回菜单)
|
||||
void _checkReachBottom() {
|
||||
final positions = itemListener.itemPositions.value;
|
||||
if (positions.isEmpty) return;
|
||||
final count = detailInfo?.urlSet?.length ?? 0;
|
||||
// 首图还在视野内就不算触底(整章不足一屏时不处理)
|
||||
if (positions.first.index == 0 || positions.last.index != count - 1) return;
|
||||
onChangeAutoPlay(false);
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel(); // 防重入:先停旧的再建新的,避免重复开启时旧定时器泄漏
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
scrollOffsetCtr.animateScroll(
|
||||
offset: 50, duration: const Duration(milliseconds: 500));
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.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/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/comment/comment_alert.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/follow_button.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart';
|
||||
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../alert/video/share_media_dialog.dart';
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
import 'cartoon_read_logic.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
// 漫画阅读页面
|
||||
class CartoonReadPage extends StatelessWidget {
|
||||
final ACGSourceManager manager;
|
||||
const CartoonReadPage({super.key, required this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CartoonReadLogic>(
|
||||
init: CartoonReadLogic(manager),
|
||||
builder: (logic) => Scaffold(
|
||||
body: ChangeNotifierProvider<ACGSourceManager>.value(
|
||||
value: manager,
|
||||
child: _buildBody(context, logic),
|
||||
),
|
||||
drawer: _buildDrawer(logic),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, CartoonReadLogic logic) {
|
||||
if (logic.detailInfo == null) return const LoadingCenterWidget();
|
||||
if (logic.detailInfo?.id == null)
|
||||
return CErrorWidget(retryOnTap: logic.loadData);
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.changeShowMenu,
|
||||
child: ScrollablePositionedList.builder(
|
||||
scrollAction: logic.onScrollAction,
|
||||
scrollOffsetController: logic.scrollOffsetCtr,
|
||||
itemPositionsListener: logic.itemListener,
|
||||
itemScrollController: logic.itemScrollCtr,
|
||||
itemCount: logic.imgsLength,
|
||||
itemBuilder: (_, index) => NetworkImageLoader(
|
||||
imageUrl: logic.detailInfo?.urlSet?[index] ?? '',
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildHeader(logic),
|
||||
_buildFooter(context, logic),
|
||||
_buildLiked(logic),
|
||||
_buildAutoPlay(logic),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// header bar
|
||||
Widget _buildHeader(CartoonReadLogic logic) {
|
||||
final headerHeight = screen.paddingTop + 50;
|
||||
return GetBuilder<CartoonReadLogic>(
|
||||
id: 'header',
|
||||
builder: (_) => AnimatedPositioned(
|
||||
duration: Duration(milliseconds: logic.animationTime),
|
||||
top: logic.showMenu ? 0 : -headerHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
height: headerHeight,
|
||||
padding: EdgeInsets.fromLTRB(10, screen.paddingTop + 8, 10, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(),
|
||||
child: const Icon(Icons.arrow_back_ios_new,
|
||||
size: 24, color: Colors.white),
|
||||
),
|
||||
10.sizeBoxW,
|
||||
Expanded(
|
||||
child: Text(
|
||||
logic.detailInfo?.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
21.sizeBoxW,
|
||||
Consumer<ACGSourceManager>(
|
||||
builder: (ctx, manager, _) {
|
||||
final col =
|
||||
manager.mediaInfo?.mediaStatus?.hasCollected ?? false;
|
||||
return FollowButton(
|
||||
mediaId: manager.mediaInfo?.id ?? '',
|
||||
isFollow: col,
|
||||
followType: FollowEnum.cartoon,
|
||||
successsAction: (isSuccess) {
|
||||
manager.updateCollectState(isSuccess);
|
||||
if (isSuccess) showToast('收藏成功');
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
12.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.dialog(
|
||||
ShareMediaDialog(
|
||||
videoModel: VideoModel()
|
||||
..id = manager.mediaInfo?.id
|
||||
..cover = manager.mediaInfo?.coverH,
|
||||
),
|
||||
),
|
||||
child: Image.asset('acg_share.png'.acgImgPath, width: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(BuildContext context, CartoonReadLogic logic) {
|
||||
return GetBuilder<CartoonReadLogic>(
|
||||
id: 'footer',
|
||||
builder: (_) => AnimatedPositioned(
|
||||
duration: Duration(milliseconds: logic.animationTime),
|
||||
bottom: logic.showMenu ? 0 : -logic.bottomHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Column(
|
||||
children: [
|
||||
15.sizeBoxH,
|
||||
GetBuilder<CartoonReadLogic>(
|
||||
id: 'progress',
|
||||
builder: (_) => AnimatedSlide(
|
||||
offset: logic.showProgress ? Offset.zero : const Offset(0, 1),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: _buildProgressView(context, logic),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Colors.black,
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (ctx) => _buildActionItem(
|
||||
title: '目录',
|
||||
icon: 'cartoon_menu.png'.acgImgPath,
|
||||
action: () => Scaffold.of(ctx).openDrawer(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildActionItem(
|
||||
title: '评论',
|
||||
icon: 'cartoon_comment.png'.acgImgPath,
|
||||
action: () => showCommentDialog(
|
||||
logic.manager.mediaInfo?.id ?? '',
|
||||
objType: logic.manager.mediaInfo?.commentType ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildActionItem(
|
||||
title: '进度',
|
||||
icon: 'cartoon_progress.png'.acgImgPath,
|
||||
action: logic.toggleProgress,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildActionItem(
|
||||
title: '自动',
|
||||
icon: 'cartoon_auto_play.png'.acgImgPath,
|
||||
action: () => logic.onChangeAutoPlay(true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionItem(
|
||||
{String? title, String? icon, VoidCallback? action, Color? color}) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: action,
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(icon ?? '', width: 24),
|
||||
4.sizeBoxH,
|
||||
Text(
|
||||
title ?? '',
|
||||
style: TextStyle(
|
||||
color: color ?? Colors.white.withValues(alpha: .45),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 进度条 + 上/下一篇
|
||||
Widget _buildProgressView(BuildContext context, CartoonReadLogic logic) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.loadOtherEpisode(false),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset('common_back_new.png'.commonImgPath, width: 24),
|
||||
4.sizeBoxH,
|
||||
Text('上一篇',
|
||||
style: textStyle(12, Colors.white, FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
13.sizeBoxW,
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 4,
|
||||
thumbShape:
|
||||
const RoundSliderThumbShape(enabledThumbRadius: 8.0),
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 12.0),
|
||||
tickMarkShape:
|
||||
const RoundSliderTickMarkShape(tickMarkRadius: 10.0),
|
||||
trackShape: const RoundedRectSliderTrackShape(),
|
||||
overlayColor: Colors.white.withValues(alpha: .3),
|
||||
activeTrackColor: const Color(0xffFFD460),
|
||||
inactiveTrackColor: Colors.white.withValues(alpha: .3),
|
||||
thumbColor: const Color(0xffFFD460),
|
||||
inactiveTickMarkColor: Colors.amber,
|
||||
),
|
||||
child: GetBuilder<CartoonReadLogic>(
|
||||
id: 'slider',
|
||||
builder: (_) => Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: logic.readScale,
|
||||
onChanged: logic.updateReadPicIndex,
|
||||
onChangeEnd: logic.onSeekEnd,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
13.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.loadOtherEpisode(true),
|
||||
child: Column(
|
||||
children: [
|
||||
Transform.rotate(
|
||||
angle: pi,
|
||||
child: Image.asset('common_back_new.png'.commonImgPath,
|
||||
width: 24),
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Text('下一篇',
|
||||
style: textStyle(12, Colors.white, FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 喜欢按钮
|
||||
Widget _buildLiked(CartoonReadLogic logic) {
|
||||
return GetBuilder<CartoonReadLogic>(
|
||||
id: 'like',
|
||||
builder: (_) => AnimatedPositioned(
|
||||
duration: Duration(milliseconds: logic.animationTime),
|
||||
bottom: 160,
|
||||
right: logic.showMenu ? 0 : -120,
|
||||
child: Consumer<ACGSourceManager>(
|
||||
builder: (_, manager, __) {
|
||||
final liked = manager.mediaInfo?.mediaStatus?.hasLiked == true;
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: manager.onLikeAction,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: .5),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(999),
|
||||
bottomLeft: Radius.circular(999),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
liked
|
||||
? 'acg_like_sel.png'.acgImgPath
|
||||
: 'acg_like_nor.png'.acgImgPath,
|
||||
width: 24,
|
||||
color: liked ? null : Colors.white,
|
||||
),
|
||||
5.sizeBoxW,
|
||||
Text('喜欢',
|
||||
style: textStyle(12, Colors.white, FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 自动播放按钮
|
||||
Widget _buildAutoPlay(CartoonReadLogic logic) {
|
||||
return Positioned(
|
||||
bottom: 30,
|
||||
child: GetBuilder<CartoonReadLogic>(
|
||||
id: 'autoplay',
|
||||
builder: (_) => AnimatedScale(
|
||||
scale: logic.autoPlay ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.linear,
|
||||
child: IgnorePointer(
|
||||
ignoring: !logic.autoPlay,
|
||||
child: GestureDetector(
|
||||
onTap: () => logic.onChangeAutoPlay(false),
|
||||
child: Container(
|
||||
height: 56,
|
||||
width: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: Center(
|
||||
child: Image.asset('cartoon_auto_play.gif'.acgImgPath,
|
||||
width: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 章节抽屉
|
||||
Widget _buildDrawer(CartoonReadLogic logic) {
|
||||
return GetBuilder<CartoonReadLogic>(
|
||||
id: 'menu',
|
||||
builder: (_) => Container(
|
||||
color: AppColors.primaryColor,
|
||||
width: 210,
|
||||
height: screen.screenHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
80.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
16.sizeBoxW,
|
||||
Expanded(
|
||||
child: Text(
|
||||
logic.manager.mediaInfo?.title ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
color: AppColors.actionRed,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'共${logic.manager.mediaInfo?.totalEpisode}话 ${logic.manager.mediaInfo?.getDetailUpdateString ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xff141414),
|
||||
fontWeight: FontWeight.w400),
|
||||
),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.toggleSort,
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: logic.sortType ? 0 : 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Image.asset('text_play_sort.png'.acgImgPath,
|
||||
color: const Color(0xff141414),
|
||||
width: 16,
|
||||
height: 16),
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
logic.sortType ? '正序' : '倒序',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xff141414),
|
||||
fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
key: ValueKey(logic.sortType),
|
||||
itemCount: logic.manager.allEpisodes.length,
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 20),
|
||||
itemBuilder: (_, index) {
|
||||
// 不使用 list.reverse(items 不够会从底部排),采用数据倒序方法
|
||||
final info = logic.sortType
|
||||
? logic.manager.allEpisodes[index]
|
||||
: logic.manager.allEpisodes[
|
||||
logic.manager.allEpisodes.length - index - 1];
|
||||
return StaggerInItem(
|
||||
index: index,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Get.back();
|
||||
if (logic.detailInfo?.id == info.id)
|
||||
return; //点的就是当前章,不重复加载
|
||||
logic.loadDataWithIndex(info.id ?? '');
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
height: 40,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildDrawerItem(
|
||||
//与详情/弹窗子集 title 格式一致:第X话(漫画不带 name)
|
||||
'第${info.episodeNumber ?? ''}${logic.manager.mediaInfo?.unit ?? ''}',
|
||||
logic.detailInfo?.id == info.id,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrawerItem(String title, bool select) {
|
||||
return Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: select
|
||||
? AppColors.actionRed
|
||||
: Colors.white.withValues(alpha: 0.55),
|
||||
fontWeight: select ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//小说/视频推荐
|
||||
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/base_list_controller.dart';
|
||||
|
||||
import '../../hj_utils/api_service/common_service.dart';
|
||||
|
||||
class CartoonRecommendLogic extends ListBaseLogic<dynamic> {
|
||||
final String? mediaId; //acg 推荐的 tagId
|
||||
final MediaStyle mediaStyle; //推荐类型:Video(真人视频) / Cartoon(动漫) / Comics(漫画)
|
||||
final String? videoTagId; //视频(SP)推荐的 tagId,按当前视频 tag 拉同类
|
||||
|
||||
CartoonRecommendLogic(
|
||||
{this.mediaId, this.mediaStyle = MediaStyle.Comics, this.videoTagId});
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
//isRefresh 下拉刷新/加载更多;showLoading 切换时先清空显示 loading
|
||||
void loadData({bool isRefresh = true, bool showLoading = false}) {
|
||||
if (showLoading) {
|
||||
dataList = null;
|
||||
update();
|
||||
}
|
||||
fetchData(isRefresh: isRefresh, fetch: _fetch);
|
||||
}
|
||||
|
||||
//按类型分流:视频走 getRecommendList,漫画/动漫走 comicsRecommendList
|
||||
Future<(List<dynamic>?, bool)> _fetch(int page) async {
|
||||
if (mediaStyle == MediaStyle.Video) {
|
||||
final resp = await CommonService.getRecommendList(
|
||||
pageNumber: page,
|
||||
pageSize: 12,
|
||||
tagId: videoTagId,
|
||||
newsType: mediaStyle.rankParam);
|
||||
return (resp?.videos, resp?.hasNext == true);
|
||||
}
|
||||
final model = await ACGService.comicsRecommendList(page, 12,
|
||||
tagId: mediaId, mediaType: mediaStyle.rankParam);
|
||||
return (model?.list, model?.hasNext == true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/cartoon_media_info.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../home/home_cell_style/video_simple_cell.dart';
|
||||
import 'acg_widget_item.dart';
|
||||
import 'cartoon_recommend_logic.dart';
|
||||
|
||||
class CartoonRecommendPage extends StatefulWidget {
|
||||
final String? mediaId;
|
||||
final String? videoTagId; //视频(SP)推荐按当前视频 tag 拉同类
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final Function()? tapAction;
|
||||
final MediaStyle mediaStyle; //推荐类型:Video(真人视频) / Cartoon(动漫) / Comics(漫画)
|
||||
final int crossAxisCount; //网格列数
|
||||
final double childAspectRatio; //网格宽高比
|
||||
final Function(VideoModel model)? onVideoTap; //视频 cell 点击:不传则走默认 push
|
||||
final Function(CartoonMediaInfo model)? onAcgTap; //acg cell 点击:不传则走默认 push
|
||||
|
||||
const CartoonRecommendPage({
|
||||
super.key,
|
||||
this.mediaId,
|
||||
this.tapAction,
|
||||
this.padding = const EdgeInsets.only(left: 16, right: 16, top: 10),
|
||||
this.mediaStyle = MediaStyle.Comics,
|
||||
this.videoTagId,
|
||||
this.crossAxisCount = 3,
|
||||
this.childAspectRatio = 111 / 188,
|
||||
this.onVideoTap,
|
||||
this.onAcgTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CartoonRecommendPage> createState() => _CartoonRecommendPageState();
|
||||
}
|
||||
|
||||
class _CartoonRecommendPageState extends State<CartoonRecommendPage>
|
||||
with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CartoonRecommendLogic>(
|
||||
tag: uniqueTag,
|
||||
init: CartoonRecommendLogic(
|
||||
mediaId: widget.mediaId,
|
||||
mediaStyle: widget.mediaStyle,
|
||||
videoTagId: widget.videoTagId,
|
||||
),
|
||||
builder: (logic) {
|
||||
if (logic.isLoading) return LoadingCenterWidget();
|
||||
if (logic.isEmptyData)
|
||||
return CErrorWidget(retryOnTap: () => logic.loadData());
|
||||
final list = logic.dataList!;
|
||||
return pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshCtr = ctr,
|
||||
onLoading: (ctr) => logic.loadData(isRefresh: false),
|
||||
enablePullDown: false,
|
||||
child: GridView.builder(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: widget.padding,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: widget.crossAxisCount,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 6,
|
||||
childAspectRatio: widget.childAspectRatio,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, index) => widget.mediaStyle == MediaStyle.Video
|
||||
? _videoCell(list[index])
|
||||
: _acgCell(list[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 视频推荐 cell:不传 onVideoTap 走 cell 默认 push,详情页传入则当前页切播放源
|
||||
Widget _videoCell(VideoModel model) {
|
||||
return VideoSimpleCell(
|
||||
videoModel: model,
|
||||
onTap: widget.onVideoTap == null ? null : () => widget.onVideoTap!(model),
|
||||
);
|
||||
}
|
||||
|
||||
// 漫画/动漫推荐 cell:不传 onAcgTap 时默认跳详情页
|
||||
Widget _acgCell(CartoonMediaInfo model) {
|
||||
return AcgItemWidget(
|
||||
info: model,
|
||||
tapCallback: widget.onAcgTap == null
|
||||
? () {
|
||||
widget.tapAction?.call();
|
||||
pushToCartoonPage(model);
|
||||
}
|
||||
: () => widget.onAcgTap!(model),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import 'cartoon_sectionlist_sub_page.dart';
|
||||
|
||||
//漫画专题列表的排序 tab(标题与排序值绑定)
|
||||
const _sortTabs = [
|
||||
SortTab('最多收藏', 3),
|
||||
SortTab('最新上架', 1),
|
||||
SortTab('最多观看', 2),
|
||||
];
|
||||
|
||||
//专题更多
|
||||
class CartoonSectionListPage extends StatefulWidget {
|
||||
final String? sectionID;
|
||||
final String? tagName;
|
||||
|
||||
const CartoonSectionListPage({
|
||||
super.key,
|
||||
this.sectionID,
|
||||
this.tagName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CartoonSectionListPage> createState() => _CartoonSectionListPageState();
|
||||
}
|
||||
|
||||
class _CartoonSectionListPageState extends State<CartoonSectionListPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController tabCtr =
|
||||
TabController(length: _sortTabs.length, vsync: this);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
tabCtr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.tagName ?? '')),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildTabs(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabCtr,
|
||||
children: List.generate(
|
||||
_sortTabs.length,
|
||||
(i) => CartoonSectionSubPage(
|
||||
sortType: _sortTabs[i].sort,
|
||||
sectionID: widget.sectionID,
|
||||
).keepAlive,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabs() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
alignment: Alignment.center,
|
||||
child: TabBar(
|
||||
tabAlignment: TabAlignment.center,
|
||||
controller: tabCtr,
|
||||
tabs: List.generate(
|
||||
_sortTabs.length,
|
||||
(index) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: index != _sortTabs.length - 1
|
||||
? Border(
|
||||
right: BorderSide(
|
||||
width: .5, color: Colors.white.withValues(alpha: .1)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Text(_sortTabs[index].name),
|
||||
),
|
||||
),
|
||||
labelColor: Colors.white.withValues(alpha: .9),
|
||||
labelPadding: EdgeInsets.zero,
|
||||
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
unselectedLabelColor: Colors.white.withValues(alpha: 0.5),
|
||||
unselectedLabelStyle:
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
indicator: const BoxDecoration(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:hgdj/hj_model/cartoon_media_info.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
|
||||
import 'package:hgdj/tools_base/base_list_controller.dart';
|
||||
|
||||
class CartoonSectionSubLogic extends ListBaseLogic<CartoonMediaInfo> {
|
||||
final int? sortType;
|
||||
final String? sectionID;
|
||||
|
||||
CartoonSectionSubLogic({this.sortType, this.sectionID});
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
void loadData() => fetchData(isRefresh: true, fetch: _fetch);
|
||||
|
||||
void loadMoreData() => fetchData(isRefresh: false, fetch: _fetch);
|
||||
|
||||
Future<(List<CartoonMediaInfo>?, bool)> _fetch(int page) async {
|
||||
final resp = await ACGService.getPreferences(
|
||||
page,
|
||||
12,
|
||||
sortType: sortType,
|
||||
sId: sectionID ?? '',
|
||||
type: 0,
|
||||
);
|
||||
return (resp?.list, resp?.hasNext == true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
|
||||
import 'acg_widget_item.dart';
|
||||
import 'cartoon_sectionlist_sub_logic.dart';
|
||||
|
||||
class CartoonSectionSubPage extends StatelessWidget {
|
||||
final int? sortType;
|
||||
final String? sectionID;
|
||||
|
||||
const CartoonSectionSubPage({super.key, this.sectionID, this.sortType});
|
||||
|
||||
//按排序值隔离三个 tab 的多实例
|
||||
String get _tag => (sortType ?? 0).toString();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CartoonSectionSubLogic>(
|
||||
tag: _tag,
|
||||
init: CartoonSectionSubLogic(sortType: sortType, sectionID: sectionID),
|
||||
builder: (logic) {
|
||||
if (logic.isLoading) return LoadingCenterWidget();
|
||||
return pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshCtr = ctr,
|
||||
onRefresh: (ctr) => logic.loadData(),
|
||||
onLoading: (ctr) => logic.loadMoreData(),
|
||||
child: logic.isEmptyData ? CErrorWidget() : _buildGrid(logic),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid(CartoonSectionSubLogic logic) {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 5,
|
||||
childAspectRatio: 111 / 190,
|
||||
),
|
||||
itemCount: logic.dataList?.length ?? 0,
|
||||
itemBuilder: (context, index) =>
|
||||
AcgItemWidget(info: logic.dataList?[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/hj_model/home/plate_model.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import 'cartoon_sub_grid_page.dart';
|
||||
|
||||
class CartoonSubDetailPage extends StatelessWidget {
|
||||
final ModuleData? tagData;
|
||||
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
|
||||
final ScrollController? scroll;
|
||||
|
||||
const CartoonSubDetailPage({
|
||||
super.key,
|
||||
this.tagData,
|
||||
this.type = MediaStyle.Pic,
|
||||
this.scroll,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CartoonSubGridPage(tagData: tagData, type: type, scrollCtr: scroll)
|
||||
.keepAlive;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/hj_model/home/module_detail_model.dart';
|
||||
import 'package:hgdj/hj_model/home/plate_model.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/base_list_controller.dart';
|
||||
|
||||
class CartoonSubGridLogic extends ListBaseLogic {
|
||||
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
|
||||
final ModuleData? tagData; //模块model
|
||||
List<SortTab<int>> sortTabs = []; //排序 tab:标题与 sort 值绑定
|
||||
List<AllSection> specials = []; //动漫和漫画 添加标签
|
||||
late TabController tabCtr;
|
||||
|
||||
int get crossAxisCount {
|
||||
if (type == MediaStyle.Cartoon) return 3;
|
||||
if (type == MediaStyle.Pic) return 3;
|
||||
if (type == MediaStyle.Game) return 2;
|
||||
if (type == MediaStyle.Novel) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
double get childAspectRatio {
|
||||
if (type == MediaStyle.Cartoon) return 111 / 190;
|
||||
if (type == MediaStyle.Pic) return 168 / 246;
|
||||
if (type == MediaStyle.Game) return 168 / 137;
|
||||
if (type == MediaStyle.Novel) return 111 / 190;
|
||||
return 1;
|
||||
}
|
||||
|
||||
String get newsType {
|
||||
if (tagData?.moduleName == '热门推荐' && tagData?.type == -100) return 'PIC';
|
||||
if (tagData?.moduleName == '热门推荐' && tagData?.type == -101)
|
||||
return 'SEED_LINK';
|
||||
return '';
|
||||
}
|
||||
|
||||
bool get isAcg =>
|
||||
type == MediaStyle.Novel || type == MediaStyle.Cartoon; //是否是acg模块
|
||||
|
||||
CartoonSubGridLogic({this.tagData, this.type = MediaStyle.Pic});
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
tabCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
onInit() {
|
||||
super.onInit();
|
||||
if (type == MediaStyle.Cartoon) {
|
||||
//动漫:排序 tab 可能来自后端,没配就用这套默认
|
||||
sortTabs = tagData?.sortTabs ??
|
||||
const [
|
||||
SortTab('热门推荐', 2),
|
||||
SortTab('最新上架', 1),
|
||||
SortTab('最新热评', 9),
|
||||
SortTab('最多收藏', 7),
|
||||
];
|
||||
} else if (type == MediaStyle.Pic || type == MediaStyle.Novel) {
|
||||
sortTabs = const [
|
||||
SortTab('最多观看', 3),
|
||||
SortTab('最新发布', 1),
|
||||
SortTab('最多收藏', 7)
|
||||
];
|
||||
} else {
|
||||
sortTabs = const [SortTab('最新', 1), SortTab('最热', 2), SortTab('畅销', 8)];
|
||||
}
|
||||
tabCtr = TabController(length: sortTabs.length, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
onReady() {
|
||||
super.onReady();
|
||||
loadData();
|
||||
}
|
||||
|
||||
//获取海角样式数据
|
||||
loadData({int pageNum = 1}) async {
|
||||
dataList ??= [];
|
||||
ModuleDetailModel? retModel;
|
||||
if (tagData?.moduleName == '热门推荐' &&
|
||||
(tagData?.type == -100 || tagData?.type == -101)) {
|
||||
//图集
|
||||
retModel = await VidService.communityRecommend(
|
||||
pageNum,
|
||||
pageSize: type == MediaStyle.Pic ? 12 : 10, //色图一行3个,按12加载凑整4行;黄游保持10
|
||||
newsType: newsType,
|
||||
sortType: sortTabs[tabCtr.index].sort,
|
||||
);
|
||||
if (retModel != null) {
|
||||
if (pageNum == 1) dataList?.clear();
|
||||
currentPage = pageNum;
|
||||
dataList?.addAll(retModel.allVideoInfo ?? []); //设置海角样式列表
|
||||
}
|
||||
} else {
|
||||
retModel = await VidService.getModuleDetail(
|
||||
tagData?.id ?? "",
|
||||
pageNumber: pageNum,
|
||||
pageSize: 12,
|
||||
moduleSort: sortTabs[tabCtr.index].sort,
|
||||
);
|
||||
|
||||
if (retModel != null) {
|
||||
currentPage = pageNum;
|
||||
if (pageNum == 1) {
|
||||
dataList?.clear();
|
||||
specials.clear();
|
||||
specials.addAll(retModel.allSection ?? []);
|
||||
}
|
||||
if (isAcg) {
|
||||
dataList?.addAll(retModel.allMediaInfo ?? []); //设置海角样式列表
|
||||
} else {
|
||||
dataList?.addAll(retModel.allVideoInfo ?? []); //设置海角样式列表
|
||||
}
|
||||
}
|
||||
}
|
||||
update();
|
||||
refreshCtr?.refreshCompleted();
|
||||
retModel?.hasNext == true
|
||||
? refreshCtr?.loadComplete()
|
||||
: refreshCtr?.loadNoData();
|
||||
}
|
||||
|
||||
loadMoreData() => loadData(pageNum: currentPage + 1);
|
||||
|
||||
//切换排序 tab:先清空列表显示 loading,延迟一帧再按新 sort 拉第一页(避免请求卡住切换动画)
|
||||
void onChangeIndexAction() {
|
||||
dataList = null;
|
||||
update();
|
||||
Future.delayed(const Duration(milliseconds: 50), () => loadData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_model/home/plate_model.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/hj_utils/sliver_delegate.dart';
|
||||
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
|
||||
import '../home/home_cell_style/quick_entry_row.dart';
|
||||
import '../home/home_cell_style/video_simple_cell.dart';
|
||||
import '../home/home_sub_module/widget/special_topics_view.dart';
|
||||
import 'acg_widget_item.dart';
|
||||
import 'cartoon_sub_grid_logic.dart';
|
||||
import 'photo_gallery_item.dart';
|
||||
|
||||
//海角样式:漫画/图集/黄游/小说共用的网格列表,item 样式与列数按 [type] 切
|
||||
class CartoonSubGridPage extends StatelessWidget {
|
||||
final ModuleData? tagData; //模块model
|
||||
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
|
||||
final ScrollController? scrollCtr;
|
||||
|
||||
const CartoonSubGridPage({
|
||||
super.key,
|
||||
this.tagData,
|
||||
this.type = MediaStyle.Pic,
|
||||
this.scrollCtr,
|
||||
});
|
||||
|
||||
// 多 type/模块 tab 经 keepAlive 并存,按 id+type 隔离各自的 logic
|
||||
String get _tag => '${tagData?.id ?? ''}$type';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CartoonSubGridLogic>(
|
||||
tag: _tag,
|
||||
init: CartoonSubGridLogic(tagData: tagData, type: type),
|
||||
builder: (logic) => pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshCtr = ctr,
|
||||
onRefresh: (ctr) => logic.loadData(),
|
||||
onLoading: (ctr) => logic.loadMoreData(),
|
||||
child: _buildContent(logic),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(CartoonSubGridLogic logic) {
|
||||
return CustomScrollView(
|
||||
controller: scrollCtr,
|
||||
slivers: [
|
||||
if (tagData?.pureVersion != true)
|
||||
SliverToBoxAdapter(
|
||||
child: AdsGridViewWidget(
|
||||
type == MediaStyle.Cartoon ? 4 : 7, //广告位标识
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
accordingAdsType: true,
|
||||
),
|
||||
),
|
||||
// 这两块只有动漫/漫画有
|
||||
if (type == MediaStyle.Cartoon) ...[
|
||||
SliverToBoxAdapter(child: QuickEntryRow(tagData)),
|
||||
SliverToBoxAdapter(
|
||||
child: SpecialTopicsView(
|
||||
logic.specials,
|
||||
module: tagData,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
_buildSortType(logic),
|
||||
_buildGridList(logic),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
//排序
|
||||
Widget _buildSortType(CartoonSubGridLogic logic) {
|
||||
return SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: MySliverDelegate(
|
||||
maxHeight: 36,
|
||||
minHeight: 36,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
color: AppColors.primaryColor,
|
||||
child: TabBar(
|
||||
tabAlignment: TabAlignment.center,
|
||||
controller: logic.tabCtr,
|
||||
isScrollable: true,
|
||||
//选中/未选中只差颜色,unselectedLabelStyle 不传会 fallback 到 labelStyle
|
||||
labelStyle:
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
labelColor: Colors.white.withValues(alpha: .9),
|
||||
unselectedLabelColor: Colors.white.withValues(alpha: .55),
|
||||
indicator: const BoxDecoration(), //去掉默认下划线
|
||||
onTap: (_) => logic.onChangeIndexAction(),
|
||||
tabs: logic.sortTabs
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 4),
|
||||
child: Text(e.name),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridList(CartoonSubGridLogic logic) {
|
||||
final list = logic.dataList;
|
||||
//null 是还没拉过,空 list 才是真没数据
|
||||
if (list == null) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 400, child: LoadingCenterWidget()));
|
||||
}
|
||||
if (list.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 400, child: CErrorWidget(retryOnTap: logic.loadData)),
|
||||
);
|
||||
}
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: logic.crossAxisCount,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 5,
|
||||
childAspectRatio: logic.childAspectRatio,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
//-1动漫/漫画 0-图集 1-黄游 2-小说
|
||||
(_, index) => switch (type) {
|
||||
MediaStyle.Cartoon ||
|
||||
MediaStyle.Novel =>
|
||||
AcgItemWidget(info: list[index], coverV: true),
|
||||
MediaStyle.Pic =>
|
||||
PhotoGalleryItem(videoModel: list[index], textline: 1),
|
||||
MediaStyle.Game => VideoSimpleCell(
|
||||
videoModel: list[index],
|
||||
isFromHY: true,
|
||||
textLines: 1,
|
||||
),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
childCount: list.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_model/home/plate_model.dart';
|
||||
import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
|
||||
class CartoonSubModuleLogic extends GetxController
|
||||
with GetTickerProviderStateMixin {
|
||||
final int tabIndex; // 未初始化时,内链跳转需要
|
||||
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
|
||||
List<ModuleData> tabs = []; //漫画
|
||||
late TabController tabController;
|
||||
|
||||
CartoonSubModuleLogic(this.tabIndex, {this.type = MediaStyle.Pic});
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
if (type == MediaStyle.Pic) {
|
||||
//0-图集 showtype = 2,列表
|
||||
tabs.addAll(Config.plateModule?.picsUi ?? []);
|
||||
} else if (type == MediaStyle.Game) {
|
||||
//1-黄游 showtype = 2,列表
|
||||
tabs.addAll(Config.plateModule?.gameUi ?? []);
|
||||
} else if (type == MediaStyle.Novel) {
|
||||
//2-小说
|
||||
tabs.addAll(Config.plateModule?.novel ?? []);
|
||||
}
|
||||
tabController = TabController(
|
||||
initialIndex: tabIndex,
|
||||
length: tabs.length,
|
||||
vsync: this,
|
||||
);
|
||||
tabController.addListener(() {
|
||||
if (tabController.indexIsChanging) return;
|
||||
CommunityBottomProvider().subIndex = tabController.index;
|
||||
});
|
||||
super.onInit();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
tabController
|
||||
.dispose(); // 自建 TabController,随 logic 释放(含内部 AnimationController + 匿名监听)
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
void skipToTabIndex(int index) {
|
||||
tabController.index = index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
|
||||
|
||||
import 'cartoon_sub_detail_page.dart';
|
||||
import 'cartoon_sub_module_logic.dart';
|
||||
|
||||
//漫画亚模块
|
||||
class CartoonSubModulePage extends StatelessWidget {
|
||||
final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说
|
||||
final int tabIndex;
|
||||
|
||||
const CartoonSubModulePage(
|
||||
{super.key, this.type = MediaStyle.Pic, this.tabIndex = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CartoonSubModuleLogic>(
|
||||
init: CartoonSubModuleLogic(tabIndex, type: type),
|
||||
tag: type.name,
|
||||
builder: (logic) => Column(
|
||||
children: [
|
||||
_buildTabbar(logic),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: logic.tabController,
|
||||
children: List.generate(
|
||||
logic.tabs.length,
|
||||
(index) => CartoonSubDetailPage(
|
||||
tagData: logic.tabs[index],
|
||||
type: type,
|
||||
scroll: CommunityBottomProvider().scrollCtr(
|
||||
type == MediaStyle.Pic
|
||||
? MediaStyle.Pic
|
||||
: MediaStyle.Novel,
|
||||
index),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabbar(CartoonSubModuleLogic logic) {
|
||||
return TabBar(
|
||||
controller: logic.tabController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tabs: List.generate(
|
||||
logic.tabs.length,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 8),
|
||||
child: Text(logic.tabs[index].moduleName ?? ''),
|
||||
),
|
||||
),
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tabAlignment: TabAlignment.start,
|
||||
isScrollable: true,
|
||||
labelColor: Colors.white.withValues(alpha: .9),
|
||||
labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
unselectedLabelColor: Colors.white.withValues(alpha: .55),
|
||||
unselectedLabelStyle:
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
indicator: CustomIndicator(offsetY: 6),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
import '../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../hj_utils/history_util.dart';
|
||||
import '../../routers/jump_router.dart';
|
||||
import 'audio_player_bottomsheet.dart';
|
||||
import 'text_novel_read_page.dart';
|
||||
import 'voice_novel_read_page.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
class NovelDetailLogic extends GetxController {
|
||||
String id;
|
||||
CartoonMediaInfo? model;
|
||||
final recommends = [];
|
||||
int page = 1;
|
||||
RefreshController? refreshCtr;
|
||||
|
||||
bool showHeader = true;
|
||||
double showOffset = 100;
|
||||
|
||||
late ACGSourceManager manager;
|
||||
late ScrollController scrollCtr = ScrollController()
|
||||
..addListener(() {
|
||||
if (scrollCtr.offset <= showOffset) {
|
||||
if (!showHeader) {
|
||||
showHeader = true;
|
||||
update(['header']);
|
||||
}
|
||||
} else {
|
||||
if (showHeader) {
|
||||
showHeader = false;
|
||||
update(['header']);
|
||||
}
|
||||
}
|
||||
});
|
||||
NovelDetailLogic(this.id) : manager = ACGSourceManager(id);
|
||||
|
||||
@override
|
||||
void onReady() async {
|
||||
super.onReady();
|
||||
// 先拿详情(含 freeEpisode),再拉子集,才能正确标记前 N 集免费
|
||||
await fetchDetailData();
|
||||
update();
|
||||
manager.fetchAllEpisodes();
|
||||
fetchRecommendData();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
scrollCtr.dispose(); //修复泄漏
|
||||
// manager 由页面的 ChangeNotifierProvider(create:) 在卸载时 dispose,本类不再重复释放
|
||||
// refreshCtr 由 CustomRefreshView 创建并 dispose,本类只持引用,绝不能再 dispose
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future fetchDetailData() async {
|
||||
final result = await ACGService.getMediaInfo(id);
|
||||
if (result != null) {
|
||||
model = result;
|
||||
manager.mediaInfo = result;
|
||||
HistoryUtil.insert(model!, MediaStyle.Novel);
|
||||
} else {
|
||||
model = CartoonMediaInfo();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取相关推荐
|
||||
Future fetchRecommendData() async {
|
||||
final result = await ACGService.comicsRecommendList(page, 12,
|
||||
tagId: model?.tagDetails?.firstOrNull?.id ?? '', mediaType: 'text');
|
||||
if (result != null) {
|
||||
page += 1;
|
||||
recommends.addAll(result.list ?? []);
|
||||
}
|
||||
result?.hasNext ?? false
|
||||
? refreshCtr?.loadComplete()
|
||||
: refreshCtr?.loadNoData();
|
||||
update();
|
||||
}
|
||||
|
||||
// 打开所有章节
|
||||
episodesBottomSheet() async {
|
||||
final result = await Get.bottomSheet(AudioPlayerBottomSheet(
|
||||
model?.title ?? '',
|
||||
allAudiobooks: manager.allEpisodes,
|
||||
currentPlayIndex: manager.index,
|
||||
unit: model?.unit,
|
||||
hasPermission: model?.hasPermission ?? false,
|
||||
));
|
||||
if (result != null) {
|
||||
gotoPlay(index: result as int);
|
||||
}
|
||||
}
|
||||
|
||||
gotoPlay({int index = 0}) async {
|
||||
final mediaSubType = model?.mediaSubType ?? -1;
|
||||
final opm = await manager
|
||||
.getEpisodeHasPermisson(index); //通过时内部已切集,不必再 changeEpisodesIndex
|
||||
if (opm != null) {
|
||||
//0-默认文本小说 1-有声小说
|
||||
if (mediaSubType == 0) {
|
||||
Get.to(() => TextNovelReadPage(manager: manager),
|
||||
preventDuplicates: false);
|
||||
} else if (mediaSubType == 1) {
|
||||
Get.to(
|
||||
() => VoiceNovelReadPage(
|
||||
manager: manager,
|
||||
id: id,
|
||||
index: index,
|
||||
title: model?.title ?? '',
|
||||
),
|
||||
opaque: false,
|
||||
preventDuplicates: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转
|
||||
jumpToOtherPage(CartoonMediaInfo info) {
|
||||
if (model?.id == info.id) {
|
||||
showToast('您当前正在观看此媒体');
|
||||
return;
|
||||
}
|
||||
pushToCartoonPage(info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
import 'package:hgdj/tools_base/widget/follow_button.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
import '../../tools_base/loading/loading_center_widget.dart';
|
||||
import '../../tools_base/refresh/pull_refresh.dart';
|
||||
import 'acg_widget_item.dart';
|
||||
import 'novel_detail_logic.dart';
|
||||
import 'novel_header.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
import 'widget/free_badge.dart';
|
||||
|
||||
//小说详情页 -
|
||||
class NovelDetailPage extends StatefulWidget {
|
||||
final String? id;
|
||||
|
||||
const NovelDetailPage(this.id, {super.key});
|
||||
|
||||
@override
|
||||
State<NovelDetailPage> createState() => _NovelDetailPageState();
|
||||
}
|
||||
|
||||
// 详情页可经「推荐 → 再开详情页」成环并存,用 UniqueTagMixin 给每个实例唯一 tag
|
||||
class _NovelDetailPageState extends State<NovelDetailPage> with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<NovelDetailLogic>(
|
||||
tag: uniqueTag,
|
||||
init: NovelDetailLogic(widget.id ?? ''),
|
||||
builder: (_) => Scaffold(
|
||||
body: () {
|
||||
if (_.model == null) return LoadingCenterWidget();
|
||||
if (_.model?.id == null) return CErrorWidget();
|
||||
// 用 create: 让 Provider 在页面卸载时自动 dispose manager(释放音频播放器+订阅),Flutter 层保证释放
|
||||
return ChangeNotifierProvider<ACGSourceManager>(
|
||||
create: (ctx) => _.manager,
|
||||
child: _buildContent(_),
|
||||
);
|
||||
}(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildContent(NovelDetailLogic _) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: pullYsRefresh(
|
||||
onInit: (ctr) => _.refreshCtr = ctr,
|
||||
enablePullDown: false,
|
||||
onLoading: (ctr) => _.fetchRecommendData(),
|
||||
child: CustomScrollView(
|
||||
controller: _.scrollCtr,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildNovelHeader(_)),
|
||||
18.sliverSizeBoxH,
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'相关推荐',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
12.sliverSizeBoxH,
|
||||
if (_.recommends.isNotEmpty) ...[
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: 80),
|
||||
sliver: SliverGrid.builder(
|
||||
itemCount: _.recommends.length,
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 6,
|
||||
childAspectRatio: 115 / 192,
|
||||
),
|
||||
itemBuilder: (ctx, index) {
|
||||
final model = _.recommends[index];
|
||||
return AcgItemWidget(
|
||||
info: _.recommends[index],
|
||||
tapCallback: () => _.jumpToOtherPage(model),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
SliverToBoxAdapter(
|
||||
child: CErrorWidget(),
|
||||
)
|
||||
],
|
||||
50.sliverSizeBoxH
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Consumer<ACGSourceManager>(
|
||||
builder: (context, value, child) => Text(
|
||||
_.manager.actionTitle(),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
)),
|
||||
onTap: () => _.gotoPlay(index: _.manager.index),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildTopHeader(_),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
_buildTopHeader(NovelDetailLogic _) {
|
||||
return GetBuilder<NovelDetailLogic>(
|
||||
tag: uniqueTag,
|
||||
id: 'header',
|
||||
builder: (_) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(left: 16, right: 16, top: screen.paddingTop),
|
||||
height: 44 + screen.paddingTop,
|
||||
color: !_.showHeader ? Color(0xff0E141E) : Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
child: Image.asset('acg_noval_back.png'.acgImgPath, width: 24),
|
||||
onTap: () => Get.back(),
|
||||
),
|
||||
40.sizeBoxW,
|
||||
Spacer(),
|
||||
Text(
|
||||
!_.showHeader ? _.model?.title ?? '' : '',
|
||||
style: textStyle(16, Colors.white, FontWeight.w500),
|
||||
),
|
||||
Spacer(),
|
||||
Consumer<ACGSourceManager>(
|
||||
builder: (context, manager, child) {
|
||||
bool col =
|
||||
manager.mediaInfo?.mediaStatus?.hasCollected ?? false;
|
||||
return FollowButton(
|
||||
mediaId: manager.mediaInfo?.id ?? '',
|
||||
isFollow: col,
|
||||
followType: FollowEnum.noval,
|
||||
successsAction: (isSuccess) {
|
||||
manager.updateCollectState(isSuccess);
|
||||
if (isSuccess) {
|
||||
showToast('收藏成功');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_buildNovelHeader(NovelDetailLogic _) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
NovelHeader(manager: _.manager),
|
||||
18.sizeBoxH,
|
||||
Consumer<ACGSourceManager>(
|
||||
builder: (ctx, manager, child) => Column(
|
||||
children: [
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'共${_.model?.totalEpisode ?? 0}${_.model?.unit}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => _.episodesBottomSheet(),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'目录',
|
||||
style: TextStyle(
|
||||
color: Color(0xffA7A7A7),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
2.sizeBoxW,
|
||||
Image.asset(
|
||||
'arrow_right_grey.webp'.commonImgPath,
|
||||
color: Color(0xff989898),
|
||||
width: 18,
|
||||
)
|
||||
],
|
||||
)),
|
||||
],
|
||||
),
|
||||
6.sizeBoxH,
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemCount: min(manager.allEpisodes.length, 4),
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: .5,
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final epm = manager.allEpisodes[index];
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => _.gotoPlay(index: index),
|
||||
child: Column(
|
||||
children: [
|
||||
12.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'第${epm.episodeNumber ?? 1}${_.model?.unit}',
|
||||
style: TextStyle(
|
||||
color: manager.index == index
|
||||
? AppColors.actionRed
|
||||
: Color(0xff989898),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
//无任何权限时,前 N 集免费展示「免费」角标
|
||||
if (manager.mediaInfo?.hasPermission == false &&
|
||||
epm.inFreeEpisode) ...[
|
||||
6.sizeBoxW,
|
||||
const FreeBadge(),
|
||||
],
|
||||
Spacer(),
|
||||
Icon(Icons.navigate_next_sharp,
|
||||
size: 20, color: Colors.white)
|
||||
],
|
||||
),
|
||||
12.sizeBoxH,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
).paddingSymmetric(horizontal: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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/widget/net_image_widget.dart';
|
||||
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
import '../../tools_base/widget/shrink_wrap.dart';
|
||||
import '../home/tag/cartoon_tag_page.dart';
|
||||
import 'widget/acg_expand_text.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
class NovelHeader extends StatelessWidget {
|
||||
final ACGSourceManager? manager;
|
||||
const NovelHeader({super.key, this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final info = manager?.mediaInfo;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: NetworkImageLoader(imageUrl: info?.coverH ?? ''),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(color: Colors.black.withValues(alpha: .7)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
(screen.paddingTop + 72).sizeBoxH,
|
||||
_titleRow(info),
|
||||
18.sizeBoxH,
|
||||
if (info?.summary?.isNotEmpty == true) ...[
|
||||
12.sizeBoxH,
|
||||
AcgShowMoreTextWidget(
|
||||
summary: info?.summary ?? '',
|
||||
maxWidth: Get.width - 32,
|
||||
),
|
||||
],
|
||||
20.sizeBoxH,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _titleRow(CartoonMediaInfo? info) {
|
||||
return Row(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
NetworkImageLoader(
|
||||
imageUrl: info?.coverH ?? '',
|
||||
width: 111,
|
||||
height: 148,
|
||||
),
|
||||
if (info?.isAudiobooks == true)
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Image.asset('noval_sign.png'.acgImgPath, width: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info?.title ?? '',
|
||||
style: textStyle(16, Colors.white, FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
6.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'共${info?.totalEpisode ?? 0}${info?.unit}',
|
||||
style:
|
||||
textStyle(12, const Color(0xff999999), FontWeight.w400),
|
||||
),
|
||||
8.sizeBoxW,
|
||||
Text(
|
||||
'/',
|
||||
style:
|
||||
textStyle(12, const Color(0xff999999), FontWeight.w400),
|
||||
),
|
||||
8.sizeBoxW,
|
||||
Text(
|
||||
info?.updateDesc ?? '',
|
||||
style: textStyle(
|
||||
12, info!.getDetailUpdateColor, FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (info.tagDetails?.isNotEmpty == true) ...[
|
||||
6.sizeBoxH,
|
||||
_tags(info.tagDetails!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tags(List<TagsBean> tags) {
|
||||
return ShrinkWrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
maxLines: 1,
|
||||
children: tags
|
||||
.map((tag) => InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(
|
||||
CartoonTagPage(title: tag.name ?? '', sId: tag.id ?? '')),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xff22252D),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'#${tag.name ?? ''}',
|
||||
maxLines: 1,
|
||||
style:
|
||||
const TextStyle(color: Color(0xFF999999), fontSize: 12),
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/find/pic_collect/pics_detail_page.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
//图集item
|
||||
class PhotoGalleryItem extends StatelessWidget {
|
||||
final VideoModel? videoModel;
|
||||
final Function? callback;
|
||||
final int textline;
|
||||
|
||||
const PhotoGalleryItem({
|
||||
super.key,
|
||||
this.videoModel,
|
||||
this.callback,
|
||||
this.textline = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
if (callback != null) {
|
||||
callback?.call();
|
||||
} else {
|
||||
Get.to(PicsDetailPage(id: videoModel?.id), preventDuplicates: false);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
NetworkImageLoader(
|
||||
imageUrl: videoModel?.cover ?? "",
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0),
|
||||
Colors.black.withValues(alpha: .6),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
)),
|
||||
height: 30,
|
||||
child: Row(
|
||||
children: [
|
||||
8.sizeBoxW,
|
||||
Image.asset("eye_white.webp".commonImgPath, width: 16),
|
||||
SizedBox(width: 2),
|
||||
Text(
|
||||
videoModel?.playCount?.countStr ?? "",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
Image.asset("tuji_icon.webp".communityPath, width: 16),
|
||||
2.sizeBoxW,
|
||||
Text(
|
||||
'${videoModel?.seriesCover?.length ?? 0}',
|
||||
textAlign: TextAlign.justify,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
8.sizeBoxW,
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
4.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${videoModel?.title}',
|
||||
maxLines: textline,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
import '../../tools_base/loading/loading_helper.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
// 文字小说阅读 logic
|
||||
class TextNovelReadLogic extends GetxController {
|
||||
final ACGSourceManager manager;
|
||||
|
||||
CartoonMediaInfo? mediaInfo;
|
||||
MediaContent? detailInfo; // 当前子集详情
|
||||
List<ComicChapterInfo> allEpisodes = [];
|
||||
int currentEpisode = 0; // 当前子集数,从 0 开始
|
||||
int episodeIndex = 1;
|
||||
bool showMenu = true;
|
||||
bool isDark = true;
|
||||
bool isAscend = true; // 排序类型 true-正序 false-倒序
|
||||
int durationTime = 250;
|
||||
|
||||
final ScrollController scrollCtr = ScrollController();
|
||||
|
||||
TextNovelReadLogic(this.manager);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
mediaInfo = manager.mediaInfo;
|
||||
currentEpisode = manager.index;
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
allEpisodes.addAll(manager.allEpisodes); // 复制,便于反转
|
||||
loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
scrollCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// ========== 公开方法 ==========
|
||||
// 获取文字小说。返回 Future 不是可有可无:CErrorWidget 靠它做重试 loading 和防连点
|
||||
Future<void> loadData(
|
||||
{bool showLoading = false, bool needToTop = false}) async {
|
||||
if (showLoading) LoadingHelper.showLoading();
|
||||
final model = await manager.getComicsMediaContent(index: currentEpisode);
|
||||
if (showLoading) LoadingHelper.dismissLoading();
|
||||
// 权限已在 getComicsMediaContent → getEpisodeHasPermisson 里判过(含前 N 集免费),直接展示
|
||||
_setData(model);
|
||||
update();
|
||||
if (needToTop) _toTop();
|
||||
}
|
||||
|
||||
// true 下一集 / false 上一集
|
||||
void loadOtherEpisode(bool next, {bool showLoading = false}) async {
|
||||
final index = next ? currentEpisode + 1 : currentEpisode - 1;
|
||||
if (index < 0) {
|
||||
showToast('这是第一${mediaInfo?.unit}了喔~');
|
||||
return;
|
||||
}
|
||||
if (index > manager.allEpisodes.length - 1) {
|
||||
showToast('这是最后一${mediaInfo?.unit}了喔~');
|
||||
return;
|
||||
}
|
||||
final model = await manager.getComicsMediaContent(index: index);
|
||||
if (model == null) return;
|
||||
currentEpisode = index;
|
||||
_setData(model);
|
||||
update();
|
||||
_toTop();
|
||||
}
|
||||
|
||||
void loadDataWithIndex(String id) async {
|
||||
// 目录列表可能倒序,按 id 定位 manager 中真实下标(不依赖"集号==下标+1",也天然不会越界)
|
||||
final realIndex = manager.allEpisodes.indexWhere((e) => e.id == id);
|
||||
if (realIndex < 0) {
|
||||
showToast('数据错误,请联系客服');
|
||||
return;
|
||||
}
|
||||
// 目录切集也要过权限:非免费/未解锁章节先弹 VIP/购买弹窗,通过后(内部已 changeEpisodesIndex)再拉详情
|
||||
final epm = await manager.getEpisodeHasPermisson(realIndex);
|
||||
if (epm == null) return;
|
||||
LoadingHelper.showLoading();
|
||||
final model =
|
||||
await ACGService.getMediaDetail(mediaId: manager.mediaInfo?.id, id: id);
|
||||
LoadingHelper.dismissLoading();
|
||||
if (model != null) {
|
||||
currentEpisode = realIndex;
|
||||
_setData(model);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
// 展示菜单
|
||||
void onShowMenuAction() {
|
||||
showMenu = !showMenu;
|
||||
update();
|
||||
}
|
||||
|
||||
// 切换暗黑模式
|
||||
void changeDarkStyle() {
|
||||
isDark = !isDark;
|
||||
update();
|
||||
}
|
||||
|
||||
// 排序
|
||||
void onChangeSortAction() {
|
||||
isAscend = !isAscend;
|
||||
allEpisodes = allEpisodes.reversed.toList();
|
||||
update(['list', 'sort']);
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
void _setData(MediaContent? model) {
|
||||
if (model != null) {
|
||||
detailInfo = model;
|
||||
manager.changeEpisodesIndex((model.episodeNumber ?? 1) - 1);
|
||||
update();
|
||||
} else {
|
||||
detailInfo = MediaContent();
|
||||
}
|
||||
}
|
||||
|
||||
// 回到顶部
|
||||
void _toTop() {
|
||||
Future.delayed(const Duration(milliseconds: 100)).then((_) {
|
||||
scrollCtr.jumpTo(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
|
||||
|
||||
import '../../tools_base/loading/loading_center_widget.dart';
|
||||
import 'text_novel_read_logic.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
// 文字小说阅读页
|
||||
class TextNovelReadPage extends StatelessWidget {
|
||||
final ACGSourceManager manager;
|
||||
const TextNovelReadPage({super.key, required this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<TextNovelReadLogic>(
|
||||
init: TextNovelReadLogic(manager),
|
||||
builder: (logic) => Scaffold(
|
||||
backgroundColor: logic.isDark ? const Color(0xff0F0F0F) : Colors.white,
|
||||
body: _buildBody(logic),
|
||||
endDrawer: _buildEndDrawer(logic),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(TextNovelReadLogic logic) {
|
||||
final detail = logic.detailInfo;
|
||||
if (detail == null) return LoadingCenterWidget();
|
||||
if (detail.id == null) return CErrorWidget(retryOnTap: logic.loadData);
|
||||
//Builder 是为了拿到 Scaffold 之下的 context——header 的目录按钮要用它 openEndDrawer
|
||||
return Builder(
|
||||
builder: (ctx) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildContent(logic),
|
||||
_buildHeader(ctx, logic),
|
||||
_buildFooter(logic),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 正文,点击任意处切换菜单显隐
|
||||
Widget _buildContent(TextNovelReadLogic logic) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.onShowMenuAction,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${logic.mediaInfo?.title ?? ''}/${logic.detailInfo?.name ?? ''}',
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xff525252)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
16.sizeBoxH,
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: logic.scrollCtr,
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 60), //给底部菜单栏留位,别被挡住最后几行
|
||||
child: Align(
|
||||
//正文不足一行时居中(长文自然撑满,看不出差别),与旧版 Column 默认的 center 保持一致
|
||||
alignment: Alignment.topCenter,
|
||||
child: Text(
|
||||
logic.detailInfo?.text ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: (logic.isDark ? Colors.white : Colors.black)
|
||||
.withValues(alpha: .9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 顶部菜单栏,收起时整条移出屏幕上方
|
||||
Widget _buildHeader(BuildContext ctx, TextNovelReadLogic logic) {
|
||||
final height = 44 + screen.paddingTop;
|
||||
final fg = logic.isDark ? Colors.white : Colors.black;
|
||||
return AnimatedPositioned(
|
||||
duration: Duration(milliseconds: logic.durationTime),
|
||||
top: logic.showMenu ? 0 : -height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: height,
|
||||
color: logic.isDark ? Colors.black : Colors.white,
|
||||
padding:
|
||||
EdgeInsets.only(top: screen.paddingTop + 5, left: 16, right: 16),
|
||||
alignment: Alignment.topCenter, //按钮贴状态栏下沿,余量留在底部
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: Get.back,
|
||||
child: Icon(Icons.arrow_back_ios, size: 24, color: fg),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
logic.detailInfo?.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 18, color: fg, fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 日/夜间模式
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.changeDarkStyle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (child, anim) =>
|
||||
ScaleTransition(scale: anim, child: child),
|
||||
child: Image.asset(
|
||||
logic.isDark
|
||||
? 'text_sun.png'.acgImgPath
|
||||
: 'text_moon.png'.acgImgPath,
|
||||
// key 按状态区分,AnimatedSwitcher 才会把它当成新 child 触发动画
|
||||
key: ValueKey(logic.isDark),
|
||||
width: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 目录
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Scaffold.of(ctx).openEndDrawer(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
child: Image.asset('text_play_menu.png'.acgImgPath,
|
||||
width: 24, color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 底部翻章栏,收起时整条移出屏幕下方
|
||||
Widget _buildFooter(TextNovelReadLogic logic) {
|
||||
final style = TextStyle(
|
||||
fontSize: 14, color: logic.isDark ? Colors.white : Colors.black);
|
||||
return AnimatedPositioned(
|
||||
duration: Duration(milliseconds: logic.durationTime),
|
||||
bottom: logic.showMenu ? 0 : -60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 60,
|
||||
color: logic.isDark ? Colors.black : Colors.white,
|
||||
padding: const EdgeInsets.only(top: 26, left: 16, right: 16),
|
||||
alignment: Alignment.topCenter, //按钮贴上沿,余量留给底部安全区
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.loadOtherEpisode(false, showLoading: true),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset('text_last.png'.acgImgPath,
|
||||
color: AppColors.actionRed, width: 20, height: 20),
|
||||
12.sizeBoxW,
|
||||
Text('上一章', style: style),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.loadOtherEpisode(true, showLoading: true),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('下一章', style: style),
|
||||
12.sizeBoxW,
|
||||
Image.asset('text_next.png'.acgImgPath,
|
||||
color: AppColors.actionRed, width: 20, height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 右侧目录抽屉
|
||||
Widget _buildEndDrawer(TextNovelReadLogic logic) {
|
||||
final fg = logic.isDark ? Colors.white : Colors.black;
|
||||
return Container(
|
||||
color: logic.isDark ? Colors.black : Colors.white,
|
||||
width: 210,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
80.sizeBoxH,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
logic.mediaInfo?.title ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: fg.withValues(alpha: .9),
|
||||
fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
// 总集数 + 正倒序切换
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: AppColors.actionRed,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'共${logic.mediaInfo?.totalEpisode ?? 0}话 ${logic.mediaInfo?.updateDesc ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.onChangeSortAction,
|
||||
child: GetBuilder<TextNovelReadLogic>(
|
||||
id: 'sort',
|
||||
builder: (_) => Row(
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: logic.isAscend ? 0 : 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Image.asset('text_play_sort.png'.acgImgPath,
|
||||
width: 20),
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
logic.isAscend ? '正序' : '倒序',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GetBuilder<TextNovelReadLogic>(
|
||||
id: 'list',
|
||||
builder: (_) => ListView.builder(
|
||||
key:
|
||||
ValueKey(logic.isAscend), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场
|
||||
itemCount: logic.allEpisodes.length,
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
itemBuilder: (_, index) {
|
||||
final info = logic.allEpisodes[index];
|
||||
final select = logic.detailInfo?.id == info.id;
|
||||
return StaggerInItem(
|
||||
index: index,
|
||||
child: InkWell(
|
||||
enableFeedback:
|
||||
false, //换掉裸 GestureDetector:整行可点,不再只有文字那一小块
|
||||
onTap: () {
|
||||
Get.back();
|
||||
logic.loadDataWithIndex(info.id ?? '');
|
||||
},
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
//与详情/弹窗子集 title 格式一致:第X章
|
||||
'第${info.episodeNumber ?? 1}${logic.mediaInfo?.unit ?? ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: select
|
||||
? AppColors.actionRed
|
||||
: fg.withValues(alpha: .55),
|
||||
fontWeight:
|
||||
select ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
// 有声小说 logic
|
||||
class VoiceNovelReadLogic extends GetxController {
|
||||
final String id;
|
||||
final String? title;
|
||||
final int index;
|
||||
final ACGSourceManager sourceManager;
|
||||
bool isFirstLoad = true;
|
||||
final AudioPlayerManager audioManager = AudioPlayerManager();
|
||||
StreamSubscription? _pauseVideoSub;
|
||||
|
||||
VoiceNovelReadLogic({
|
||||
required this.sourceManager,
|
||||
required this.id,
|
||||
required this.index,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
audioManager.initPlayer();
|
||||
audioManager.sourceManger = sourceManager;
|
||||
sourceManager.index = index;
|
||||
// 耳机/蓝牙断开、来电中断时暂停,防止外放泄露(有声小说为纯音频,外放泄露更直接)
|
||||
_pauseVideoSub = eventBus.on<PauseVideoEvent>((_) => audioManager.pause());
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
isFirstLoad = false;
|
||||
update();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_pauseVideoSub?.cancel();
|
||||
audioManager.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/hj_utils/widget_util.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'audio_player_content.dart';
|
||||
import 'cartoon_recommend_page.dart';
|
||||
import 'voice_novel_read_logic.dart';
|
||||
import 'widget/acg_source_manager.dart';
|
||||
|
||||
// 有声小说页面
|
||||
class VoiceNovelReadPage extends StatefulWidget {
|
||||
final ACGSourceManager manager;
|
||||
final String id;
|
||||
final int index;
|
||||
final String? title;
|
||||
|
||||
const VoiceNovelReadPage({
|
||||
super.key,
|
||||
required this.manager,
|
||||
required this.id,
|
||||
required this.index,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VoiceNovelReadPage> createState() => _VoiceNovelReadPageState();
|
||||
}
|
||||
|
||||
// 可重复 push(preventDuplicates: false)+ 间接环 NovelDetail→VoiceNovelRead 可能多实例并存,
|
||||
// 用 UniqueTagMixin 给每个实例分独立 tag 隔离
|
||||
class _VoiceNovelReadPageState extends State<VoiceNovelReadPage>
|
||||
with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<VoiceNovelReadLogic>(
|
||||
tag: uniqueTag,
|
||||
init: VoiceNovelReadLogic(
|
||||
sourceManager: widget.manager,
|
||||
id: widget.id,
|
||||
index: widget.index,
|
||||
title: widget.title,
|
||||
),
|
||||
builder: (logic) => ChangeNotifierProvider<ACGSourceManager>.value(
|
||||
value: logic.sourceManager,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(),
|
||||
child: const Icon(Icons.arrow_back_ios,
|
||||
size: 24, color: Colors.white),
|
||||
),
|
||||
centerTitle: false,
|
||||
titleSpacing: -10,
|
||||
title: Text(
|
||||
logic.title ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _buildBody(logic),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(VoiceNovelReadLogic logic) {
|
||||
if (logic.isFirstLoad) return LoadingCenterWidget();
|
||||
if (logic.sourceManager.allEpisodes.isEmpty) return CErrorWidget();
|
||||
return ExtendedNestedScrollView(
|
||||
headerSliverBuilder: (_, __) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AudioPlayerContent(
|
||||
logic.sourceManager,
|
||||
index: logic.index,
|
||||
manager: logic.audioManager,
|
||||
),
|
||||
),
|
||||
),
|
||||
0.5.sliverLine,
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 12),
|
||||
child: Text(
|
||||
'相关推荐',
|
||||
style: textStyle(
|
||||
18, Colors.white.withValues(alpha: .9), FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: CartoonRecommendPage(
|
||||
mediaId:
|
||||
logic.sourceManager.mediaInfo?.tagDetails?.firstOrNull?.id ?? '',
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
tapAction: () => logic.audioManager.pause(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//查看更多
|
||||
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 '../../../hj_utils/widget_util.dart';
|
||||
|
||||
class AcgShowMoreTextWidget extends StatefulWidget {
|
||||
final String? summary;
|
||||
final int? maxLine;
|
||||
final double? maxWidth;
|
||||
final Color txColor;
|
||||
|
||||
const AcgShowMoreTextWidget({
|
||||
super.key,
|
||||
this.summary,
|
||||
this.maxLine = 3,
|
||||
this.maxWidth,
|
||||
this.txColor = const Color(0xff989898),
|
||||
});
|
||||
|
||||
@override
|
||||
State<AcgShowMoreTextWidget> createState() => _AcgShowMoreTextWidgetState();
|
||||
}
|
||||
|
||||
class _AcgShowMoreTextWidgetState extends State<AcgShowMoreTextWidget> {
|
||||
double turns = 1.0; //翻转动画累加值,整数=折叠态,每点击 +0.5 转半圈
|
||||
|
||||
bool get _isFold => turns % 1 == 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//用 LayoutBuilder 拿真实约束宽度测量,避免传入的 maxWidth 与实际渲染宽度不符导致"该展开却没按钮"
|
||||
return LayoutBuilder(builder: (context, cons) {
|
||||
final maxWidth = cons.maxWidth.isFinite
|
||||
? cons.maxWidth
|
||||
: (widget.maxWidth ?? Get.width);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
//宽度拉满 + 居左:短文本也占满整行左对齐,避免父级把窄 Column 居中
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
widget.summary?.isNotEmpty == true
|
||||
? widget.summary ?? ''
|
||||
: '暂无简介',
|
||||
textAlign: TextAlign.left,
|
||||
style: textStyle(12, widget.txColor, FontWeight.w400),
|
||||
maxLines: _isFold ? widget.maxLine : null,
|
||||
overflow: _isFold ? TextOverflow.ellipsis : null,
|
||||
),
|
||||
),
|
||||
if (_showMoreText(maxWidth))
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _toggleExpand,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_isFold ? '更多简介' : '收起简介',
|
||||
style: textStyle(
|
||||
12, const Color(0xffEFEFEF), FontWeight.w400),
|
||||
),
|
||||
2.sizeBoxW,
|
||||
AnimatedRotation(
|
||||
turns: turns,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Image.asset(
|
||||
'acg_narrow.png'.acgImgPath,
|
||||
width: 18,
|
||||
color: const Color(0xffDCDCDC),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//展开/收起
|
||||
void _toggleExpand() => setState(() => turns += 0.5);
|
||||
|
||||
//文本超过 maxLine 才显示"更多简介"按钮
|
||||
bool _showMoreText(double maxWidth) {
|
||||
return _textExceedMaxLines(
|
||||
widget.summary ?? '.',
|
||||
textStyle(12, widget.txColor, FontWeight.w400),
|
||||
widget.maxLine ?? 3,
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
bool _textExceedMaxLines(
|
||||
String text, TextStyle style, int maxLine, double maxWidth) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: maxLine,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: maxWidth);
|
||||
return painter.didExceedMaxLines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/cartoon_media_info.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/common_service.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_helper.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../../alert/video/comic_buy_alert.dart';
|
||||
import '../../../alert/video/vip_acg_dialog.dart';
|
||||
import '../../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../../tools_base/global_store/store.dart';
|
||||
|
||||
/// 小说/漫画/有声资源管理器
|
||||
class ACGSourceManager extends ChangeNotifier {
|
||||
CartoonMediaInfo? mediaInfo;
|
||||
List<ComicChapterInfo> allEpisodes = <ComicChapterInfo>[];
|
||||
final mediaContentCache = <int, MediaContent>{}; // 章节详情缓存
|
||||
|
||||
static const _maxPage = 50; // 章节分页拉取上限(50 页 × 60 条 = 3000 章)
|
||||
static const _pageSize = 60; // 每页集数:一页尽量拉多点,少几轮串行翻页
|
||||
|
||||
String id;
|
||||
int page = 1;
|
||||
int index = 0; // 当前选中下标,从 0 开始
|
||||
|
||||
ACGSourceManager(this.id);
|
||||
|
||||
/// 更新收藏状态
|
||||
void updateCollectState(bool collected) {
|
||||
mediaInfo?.mediaStatus?.hasCollected = collected;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 点赞/取消点赞
|
||||
Future<void> onLikeAction() async {
|
||||
LoadingHelper.showLoading();
|
||||
final liked = mediaInfo?.mediaStatus?.hasLiked ?? false;
|
||||
final mid = mediaInfo?.id ?? '';
|
||||
final type = mediaInfo?.mediaType ?? '';
|
||||
final suc = liked
|
||||
? await CommonService.cancelLike(mid, type)
|
||||
: await CommonService.sendLike(mid, type);
|
||||
LoadingHelper.dismissLoading();
|
||||
if (suc) {
|
||||
mediaInfo?.mediaStatus?.hasLiked = !liked;
|
||||
// 取消 like 数 -1;点 like 数 +1(原代码两个分支都 -- 是 bug)
|
||||
mediaInfo?.countLike = (mediaInfo?.countLike ?? 0) + (liked ? -1 : 1);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 更新章节购买权限:buyAll=true 整本解锁,否则只解锁 subId 对应章节
|
||||
void updatePermission({bool? buyAll, String? subId}) {
|
||||
if (buyAll == true) {
|
||||
mediaInfo?.mediaStatus?.hasPaid = true;
|
||||
for (final e in allEpisodes) {
|
||||
e.hasBuy = true;
|
||||
}
|
||||
} else {
|
||||
for (final e in allEpisodes) {
|
||||
if (e.id == subId) e.hasBuy = true;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 阅读按钮文案:第一章=开始阅读;其它=续看 NNN集
|
||||
String actionTitle() {
|
||||
final showCount = index + 1;
|
||||
if (showCount == 1) return "开始阅读";
|
||||
final showText = showCount.toString().padLeft(3, '0');
|
||||
return "续看$showText${mediaInfo?.unit ?? ''}";
|
||||
}
|
||||
|
||||
/// 切换当前播放章节
|
||||
void changeEpisodesIndex(int current, {bool jump = true}) {
|
||||
index = current;
|
||||
notifyListeners();
|
||||
if (jump) eventBus.emit(ACGMenuChanged(index: index));
|
||||
}
|
||||
|
||||
/// 拉所有章节(分页,每页 [_pageSize] 条,最多 [_maxPage] 页)
|
||||
/// 注:暂不支持刷新重拉——真要加,必须连 mediaContentCache 一起清(它以下标为 key,列表变了会串章)
|
||||
Future<void> fetchAllEpisodes() async {
|
||||
// 改为 while 循环:原代码递归没 await;再加页数上限,防后端 hasNext 恒 true 时无限翻页
|
||||
while (page <= _maxPage) {
|
||||
final result = await ACGService.getChapterList(id, page, _pageSize);
|
||||
if (result == null) break;
|
||||
final list = result.list ?? [];
|
||||
// 每页拉到就按集号标免费集(与播放放行/角标同一口径),避免全部拉完才标导致角标迟到
|
||||
for (final e in list) {
|
||||
e.inFreeEpisode =
|
||||
mediaInfo?.isFreeEpisodeNumber(e.episodeNumber) ?? false;
|
||||
}
|
||||
allEpisodes.addAll(list);
|
||||
notifyListeners();
|
||||
if (!(result.hasNext ?? false)) break;
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测章节权限。permission:0=VIP可看 1=金币购买 2=免费
|
||||
/// 拿到权限后会同时切换 current index 并返回 episode;否则返回 null
|
||||
Future<ComicChapterInfo?> getEpisodeHasPermisson(int index_) async {
|
||||
if (allEpisodes.isEmpty) return null;
|
||||
final epm = allEpisodes[index_];
|
||||
final permission = mediaInfo?.permission ?? 0;
|
||||
final unLock = mediaInfo?.mediaStatus?.hasPaid ?? false;
|
||||
final isPremiumVIP = globalStore.isSuperUp;
|
||||
final inFreeEpisode = epm.inFreeEpisode; // 本地字段:前 N 集免费,直接放行
|
||||
|
||||
bool hasPermission = false;
|
||||
if (unLock || isPremiumVIP || inFreeEpisode) {
|
||||
hasPermission = true;
|
||||
} else {
|
||||
switch (permission) {
|
||||
case 2: // 免费
|
||||
hasPermission = true;
|
||||
break;
|
||||
case 0: // 需要 VIP
|
||||
hasPermission = globalStore.isVIP;
|
||||
if (!hasPermission) {
|
||||
await Get.dialog(VipACGDialog(), useSafeArea: false);
|
||||
}
|
||||
break;
|
||||
case 1: // 金币购买
|
||||
hasPermission = mediaInfo?.mediaStatus?.hasPaid ?? false;
|
||||
if (!hasPermission) {
|
||||
final result = await ComicBuyAlert.show(mediaInfo: mediaInfo);
|
||||
// 1 购买单集 2 购买全集
|
||||
if (result == 1 || result == 2) {
|
||||
hasPermission = true;
|
||||
if (result == 2) updatePermission(buyAll: true);
|
||||
epm.hasBuy = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasPermission) changeEpisodesIndex(index_);
|
||||
return hasPermission ? epm : null;
|
||||
}
|
||||
|
||||
/// 拉漫画章节详情(带缓存 + 权限校验)
|
||||
Future<MediaContent?> getComicsMediaContent(
|
||||
{int index = 0, bool showLoading = false}) async {
|
||||
final cached = mediaContentCache[index];
|
||||
if (cached != null) {
|
||||
changeEpisodesIndex(index); // 命中缓存也要同步当前章,否则退回详情页「续看」还停在旧章
|
||||
return cached;
|
||||
}
|
||||
final episode = await getEpisodeHasPermisson(index);
|
||||
if (episode == null) return null;
|
||||
// loading 开在权限校验之后:VIP/购买弹窗不能被 loading 遮罩挡住
|
||||
if (showLoading) LoadingHelper.showLoading();
|
||||
final result = await ACGService.getMediaDetail(id: episode.id ?? '');
|
||||
if (showLoading) LoadingHelper.dismissLoading();
|
||||
if (result != null) mediaContentCache[index] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 上一集
|
||||
Future<ComicChapterInfo?> backToForward() async {
|
||||
if (index == 0) {
|
||||
showToast('当前是第一章~~~');
|
||||
return null;
|
||||
}
|
||||
return getEpisodeHasPermisson(index - 1);
|
||||
}
|
||||
|
||||
/// 下一集
|
||||
Future<ComicChapterInfo?> jumpToNext() async {
|
||||
if (index == allEpisodes.length - 1) {
|
||||
showToast('当前是最后一章了~~~');
|
||||
return null;
|
||||
}
|
||||
return getEpisodeHasPermisson(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioPlayMod {
|
||||
loop,
|
||||
loopList,
|
||||
}
|
||||
|
||||
class AudioPlayerValue {
|
||||
Duration? position;
|
||||
Duration? duration;
|
||||
bool? isPlaying;
|
||||
bool isLooping;
|
||||
double volume;
|
||||
bool isCompleted;
|
||||
bool isInitialized;
|
||||
int? onPlayIndex;
|
||||
|
||||
AudioPlayerValue({
|
||||
this.position,
|
||||
this.duration,
|
||||
this.isPlaying,
|
||||
this.volume = 1,
|
||||
this.isLooping = true,
|
||||
this.isCompleted = false,
|
||||
this.isInitialized = false,
|
||||
this.onPlayIndex,
|
||||
});
|
||||
|
||||
static AudioPlayerValue copyWith(AudioPlayerValue value) {
|
||||
return AudioPlayerValue(
|
||||
duration: value.duration,
|
||||
position: value.position,
|
||||
isPlaying: value.isPlaying,
|
||||
isCompleted: value.isCompleted,
|
||||
volume: value.volume,
|
||||
isLooping: value.isLooping,
|
||||
isInitialized: value.isInitialized,
|
||||
onPlayIndex: value.onPlayIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 有声小说播放器封装:包装 audioplayers + 列表切换
|
||||
class AudioPlayerManager {
|
||||
AudioPlayerManager() {
|
||||
audioValue = ValueNotifier(value);
|
||||
}
|
||||
|
||||
AudioPlayer? _audioPlayer;
|
||||
|
||||
AudioPlayMod playMod = AudioPlayMod.loopList;
|
||||
ACGSourceManager? sourceManger;
|
||||
|
||||
StreamSubscription? _audioPositionSub;
|
||||
StreamSubscription? _audioDurationSub;
|
||||
StreamSubscription? _audioStateSub;
|
||||
StreamSubscription? _audioCompleSub;
|
||||
StreamSubscription? _seekSub;
|
||||
|
||||
final AudioPlayerValue value = AudioPlayerValue();
|
||||
late ValueNotifier<AudioPlayerValue> audioValue;
|
||||
|
||||
double playSpeed = 1;
|
||||
|
||||
void initPlayer() {
|
||||
if (_audioPlayer != null)
|
||||
return; // 防重入:已初始化则跳过,避免 5 个 stream 监听重复注册、旧 sub 泄漏
|
||||
_audioPlayer = AudioPlayer();
|
||||
_audioPlayer?.setReleaseMode(
|
||||
playMod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop);
|
||||
|
||||
_audioDurationSub = _audioPlayer?.onDurationChanged.listen((event) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..duration = event);
|
||||
});
|
||||
|
||||
_audioPositionSub = _audioPlayer?.onPositionChanged.listen((event) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..position = event
|
||||
..isPlaying = true
|
||||
..onPlayIndex = sourceManger?.index ?? 0);
|
||||
});
|
||||
|
||||
_audioStateSub = _audioPlayer?.onPlayerStateChanged.listen((event) {
|
||||
switch (event) {
|
||||
case PlayerState.completed:
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..isCompleted = true
|
||||
..position = null);
|
||||
break;
|
||||
case PlayerState.playing:
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
|
||||
break;
|
||||
case PlayerState.paused:
|
||||
audioValue.value =
|
||||
AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_audioCompleSub = _audioPlayer?.onPlayerComplete.listen((event) {
|
||||
if (playMod == AudioPlayMod.loopList && (value.isPlaying ?? false)) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
playNext();
|
||||
} else {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..isCompleted = true
|
||||
..isPlaying = false);
|
||||
}
|
||||
});
|
||||
|
||||
_seekSub = _audioPlayer?.onSeekComplete.listen((event) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
|
||||
});
|
||||
|
||||
audioValue.value =
|
||||
AudioPlayerValue.copyWith(value..isInitialized = _audioPlayer != null);
|
||||
}
|
||||
|
||||
void setPlaySpeed(double speed) {
|
||||
playSpeed = speed;
|
||||
_audioPlayer?.setPlaybackRate(speed);
|
||||
}
|
||||
|
||||
/// 开始播放:url 为空则重置状态
|
||||
void play(String url) {
|
||||
if (_audioPlayer == null) initPlayer();
|
||||
audioValue.value = AudioPlayerValue.copyWith(
|
||||
value
|
||||
..isPlaying = false
|
||||
..duration = null
|
||||
..position = Duration.zero,
|
||||
);
|
||||
if (url.isEmpty) return;
|
||||
|
||||
// play 是 Future 但不 await(保留 fire-and-forget 行为);
|
||||
// 必须挂 catchError,否则 audioplayers 30s 没收到 prepared 时抛 TimeoutException
|
||||
// 会变成未处理异常炸 console,UI 也卡在 "加载中"
|
||||
_audioPlayer?.play(UrlSource(url)).catchError((Object e) {
|
||||
debugPrint('[AudioPlayer] play failed: $e');
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..isPlaying = false
|
||||
..isCompleted = true
|
||||
..duration = null);
|
||||
showToast('音频加载失败');
|
||||
});
|
||||
}
|
||||
|
||||
/// 上一集
|
||||
Future<void> playForward() async {
|
||||
final epm = await sourceManger?.backToForward();
|
||||
if (epm != null) {
|
||||
play(epm.getRealAudioUrl);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一集
|
||||
Future<void> playNext() async {
|
||||
final epm = await sourceManger?.jumpToNext();
|
||||
if (epm != null) {
|
||||
play(epm.getRealAudioUrl);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
await _audioPlayer?.pause();
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
await _audioPlayer?.resume();
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
|
||||
}
|
||||
|
||||
void setPlayMod(AudioPlayMod mod) {
|
||||
_audioPlayer?.setReleaseMode(
|
||||
mod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop);
|
||||
playMod = mod;
|
||||
audioValue.value =
|
||||
AudioPlayerValue.copyWith(value..isLooping = mod == AudioPlayMod.loop);
|
||||
}
|
||||
|
||||
Future<void> playWithIndex(int index) async {
|
||||
if (index == sourceManger?.index) {
|
||||
if (!(value.isPlaying ?? false)) resume();
|
||||
return;
|
||||
}
|
||||
final epm = await sourceManger?.getEpisodeHasPermisson(index);
|
||||
if (epm != null) {
|
||||
play(epm.getRealAudioUrl);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到指定秒数
|
||||
void seek(int seconds) {
|
||||
_audioPlayer?.seek(Duration(seconds: seconds));
|
||||
if (value.isPlaying ?? false) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 前进/后退 10 秒(dir: -1 后退,1 前进)
|
||||
void seekTenMinsWithDirection(int dir) {
|
||||
final cur = audioValue.value.position?.inSeconds ?? 0;
|
||||
final total = audioValue.value.duration?.inSeconds ?? 0;
|
||||
int target;
|
||||
if (dir == -1) {
|
||||
target = cur < 10 ? 0 : cur - 10;
|
||||
} else {
|
||||
target = (total - cur) < 10 ? total : cur + 10;
|
||||
}
|
||||
seek(target);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_audioPlayer?.pause();
|
||||
_audioPlayer?.release();
|
||||
_audioPlayer = null;
|
||||
_audioPositionSub?.cancel();
|
||||
_audioDurationSub?.cancel();
|
||||
_audioStateSub?.cancel();
|
||||
_audioCompleSub?.cancel();
|
||||
_seekSub?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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_model/acg/comic_chapters_model.dart';
|
||||
import 'package:hgdj/hj_model/cartoon_media_info.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
|
||||
|
||||
import 'acg_source_manager.dart';
|
||||
import 'free_badge.dart';
|
||||
|
||||
//漫画子集底部弹窗
|
||||
class CartoonSubSetAlert extends StatefulWidget {
|
||||
final ACGSourceManager? sourceManager;
|
||||
|
||||
const CartoonSubSetAlert({super.key, this.sourceManager});
|
||||
|
||||
@override
|
||||
State<CartoonSubSetAlert> createState() => _CartoonSubSetAlertState();
|
||||
}
|
||||
|
||||
class _CartoonSubSetAlertState extends State<CartoonSubSetAlert> {
|
||||
CartoonMediaInfo? get mediaInfo => widget.sourceManager?.mediaInfo;
|
||||
List<ComicChapterInfo> allEpisodes = [];
|
||||
bool isAscending = true; // true-升序 false-降序
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
allEpisodes.addAll(widget.sourceManager?.allEpisodes ?? []);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: Get.width,
|
||||
height: 425,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
18.sizeBoxH,
|
||||
const SheetHandleBar(color: Color(0xff989898)),
|
||||
18.sizeBoxH,
|
||||
_buildHeader(),
|
||||
Expanded(child: _buildEpisodeList()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
16.sizeBoxW,
|
||||
const Text(
|
||||
'漫画列表',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
10.sizeBoxW,
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: changeSortType,
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: isAscending ? 0 : 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Image.asset('text_play_sort.png'.acgImgPath, width: 20),
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
isAscending ? '正序' : '倒序',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .5),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
22.sizeBoxW,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodeList() {
|
||||
return ListView.builder(
|
||||
key: ValueKey(isAscending), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
itemCount: allEpisodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final info = allEpisodes[index];
|
||||
final realIndex = (info.episodeNumber ?? 1) - 1;
|
||||
final sel = realIndex == widget.sourceManager?.index;
|
||||
return StaggerInItem(
|
||||
index: index,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: realIndex),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
//与详情/阅读抽屉子集 title 一致:第X话(CartoonDetailPage 只处理 image,恒为漫画)
|
||||
'第${info.episodeNumber ?? ''}${mediaInfo?.unit ?? ''}',
|
||||
style: TextStyle(
|
||||
color: sel
|
||||
? Colors.white.withValues(alpha: .9)
|
||||
: Colors.white.withValues(alpha: .45),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
//无任何权限时,前 N 集免费展示「免费」角标
|
||||
if (mediaInfo?.hasPermission == false &&
|
||||
info.inFreeEpisode) ...[
|
||||
8.sizeBoxW,
|
||||
const FreeBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void changeSortType() {
|
||||
isAscending = !isAscending;
|
||||
final list = widget.sourceManager?.allEpisodes ?? [];
|
||||
allEpisodes = isAscending ? List.of(list) : list.reversed.toList();
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ACG「免费」角标:整本无任何权限(hasPermission == false)且该集落在前 N 集免费区间时展示。
|
||||
/// 默认行内胶囊(详情页/子集抽屉/有声列表);[isCorner] 为卡片左上角贴边样式(选集面板小方块)。
|
||||
class FreeBadge extends StatelessWidget {
|
||||
final bool isCorner;
|
||||
|
||||
const FreeBadge({super.key, this.isCorner = false});
|
||||
|
||||
static const _bgColor = Color(0xff64CBC2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isCorner) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: const BoxDecoration(
|
||||
color: _bgColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(7),
|
||||
bottomRight: Radius.circular(7),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'免费',
|
||||
style: TextStyle(color: Colors.white, fontSize: 9, height: 1.2, fontWeight: FontWeight.w400),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _bgColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: const Text(
|
||||
'免费',
|
||||
style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w400),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
import 'package:hgdj/hj_model/banner/comment_top_banner_model.dart';
|
||||
import 'package:hgdj/hj_model/comment/comment_model.dart';
|
||||
import 'package:hgdj/hj_model/comment/reply_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/comment/cell/reply_comment_item.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/comment_service.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/common_service.dart';
|
||||
import 'package:hgdj/hj_utils/date_time_util.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
|
||||
import 'package:like_button/like_button.dart';
|
||||
|
||||
import '../../../tools_base/widget/net_image_widget.dart';
|
||||
import '../../home/widget/user_avatar.dart';
|
||||
import '../../home/widget/user_name_view.dart';
|
||||
import '../widget/comment_rich_text.dart';
|
||||
import '../widget/comment_top_banner.dart';
|
||||
|
||||
/// 一条评论:头像 + 昵称 + 正文(可带图) + 时间/点赞/回复 + 折叠的回复列表
|
||||
class CommentItem extends StatefulWidget {
|
||||
final CommentModel comment;
|
||||
final String objId;
|
||||
final List<CommentTopBannerModel>? topBanners; // 置顶评论下方配置 Banner
|
||||
final Function(CommentModel, {ReplyModel? reply})? replyHandler;
|
||||
|
||||
const CommentItem(
|
||||
this.comment, {
|
||||
super.key,
|
||||
required this.objId,
|
||||
this.replyHandler,
|
||||
this.topBanners,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CommentItem> createState() => _CommentItemState();
|
||||
}
|
||||
|
||||
class _CommentItemState extends State<CommentItem> {
|
||||
CommentModel get comment => widget.comment;
|
||||
|
||||
late final isLikedNof = ValueNotifier(comment.isLike ?? false);
|
||||
bool _isLoadingMore = false;
|
||||
bool _isExpand = false;
|
||||
int _page = 1;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
isLikedNof.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UserAvatar(
|
||||
size: 40,
|
||||
model: Publisher()
|
||||
..uid = comment.userID
|
||||
..portrait = comment.userPortrait
|
||||
..superUser = (comment.superUser ?? false),
|
||||
showVip: false,
|
||||
), //头像
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
UserNameView(
|
||||
name: comment.userName,
|
||||
isVip: (comment.vipLevel ?? 0) > 0,
|
||||
nameColor: Color(0xff9A9A9A),
|
||||
),
|
||||
if ((comment.vipLevel ?? 0) > 0) ...[
|
||||
4.sizeBoxW,
|
||||
Image.asset("vip_v.webp".commentPath,
|
||||
width: 16),
|
||||
]
|
||||
],
|
||||
),
|
||||
8.sizeBoxH,
|
||||
CommentRichText(model: comment),
|
||||
if (comment.image?.isNotEmpty == true)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
ImageBrowserPage.open([comment.image ?? '']);
|
||||
},
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 6),
|
||||
alignment: Alignment.centerLeft,
|
||||
constraints: BoxConstraints(maxWidth: 150),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: comment.image ?? ""),
|
||||
),
|
||||
),
|
||||
// 置顶评论配置 Banner(多图轮播,支持 GIF / 内外链)
|
||||
if (widget.topBanners?.isNotEmpty == true)
|
||||
CommentTopBanner(banners: widget.topBanners!),
|
||||
8.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
comment.createdAt?.utcToAgo() ?? "",
|
||||
style: TextStyle(
|
||||
color: Color(0x73FFFFFF),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!comment.isOfficial &&
|
||||
!comment.isHideLikeOrComment) ...[
|
||||
ValueListenableBuilder(
|
||||
valueListenable: isLikedNof,
|
||||
builder: (context, value, child) => Row(
|
||||
children: [
|
||||
LikeButton(
|
||||
isLiked: value,
|
||||
onTap: (_) => _toggleLike(),
|
||||
size: 20,
|
||||
likeBuilder: (isLiked) {
|
||||
return Image.asset(isLiked
|
||||
? 'like_red.webp'.commentPath
|
||||
: 'like_white.webp'.commentPath);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
comment.likeCount?.countOr("0") ?? "0",
|
||||
style: TextStyle(
|
||||
color: Color(0xE5FFFFFF),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
widget.replyHandler?.call(comment),
|
||||
child: Image.asset('msg_icon.png'.commentPath,
|
||||
width: 20),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
_replyList(),
|
||||
// 展开更多评论
|
||||
if ((comment.replies?.length ?? 0) > 0)
|
||||
_moreReplyBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
const Divider(height: 1, color: Color(0x0fffffff)),
|
||||
18.sizeBoxH,
|
||||
],
|
||||
),
|
||||
if ((comment.isGodComment ?? false))
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: -10,
|
||||
child: Transform.rotate(
|
||||
angle: pi / 60,
|
||||
child: Image.asset(
|
||||
'god_comment.webp'.commentPath,
|
||||
width: 72,
|
||||
height: 60,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
/// 展开 / 收起那一行:短横线 + 文案 + 箭头
|
||||
Widget _replyBarRow(String text, {bool isUp = false}) {
|
||||
final chevron = Image.asset("chevron_down.webp".commonImgPath, width: 16);
|
||||
return Row(
|
||||
children: [
|
||||
Container(width: 20, height: 1, color: const Color(0xff989898)),
|
||||
4.sizeBoxW,
|
||||
Text(text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xff989898),
|
||||
fontWeight: FontWeight.w500)),
|
||||
2.sizeBoxW,
|
||||
isUp ? Transform.rotate(angle: pi, child: chevron) : chevron,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _moreReplyBar() {
|
||||
final replyCount = comment.replies?.length ?? 0;
|
||||
if (replyCount > 1 && _isExpand) {
|
||||
//收起:padding 在点击区里面,跟改动前一致
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _isExpand = false),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _replyBarRow("收起${comment.commCount ?? 0}条回复", isUp: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
final diff = (comment.commCount ?? 0) - (_isExpand ? replyCount : 1);
|
||||
if (diff <= 0) return const SizedBox.shrink();
|
||||
if (_isLoadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 12, left: 6),
|
||||
child: CupertinoActivityIndicator(color: Colors.white, radius: 8),
|
||||
);
|
||||
}
|
||||
//展开:padding 在点击区外面别算进热区;外面这层 Row 也不能省——
|
||||
//它给 InkWell 的是无界宽约束,InkWell 才会只包住文字而不是撑满整行
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _expandReplies,
|
||||
child: _replyBarRow(replyCount > 99 ? '展开更多回复' : '展开$diff条回复'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 展开回复
|
||||
Future _expandReplies() async {
|
||||
if (comment.replies?.isNotEmpty != true) {
|
||||
comment.commCount = 0;
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
if (_isLoadingMore) return;
|
||||
_isLoadingMore = true;
|
||||
setState(() {});
|
||||
_isExpand = true;
|
||||
try {
|
||||
if (comment.hasMoreReply ?? false) {
|
||||
final curTime = DateTimeUtil.format2utc(DateTime.now());
|
||||
final fastId = comment.replies!.first.id ?? '';
|
||||
final result = await CommentService.getReplyList(widget.objId,
|
||||
comment.id ?? '', curTime, comment.replyPage ?? _page, 5, fastId);
|
||||
comment.hasMoreReply = result?.hasNext ?? false;
|
||||
comment.replies?.addAll(result?.list ?? []);
|
||||
if (result?.hasNext == true) {
|
||||
_page += 1;
|
||||
comment.replyPage = (comment.replyPage ?? 0) + 1;
|
||||
} else {
|
||||
comment.commCount = comment.replies?.length ?? 0;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
_isLoadingMore = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<bool> _toggleLike() async {
|
||||
final isLike = comment.isLike ?? false;
|
||||
bool result;
|
||||
if (!isLike) {
|
||||
result = await CommonService.sendLike(comment.id ?? '', 'COMMENT');
|
||||
if (result) comment.likeCount = (comment.likeCount ?? 0) + 1;
|
||||
} else {
|
||||
result = await CommonService.cancelLike(comment.id ?? '', 'COMMENT');
|
||||
if (result) comment.likeCount = (comment.likeCount ?? 1) - 1;
|
||||
}
|
||||
if (result) comment.isLike = !isLike;
|
||||
isLikedNof.value = comment.isLike!;
|
||||
return isLikedNof.value;
|
||||
}
|
||||
|
||||
//没展开时只露第一条
|
||||
Widget _replyList() {
|
||||
final replies = comment.replies ?? [];
|
||||
if (replies.isEmpty) return const SizedBox.shrink();
|
||||
final length = (!_isExpand && replies.length > 1) ? 1 : replies.length;
|
||||
return Column(
|
||||
//stretch 对齐原来 ListView 的行为:子项撑满宽度,不按内容自适应
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: List.generate(
|
||||
length,
|
||||
(i) => ReplyCommentItem(
|
||||
replies[i],
|
||||
replyHandler: (reply) =>
|
||||
widget.replyHandler?.call(comment, reply: reply),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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/extension/extensions.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
|
||||
import 'package:like_button/like_button.dart';
|
||||
|
||||
import '../../../hj_model/comment/reply_model.dart';
|
||||
import '../../../hj_utils/api_service/common_service.dart';
|
||||
import '../../../tools_base/widget/net_image_widget.dart';
|
||||
import '../../user_center_page/user_center_page.dart';
|
||||
|
||||
/// 评论下的一条回复:小头像 + 「A ▸ B」 + 正文(可带图) + 时间/点赞/回复
|
||||
class ReplyCommentItem extends StatefulWidget {
|
||||
final ReplyModel reply;
|
||||
final Function(ReplyModel)? replyHandler;
|
||||
|
||||
const ReplyCommentItem(this.reply, {super.key, this.replyHandler});
|
||||
|
||||
@override
|
||||
State<ReplyCommentItem> createState() => _ReplyCommentItemState();
|
||||
}
|
||||
|
||||
class _ReplyCommentItemState extends State<ReplyCommentItem> {
|
||||
ReplyModel get reply => widget.reply;
|
||||
late final isLikedNof = ValueNotifier(reply.isLike ?? false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
isLikedNof.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
16.sizeBoxH,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (reply.userID != null) {
|
||||
Get.to(() => UserCenterPage(uid: reply.userID ?? 0));
|
||||
}
|
||||
},
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: reply.userPortrait ?? '',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 18,
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
reply.userName ?? '',
|
||||
style: TextStyle(
|
||||
color: Color(0x73FFFFFF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
3.sizeBoxW,
|
||||
if (reply.toUserName?.isNotEmpty == true) ...[
|
||||
Image.asset(
|
||||
"arrow_grey.webp".commentPath,
|
||||
width: 16,
|
||||
height: 16,
|
||||
),
|
||||
3.sizeBoxW,
|
||||
Text(
|
||||
reply.toUserName ?? '',
|
||||
style:
|
||||
TextStyle(color: Color(0x73FFFFFF), fontSize: 14),
|
||||
),
|
||||
]
|
||||
],
|
||||
), // 还有个类似评分的东西
|
||||
8.sizeBoxH,
|
||||
Text(
|
||||
reply.content ?? '',
|
||||
style: TextStyle(
|
||||
color: Color(0xE5FFFFFF),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (reply.image?.isNotEmpty == true)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
ImageBrowserPage.open([reply.image ?? '']);
|
||||
},
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 6),
|
||||
alignment: Alignment.centerLeft,
|
||||
constraints: BoxConstraints(maxWidth: 150),
|
||||
child: NetworkImageLoader(imageUrl: reply.image ?? ""),
|
||||
),
|
||||
),
|
||||
8.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
reply.createdAt?.utcToAgo() ?? "",
|
||||
style: const TextStyle(
|
||||
color: Color(0x73FFFFFF), fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: isLikedNof,
|
||||
builder: (context, value, child) => Row(
|
||||
children: [
|
||||
LikeButton(
|
||||
isLiked: value,
|
||||
onTap: (_) => _toggleLike(),
|
||||
size: 20,
|
||||
likeBuilder: (isLiked) {
|
||||
return Image.asset(isLiked
|
||||
? 'like_red.webp'.commentPath
|
||||
: 'like_white.webp'.commentPath);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
reply.likeCount?.countOr("0") ?? "0",
|
||||
style: TextStyle(
|
||||
color: Color(0xE5FFFFFF),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
GestureDetector(
|
||||
onTap: () => widget.replyHandler?.call(reply),
|
||||
child:
|
||||
Image.asset('msg_icon.png'.commentPath, width: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 评论点赞
|
||||
Future<bool> _toggleLike() async {
|
||||
final isLike = reply.isLike ?? false;
|
||||
bool result;
|
||||
if (!isLike) {
|
||||
result = await CommonService.sendLike(reply.id ?? '', 'COMMENT');
|
||||
if (result) reply.likeCount = (reply.likeCount ?? 0) + 1;
|
||||
} else {
|
||||
result = await CommonService.cancelLike(reply.id ?? '', 'COMMENT');
|
||||
if (result) reply.likeCount = (reply.likeCount ?? 1) - 1;
|
||||
}
|
||||
if (result) reply.isLike = !isLike;
|
||||
isLikedNof.value = reply.isLike!;
|
||||
return isLikedNof.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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 'comment_views.dart';
|
||||
|
||||
Future showCommentDialog(
|
||||
String objId, {
|
||||
String objType = "video",
|
||||
int? commentCount,
|
||||
}) async {
|
||||
return Get.bottomSheet(
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
height: screen.screenHeight * 0.72,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xff141414),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.only(top: 12, bottom: 12),
|
||||
child: const SheetHandleBar(color: Color(0x1AFFFFFF)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 16),
|
||||
child: Text(
|
||||
"${commentCount ?? 0}条评论",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 14),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CommentView(objId, objType: objType),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
elevation: 0.72,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'dart:io';
|
||||
|
||||
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/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/group_text_filed.dart';
|
||||
import 'package:image_pickers/image_pickers.dart';
|
||||
|
||||
/// 评论输入弹层:点空白关闭,自动聚焦拉起键盘
|
||||
class CommentInputView extends StatefulWidget {
|
||||
static const String routeName = 'comment_input';
|
||||
final String? hint;
|
||||
final TextEditingController textCtr;
|
||||
final String? imgPath;
|
||||
final Function(String?)? onImgChange;
|
||||
|
||||
const CommentInputView({
|
||||
super.key,
|
||||
this.hint,
|
||||
required this.textCtr,
|
||||
this.imgPath,
|
||||
this.onImgChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CommentInputView> createState() => _CommentInputViewState();
|
||||
}
|
||||
|
||||
class _CommentInputViewState extends State<CommentInputView> {
|
||||
final focus = FocusNode();
|
||||
|
||||
TextEditingController get textCtr => widget.textCtr;
|
||||
//弹层内自己维护一份,选/删图先本地刷新再回调出去
|
||||
String? _imgPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_imgPath = widget.imgPath;
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
focus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _pickImage() async {
|
||||
final listMedia = await ImagePickers.pickerPaths(
|
||||
uiConfig: UIConfig(uiThemeColor: Colors.white),
|
||||
galleryMode: GalleryMode.image,
|
||||
selectCount: 1,
|
||||
showCamera: false,
|
||||
);
|
||||
if (listMedia.isNotEmpty) {
|
||||
_imgPath = listMedia.first.path ?? '';
|
||||
widget.onImgChange?.call(_imgPath);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(),
|
||||
child: Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_imgPath?.isNotEmpty == true)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.file(
|
||||
File(_imgPath!),
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 6,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_imgPath = null;
|
||||
widget.onImgChange?.call(null);
|
||||
setState(() {});
|
||||
},
|
||||
child: Image.asset(
|
||||
"close_grey.png".commonImgPath,
|
||||
width: 12,
|
||||
height: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: GroupTextFiled(
|
||||
focusNode: focus,
|
||||
controller: textCtr,
|
||||
height: 32,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
textStyle:
|
||||
TextStyle(color: Colors.white, fontSize: 14),
|
||||
onSubmitted: (value) {
|
||||
Get.back(result: true);
|
||||
},
|
||||
placeholder: widget.hint ?? '请在此输入评论...',
|
||||
placeholderTextStyle: TextStyle(
|
||||
color: Color(0xff4D4D4D), fontSize: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xff1A1A1A),
|
||||
borderRadius: BorderRadius.circular(40)),
|
||||
),
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 12, top: 4, bottom: 4),
|
||||
child: GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Image.asset(
|
||||
'icon_pic.webp'.commentPath,
|
||||
width: 30,
|
||||
height: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (textCtr.text.isEmpty) {
|
||||
showToast('请输入想要说的话哦~~');
|
||||
return;
|
||||
}
|
||||
Get.back(result: true);
|
||||
},
|
||||
child: Image.asset(
|
||||
'ic_send.webp'.commentPath,
|
||||
width: 30,
|
||||
height: 30,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/comment/comment_model.dart';
|
||||
import 'package:hgdj/hj_model/comment/reply_model.dart';
|
||||
import 'package:hgdj/hj_page/comment/comment_input_view.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/comment_service.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/common_service.dart';
|
||||
import 'package:hgdj/hj_utils/date_time_util.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../hj_model/banner/comment_top_banner_model.dart';
|
||||
import '../../hj_model/comment/comment_list_res.dart';
|
||||
import '../../tools_base/file_upload/file_upload_tool.dart';
|
||||
import '../../tools_base/file_upload/upload_result_model.dart';
|
||||
|
||||
mixin CommentMixin on GetxController {
|
||||
final comments = <CommentModel>[];
|
||||
final commentController = TextEditingController();
|
||||
Function(int)? totalCallback;
|
||||
CommentModel? replyComment;
|
||||
ReplyModel? replyCommentModel;
|
||||
int page = 1;
|
||||
String? hintText;
|
||||
Rx<String?> selectedImagePath = Rx<String?>(null);
|
||||
RefreshController? refreshCtr;
|
||||
final quickSearchList = <CommentLink>[];
|
||||
final commentTopBanners = <CommentTopBannerModel>[];
|
||||
String? id;
|
||||
String? objType;
|
||||
RxBool isSendingMsg = false.obs;
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
commentController.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future sendComment() async {
|
||||
String content = commentController.text;
|
||||
if (content.isEmpty) {
|
||||
showToast('请输入评论内容哦~');
|
||||
return;
|
||||
}
|
||||
isSendingMsg.value = true;
|
||||
String? sendImagePath;
|
||||
if (selectedImagePath.value?.isNotEmpty == true) {
|
||||
try {
|
||||
ImageUploadResultModel? imgResult =
|
||||
await FileUploadTool().uploadImage(selectedImagePath.value!);
|
||||
sendImagePath = imgResult?.coverImg ?? "";
|
||||
if (sendImagePath.isEmpty) {
|
||||
isSendingMsg.value = false;
|
||||
showToast("图片上传失败!");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
isSendingMsg.value = false;
|
||||
showToast("图片上传失败!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (replyComment != null) {
|
||||
// 回复评论
|
||||
final res = await CommentService.sendReply(
|
||||
id,
|
||||
2,
|
||||
content,
|
||||
rid: replyCommentModel?.id,
|
||||
cid: replyComment?.id,
|
||||
toUserID: replyCommentModel?.toUserID,
|
||||
objType: objType,
|
||||
image: sendImagePath,
|
||||
);
|
||||
if (res != null) {
|
||||
replyComment?.replies ??= [];
|
||||
replyComment?.replies?.insert(0, res);
|
||||
replyComment?.commCount = replyComment?.replies?.length ?? 0;
|
||||
replyComment = null;
|
||||
replyCommentModel = null;
|
||||
hintText = null;
|
||||
showToast('已提交,审核通过后将展示');
|
||||
}
|
||||
update();
|
||||
} else {
|
||||
// 评论
|
||||
final res = await CommentService.sendComment(
|
||||
id,
|
||||
1,
|
||||
content,
|
||||
objType ?? '',
|
||||
image: sendImagePath,
|
||||
);
|
||||
if (res == null) {
|
||||
hintText = null;
|
||||
} else {
|
||||
showToast('已提交,审核通过后将展示');
|
||||
hintText = null;
|
||||
//插在第一条普通评论前面,置顶那几条不动
|
||||
final insertIndex =
|
||||
comments.indexWhere((e) => !e.isHideLikeOrComment);
|
||||
comments.insert(
|
||||
insertIndex == -1 ? comments.length : insertIndex, res);
|
||||
}
|
||||
update();
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
selectedImagePath.value = null;
|
||||
commentController.text = "";
|
||||
isSendingMsg.value = false;
|
||||
}
|
||||
|
||||
// isSend: true 点击了发送按钮, 如果没有发送内容弹键盘, 有内容直接发送
|
||||
readyReplyComment(CommentModel? comment,
|
||||
{ReplyModel? replyModel, bool? isSend}) async {
|
||||
replyComment = comment;
|
||||
replyCommentModel = replyModel;
|
||||
if (replyModel != null) {
|
||||
hintText = '回复:${replyModel.userName ?? ''}';
|
||||
}
|
||||
if (comment != null && hintText == null) {
|
||||
hintText = '回复:${comment.userName ?? ''}';
|
||||
}
|
||||
if (isSend == true && commentController.text.isNotEmpty) {
|
||||
update();
|
||||
sendComment();
|
||||
} else {
|
||||
final result = await Get.bottomSheet(
|
||||
CommentInputView(
|
||||
hint: hintText,
|
||||
textCtr: commentController,
|
||||
imgPath: selectedImagePath.value,
|
||||
onImgChange: (value) => selectedImagePath.value = value,
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.transparent,
|
||||
enableDrag: false,
|
||||
settings: RouteSettings(name: CommentInputView.routeName),
|
||||
useRootNavigator: true,
|
||||
);
|
||||
if (result == true) {
|
||||
update();
|
||||
sendComment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取评论
|
||||
Future fetchComments(String id, {bool isRefresh = false}) async {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
page = 1;
|
||||
}
|
||||
final currentT = DateTimeUtil.format2utc(DateTime.now()) ?? "";
|
||||
final result =
|
||||
await CommentService.getCommentList(id, currentT, page, 20);
|
||||
totalCallback?.call(result?.total ?? 0);
|
||||
refreshCtr?.refreshCompleted();
|
||||
refreshCtr?.loadComplete();
|
||||
if (!(result?.hasNext ?? false)) {
|
||||
refreshCtr?.loadNoData();
|
||||
}
|
||||
if (page == 1) {
|
||||
comments.clear();
|
||||
quickSearchList.clear();
|
||||
// 首屏并行拉置顶 Banner;失败不影响评论列表
|
||||
_fetchCommentTopBanners();
|
||||
}
|
||||
comments.addAll(result?.list ?? []);
|
||||
if (page == 1) {
|
||||
quickSearchList.addAll(result?.quickSearchList ?? []);
|
||||
}
|
||||
page += 1;
|
||||
for (final e in comments) {
|
||||
e.hasMoreReply = (e.commCount ?? 0) > (e.replies?.length ?? 0);
|
||||
}
|
||||
} catch (e) {
|
||||
refreshCtr?.refreshCompleted();
|
||||
refreshCtr?.loadComplete();
|
||||
debugLog(e);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
/// 评论区置顶 Banner(scene=COMMENT_TOP)
|
||||
Future<void> _fetchCommentTopBanners() async {
|
||||
try {
|
||||
final list = await CommonService.fetchBannerList(scene: 'COMMENT_TOP');
|
||||
commentTopBanners
|
||||
..clear()
|
||||
..addAll(list);
|
||||
update();
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 首条置顶评论下标:官方评论或带内外链的置顶样式评论
|
||||
int get topPinnedCommentIndex {
|
||||
for (int i = 0; i < comments.length; i++) {
|
||||
final c = comments[i];
|
||||
if (c.isOfficial || c.isHideLikeOrComment) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/comment/cell/comment_item.dart';
|
||||
import 'package:hgdj/hj_page/comment/comment_mixin.dart';
|
||||
import 'package:hgdj/hj_page/comment/widget/comment_foot_view.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
|
||||
import '../home/widget/quick_search_view.dart';
|
||||
|
||||
class CommentLogic extends GetxController with CommentMixin {
|
||||
final String objId;
|
||||
|
||||
final String commentType;
|
||||
|
||||
@override
|
||||
final Function(int)? totalCallback;
|
||||
|
||||
CommentLogic(
|
||||
this.objId, {
|
||||
this.commentType = 'video',
|
||||
this.totalCallback,
|
||||
}) : super();
|
||||
|
||||
bool isLoading = true;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
id = objId;
|
||||
objType = commentType;
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
isLoading = false;
|
||||
fetchComments(objId);
|
||||
}
|
||||
}
|
||||
|
||||
class CommentView extends StatelessWidget {
|
||||
final bool showInput;
|
||||
final String objId;
|
||||
final String objType; // video, cartoon
|
||||
final Function(int)? totalCallback;
|
||||
|
||||
const CommentView(
|
||||
this.objId, {
|
||||
super.key,
|
||||
this.showInput = true,
|
||||
this.objType = 'video',
|
||||
this.totalCallback,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder(
|
||||
init: CommentLogic(objId,
|
||||
commentType: objType, totalCallback: totalCallback),
|
||||
tag: objId,
|
||||
builder: (logic) => LayoutBuilder(builder: (_, cons) {
|
||||
if (cons.maxHeight < 58) return SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: pullYsRefresh(
|
||||
onInit: (refreshCtr) => logic.refreshCtr = refreshCtr,
|
||||
onLoading: (_) => logic.fetchComments(objId),
|
||||
onRefresh: (_) => logic.fetchComments(objId, isRefresh: true),
|
||||
child: () {
|
||||
if (logic.isLoading) return LoadingCenterWidget();
|
||||
if (logic.comments.isEmpty) return CErrorWidget();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
if (logic.quickSearchList.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
margin: EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom:
|
||||
BorderSide(color: Color(0x08ffffff)))),
|
||||
child: QuickSearchView(logic.quickSearchList),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: logic.comments.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final topIndex = logic.topPinnedCommentIndex;
|
||||
final showBanner = index == topIndex &&
|
||||
logic.commentTopBanners.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: CommentItem(
|
||||
key: ValueKey(logic.comments[index].id),
|
||||
logic.comments[index],
|
||||
objId: objId,
|
||||
topBanners:
|
||||
showBanner ? logic.commentTopBanners : null,
|
||||
replyHandler: (comment, {reply}) =>
|
||||
logic.readyReplyComment(comment,
|
||||
replyModel: reply),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}(),
|
||||
),
|
||||
),
|
||||
if (showInput)
|
||||
Obx(
|
||||
() => CommentFootView(
|
||||
isSending: logic.isSendingMsg.value,
|
||||
onComment: (isSend) =>
|
||||
logic.readyReplyComment(null, isSend: isSend),
|
||||
textCtr: logic.commentController,
|
||||
imgPath: logic.selectedImagePath.value,
|
||||
onImgChange: (value) => logic.selectedImagePath.value = value,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
global: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/group_text_filed.dart';
|
||||
import 'package:image_pickers/image_pickers.dart';
|
||||
|
||||
import '../../../hj_utils/api_service/mine_service.dart';
|
||||
import '../../mine/collec_history_buy/col_his_buy_sub_page.dart';
|
||||
|
||||
/// 底部评论条:假输入框 + 选图 + 收藏 + 发送
|
||||
class CommentFootView extends StatefulWidget {
|
||||
final VideoModel? video;
|
||||
final Function(bool isSend) onComment; //false 点的输入框(唤起输入弹层),true 点的发送
|
||||
final bool isSending;
|
||||
final TextEditingController textCtr;
|
||||
final String? imgPath;
|
||||
final Function(String?)? onImgChange;
|
||||
final bool showCollect;
|
||||
const CommentFootView({
|
||||
super.key,
|
||||
this.video,
|
||||
required this.onComment,
|
||||
this.isSending = false,
|
||||
required this.textCtr,
|
||||
this.imgPath,
|
||||
this.onImgChange,
|
||||
this.showCollect = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CommentFootView> createState() => _CommentFootViewState();
|
||||
}
|
||||
|
||||
class _CommentFootViewState extends State<CommentFootView> {
|
||||
bool _isCollectLoading = false;
|
||||
|
||||
bool get isCollect => widget.video?.vidStatus?.hasCollected ?? false;
|
||||
|
||||
void _pickImage() async {
|
||||
final listMedia = await ImagePickers.pickerPaths(
|
||||
uiConfig: UIConfig(uiThemeColor: Colors.white),
|
||||
galleryMode: GalleryMode.image,
|
||||
selectCount: 1,
|
||||
showCamera: false,
|
||||
);
|
||||
if (listMedia.isNotEmpty)
|
||||
widget.onImgChange?.call(listMedia.first.path ?? '');
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onCollect() async {
|
||||
if (_isCollectLoading) return;
|
||||
_isCollectLoading = true;
|
||||
try {
|
||||
final preCollect = isCollect;
|
||||
|
||||
await MineService.postCollect(
|
||||
widget.video?.id, LoadDataType.post.apiType, !isCollect);
|
||||
|
||||
if (!preCollect) {
|
||||
widget.video?.collectCount = (widget.video?.collectCount ?? 0) + 1;
|
||||
} else {
|
||||
widget.video?.collectCount = (widget.video?.collectCount ?? 1) - 1;
|
||||
}
|
||||
_isCollectLoading = false;
|
||||
widget.video?.vidStatus?.hasCollected = !isCollect;
|
||||
if (!preCollect) {
|
||||
showToast("收藏成功");
|
||||
}
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
_isCollectLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
padding: EdgeInsets.only(
|
||||
left: 18.w, right: 18.w, bottom: screen.paddingBottom),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
height: 58,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onComment(false),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 2),
|
||||
child: GroupTextFiled(
|
||||
controller: widget.textCtr,
|
||||
height: 32,
|
||||
enabled: false,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
textStyle: TextStyle(color: Colors.white, fontSize: 14),
|
||||
placeholder: '请在此输入评论...',
|
||||
placeholderTextStyle:
|
||||
TextStyle(color: Color(0xff525252), fontSize: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: EdgeInsets.fromLTRB(0, 4, 4, 4),
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
child: GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: widget.imgPath?.isNotEmpty == true
|
||||
? Image.file(
|
||||
File(widget.imgPath!),
|
||||
width: 30,
|
||||
height: 30,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Image.asset(
|
||||
'icon_pic.webp'.commentPath,
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.imgPath?.isNotEmpty == true)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
widget.onImgChange?.call(null);
|
||||
setState(() {});
|
||||
},
|
||||
child: Image.asset(
|
||||
"close_grey.png".commonImgPath,
|
||||
width: 12,
|
||||
height: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.showCollect)
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _onCollect,
|
||||
child: Image.asset(
|
||||
isCollect
|
||||
? "collect_red.png".commonImgPath
|
||||
: "collect_path.png".commonImgPath,
|
||||
width: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onComment(true),
|
||||
child: widget.isSending
|
||||
? CupertinoActivityIndicator(
|
||||
color: AppColors.actionRed,
|
||||
radius: 12,
|
||||
)
|
||||
: Image.asset('ic_send.webp'.commentPath),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/hj_model/comment/comment_model.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
|
||||
/// 评论正文:命中搜索词时把该词渲染成可点的橙色链接,其余按纯文本
|
||||
class CommentRichText extends StatelessWidget {
|
||||
final CommentModel? model;
|
||||
|
||||
const CommentRichText({super.key, this.model});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const style = TextStyle(color: Color(0xE5FFFFFF), fontSize: 14);
|
||||
final keyword = model?.searchKeyword ?? "";
|
||||
//按关键词切开,段与段之间插链接;切不出两段就当纯文本
|
||||
final parts =
|
||||
keyword.isEmpty ? <String>[] : (model?.content?.split(keyword) ?? []);
|
||||
if (model?.linkStr?.isNotEmpty != true ||
|
||||
keyword.isEmpty ||
|
||||
parts.length <= 1) {
|
||||
return Text(model?.content ?? '', style: style);
|
||||
}
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
style: style,
|
||||
children: [
|
||||
for (int i = 0; i < parts.length; i++) ...[
|
||||
TextSpan(text: parts[i]),
|
||||
if (i != parts.length - 1)
|
||||
WidgetSpan(
|
||||
child: GestureDetector(
|
||||
onTap: () => pushToPageByLink(model?.linkStr),
|
||||
child: Text(
|
||||
keyword,
|
||||
style: const TextStyle(
|
||||
color: Color(0xffF68804), fontSize: 14, height: 1.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/hj_model/banner/comment_top_banner_model.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/banner/ads_banner_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/card_swiper/src/swiper.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
/// 评论区置顶评论下方 Banner:支持多图轮播(含 GIF),按 linkType 跳转内外链
|
||||
class CommentTopBanner extends StatefulWidget {
|
||||
final List<CommentTopBannerModel> banners;
|
||||
final double borderRadius;
|
||||
|
||||
/// 宽高比,默认对齐站内 Banner 素材 720×150;勿用固定高度,窄/宽屏 cover 会裁切
|
||||
final double aspectRatio;
|
||||
|
||||
const CommentTopBanner({
|
||||
super.key,
|
||||
required this.banners,
|
||||
this.borderRadius = 12,
|
||||
this.aspectRatio = 720 / 150,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CommentTopBanner> createState() => _CommentTopBannerState();
|
||||
}
|
||||
|
||||
class _CommentTopBannerState extends State<CommentTopBanner> {
|
||||
final ValueNotifier<int> _selectIndex = ValueNotifier(0);
|
||||
|
||||
int get _count => widget.banners.length;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_selectIndex.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _onTap(CommentTopBannerModel item) async {
|
||||
final type = (item.linkType ?? '').toUpperCase();
|
||||
final value = item.linkValue?.trim() ?? '';
|
||||
if (type == 'NONE' || value.isEmpty) return;
|
||||
|
||||
if (type == 'EXTERNAL') {
|
||||
if (value.startsWith('http')) {
|
||||
await launchUrlToWeb(value);
|
||||
} else {
|
||||
await pushToPageByLink(value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'INTERNAL') {
|
||||
// 兼容 video://detail?id=xxx
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri != null && uri.scheme == 'video') {
|
||||
final id = uri.queryParameters['id'];
|
||||
if (id?.isNotEmpty == true) {
|
||||
await pushToVideoPage(videoId: id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await pushToPageByLink(value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_count == 0) return const SizedBox.shrink();
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
width: double.infinity,
|
||||
child: AspectRatio(
|
||||
aspectRatio: widget.aspectRatio,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Swiper(
|
||||
autoplay: _count > 1,
|
||||
autoplayDelay: 3000,
|
||||
loop: _count > 1,
|
||||
itemCount: _count,
|
||||
onIndexChanged: (index) => _selectIndex.value = index,
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.banners[index];
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _onTap(item),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: item.imageUrl ?? '',
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_count > 1)
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _selectIndex,
|
||||
builder: (_, index, __) => CIndicator(
|
||||
itemCount: _count,
|
||||
selectIndex: index,
|
||||
space: 3,
|
||||
dotSize: 4,
|
||||
selectWidth: 10,
|
||||
isBarStyle: true,
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
selectColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import '../../main_page/provider/bottom_bar_provider.dart';
|
||||
|
||||
class CommunityMainModuleLogic extends GetxController
|
||||
with GetTickerProviderStateMixin {
|
||||
final int defaultIndex;
|
||||
final modules = [];
|
||||
late final TabController tabController;
|
||||
|
||||
CommunityMainModuleLogic(this.defaultIndex);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
modules.addAll(Config.plateModule?.community ?? []);
|
||||
tabController = TabController(
|
||||
length: modules.length, vsync: this, initialIndex: defaultIndex);
|
||||
tabController.addListener(() {
|
||||
if (!tabController.indexIsChanging) {
|
||||
CommunityBottomProvider().subIndex = tabController.index;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void skipToTabIndex(int index) {
|
||||
tabController.index = index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import '../../../tools_base/indicator/custom_tab_indicator.dart';
|
||||
import '../../../tools_base/loading/loading_center_widget.dart';
|
||||
import '../../home/widget/publist_entry_alert.dart';
|
||||
import 'community_module_logic.dart';
|
||||
import 'community_tab_page.dart';
|
||||
|
||||
//社区帖子亚模块
|
||||
class CommunityModulePage extends StatelessWidget {
|
||||
final int defaultIndex;
|
||||
const CommunityModulePage({super.key, this.defaultIndex = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CommunityMainModuleLogic>(
|
||||
init: CommunityMainModuleLogic(defaultIndex),
|
||||
builder: (logic) {
|
||||
if (logic.modules.isEmpty) return CErrorWidget();
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
TabBar(
|
||||
labelPadding: EdgeInsets.zero,
|
||||
padding: EdgeInsets.zero,
|
||||
tabAlignment: TabAlignment.start,
|
||||
indicator: CustomIndicator(offsetY: 6),
|
||||
indicatorWeight: 1,
|
||||
unselectedLabelStyle:
|
||||
TextStyle(color: Color(0x8cFFFFFF), fontSize: 16),
|
||||
labelStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
isScrollable: true,
|
||||
tabs: logic.modules
|
||||
.map(
|
||||
(e) => Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w, vertical: 13.h),
|
||||
child: Text(e.moduleName ?? ''),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
controller: logic.tabController,
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: logic.tabController,
|
||||
children: logic.modules
|
||||
.asMap()
|
||||
.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
CommunityTabPage(
|
||||
model: value,
|
||||
index: key,
|
||||
cancelCallBack: () {
|
||||
logic.tabController.index =
|
||||
logic.tabController.previousIndex;
|
||||
},
|
||||
).keepAlive,
|
||||
),
|
||||
)
|
||||
.values
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: 50,
|
||||
child: PublishButton(entry: PublishEntry.community),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../../hj_model/home/plate_model.dart';
|
||||
import '../../home/widget/collect_status_wrapper.dart';
|
||||
import '../../main_page/provider/bottom_bar_provider.dart';
|
||||
|
||||
class CommunityTabLogic extends GetxController {
|
||||
final ModuleData? model;
|
||||
final int index;
|
||||
|
||||
CommunityTabLogic(this.model, {this.index = 0});
|
||||
|
||||
RefreshController? refreshController;
|
||||
late ScrollController sctrl =
|
||||
CommunityBottomProvider().scrollCtr(MediaStyle.Community, index);
|
||||
|
||||
int page = 1;
|
||||
bool isLoading = true;
|
||||
|
||||
/// 索引
|
||||
int sort = 0;
|
||||
//标题与后端 moduleSort 绑在一起:原来标题在 CommunityMiddleHeader、值在这里,
|
||||
//两个数组分家,任一边加减一项就会整体错位
|
||||
static const sortTabs = [
|
||||
SortTab('推荐', 7),
|
||||
SortTab('最新', 1),
|
||||
SortTab('最热', 2),
|
||||
SortTab('热评', 9),
|
||||
];
|
||||
int? tagIndex;
|
||||
String? tagId;
|
||||
List<VideoModel> dataSource = [];
|
||||
|
||||
/// 顶部的专题
|
||||
final List<TagsBean> specials = [];
|
||||
|
||||
StreamSubscription? _collectStatusSub;
|
||||
|
||||
void _onCollectStatusChanged(CollectStatusModel event) {
|
||||
if (event.isLiked == null && event.likeCountDelta == null) return;
|
||||
var changed = false;
|
||||
for (final item in dataSource) {
|
||||
if (item.id == event.id) {
|
||||
applyVideoModelCollectStatus(item, event);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) update();
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_collectStatusSub =
|
||||
eventBus.on<CollectStatusModel>(_onCollectStatusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_collectStatusSub?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
tagId = model?.defaultTagId;
|
||||
fetchPagedata();
|
||||
}
|
||||
|
||||
void changeSpecialsIndex(int index) {
|
||||
if (tagIndex == index) {
|
||||
tagId = null;
|
||||
tagIndex = null;
|
||||
} else {
|
||||
tagIndex = index;
|
||||
tagId = specials[index].id;
|
||||
}
|
||||
fetchPagedata(needLoading: true);
|
||||
}
|
||||
|
||||
void scrollerToTop() {
|
||||
sctrl.jumpTo(0);
|
||||
}
|
||||
|
||||
Future<void> fetchPagedata(
|
||||
{bool isRefresh = true, bool needLoading = false}) async {
|
||||
if (isRefresh) {
|
||||
page = 1;
|
||||
}
|
||||
if (needLoading) {
|
||||
isLoading = true;
|
||||
update();
|
||||
}
|
||||
try {
|
||||
final res = await VidService.getModuleDetail(
|
||||
model?.id ?? '',
|
||||
pageNumber: page,
|
||||
moduleSort: sortTabs[sort].sort,
|
||||
tagId: tagId,
|
||||
);
|
||||
if (isRefresh) {
|
||||
dataSource.clear();
|
||||
refreshController?.refreshCompleted();
|
||||
}
|
||||
(res?.hasNext ?? false)
|
||||
? refreshController?.loadComplete()
|
||||
: refreshController?.loadNoData();
|
||||
dataSource.addAll(res?.allVideoInfo ?? []);
|
||||
if ((res?.allSection?.isNotEmpty ?? false) && specials.isEmpty) {
|
||||
specials.addAll(res!.allSection?.firstOrNull?.allTags ?? []);
|
||||
if (model?.defaultTagId?.isNotEmpty == true) {
|
||||
tagIndex = specials
|
||||
.indexWhere((element) => element.id == model?.defaultTagId);
|
||||
}
|
||||
}
|
||||
page += 1;
|
||||
} catch (e) {
|
||||
// 接口异常兜底:结束刷新/加载态,避免 loading、下拉/上拉指示器卡死
|
||||
debugLog(e);
|
||||
isRefresh
|
||||
? refreshController?.refreshCompleted()
|
||||
: refreshController?.loadComplete();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.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/banner/ads_grid_view_widget.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
|
||||
import '../../../hj_model/home/plate_model.dart';
|
||||
import '../../../hj_model/video_model.dart';
|
||||
import '../../../hj_utils/sliver_delegate.dart';
|
||||
import '../../../tools_base/widget/net_image_widget.dart';
|
||||
import '../widget/community_middle_header.dart';
|
||||
import '../widget/community_post_widget.dart';
|
||||
import 'community_tab_logic.dart';
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
|
||||
class CommunityTabPage extends StatelessWidget {
|
||||
final ModuleData model;
|
||||
final int index;
|
||||
final Function()? cancelCallBack;
|
||||
|
||||
const CommunityTabPage({
|
||||
super.key,
|
||||
required this.model,
|
||||
this.index = 0,
|
||||
this.cancelCallBack,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CommunityTabLogic>(
|
||||
tag: 'community_tab_${model.id ?? index}',
|
||||
init: CommunityTabLogic(model, index: index),
|
||||
builder: (logic) {
|
||||
return pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshController = ctr,
|
||||
onRefresh: (_) => logic.fetchPagedata(),
|
||||
onLoading: (_) => logic.fetchPagedata(isRefresh: false),
|
||||
child: CustomScrollView(
|
||||
controller: logic.sctrl,
|
||||
slivers: [
|
||||
if (model.pureVersion != true)
|
||||
SliverToBoxAdapter(
|
||||
child: AdsGridViewWidget(
|
||||
7,
|
||||
accordingAdsType: true,
|
||||
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 10.h),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSpecialView(logic),
|
||||
),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
floating: true,
|
||||
delegate: MySliverDelegate(
|
||||
maxHeight: 34,
|
||||
minHeight: 34,
|
||||
child: CommunityMiddleHeader(
|
||||
sortOnTap: (sort) {
|
||||
logic.sort = sort;
|
||||
logic.fetchPagedata(needLoading: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildTableView(logic),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpecialView(CommunityTabLogic logic) {
|
||||
if (logic.specials.isEmpty) return SizedBox.shrink();
|
||||
return GridView.builder(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(left: 16.w, right: 16.w, bottom: 18.h),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 7,
|
||||
crossAxisSpacing: 7,
|
||||
childAspectRatio: 120 / 57,
|
||||
),
|
||||
itemCount: logic.specials.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => logic.changeSpecialsIndex(index),
|
||||
child: _buildSpecialTagCell(logic, index),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpecialTagCell(CommunityTabLogic logic, int index) {
|
||||
TagsBean model = logic.specials[index];
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: logic.tagIndex == index
|
||||
? BoxDecoration(
|
||||
color: Color(0xff303030),
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
border: Border.all(
|
||||
color: AppColors.actionRed,
|
||||
width: 1,
|
||||
))
|
||||
: BoxDecoration(
|
||||
color: Color(0xff303030),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: 0),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
NetworkImageLoader(
|
||||
imageUrl: model.coverImg ?? '',
|
||||
borderRadius: 6,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.black.withValues(alpha: .6)),
|
||||
),
|
||||
if (model.hotMark?.isNotEmpty == true)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color.fromRGBO(252, 118, 118, 1),
|
||||
Color.fromRGBO(212, 39, 39, 1),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(6),
|
||||
topLeft: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
"${model.hotMark}",
|
||||
style: TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"${model.tagName}",
|
||||
style: TextStyle(
|
||||
color: logic.tagIndex == index
|
||||
? AppColors.actionRed
|
||||
: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
2.sizeBoxH,
|
||||
Text(
|
||||
"${model.videoCount?.countStr}个帖子",
|
||||
style: TextStyle(
|
||||
color: logic.tagIndex == index
|
||||
? AppColors.actionRed
|
||||
: Colors.white,
|
||||
fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTableView(CommunityTabLogic logic) {
|
||||
if (logic.isLoading) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: LoadingCenterWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (logic.dataSource.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: CErrorWidget(
|
||||
retryOnTap: () => logic.fetchPagedata(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList.builder(
|
||||
itemCount: logic.dataSource.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
VideoModel videoModel = logic.dataSource[index];
|
||||
return CommunityPostWidget(
|
||||
videoModel: videoModel,
|
||||
videoModels: logic.dataSource,
|
||||
showLine: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
|
||||
import '../../main_page/main_logic.dart';
|
||||
import '../../main_page/provider/bottom_bar_provider.dart';
|
||||
|
||||
class CommunityMainLogic extends GetxController
|
||||
with GetTickerProviderStateMixin {
|
||||
final int index;
|
||||
|
||||
CommunityMainLogic({this.index = 0});
|
||||
|
||||
List<MainTabModel> tabs = [
|
||||
MainTabModel(
|
||||
title: '社区',
|
||||
imageNor: 'community_main_sel.png'.communityPath,
|
||||
imageSel: 'community_main_sel.png'.communityPath,
|
||||
),
|
||||
MainTabModel(
|
||||
title: '色图',
|
||||
imageNor: 'community_pic_sel.png'.communityPath,
|
||||
imageSel: 'community_pic_sel.png'.communityPath,
|
||||
),
|
||||
MainTabModel(
|
||||
title: '小说',
|
||||
imageNor: 'community_noval_sel.png'.communityPath,
|
||||
imageSel: 'community_noval_sel.png'.communityPath,
|
||||
),
|
||||
];
|
||||
|
||||
// tab 顺序对应的媒体类型:社区/色图/小说
|
||||
static const List<MediaStyle> _tabTypes = [
|
||||
MediaStyle.Community,
|
||||
MediaStyle.Pic,
|
||||
MediaStyle.Novel
|
||||
];
|
||||
|
||||
late TabController tabCtr;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
tabCtr =
|
||||
TabController(length: tabs.length, vsync: this, initialIndex: index);
|
||||
tabCtr.addListener(() {
|
||||
if (tabCtr.indexIsChanging) return;
|
||||
CommunityBottomProvider().communityType = _tabTypes[tabCtr.index];
|
||||
});
|
||||
super.onInit();
|
||||
}
|
||||
|
||||
// 跳转相关模块的亚模块
|
||||
void jumpSubModule(int index, {int tabIndex = 0}) {
|
||||
tabCtr.index = index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
|
||||
import '../../../config/config.dart';
|
||||
import '../../cartoon/cartoon_sub_module_page.dart';
|
||||
import '../../home/search_page/search_main_page.dart';
|
||||
import '../../mine/welfare/sign_daily_page.dart';
|
||||
import '../community/community_module_page.dart';
|
||||
import 'community_main_logic.dart';
|
||||
import 'community_section_tab.dart';
|
||||
|
||||
class CommunityMainPage extends StatefulWidget {
|
||||
final int index;
|
||||
final int subTabIndex;
|
||||
|
||||
CommunityMainPage({super.key, this.index = 0, this.subTabIndex = 0});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return CommunityMainPageState();
|
||||
}
|
||||
}
|
||||
|
||||
class CommunityMainPageState extends State<CommunityMainPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
//提前加载图片资源
|
||||
precacheImage(AssetImage('community_main_sel.png'.communityPath), context);
|
||||
precacheImage(AssetImage('community_pic_sel.png'.communityPath), context);
|
||||
precacheImage(
|
||||
AssetImage('community_chatgroup_sel.png'.communityPath), context);
|
||||
precacheImage(AssetImage('community_noval_sel.png'.communityPath), context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: GetBuilder(
|
||||
init: CommunityMainLogic(index: widget.index),
|
||||
builder: (_) => Column(
|
||||
children: [
|
||||
screen.paddingTop.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildTabbar(_)),
|
||||
if (Config.signIcon != null &&
|
||||
Config.signIcon!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(SignDailyPage()),
|
||||
child: Image.asset(
|
||||
"community_sign.webp".communityPath,
|
||||
width: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
12.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.to(SearchMainPage()),
|
||||
child: Image.asset(
|
||||
'search_icon.png'.commonImgPath,
|
||||
width: 24,
|
||||
color: Color(0xff989898),
|
||||
),
|
||||
),
|
||||
16.sizeBoxW,
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _.tabCtr,
|
||||
children: [
|
||||
//社区
|
||||
CommunityModulePage(
|
||||
defaultIndex:
|
||||
widget.index == 0 ? widget.subTabIndex : 0,
|
||||
).keepAlive,
|
||||
//图集
|
||||
CartoonSubModulePage(
|
||||
type: MediaStyle.Pic,
|
||||
tabIndex: widget.index == 1 ? widget.subTabIndex : 0,
|
||||
).keepAlive,
|
||||
// CartoonSubModulePage(
|
||||
// type: MediaStyle.Game,
|
||||
// tabIndex: widget.index == 2 ? widget.subTabIndex : 0,
|
||||
// ).keepAlive,
|
||||
//小说
|
||||
CartoonSubModulePage(
|
||||
type: MediaStyle.Novel,
|
||||
tabIndex: widget.index == 3 ? widget.subTabIndex : 0,
|
||||
).keepAlive,
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
_buildTabbar(CommunityMainLogic _) {
|
||||
return CommunitySectionTabMenu(
|
||||
_.tabs,
|
||||
curIndex: widget.index,
|
||||
tabCtr: _.tabCtr,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Get.delete<CommunityMainPageLogic>();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//发现模块tabbar
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
import '../../main_page/main_logic.dart';
|
||||
|
||||
class CommunitySectionTabMenu extends StatefulWidget {
|
||||
final int curIndex;
|
||||
final List<MainTabModel> tabs;
|
||||
final TabController? tabCtr;
|
||||
final double? fontSize;
|
||||
|
||||
const CommunitySectionTabMenu(
|
||||
this.tabs, {
|
||||
super.key,
|
||||
this.curIndex = 0,
|
||||
this.tabCtr,
|
||||
this.fontSize = 16,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _CommunitySectionTabMenuState();
|
||||
}
|
||||
}
|
||||
|
||||
class _CommunitySectionTabMenuState extends State<CommunitySectionTabMenu> {
|
||||
late int curIndex = widget.curIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.tabCtr?.addListener(_listener);
|
||||
}
|
||||
|
||||
void _listener() {
|
||||
setState(() => curIndex = widget.tabCtr?.index ?? 0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.tabCtr?.removeListener(_listener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: TabBar(
|
||||
tabAlignment: TabAlignment.start,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
controller: widget.tabCtr,
|
||||
indicatorWeight: 0,
|
||||
isScrollable: true,
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
labelStyle: TextStyle(fontSize: widget.fontSize, fontWeight: FontWeight.w600),
|
||||
unselectedLabelStyle: TextStyle(fontSize: widget.fontSize, fontWeight: FontWeight.w400),
|
||||
indicator: const BoxDecoration(),
|
||||
tabs: List.generate(widget.tabs.length, _tabItem),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabItem(int index) {
|
||||
final sel = curIndex == index;
|
||||
// 固定 49 宽槽位,选中(图标)/未选中(文字)就地交叉淡入淡出,宽度恒定不跳动
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: SizedBox(
|
||||
key: ValueKey(sel),
|
||||
width: 49,
|
||||
child: sel
|
||||
? Center(child: Image.asset(widget.tabs[index].imageNor, height: 31))
|
||||
: Align(
|
||||
alignment: Alignment(0, -0.5),
|
||||
child: Text(
|
||||
widget.tabs[index].title,
|
||||
style: textStyle(14, Colors.white.withValues(alpha: 0.55), FontWeight.w400),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/tag_service.dart';
|
||||
|
||||
class CommunityTagLogic extends GetxController
|
||||
with GetTickerProviderStateMixin {
|
||||
final scrollCtr = ScrollController();
|
||||
double? offset;
|
||||
bool isPinned = false;
|
||||
TagsBean model;
|
||||
StreamController<bool> showAppCtr = StreamController.broadcast();
|
||||
late final TabController mainTabCtr =
|
||||
TabController(length: sorts.length, vsync: this);
|
||||
|
||||
RxBool isLoadingFollow = false.obs;
|
||||
final sorts = [2, 1, 5]; //排序类型 1:最新上架 2:最多收藏 3:本月最热 4:最多观看
|
||||
|
||||
CommunityTagLogic(this.model);
|
||||
|
||||
@override
|
||||
onReady() async {
|
||||
super.onReady();
|
||||
final newsType = model.newsType;
|
||||
final res = await TagService.fetchInfo(model.id ?? '');
|
||||
if (res != null) {
|
||||
model = res;
|
||||
model.newsType = newsType;
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
showAppCtr.close();
|
||||
|
||||
mainTabCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../tools_base/widget/follow_button.dart';
|
||||
import '../../home/widget/collect_status_wrapper.dart';
|
||||
import '../widget/community_sort_header.dart';
|
||||
import 'community_tag_logic.dart';
|
||||
import 'subviews/tag_detail_sub_controller.dart';
|
||||
import 'subviews/tag_detail_subview.dart';
|
||||
|
||||
class UserCenterSortTypeEvent {
|
||||
int sortIndex;
|
||||
UserCenterSortTypeEvent(this.sortIndex);
|
||||
}
|
||||
|
||||
class CommunityTagDetailPage extends StatefulWidget {
|
||||
final TagsBean model;
|
||||
|
||||
const CommunityTagDetailPage({super.key, required this.model});
|
||||
|
||||
@override
|
||||
State<CommunityTagDetailPage> createState() => _CommunityTagDetailPageState();
|
||||
}
|
||||
|
||||
class _CommunityTagDetailPageState extends State<CommunityTagDetailPage>
|
||||
with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<CommunityTagLogic>(
|
||||
tag: uniqueTag,
|
||||
init: CommunityTagLogic(widget.model),
|
||||
builder: (controller) => Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
ExtendedNestedScrollView(
|
||||
controller: controller.scrollCtr,
|
||||
pinnedHeaderSliverHeightBuilder: () =>
|
||||
AppBar().preferredSize.height + screen.paddingTop,
|
||||
headerSliverBuilder: (_, istop) {
|
||||
return [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
floating: false,
|
||||
expandedHeight: 64,
|
||||
titleSpacing: 18.w,
|
||||
automaticallyImplyLeading: false,
|
||||
title: _buildAppBarTitle(controller),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: SizedBox(),
|
||||
),
|
||||
)
|
||||
];
|
||||
},
|
||||
body: Column(
|
||||
children: [
|
||||
12.sizeBoxH,
|
||||
CommunitySortHeader(
|
||||
controller: controller.mainTabCtr,
|
||||
sortOnTap: (index) {},
|
||||
),
|
||||
12.sizeBoxH,
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: controller.mainTabCtr,
|
||||
children: controller.sorts
|
||||
.map(
|
||||
(e) => TagDetailSubView<TagCOVERController>(
|
||||
tag: controller.model, postSort: e)
|
||||
.keepAlive,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBarTitle(CommunityTagLogic controller) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: controller.scrollCtr,
|
||||
child: Consumer<ScrollController>(
|
||||
builder: (_, ctr, __) {
|
||||
final isTop = (180 - ctr.offset - kToolbarHeight) == 0;
|
||||
return Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Image.asset(
|
||||
isTop
|
||||
? 'common_back.png'.commonImgPath
|
||||
: 'back_circle.png'.commonImgPath,
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
if (isTop) ...[
|
||||
4.sizeBoxW,
|
||||
NetworkImageLoader(
|
||||
imageUrl: controller.model.coverImg ?? '',
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
),
|
||||
6.sizeBoxW,
|
||||
Text(
|
||||
controller.model.name ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xffffffff),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
CollectStatusWrapper(
|
||||
tagModel: controller.model,
|
||||
builder: () => FollowButton(
|
||||
mediaId: controller.model.id,
|
||||
isFollow: controller.model.hasCollected ?? false,
|
||||
followType: FollowEnum.tag,
|
||||
successsAction: (success) =>
|
||||
controller.model.hasCollected = success,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
controller.model.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
(24 + 18.w).sizeBoxH,
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/tag_service.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../../home/widget/collect_status_wrapper.dart';
|
||||
|
||||
abstract class TagDetailSubController extends GetxController {
|
||||
List<VideoModel> dataSource = [];
|
||||
bool isGridStyle = true;
|
||||
double childRatio = 1;
|
||||
bool isLoading = true;
|
||||
String? loadType;
|
||||
String? newsType;
|
||||
String? sort;
|
||||
int? postSort;
|
||||
int page = 1;
|
||||
TagsBean tag;
|
||||
|
||||
TagDetailSubController(this.tag, {this.postSort});
|
||||
|
||||
RefreshController? controller;
|
||||
|
||||
StreamSubscription? _collectStatusSub;
|
||||
|
||||
void _onCollectStatusChanged(CollectStatusModel event) {
|
||||
if (event.isLiked == null && event.likeCountDelta == null) return;
|
||||
var changed = false;
|
||||
for (final item in dataSource) {
|
||||
if (item.id == event.id) {
|
||||
applyVideoModelCollectStatus(item, event);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) update();
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_collectStatusSub =
|
||||
eventBus.on<CollectStatusModel>(_onCollectStatusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_collectStatusSub?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
// isLoading = false;
|
||||
fetchPageData(needLoading: true);
|
||||
}
|
||||
|
||||
fetchPageData({bool isRefresh = true, bool needLoading = false}) async {
|
||||
if (isRefresh) {
|
||||
page = 1;
|
||||
}
|
||||
|
||||
isLoading = needLoading;
|
||||
update();
|
||||
final res = await TagService.fetchList(
|
||||
tag.id ?? '',
|
||||
newsType: newsType,
|
||||
sortType: postSort,
|
||||
page: page,
|
||||
size: 10,
|
||||
);
|
||||
if (isRefresh) {
|
||||
dataSource.clear();
|
||||
controller?.refreshCompleted();
|
||||
}
|
||||
|
||||
dataSource.addAll(res?.videos ?? []);
|
||||
page += 1;
|
||||
isLoading = false;
|
||||
update();
|
||||
res?.hasNext ?? false
|
||||
? controller?.loadComplete()
|
||||
: controller?.loadNoData();
|
||||
}
|
||||
}
|
||||
|
||||
class TagVideoController extends TagDetailSubController {
|
||||
TagVideoController(super.tag);
|
||||
|
||||
@override
|
||||
String get loadType => 'MOVIE';
|
||||
String get newsType => tag.newsType ?? "MOVIE";
|
||||
|
||||
@override
|
||||
double get childRatio => 168 / 119;
|
||||
}
|
||||
|
||||
class TagSPController extends TagDetailSubController {
|
||||
TagSPController(super.tag);
|
||||
@override
|
||||
String get loadType => 'SP';
|
||||
String get newsType => tag.newsType ?? "SP";
|
||||
|
||||
@override
|
||||
double get childRatio => 168 / 248;
|
||||
}
|
||||
|
||||
class TagCOVERController extends TagDetailSubController {
|
||||
TagCOVERController(super.tag, {super.postSort});
|
||||
|
||||
@override
|
||||
String? get loadType => null;
|
||||
String get newsType => tag.newsType ?? "";
|
||||
|
||||
@override
|
||||
bool get isGridStyle => false;
|
||||
}
|
||||
|
||||
T instanceController<T>(TagsBean tag, {int? postSort}) {
|
||||
// Type 对象比较,避免 release 混淆下类名字符串匹配失败导致 throw→灰屏
|
||||
if (T == TagCOVERController)
|
||||
return TagCOVERController(tag, postSort: postSort) as T;
|
||||
if (T == TagSPController) return TagSPController(tag) as T;
|
||||
if (T == TagVideoController) return TagVideoController(tag) as T;
|
||||
throw '$T 没有对应类型';
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
|
||||
import '../../../home/home_cell_style/video_simple_cell.dart';
|
||||
import '../../widget/community_post_widget.dart';
|
||||
import 'tag_detail_sub_controller.dart';
|
||||
|
||||
class TagDetailSubView<T extends TagDetailSubController>
|
||||
extends StatelessWidget {
|
||||
final TagsBean tag;
|
||||
|
||||
final int? postSort;
|
||||
|
||||
const TagDetailSubView({super.key, required this.tag, this.postSort});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<T>(
|
||||
init: instanceController<T>(tag, postSort: postSort),
|
||||
global: false,
|
||||
builder: (controller) {
|
||||
if (controller.isLoading) return LoadingCenterWidget();
|
||||
return pullYsRefresh(
|
||||
onLoading: (refreshController) =>
|
||||
controller.fetchPageData(isRefresh: false),
|
||||
onRefresh: (refreshController) => controller.fetchPageData(),
|
||||
child: () {
|
||||
if (controller.dataSource.isEmpty) return CErrorWidget();
|
||||
if (controller.isGridStyle)
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: controller.childRatio,
|
||||
crossAxisSpacing: 7,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: controller.dataSource.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final model = controller.dataSource[index];
|
||||
return GestureDetector(
|
||||
onTap: () => pushToVideoPage(videoModel: model),
|
||||
child: VideoSimpleCell(videoModel: model),
|
||||
);
|
||||
},
|
||||
);
|
||||
return ListView.separated(
|
||||
padding: EdgeInsets.only(top: 0),
|
||||
separatorBuilder: (_, __) => 14.sizeBoxH,
|
||||
itemCount: controller.dataSource.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final model = controller.dataSource[index];
|
||||
return CommunityPostWidget(
|
||||
videoModel: model,
|
||||
videoModels: controller.dataSource,
|
||||
showTags: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
onInit: (ctr) => controller.controller = ctr,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_utils/pay/pay_manager.dart';
|
||||
import 'package:hgdj/extension/extensions.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';
|
||||
|
||||
import '../model/im_group_list_model.dart';
|
||||
|
||||
class GroupBuyBottomSheet extends StatefulWidget {
|
||||
final IMGroupItemModel groupModel;
|
||||
|
||||
const GroupBuyBottomSheet({super.key, required this.groupModel});
|
||||
|
||||
@override
|
||||
State<GroupBuyBottomSheet> createState() => _GroupBuyBottomSheetState();
|
||||
}
|
||||
|
||||
class _GroupBuyBottomSheetState extends State<GroupBuyBottomSheet> {
|
||||
@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) >= (widget.groupModel.price ?? 0);
|
||||
return _buildNormal(enough, store);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNormal(bool enough, GlobalStore store) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SheetHandleBar(),
|
||||
18.sizeBoxH,
|
||||
Text(
|
||||
'购买',
|
||||
style: TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
36.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'解锁群聊',
|
||||
style: TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => pushToWalletPage(tabPosition: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.groupModel.price ?? 0} 金币',
|
||||
style:
|
||||
TextStyle(color: AppColors.actionRed, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
0.5.line,
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'我的金币',
|
||||
style: TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${GlobalStore().wallet?.amount?.toDouble().fixed(2) ?? 0}',
|
||||
style: TextStyle(
|
||||
color: Color(0xE5ffffff),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => pushToWalletPage(tabPosition: 1),
|
||||
child: Text(
|
||||
'充值',
|
||||
style: TextStyle(
|
||||
color: Color(0xffA4634D),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Color(0xffA4634D),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => _doPayAction(enough),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: AppColors.actionRed,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
enough
|
||||
? '${widget.groupModel.price ?? 0} 金币/立即支付'
|
||||
: '余额不足,前往充值',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _doPayAction(bool enough) async {
|
||||
if (!enough) {
|
||||
await pushToWalletPage(tabPosition: 1);
|
||||
return;
|
||||
}
|
||||
await PayManager().buy(
|
||||
widget.groupModel.id,
|
||||
ProductType.group,
|
||||
source: 'group_buy',
|
||||
onSuccess: (data) {
|
||||
globalStore.refreshWallet();
|
||||
Get.back(result: true);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../../../hj_utils/api_service/group_service.dart';
|
||||
import '../../../../tools_base/file_upload/file_upload_tool.dart';
|
||||
import '../../../../tools_base/file_upload/upload_result_model.dart';
|
||||
import '../../../comment/comment_input_view.dart';
|
||||
import '../model/im_group_list_model.dart';
|
||||
import '../model/im_message_resp.dart';
|
||||
|
||||
class GroupChatDetailLogic extends GetxController {
|
||||
final IMGroupItemModel model;
|
||||
|
||||
GroupChatDetailLogic(this.model);
|
||||
|
||||
int pageNum = 1;
|
||||
List<IMMessageModel>? msgList;
|
||||
RefreshController? refreshController;
|
||||
TextEditingController textEditController = TextEditingController();
|
||||
Rx<String?> selectedImagePath = Rx<String?>(null);
|
||||
RxBool isSendingMsg = false.obs;
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
refreshData();
|
||||
super.onReady();
|
||||
}
|
||||
|
||||
void refreshData() {
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void loadMoreData() {
|
||||
_loadData(page: pageNum + 1);
|
||||
}
|
||||
|
||||
void _loadData({int page = 1, int size = 20}) async {
|
||||
try {
|
||||
final retResp = await GroupService.getMessages(model.groupId, page, size);
|
||||
pageNum = page;
|
||||
msgList ??= [];
|
||||
if (pageNum == 1) {
|
||||
msgList?.clear();
|
||||
}
|
||||
msgList?.addAll(retResp.list ?? []);
|
||||
retResp.hasNext == true
|
||||
? refreshController?.loadComplete()
|
||||
: refreshController?.loadNoData();
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
refreshController?.loadComplete();
|
||||
}
|
||||
refreshController?.refreshCompleted();
|
||||
update();
|
||||
}
|
||||
|
||||
Future readySendMsg({bool? isSend}) async {
|
||||
if (isSend == true &&
|
||||
(textEditController.text.isNotEmpty ||
|
||||
selectedImagePath.value?.isNotEmpty == true)) {
|
||||
// 点击发送按钮有内容,直接发送内容, 否则弹出输入框
|
||||
sendMsgEvent();
|
||||
} else {
|
||||
final result = await Get.bottomSheet(
|
||||
CommentInputView(
|
||||
hint: "请输入消息",
|
||||
textCtr: textEditController,
|
||||
imgPath: selectedImagePath.value,
|
||||
onImgChange: (value) => selectedImagePath.value = value,
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.transparent,
|
||||
enableDrag: false,
|
||||
settings: RouteSettings(name: CommentInputView.routeName),
|
||||
useRootNavigator: true,
|
||||
);
|
||||
if (result == true) {
|
||||
update();
|
||||
}
|
||||
sendMsgEvent();
|
||||
}
|
||||
}
|
||||
|
||||
Future sendMsgEvent() async {
|
||||
if (textEditController.text.isEmpty &&
|
||||
selectedImagePath.value?.isNotEmpty != true) {
|
||||
showToast("请输入消息内容");
|
||||
return;
|
||||
}
|
||||
if (isSendingMsg.value) return;
|
||||
isSendingMsg.value = true;
|
||||
String? sendImagePath;
|
||||
if (selectedImagePath.value?.isNotEmpty == true) {
|
||||
try {
|
||||
ImageUploadResultModel? imgResult =
|
||||
await FileUploadTool().uploadImage(selectedImagePath.value!);
|
||||
sendImagePath = imgResult?.coverImg ?? "";
|
||||
if (sendImagePath.isEmpty) {
|
||||
isSendingMsg.value = false;
|
||||
showToast("图片上传失败!");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
isSendingMsg.value = false;
|
||||
showToast("图片上传失败!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
var ret = await GroupService.sendMessage(
|
||||
model.groupId, textEditController.text, sendImagePath);
|
||||
if (ret.isSuccess) {
|
||||
textEditController.text = "";
|
||||
selectedImagePath.value = null;
|
||||
showToast("消息已发送");
|
||||
if (ret.data is Map && (ret.data['message'] is Map)) {
|
||||
IMMessageModel sendModel =
|
||||
IMMessageModel.fromJson(ret.data['message']);
|
||||
sendModel.portrait = globalStore.meInfo?.portrait;
|
||||
sendModel.name = globalStore.meInfo?.name;
|
||||
msgList?.insert(0, sendModel);
|
||||
update();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("消息发送失败!");
|
||||
debugLog(e);
|
||||
}
|
||||
isSendingMsg.value = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/comment/widget/comment_foot_view.dart';
|
||||
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
|
||||
import '../model/im_group_list_model.dart';
|
||||
import 'soul_group_detail_logic.dart';
|
||||
import 'widget/chat_item_cell.dart';
|
||||
|
||||
class GroupChatDetailPage extends StatelessWidget {
|
||||
final IMGroupItemModel model;
|
||||
|
||||
const GroupChatDetailPage(this.model, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<GroupChatDetailLogic>(
|
||||
global: false,
|
||||
init: GroupChatDetailLogic(model),
|
||||
builder: (logic) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(model.name ?? ""),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshController = ctr,
|
||||
onRefresh: (ctr) => logic.refreshData(),
|
||||
onLoading: (ctr) => logic.loadMoreData(),
|
||||
child: ListView.builder(
|
||||
itemCount: logic.msgList?.length ?? 0,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(bottom: 12, top: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return ChatItemCell(model: logic.msgList![index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => CommentFootView(
|
||||
imgPath: logic.selectedImagePath.value,
|
||||
isSending: logic.isSendingMsg.value,
|
||||
onImgChange: (value) => logic.selectedImagePath.value = value,
|
||||
onComment: (isSend) => logic.readySendMsg(isSend: isSend),
|
||||
textCtr: logic.textEditController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/routers/jump_router.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../../../../../tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
|
||||
import '../../model/im_message_resp.dart';
|
||||
|
||||
class ChatItemCell extends StatefulWidget {
|
||||
final IMMessageModel? model;
|
||||
|
||||
ChatItemCell({super.key, this.model});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _ChatItemCellState();
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatItemCellState extends State<ChatItemCell> {
|
||||
bool get isImg => widget.model?.image?.isNotEmpty == true;
|
||||
|
||||
bool get isMe => globalStore.isMe(widget.model?.uid);
|
||||
|
||||
double imageWidth = 173;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _showImageScan(String imageUrl) {
|
||||
if (imageUrl.isNotEmpty) {
|
||||
ImageBrowserPage.open([widget.model?.image ?? '']);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isMe) {
|
||||
return _buildRightStyle();
|
||||
} else {
|
||||
return _buildLeftStyle();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLeftStyle() {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(0, 12, 0, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTime(),
|
||||
Container(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 50, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
pushToPersonCenter(widget.model?.uid);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: widget.model?.portrait ?? "",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.model?.name ?? "",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
if (widget.model?.content?.isNotEmpty == true)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 10),
|
||||
margin: EdgeInsets.only(top: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
topLeft: Radius.circular(2)),
|
||||
),
|
||||
child: Text(
|
||||
widget.model?.content ?? "",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isImg)
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
_showImageScan(widget.model?.image ?? "");
|
||||
},
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 5),
|
||||
alignment: Alignment.centerLeft,
|
||||
constraints: BoxConstraints(maxWidth: 173),
|
||||
child: _buildImageWidget(isLeft: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRightStyle() {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(0, 12, 0, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTime(),
|
||||
Container(
|
||||
padding: EdgeInsets.fromLTRB(50, 0, 16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
widget.model?.name ?? "",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
if (widget.model?.content?.isNotEmpty == true)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 10),
|
||||
margin: EdgeInsets.only(top: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xff1DC194),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(4)),
|
||||
),
|
||||
child: Text(
|
||||
widget.model?.content ?? "",
|
||||
style: TextStyle(
|
||||
color: Color(0xff141414),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isImg)
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
_showImageScan(widget.model?.image ?? "");
|
||||
},
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 5),
|
||||
alignment: Alignment.centerRight,
|
||||
constraints: BoxConstraints(maxWidth: 173),
|
||||
child: _buildImageWidget(isLeft: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: widget.model?.portrait,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageWidget({bool isLeft = false}) {
|
||||
return Stack(
|
||||
children: [
|
||||
NetworkImageLoader(
|
||||
imageUrl: widget.model?.image ?? "",
|
||||
width: imageWidth,
|
||||
imgBorderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
topRight: Radius.circular(isLeft ? 12 : 0),
|
||||
topLeft: Radius.circular(isLeft ? 0 : 12),
|
||||
),
|
||||
),
|
||||
if (isLeft)
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
color: AppColors.actionRed,
|
||||
),
|
||||
child: Text(
|
||||
'查看原图',
|
||||
style: TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTime() {
|
||||
// if (widget.model?.isShowTime == true) {
|
||||
// return Container(
|
||||
// margin: EdgeInsets.fromLTRB(0, 12, 0, 12),
|
||||
// padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
// // decoration: BoxDecoration(
|
||||
// // borderRadius: BorderRadius.circular(30),
|
||||
// // color: Color(0xfff7f7f7).withValues(alpha: 0.42),
|
||||
// // ),
|
||||
// child: Text(
|
||||
// widget.model?.createAtDesc ?? "",
|
||||
// style: TextStyle(
|
||||
// color: Color(0xff666666),
|
||||
// fontSize: 12,
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
return SizedBox();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../../hj_utils/api_service/group_service.dart';
|
||||
import 'model/im_group_list_model.dart';
|
||||
|
||||
class GroupChatLogic extends GetxController {
|
||||
int pageNum = 1;
|
||||
List<IMGroupItemModel>? groupList;
|
||||
RefreshController? refreshController;
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
refreshData();
|
||||
super.onReady();
|
||||
}
|
||||
|
||||
void refreshData() {
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void loadMoreData() {
|
||||
_loadData(page: pageNum + 1);
|
||||
}
|
||||
|
||||
void _loadData({int page = 1}) async {
|
||||
try {
|
||||
final retResp = await GroupService.getList(page);
|
||||
pageNum = page;
|
||||
groupList ??= [];
|
||||
if (pageNum == 1) {
|
||||
groupList?.clear();
|
||||
}
|
||||
groupList?.addAll(retResp?.list ?? []);
|
||||
retResp?.hasNext == true
|
||||
? refreshController?.loadComplete()
|
||||
: refreshController?.loadNoData();
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
refreshController?.loadComplete();
|
||||
}
|
||||
refreshController?.refreshCompleted();
|
||||
update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
|
||||
import 'group_chat_logic.dart';
|
||||
import 'widget/chat_group_cell.dart';
|
||||
|
||||
class GroupChatPage extends StatelessWidget {
|
||||
const GroupChatPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<GroupChatLogic>(
|
||||
global: false,
|
||||
init: GroupChatLogic(),
|
||||
builder: (logic) {
|
||||
return pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshController = ctr,
|
||||
onRefresh: (ctr) => logic.refreshData(),
|
||||
onLoading: (ctr) => logic.loadMoreData(),
|
||||
child: CustomScrollView(
|
||||
controller:
|
||||
CommunityBottomProvider().scrollCtr(MediaStyle.GroupChat, 0),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: AdsGridViewWidget(
|
||||
6,
|
||||
accordingAdsType: true,
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 18),
|
||||
),
|
||||
),
|
||||
if (logic.groupList == null)
|
||||
SliverToBoxAdapter(child: LoadingCenterWidget(height: 400))
|
||||
else if (logic.groupList!.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 400,
|
||||
child: CErrorWidget(
|
||||
retryOnTap: () => logic.refreshData(),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.builder(
|
||||
itemBuilder: (ctx, index) =>
|
||||
ChatGroupCell(logic.groupList![index]),
|
||||
itemCount: logic.groupList!.length,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
class IMGroupListModel {
|
||||
bool? hasNext;
|
||||
List<IMGroupItemModel>? list;
|
||||
int? total;
|
||||
|
||||
IMGroupListModel({this.hasNext, this.list, this.total});
|
||||
|
||||
IMGroupListModel.fromJson(Map<String, dynamic> json) {
|
||||
hasNext = json['hasNext'];
|
||||
if (json['list'] != null) {
|
||||
list = <IMGroupItemModel>[];
|
||||
json['list'].forEach((v) {
|
||||
list!.add(new IMGroupItemModel.fromJson(v));
|
||||
});
|
||||
}
|
||||
total = json['total'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = new Map<String, dynamic>();
|
||||
data['hasNext'] = this.hasNext;
|
||||
if (this.list != null) {
|
||||
data['list'] = this.list!.map((v) => v.toJson()).toList();
|
||||
}
|
||||
data['total'] = this.total;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class IMGroupItemModel {
|
||||
String? cover;
|
||||
String? createdAt;
|
||||
|
||||
///成员数量(假的)
|
||||
int? fakeMemberNum;
|
||||
int? groupId;
|
||||
String? id;
|
||||
|
||||
///成员数量(真实)
|
||||
int? memberNum;
|
||||
String? name;
|
||||
|
||||
///加入群聊价格 0-免费
|
||||
int? price;
|
||||
String? summary;
|
||||
String? updatedAt;
|
||||
|
||||
IMGroupItemModel(
|
||||
{this.cover,
|
||||
this.createdAt,
|
||||
this.fakeMemberNum,
|
||||
this.groupId,
|
||||
this.id,
|
||||
this.memberNum,
|
||||
this.name,
|
||||
this.price,
|
||||
this.summary,
|
||||
this.updatedAt});
|
||||
|
||||
IMGroupItemModel.fromJson(Map<String, dynamic> json) {
|
||||
cover = json['cover'];
|
||||
createdAt = json['createdAt'];
|
||||
fakeMemberNum = json['fakeMemberNum'];
|
||||
groupId = json['groupId'];
|
||||
id = json['id'];
|
||||
memberNum = json['memberNum'];
|
||||
name = json['name'];
|
||||
price = json['price'];
|
||||
summary = json['summary'];
|
||||
updatedAt = json['updatedAt'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = new Map<String, dynamic>();
|
||||
data['cover'] = this.cover;
|
||||
data['createdAt'] = this.createdAt;
|
||||
data['fakeMemberNum'] = this.fakeMemberNum;
|
||||
data['groupId'] = this.groupId;
|
||||
data['id'] = this.id;
|
||||
data['memberNum'] = this.memberNum;
|
||||
data['name'] = this.name;
|
||||
data['price'] = this.price;
|
||||
data['summary'] = this.summary;
|
||||
data['updatedAt'] = this.updatedAt;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
class IMMessageResp {
|
||||
bool? hasNext;
|
||||
List<IMMessageModel>? list;
|
||||
int? total;
|
||||
|
||||
IMMessageResp({this.hasNext, this.list, this.total});
|
||||
|
||||
IMMessageResp.fromJson(Map<String, dynamic> json) {
|
||||
hasNext = json['hasNext'];
|
||||
if (json['list'] is List) {
|
||||
list = <IMMessageModel>[];
|
||||
json['list'].forEach((v) {
|
||||
list!.add(IMMessageModel.fromJson(v));
|
||||
});
|
||||
}
|
||||
total = json['total'];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class IMMessageModel {
|
||||
|
||||
String? id;
|
||||
int? groupId;
|
||||
int? uid;
|
||||
String? name;
|
||||
String? gender;
|
||||
String? portrait;
|
||||
String? content;
|
||||
String? image;
|
||||
String? createdAt;
|
||||
|
||||
|
||||
IMMessageModel.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
groupId = json['groupId'];
|
||||
uid = json['uid'];
|
||||
name = json['name'];
|
||||
// gender = json['gender'];
|
||||
portrait = json['portrait'];
|
||||
content = json['content'];
|
||||
image = json['image'];
|
||||
createdAt = json['createdAt'];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
|
||||
|
||||
import '../../../../alert/mine/vip_level_dialog.dart';
|
||||
import '../../../../assets_tool/app_colors.dart';
|
||||
import '../../../../hj_utils/api_service/group_service.dart';
|
||||
import '../../../../tools_base/widget/header_widget.dart';
|
||||
import '../alert/group_buy_bottom_sheet.dart';
|
||||
import '../detail/soul_group_detail_page.dart';
|
||||
import '../model/im_group_list_model.dart';
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
|
||||
class ChatGroupCell extends StatefulWidget {
|
||||
final IMGroupItemModel model;
|
||||
|
||||
ChatGroupCell(this.model, {super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _ChatGroupCellState();
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatGroupCellState extends State<ChatGroupCell> {
|
||||
IMGroupItemModel get model => widget.model;
|
||||
|
||||
void _addGroupEvent() async {
|
||||
if (!globalStore.isVIPTopLevel) {
|
||||
showVipLevelDialog('本内容需要开通至尊VIP会员\n\n开通会员 即可畅享高级群聊特权');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
LoadingAlertWidget.show();
|
||||
final retResp = await GroupService.getHasJoin(model.groupId);
|
||||
LoadingAlertWidget.cancel();
|
||||
if (retResp.isSuccess) {
|
||||
int status = retResp.data['status']; // 0-已被禁言 1-可发言 2-未加入
|
||||
if (status == 0) {
|
||||
showToast("您已被禁言");
|
||||
} else if (status == 1) {
|
||||
Get.to(GroupChatDetailPage(model));
|
||||
} else if (status == 2) {
|
||||
var ret = await Get.bottomSheet(
|
||||
GroupBuyBottomSheet(groupModel: widget.model));
|
||||
if (ret == true) {
|
||||
Get.to(GroupChatDetailPage(model));
|
||||
}
|
||||
} else {
|
||||
showToast("群未知状态");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
LoadingAlertWidget.cancel();
|
||||
showToast("网络数据异常");
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(left: 16, right: 16, bottom: 12),
|
||||
padding: EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _addGroupEvent,
|
||||
child: Row(
|
||||
children: [
|
||||
HeaderWidget(
|
||||
headPath: model.cover ?? '',
|
||||
level: 1,
|
||||
headWidth: 64,
|
||||
headHeight: 64,
|
||||
isCircle: false,
|
||||
radius: 32,
|
||||
),
|
||||
12.sizeBoxW,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
model.name ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
2.sizeBoxH,
|
||||
Text(
|
||||
model.summary ?? '',
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
6.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'ID: ${model.groupId ?? 0} ${model.fakeMemberNum.countStr}人在玩',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
GestureDetector(
|
||||
onTap: _addGroupEvent,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: AppColors.actionRed),
|
||||
child: Text(
|
||||
'加入群聊',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user