FunyMeeAI/lib/features/generate/generate_screen.dart

1066 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] 作 720p480p 按半档估算。
/// 与 [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,
),
),
],
],
),
),
],
),
),
),
);
}
/// 设计稿 Lcol114 宽,双槽 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));
}