初始化
This commit is contained in:
@@ -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))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user