FunyMeeAI/lib/features/generate/generate_screen.dart

672 lines
21 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: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,
),
),
],
),
),
],
),
),
),
);
}
/// 设计稿 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: _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,
),
),
),
),
);
}
}