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 createState() => _GenerateScreenState(); } class _GenerateScreenState extends State { 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 _refreshUploadLimitFromCache() async { final v = await ImageUploadExpectedSizeCache.readImageMaxBytesForUi(); if (!mounted) return; setState(() => _maxUploadBytes = v); if (!mounted) return; await _prunePickedIfOverLimit(); } Future _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 _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 _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; } 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( 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(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)); }