672 lines
21 KiB
Dart
672 lines
21 KiB
Dart
import 'dart:io';
|
||
|
||
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/user/user_state.dart';
|
||
import '../../design/pencil_theme.dart';
|
||
import '../../widgets/pencil_chrome.dart';
|
||
import 'generate_progress_screen.dart';
|
||
|
||
/// `EYsUi` 生成图片页 — 左 112×108 槽位 +「=」+ 右侧效果图;分辨率在「开始生成」上方横排。
|
||
/// [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;
|
||
String _heatmap = '720p';
|
||
bool _busy = false;
|
||
|
||
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;
|
||
|
||
Future<void> _pickSlot(int slot) async {
|
||
if (!mounted) return;
|
||
final source = await _showPickImageSourceSheet(context);
|
||
if (source == null || !mounted) return;
|
||
final x = await _picker.pickImage(
|
||
source: source,
|
||
imageQuality: 92,
|
||
);
|
||
if (x == null || !mounted) return;
|
||
setState(() {
|
||
if (slot == 0) {
|
||
_picked = File(x.path);
|
||
} else {
|
||
_picked2 = File(x.path);
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
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(() => _busy = true);
|
||
try {
|
||
final ImagePresignedUploadCreateTaskResult result;
|
||
if (_needTwoImages) {
|
||
result = await ImagePresignedUploadCreateTaskFlow.runTwoSourceFiles(
|
||
sourceFile1: _picked!,
|
||
sourceFile2: _picked2!,
|
||
userId: uid,
|
||
heatmap: _heatmap,
|
||
cipher: '',
|
||
compressFirst: true,
|
||
compressOptions: const CompressImageForUploadOptions(
|
||
maxSide: 1024,
|
||
jpegQuality: 75,
|
||
),
|
||
saveLocalUploadCover: true,
|
||
);
|
||
} else {
|
||
result = await ImagePresignedUploadCreateTaskFlow.run(
|
||
sourceFile: _primaryFile!,
|
||
userId: uid,
|
||
heatmap: _heatmap,
|
||
cipher: '',
|
||
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;
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => GenerateProgressScreen(
|
||
taskId: taskId,
|
||
localPreviewPath: result.fileUsedForUpload.path,
|
||
),
|
||
),
|
||
);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('$e')),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _busy = false);
|
||
}
|
||
}
|
||
|
||
int get _estimatedCost {
|
||
final c = widget.template?.cost ?? 0;
|
||
return c > 0 ? c : 20;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final credits = UserState.credits.value;
|
||
return Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: PencilTheme.yellowWhitePageGradient,
|
||
),
|
||
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: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_resoChip(
|
||
'480p',
|
||
_heatmap == '480p',
|
||
() => setState(() => _heatmap = '480p'),
|
||
),
|
||
const SizedBox(width: 12),
|
||
_resoChip(
|
||
'720p',
|
||
_heatmap == '720p',
|
||
() => setState(() => _heatmap = '720p'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: PencilTheme.underlineGold,
|
||
foregroundColor: Colors.white,
|
||
minimumSize: const Size.fromHeight(54),
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
),
|
||
onPressed: _busy ? null : _start,
|
||
child: _busy
|
||
? const SizedBox(
|
||
height: 22,
|
||
width: 22,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: Text(
|
||
'Start',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'Est. cost · $_estimatedCost credits',
|
||
textAlign: TextAlign.center,
|
||
style: GoogleFonts.inter(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.stone600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Balance · ${credits.toStringAsFixed(2)}',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 12,
|
||
color: PencilTheme.inkSoft,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 设计稿 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: _buildPreviewImageLayer(url, fix),
|
||
),
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(_previewOuterR),
|
||
border: Border.all(
|
||
color: PencilTheme.profileAvatarRing,
|
||
width: _previewBorderW,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPreviewImageLayer(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: () => _pickSlot(slotIndex),
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: 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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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(
|
||
'Upload JPG or PNG (≤ 5 MB each, up to 2). You can use the camera or photo library. Use clear, front-facing photos when possible.',
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|