1066 lines
35 KiB
Dart
1066 lines
35 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
import 'dart:math' show max;
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
|
||
import '../../core/app_env.dart';
|
||
import '../../core/auth/auth_service.dart';
|
||
import '../../core/open_purchase_store.dart';
|
||
import '../../core/user/user_state.dart';
|
||
import '../../design/pencil_theme.dart';
|
||
import '../../widgets/image_upload_feedback_snackbars.dart';
|
||
import '../../widgets/pencil_chrome.dart';
|
||
import '../../widgets/pencil_yellow_white_background.dart';
|
||
import '../../widgets/resilient_network_video.dart';
|
||
import 'generate_result_screen.dart';
|
||
|
||
/// `EYsUi` 生成图片页 — 左 112×108 槽位 +「=」+ 右侧效果图;分辨率在「开始生成」上方横排。
|
||
/// 提交后在同页轮询进度,完成后 [pushReplacement] 至 [GenerateResultScreen]。
|
||
/// [ExtConfigItem.imgNeed] == 2 时需两张图并上传;其余(含视频类模板)只传一张,请在配置里将视频项 `img_need` 设为 1。
|
||
/// [template] 来自首页选中的 [ExtConfigItem]。
|
||
class GenerateScreen extends StatefulWidget {
|
||
const GenerateScreen({super.key, this.template});
|
||
|
||
final ExtConfigItem? template;
|
||
|
||
@override
|
||
State<GenerateScreen> createState() => _GenerateScreenState();
|
||
}
|
||
|
||
class _GenerateScreenState extends State<GenerateScreen> {
|
||
final _picker = ImagePicker();
|
||
File? _picked;
|
||
File? _picked2;
|
||
|
||
/// 正在从相册/相机加载并校验的槽位(`0` / `1`);`null` 表示未在选图。
|
||
int? _pickLoadingSlot;
|
||
|
||
/// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。
|
||
String _outputSize = '720p';
|
||
|
||
/// 从开始点击到任务结束(成功跳转或失败):按钮保持同色加载态。
|
||
bool _generating = false;
|
||
|
||
ImageProgressPollHandle? _pollHandle;
|
||
String _genStatus = '';
|
||
int _genProgress = 0;
|
||
String? _genResultUrl;
|
||
String? _genError;
|
||
bool _pollNavigated = false;
|
||
String? _pollTaskId;
|
||
|
||
/// 来自本地缓存的 [UploadPresignedUrlResponse.expectedSize](预签名成功后写入);无缓存时为 [ImageUploadExpectedSizeCache.fallbackMaxBytes]。
|
||
int _maxUploadBytes = ImageUploadExpectedSizeCache.fallbackMaxBytes;
|
||
|
||
static const double _slotW = 112;
|
||
static const double _slotH = 108;
|
||
static const double _previewH = 359;
|
||
static const double _previewOuterR = 20;
|
||
static const double _previewBorderW = 1.5;
|
||
|
||
File? get _primaryFile => _picked ?? _picked2;
|
||
|
||
/// 双图:仅当扩展配置 `img_need == 2`(与 [ExtConfigItem.imgNeed] 一致)。
|
||
bool get _needTwoImages => widget.template?.imgNeed == 2;
|
||
|
||
/// 与 [app_client] `ImageApi.createTask` 的 `congregation` 一致:值为 [ExtConfigItem.templateName],
|
||
/// 缺省(纯本地 extConfig)时用 [ExtConfigItem.title];`BananaTask` 不传。
|
||
String? get _templateNameForCreateTask {
|
||
final tpl = widget.template;
|
||
if (tpl == null) return null;
|
||
final tn = tpl.templateName?.trim();
|
||
final raw = (tn != null && tn.isNotEmpty ? tn : tpl.title.trim());
|
||
if (raw.isEmpty || raw == 'BananaTask') return null;
|
||
return raw;
|
||
}
|
||
|
||
/// 任务类型:FunyMee 文档/换皮为逻辑 `taskType`(V2 `liaison`);优先 [ExtConfigItem.taskType],再 [params]/[detail]。
|
||
String? get _taskTypeForCreateTask {
|
||
final tt = widget.template?.taskType?.trim();
|
||
if (tt != null && tt.isNotEmpty) return tt;
|
||
final p = widget.template?.params?.trim();
|
||
if (p != null && p.isNotEmpty) return p;
|
||
final d = widget.template?.detail?.trim();
|
||
return (d != null && d.isNotEmpty) ? d : null;
|
||
}
|
||
|
||
/// 对应 create-task body 的 `ext`(换皮如 `profile`)。
|
||
/// - [ExtConfigItem.detail] 非空时优先作为 `ext`;
|
||
/// - 否则若 [ExtConfigItem.params] 非空且**未**作为 [taskType] 使用(与 [_taskTypeForCreateTask] 不同),则传 `params`(列表项 `ext` 经 [ExtConfigItem.fromTaskItem] 常落在 `params`)。
|
||
/// 避免与 `taskType` 重复传同一字符串。
|
||
String? get _extForCreateTask {
|
||
final d = widget.template?.detail?.trim();
|
||
if (d != null && d.isNotEmpty) return d;
|
||
final p = widget.template?.params?.trim();
|
||
if (p == null || p.isEmpty) return null;
|
||
final tt = _taskTypeForCreateTask;
|
||
if (tt == null || tt != p) return p;
|
||
return null;
|
||
}
|
||
|
||
/// 仅当模板同时提供 [ExtConfigItem.cost480p]、[ExtConfigItem.cost720p](均大于 0)时展示 480p/720p 切换。
|
||
bool get _showResolutionToggles {
|
||
final t = widget.template;
|
||
if (t == null) return false;
|
||
final c480 = t.cost480p;
|
||
final c720 = t.cost720p;
|
||
return c480 != null && c480 > 0 && c720 != null && c720 > 0;
|
||
}
|
||
|
||
/// 无双档积分信息时固定 [_outputSize]:仅有 720p 档、仅有 480p 档、或仅有通用 [ExtConfigItem.cost] 时不再允许切换。
|
||
void _syncOutputSizeForTemplate() {
|
||
final t = widget.template;
|
||
if (t == null) {
|
||
_outputSize = '720p';
|
||
return;
|
||
}
|
||
final has480 = t.cost480p != null && t.cost480p! > 0;
|
||
final has720 = t.cost720p != null && t.cost720p! > 0;
|
||
if (has480 && has720) return;
|
||
if (has720) {
|
||
_outputSize = '720p';
|
||
} else if (has480) {
|
||
_outputSize = '480p';
|
||
} else {
|
||
_outputSize = '720p';
|
||
}
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_syncOutputSizeForTemplate();
|
||
unawaited(_refreshUploadLimitFromCache());
|
||
}
|
||
|
||
Future<void> _refreshUploadLimitFromCache() async {
|
||
final v = await ImageUploadExpectedSizeCache.readImageMaxBytesForUi();
|
||
if (!mounted) return;
|
||
setState(() => _maxUploadBytes = v);
|
||
if (!mounted) return;
|
||
await _prunePickedIfOverLimit();
|
||
}
|
||
|
||
Future<void> _prunePickedIfOverLimit() async {
|
||
final maxB = _maxUploadBytes;
|
||
if (maxB <= 0) return;
|
||
var cleared = false;
|
||
if (_picked != null && await _picked!.length() > maxB) {
|
||
_picked = null;
|
||
cleared = true;
|
||
}
|
||
if (_picked2 != null && await _picked2!.length() > maxB) {
|
||
_picked2 = null;
|
||
cleared = true;
|
||
}
|
||
if (cleared && mounted) {
|
||
setState(() {});
|
||
showImageClearedOverLimitSnackBar(context);
|
||
}
|
||
}
|
||
|
||
String _uploadTipsBody() {
|
||
final cap = _formatMaxUploadLabel(_maxUploadBytes);
|
||
return 'Upload JPG or PNG ($cap each). You can use the camera or photo library. Use clear, front-facing photos when possible.';
|
||
}
|
||
|
||
String _formatMaxUploadLabel(int bytes) {
|
||
if (bytes <= 0) return 'max —';
|
||
if (bytes >= 1024 * 1024) {
|
||
final mb = bytes / (1024 * 1024);
|
||
final s = mb >= 10 ? mb.toStringAsFixed(0) : mb.toStringAsFixed(1);
|
||
return 'max $s MB';
|
||
}
|
||
if (bytes >= 1024) {
|
||
return 'max ${(bytes / 1024).round()} KB';
|
||
}
|
||
return 'max $bytes bytes';
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant GenerateScreen oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.template != widget.template) {
|
||
_syncOutputSizeForTemplate();
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
Future<void> _pickSlot(int slot) async {
|
||
if (_generating || _pickLoadingSlot != null) return;
|
||
if (!mounted) return;
|
||
final source = await _showPickImageSourceSheet(context);
|
||
if (source == null || !mounted) return;
|
||
setState(() => _pickLoadingSlot = slot);
|
||
try {
|
||
final x = await AuthService.runWithNativeMediaPicker(
|
||
() => _picker.pickImage(source: source, imageQuality: 92),
|
||
);
|
||
if (x == null || !mounted) return;
|
||
final f = File(x.path);
|
||
final len = await f.length();
|
||
final maxB = await ImageUploadExpectedSizeCache.readImageMaxBytesForUi();
|
||
if (!mounted) return;
|
||
if (len > maxB) {
|
||
if (!mounted) return;
|
||
showImageExceedsMaxUploadSnackBar(context);
|
||
return;
|
||
}
|
||
setState(() {
|
||
if (maxB != _maxUploadBytes) _maxUploadBytes = maxB;
|
||
if (slot == 0) {
|
||
_picked = f;
|
||
} else {
|
||
_picked2 = f;
|
||
}
|
||
});
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _pickLoadingSlot = null);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<ImageSource?> _showPickImageSourceSheet(BuildContext context) {
|
||
return showModalBottomSheet<ImageSource>(
|
||
context: context,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (ctx) {
|
||
return Container(
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Color(0x20000000),
|
||
blurRadius: 16,
|
||
offset: Offset(0, -4),
|
||
),
|
||
],
|
||
),
|
||
child: SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: PencilTheme.genNavBackStroke,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
ListTile(
|
||
leading: Icon(
|
||
Icons.camera_alt_outlined,
|
||
color: PencilTheme.profileAvatarIcon,
|
||
),
|
||
title: Text(
|
||
'Camera',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.stone900,
|
||
),
|
||
),
|
||
onTap: () => Navigator.pop(ctx, ImageSource.camera),
|
||
),
|
||
ListTile(
|
||
leading: Icon(
|
||
Icons.photo_library_outlined,
|
||
color: PencilTheme.profileAvatarIcon,
|
||
),
|
||
title: Text(
|
||
'Photo library',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.stone900,
|
||
),
|
||
),
|
||
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
|
||
),
|
||
const Divider(height: 1),
|
||
ListTile(
|
||
title: Text(
|
||
'Cancel',
|
||
textAlign: TextAlign.center,
|
||
style: GoogleFonts.inter(
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.stone600,
|
||
),
|
||
),
|
||
onTap: () => Navigator.pop(ctx),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _start() async {
|
||
final uid = UserState.userId.value;
|
||
if (uid == null || uid.isEmpty) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('Please sign in first.')));
|
||
return;
|
||
}
|
||
final need = _estimatedCost;
|
||
if (UserState.credits.value < need) {
|
||
openPurchaseStore(context);
|
||
return;
|
||
}
|
||
if (_needTwoImages) {
|
||
if (_picked == null || _picked2 == null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Please select two images first.')),
|
||
);
|
||
return;
|
||
}
|
||
} else {
|
||
final file = _primaryFile;
|
||
if (file == null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Please select an image first.')),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setState(() {
|
||
_generating = true;
|
||
_genError = null;
|
||
});
|
||
var pollStarted = false;
|
||
try {
|
||
final ImagePresignedUploadCreateTaskResult result;
|
||
if (_needTwoImages) {
|
||
result = await ImagePresignedUploadCreateTaskFlow.runTwoSourceFiles(
|
||
sourceFile1: _picked!,
|
||
sourceFile2: _picked2!,
|
||
userId: uid,
|
||
size: _outputSize,
|
||
taskType: _taskTypeForCreateTask,
|
||
templateName: _templateNameForCreateTask,
|
||
ext: _extForCreateTask,
|
||
compressFirst: true,
|
||
compressOptions: const CompressImageForUploadOptions(
|
||
maxSide: 1024,
|
||
jpegQuality: 75,
|
||
),
|
||
saveLocalUploadCover: true,
|
||
);
|
||
} else {
|
||
result = await ImagePresignedUploadCreateTaskFlow.run(
|
||
sourceFile: _primaryFile!,
|
||
userId: uid,
|
||
size: _outputSize,
|
||
taskType: _taskTypeForCreateTask,
|
||
templateName: _templateNameForCreateTask,
|
||
ext: _extForCreateTask,
|
||
compressFirst: true,
|
||
compressOptions: const CompressImageForUploadOptions(
|
||
maxSide: 1024,
|
||
jpegQuality: 75,
|
||
),
|
||
saveLocalUploadCover: true,
|
||
);
|
||
}
|
||
|
||
final taskId = result.createResponse.taskId;
|
||
if (taskId == null || taskId.isEmpty) {
|
||
throw StateError('No task id');
|
||
}
|
||
|
||
await UserAccountRefresh.fetchAndNotify(
|
||
app: currentBackendAppType(),
|
||
userId: uid,
|
||
onAccount: (a) {
|
||
if (a.credits != null) UserState.setCredits(a.credits!);
|
||
},
|
||
);
|
||
|
||
if (!mounted) return;
|
||
pollStarted = true;
|
||
_startProgressPoll(taskId);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text('$e')));
|
||
}
|
||
} finally {
|
||
if (mounted && !pollStarted) {
|
||
setState(() => _generating = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _startProgressPoll(String taskId) {
|
||
_pollHandle?.cancel();
|
||
_pollTaskId = taskId;
|
||
setState(() {
|
||
_genStatus = '';
|
||
_genProgress = 0;
|
||
_genResultUrl = null;
|
||
_genError = null;
|
||
_pollNavigated = false;
|
||
});
|
||
_pollHandle = ImageProgressPoll.start(
|
||
app: currentBackendAppType(),
|
||
taskId: taskId,
|
||
userId: UserState.userId.value,
|
||
interval: const Duration(seconds: 5),
|
||
onTick: _onProgressTick,
|
||
onTransientNetworkFailure: (n, max) {
|
||
if (!mounted || _pollNavigated) return;
|
||
setState(() {
|
||
_genStatus = 'Reconnecting… ($n/$max)';
|
||
});
|
||
},
|
||
onFatalError: (msg) {
|
||
if (!mounted || _pollNavigated) return;
|
||
_pollHandle?.cancel();
|
||
_pollHandle = null;
|
||
setState(() {
|
||
_genError = msg;
|
||
_generating = false;
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
void _onProgressTick(ProgressPollTick tick) {
|
||
if (!mounted || _pollNavigated) return;
|
||
final res = tick.response;
|
||
if (!res.isSuccess || res.data == null) {
|
||
setState(
|
||
() => _genError = res.msg.isNotEmpty ? res.msg : 'Progress error',
|
||
);
|
||
return;
|
||
}
|
||
final p = res.data!;
|
||
setState(() {
|
||
_genError = null;
|
||
_genStatus = p.status ?? '';
|
||
_genProgress = p.progress ?? 0;
|
||
_genResultUrl = p.resultUrl;
|
||
});
|
||
|
||
final doneSuccess = ProgressPollSemantics.isProgressSuccess(p);
|
||
if (doneSuccess) {
|
||
_pollNavigated = true;
|
||
_pollHandle?.cancel();
|
||
_pollHandle = null;
|
||
final url = _genResultUrl ?? '';
|
||
final tid = _pollTaskId ?? '';
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => GenerateResultScreen(taskId: tid, resultUrl: url),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (ProgressPollSemantics.isProgressFailure(p)) {
|
||
_pollHandle?.cancel();
|
||
_pollHandle = null;
|
||
setState(() {
|
||
_genError ??= 'Task failed (${p.status})';
|
||
_generating = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 与当前 [_outputSize] 一致;优先 [ExtConfigItem.cost480p] / [ExtConfigItem.cost720p],
|
||
/// 否则用 [ExtConfigItem.cost] 作 720p,480p 按半档估算。
|
||
/// 与 [HomeScreen] `_HomeItemVideoBackground`:优先 [ExtConfigItem.videoUrl],否则用 `image`。
|
||
String? _previewPlayUrl(ExtConfigItem? t) {
|
||
if (t == null) return null;
|
||
final v = t.videoUrl?.trim();
|
||
if (v != null && v.isNotEmpty) return v;
|
||
final u = t.image.trim();
|
||
return u.isEmpty ? null : u;
|
||
}
|
||
|
||
bool get _previewIsVideo {
|
||
final u = _previewPlayUrl(widget.template);
|
||
return u != null && _urlLooksLikeVideo(u);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_pollHandle?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
int get _estimatedCost {
|
||
const fallback = 20;
|
||
final t = widget.template;
|
||
if (t == null) return fallback;
|
||
|
||
int effective720() {
|
||
if (t.cost720p != null && t.cost720p! > 0) return t.cost720p!;
|
||
if (t.cost > 0) return t.cost;
|
||
return fallback;
|
||
}
|
||
|
||
if (_outputSize == '480p') {
|
||
if (t.cost480p != null && t.cost480p! > 0) return t.cost480p!;
|
||
final h = effective720();
|
||
return max(1, (h / 2).round());
|
||
}
|
||
final c = effective720();
|
||
return c > 0 ? c : fallback;
|
||
}
|
||
|
||
/// 进度条已出现(轮询开始)时隐藏底部档位/按钮/积分信息。
|
||
bool get _showProgressBar => _pollHandle != null;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return PencilYellowWhitePageBackground(
|
||
child: Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
body: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
||
child: SizedBox(
|
||
height: 56,
|
||
child: Row(
|
||
children: [
|
||
PencilRoundBackButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
Expanded(
|
||
child: Center(
|
||
child: Text(
|
||
'Generate',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 19,
|
||
fontWeight: FontWeight.w700,
|
||
fontStyle: FontStyle.italic,
|
||
color: PencilTheme.stone900,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 44),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: _hintBox(),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
_needTwoImages
|
||
? _leftTwoSlotsColumn()
|
||
: _leftSingleSlotColumn(),
|
||
const SizedBox(width: 14),
|
||
_equalsColumn(),
|
||
const SizedBox(width: 14),
|
||
Expanded(child: _effectPreviewPanel()),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 28),
|
||
child: Column(
|
||
children: [
|
||
if (!_showProgressBar && _showResolutionToggles)
|
||
IgnorePointer(
|
||
ignoring: _generating,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_resoChip(
|
||
'480p',
|
||
_outputSize == '480p',
|
||
() => setState(() => _outputSize = '480p'),
|
||
),
|
||
const SizedBox(width: 12),
|
||
_resoChip(
|
||
'720p',
|
||
_outputSize == '720p',
|
||
() => setState(() => _outputSize = '720p'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (_genError != null) ...[
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
_genError!,
|
||
style: GoogleFonts.inter(color: Colors.red),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
] else if (_showProgressBar) ...[
|
||
const SizedBox(height: 16),
|
||
LinearProgressIndicator(
|
||
value: _genProgress > 0 ? _genProgress / 100 : null,
|
||
color: PencilTheme.underlineGold,
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
_genStatus.isEmpty ? 'Processing…' : _genStatus,
|
||
style: GoogleFonts.inter(
|
||
fontSize: 13,
|
||
color: PencilTheme.stone600,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
Text(
|
||
'$_genProgress%',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w700,
|
||
color: PencilTheme.stone900,
|
||
),
|
||
),
|
||
],
|
||
if (!_showProgressBar) ...[
|
||
const SizedBox(height: 12),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: PencilTheme.underlineGold,
|
||
foregroundColor: Colors.white,
|
||
disabledBackgroundColor: PencilTheme.underlineGold,
|
||
disabledForegroundColor: Colors.white,
|
||
minimumSize: const Size.fromHeight(54),
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
),
|
||
onPressed: _generating ? null : _start,
|
||
child: _generating
|
||
? Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(
|
||
height: 22,
|
||
width: 22,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
'Loading',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Text(
|
||
'Start',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'cost · $_estimatedCost credits',
|
||
textAlign: TextAlign.center,
|
||
style: GoogleFonts.inter(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.stone600,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 设计稿 Lcol:114 宽,双槽 112×108,中间 32 高加号。
|
||
Widget _leftTwoSlotsColumn() {
|
||
return SizedBox(
|
||
width: 114,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_imageSlot(slotIndex: 0, label: 'Image 1'),
|
||
const SizedBox(height: 10),
|
||
SizedBox(
|
||
height: 32,
|
||
child: Center(
|
||
child: Icon(
|
||
Icons.add,
|
||
size: 24,
|
||
color: PencilTheme.profileCredits,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_imageSlot(slotIndex: 1, label: 'Image 2'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 单图:仅 `img_need != 2` 或视频菜单开启时;槽位尺寸与设计一致。
|
||
Widget _leftSingleSlotColumn() {
|
||
return SizedBox(
|
||
width: 114,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [_imageSlot(slotIndex: 0, label: 'Image 1')],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _equalsColumn() {
|
||
return SizedBox(
|
||
width: 32,
|
||
child: Center(
|
||
child: Text(
|
||
'=',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 26,
|
||
fontWeight: FontWeight.w800,
|
||
color: PencilTheme.profileCredits,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 设计稿 prevBx:高 359,圆角 20,描边 #FBBF24。
|
||
/// 描边叠在图片之上(否则子组件会先盖住 decoration 内缘的线)。
|
||
Widget _effectPreviewPanel() {
|
||
final t = widget.template;
|
||
final url = t?.image.trim() ?? '';
|
||
final fix = t?.imageFix?.trim();
|
||
|
||
final innerR = _previewOuterR - _previewBorderW;
|
||
|
||
return SizedBox(
|
||
height: _previewH,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(_previewOuterR),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.08),
|
||
blurRadius: 24,
|
||
offset: const Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
clipBehavior: Clip.none,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(innerR),
|
||
child: _buildPreviewMediaLayer(url, fix),
|
||
),
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(_previewOuterR),
|
||
border: Border.all(
|
||
color: PencilTheme.profileAvatarRing,
|
||
width: _previewBorderW,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 视频预览:[ResilientNetworkVideoCover] 与首页一致处理 416 / 坏缓存并重试;失败则回退静态图。
|
||
Widget _buildPreviewMediaLayer(String url, String? fix) {
|
||
if (_previewIsVideo) {
|
||
final playUrl = _previewPlayUrl(widget.template);
|
||
if (playUrl == null || playUrl.isEmpty) {
|
||
return _previewPlaceholder();
|
||
}
|
||
return ResilientNetworkVideoCover(
|
||
key: ValueKey<String>(playUrl),
|
||
url: playUrl,
|
||
loadingWidget: _previewPlaceholder(loading: true),
|
||
failedWidget: _buildPreviewStaticImageLayer(url, fix),
|
||
);
|
||
}
|
||
return _buildPreviewStaticImageLayer(url, fix);
|
||
}
|
||
|
||
Widget _buildPreviewStaticImageLayer(String url, String? fix) {
|
||
if (url.isNotEmpty) {
|
||
return CachedNetworkImage(
|
||
imageUrl: url,
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
height: _previewH,
|
||
placeholder: (_, _) => _previewPlaceholder(loading: true),
|
||
errorWidget: (_, _, _) {
|
||
if (fix != null && fix.isNotEmpty) {
|
||
return CachedNetworkImage(
|
||
imageUrl: fix,
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
height: _previewH,
|
||
errorWidget: (_, _, _) => _previewPlaceholder(),
|
||
);
|
||
}
|
||
return _previewPlaceholder();
|
||
},
|
||
);
|
||
}
|
||
if (fix != null && fix.isNotEmpty) {
|
||
return CachedNetworkImage(
|
||
imageUrl: fix,
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
height: _previewH,
|
||
errorWidget: (_, _, _) => _previewPlaceholder(),
|
||
);
|
||
}
|
||
return _previewPlaceholder();
|
||
}
|
||
|
||
Widget _previewPlaceholder({bool loading = false}) {
|
||
return Container(
|
||
color: PencilTheme.cardThumbBg,
|
||
alignment: Alignment.center,
|
||
child: loading
|
||
? SizedBox(
|
||
width: 28,
|
||
height: 28,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: PencilTheme.profileAvatarIcon.withValues(alpha: 0.8),
|
||
),
|
||
)
|
||
: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.auto_awesome_outlined,
|
||
size: 40,
|
||
color: PencilTheme.stone600.withValues(alpha: 0.5),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Preview',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.stone600.withValues(alpha: 0.75),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _imageSlot({required int slotIndex, required String label}) {
|
||
final file = slotIndex == 0 ? _picked : _picked2;
|
||
|
||
return SizedBox(
|
||
width: _slotW,
|
||
height: _slotH,
|
||
child: Material(
|
||
color: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
side: const BorderSide(color: PencilTheme.genSlotBorder, width: 1.5),
|
||
),
|
||
child: InkWell(
|
||
onTap: (_generating || _pickLoadingSlot != null)
|
||
? null
|
||
: () => _pickSlot(slotIndex),
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
file == null
|
||
? Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.add_photo_alternate_outlined,
|
||
color: PencilTheme.profileAvatarIcon,
|
||
size: 26,
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
label,
|
||
style: GoogleFonts.inter(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w700,
|
||
color: PencilTheme.stone600,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: ClipRRect(
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: Image.file(file, fit: BoxFit.cover),
|
||
),
|
||
if (_pickLoadingSlot == slotIndex)
|
||
ColoredBox(
|
||
color: Colors.white.withValues(alpha: 0.88),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SizedBox(
|
||
width: 26,
|
||
height: 26,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2.5,
|
||
color: PencilTheme.profileAvatarIcon,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Loading…',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w800,
|
||
color: PencilTheme.stone700,
|
||
letterSpacing: 0.2,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _hintBox() {
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: PencilTheme.genHintBorder),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: const Color(0x30CA8A04),
|
||
blurRadius: 20,
|
||
offset: const Offset(0, 6),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.auto_awesome,
|
||
size: 18,
|
||
color: PencilTheme.profileAvatarIcon,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'Upload tips',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: PencilTheme.genHintTitle,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
_uploadTipsBody(),
|
||
style: GoogleFonts.inter(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.45,
|
||
color: PencilTheme.stone600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _resoChip(String t, bool on, VoidCallback fn) {
|
||
return Material(
|
||
color: on
|
||
? PencilTheme.underlineGold.withValues(alpha: 0.2)
|
||
: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
side: BorderSide(
|
||
color: on ? PencilTheme.underlineGold : PencilTheme.genNavBackStroke,
|
||
),
|
||
),
|
||
child: InkWell(
|
||
onTap: fn,
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||
child: Text(
|
||
t,
|
||
style: GoogleFonts.inter(
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.stone900,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
bool _urlLooksLikeVideo(String url) {
|
||
if (url.isEmpty) return false;
|
||
final lower = url.toLowerCase();
|
||
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv', '.avi'];
|
||
return hints.any((h) => lower.contains(h));
|
||
}
|