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 createState() => _GenerateScreenState(); } class _GenerateScreenState extends State { 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 _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 _showPickImageSourceSheet(BuildContext context) { return showModalBottomSheet( 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 _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( 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, ), ), ), ), ); } }