初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+134
View File
@@ -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),
),
),
],
),
);
}
}
+215
View File
@@ -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);
}
}
}
+193
View File
@@ -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,
),
],
),
);
}
}
+53
View File
@@ -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),
),
],
),
);
}
}
+344
View File
@@ -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,
),
);
}
}
+251
View File
@@ -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),
),
],
),
);
}
}
+132
View File
@@ -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))),
],
),
),
);
}
}