import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:http/http.dart' as http; import 'package:video_player/video_player.dart'; import '../../core/auth/auth_service.dart'; import '../../core/log/app_logger.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; import '../../core/user/account_refresh.dart'; import '../../core/user/user_state.dart'; import '../../features/home/home_playback_resume.dart'; import '../../features/home/models/task_item.dart'; import '../../shared/widgets/top_nav_bar.dart'; import '../../core/api/services/image_api.dart'; import 'widgets/album_picker_sheet.dart'; /// Generate Video screen - matches Pencil mmLB5 class GenerateVideoScreen extends StatefulWidget { const GenerateVideoScreen({super.key, this.task}); static final _log = AppLogger('GenerateVideo'); final TaskItem? task; @override State createState() => _GenerateVideoScreenState(); } enum _Resolution { p480, p720 } class _GenerateVideoScreenState extends State { _Resolution _selectedResolution = _Resolution.p480; bool _isGenerating = false; int get _currentCredits { final task = widget.task; if (task == null) return 50; switch (_selectedResolution) { case _Resolution.p480: return task.credits480p ?? 50; case _Resolution.p720: return task.credits720p ?? 50; } } String get _heatmap => _selectedResolution == _Resolution.p480 ? '480p' : '720p'; bool get _hasVideo { final url = widget.task?.previewVideoUrl; return url != null && url.isNotEmpty; } @override void initState() { super.initState(); refreshAccount(); WidgetsBinding.instance.addPostFrameCallback((_) { GenerateVideoScreen._log.d('opened with task: ${widget.task}'); }); } @override void dispose() { // 从本页 pop(含关闭相册后再返回首页)时,底层首页需重新触发可见性;仅 [RouteAware.didPopNext] 有时序不足 homePlaybackResumeNonce.value++; super.dispose(); } /// Click flow per docs/generate_video.md: tap Generate Video -> image picker /// (camera or gallery) -> after image selected -> proceed to API. Future _onGenerateButtonTap() async { if (_isGenerating) return; final userCredits = UserState.credits.value ?? 0; if (userCredits < _currentCredits) { if (mounted) { Navigator.of(context).pushNamed('/recharge'); } return; } final path = await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => SizedBox( height: MediaQuery.sizeOf(ctx).height * 0.92, child: const AlbumPickerSheet(), ), ); if (path == null || path.isEmpty || !mounted) return; final file = File(path); await _runGenerationApi(file); } Future _runGenerationApi(File file) async { setState(() => _isGenerating = true); try { await AuthService.loginComplete; final size = await file.length(); final ext = file.path.split('.').last.toLowerCase(); final contentType = ext == 'png' ? 'image/png' : ext == 'gif' ? 'image/gif' : 'image/jpeg'; final fileName = 'img_${DateTime.now().millisecondsSinceEpoch}.$ext'; final presignedRes = await ImageApi.getUploadPresignedUrl( fileName1: fileName, contentType: contentType, expectedSize: size, ); if (!presignedRes.isSuccess || presignedRes.data == null) { throw Exception(presignedRes.msg.isNotEmpty ? presignedRes.msg : 'Failed to get upload URL'); } final data = presignedRes.data as Map; final uploadUrl = data['bolster'] as String? ?? data['expound'] as String?; final filePath = data['recruit'] as String? ?? data['train'] as String?; final requiredHeaders = data['remap'] as Map?; if (uploadUrl == null || uploadUrl.isEmpty || filePath == null || filePath.isEmpty) { throw Exception('Invalid presigned URL response'); } final headers = {}; if (requiredHeaders != null) { for (final e in requiredHeaders.entries) { headers[e.key] = e.value?.toString() ?? ''; } } if (!headers.containsKey('Content-Type')) { headers['Content-Type'] = contentType; } final bytes = await file.readAsBytes(); final uploadResponse = await http.put( Uri.parse(uploadUrl), headers: headers, body: bytes, ); if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) { throw Exception('Upload failed: ${uploadResponse.statusCode}'); } final userId = UserState.userId.value; if (userId == null || userId.isEmpty) { throw Exception('User not logged in'); } final templateName = widget.task?.templateName ?? ''; final createRes = await ImageApi.createTask( asset: userId, guild: filePath, allowance: false, cipher: widget.task?.taskType ?? '', congregation: templateName == 'BananaTask' ? null : templateName, heatmap: _heatmap, ext: widget.task?.ext, ); if (!createRes.isSuccess) { throw Exception( createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task'); } final taskData = createRes.data as Map?; final taskId = taskData?['tree']; // 创建任务成功后刷新用户账户信息(积分等) await refreshAccount(); if (!mounted) return; Navigator.of(context).pushReplacementNamed( '/progress', arguments: {'taskId': taskId, 'imagePath': file.path}, ); } catch (e, st) { GenerateVideoScreen._log.e('Generate failed', e, st); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Generation failed: ${e.toString().replaceAll('Exception: ', '')}'), behavior: SnackBarBehavior.floating, ), ); } } finally { if (mounted) { setState(() => _isGenerating = false); } } } @override Widget build(BuildContext context) { final topInset = MediaQuery.paddingOf(context).top; final creditsDisplay = UserCreditsData.of(context)?.creditsDisplay ?? '--'; return Scaffold( extendBodyBehindAppBar: true, backgroundColor: Colors.black, appBar: PreferredSize( preferredSize: Size.fromHeight(topInset + 56), child: Container( padding: EdgeInsets.only(top: topInset), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.72), Colors.black.withValues(alpha: 0.35), Colors.black.withValues(alpha: 0.0), ], stops: const [0.0, 0.55, 1.0], ), ), child: SizedBox( height: 56, child: TopNavBar( title: 'Generate', credits: creditsDisplay, onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), showBackButton: true, onBack: () => Navigator.of(context).pop(), backgroundColor: Colors.transparent, foregroundColor: AppColors.surface, ), ), ), ), body: Stack( fit: StackFit.expand, children: [ _GenerateFullScreenBackground( videoUrl: widget.task?.previewVideoUrl, imageUrl: widget.task?.previewImageUrl, ), Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.12), Colors.black.withValues(alpha: 0.55), ], stops: const [0.45, 1.0], ), ), ), ), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.screenPaddingLarge, AppSpacing.lg, AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Spacer(), if (_hasVideo) Center( child: _ResolutionToggle( selected: _selectedResolution, onChanged: (r) => setState(() => _selectedResolution = r), ), ), if (_hasVideo) const SizedBox(height: AppSpacing.xxl), _GenerateButton( onGenerate: _onGenerateButtonTap, isLoading: _isGenerating, credits: _currentCredits.toString(), ), ], ), ), ), ], ), ); } } /// 列表传入的预览图/视频:全屏背景层,超出视口 [BoxFit.cover] 裁切。 class _GenerateFullScreenBackground extends StatefulWidget { const _GenerateFullScreenBackground({ this.videoUrl, this.imageUrl, }); final String? videoUrl; final String? imageUrl; @override State<_GenerateFullScreenBackground> createState() => _GenerateFullScreenBackgroundState(); } class _GenerateFullScreenBackgroundState extends State<_GenerateFullScreenBackground> { VideoPlayerController? _controller; @override void initState() { super.initState(); if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) { _loadAndPlay(); } } @override void dispose() { _controller?.dispose(); _controller = null; super.dispose(); } @override void didUpdateWidget(covariant _GenerateFullScreenBackground oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.videoUrl != widget.videoUrl) { _controller?.dispose(); _controller = null; if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) { _loadAndPlay(); } } } Future _loadAndPlay() async { final url = widget.videoUrl; if (url == null || url.isEmpty) return; setState(() {}); try { final file = await DefaultCacheManager().getSingleFile(url); if (!mounted) return; final controller = VideoPlayerController.file( file, videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); await controller.initialize(); if (!mounted) return; await controller.setVolume(1.0); await controller.setLooping(true); await controller.play(); if (mounted) { setState(() { _controller = controller; }); } } catch (e) { GenerateVideoScreen._log.e('Video load failed', e); if (mounted) setState(() {}); } } @override Widget build(BuildContext context) { final isReady = _controller != null && _controller!.value.isInitialized; final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty; return Stack( fit: StackFit.expand, children: [ if (!hasVideo && hasImage) Positioned.fill( child: CachedNetworkImage( imageUrl: widget.imageUrl!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, placeholder: (_, __) => const _BgLoadingPlaceholder(), errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), ), ) else if (hasVideo && isReady) Positioned.fill( child: ClipRect( child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _controller!.value.size.width, height: _controller!.value.size.height, child: VideoPlayer(_controller!), ), ), ), ) else if (hasVideo && hasImage) Positioned.fill( child: CachedNetworkImage( imageUrl: widget.imageUrl!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, placeholder: (_, __) => const _BgLoadingPlaceholder(), errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), ), ) else if (hasVideo) const Positioned.fill(child: _BgLoadingPlaceholder()) else const Positioned.fill(child: _BgErrorPlaceholder()), ], ); } } class _BgLoadingPlaceholder extends StatelessWidget { const _BgLoadingPlaceholder(); @override Widget build(BuildContext context) { return Container( color: AppColors.textPrimary, alignment: Alignment.center, child: const SizedBox( width: 40, height: 40, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.surface, ), ), ); } } class _BgErrorPlaceholder extends StatelessWidget { const _BgErrorPlaceholder(); @override Widget build(BuildContext context) { return Container( color: AppColors.textPrimary, alignment: Alignment.center, child: Icon( LucideIcons.image_off, size: 56, color: AppColors.surface.withValues(alpha: 0.45), ), ); } } /// Resolution row - 1:1 match Pencil oYwsm: label + 480P/720P on same row. /// Button: height 36, cornerRadius 18, padding 8x16, gap 12 label-to-btns, gap 8 between btns. class _ResolutionToggle extends StatelessWidget { const _ResolutionToggle({ required this.selected, required this.onChanged, }); final _Resolution selected; final ValueChanged<_Resolution> onChanged; @override Widget build(BuildContext context) { return SizedBox( height: 44, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( 'Resolution', style: AppTypography.bodyMedium.copyWith( fontSize: 14, color: AppColors.surface, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 12), _ResolutionOption( label: '480P', isSelected: selected == _Resolution.p480, onTap: () => onChanged(_Resolution.p480), ), const SizedBox(width: 8), _ResolutionOption( label: '720P', isSelected: selected == _Resolution.p720, onTap: () => onChanged(_Resolution.p720), ), ], ), ); } } class _ResolutionOption extends StatelessWidget { const _ResolutionOption({ required this.label, required this.isSelected, required this.onTap, }); final String label; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isSelected ? AppColors.primaryGlass : AppColors.surface.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(18), border: isSelected ? Border.all( color: AppColors.primary.withValues(alpha: 0.42), width: 1) : Border.all( color: AppColors.surface.withValues(alpha: 0.45), width: 1, ), boxShadow: isSelected ? [ BoxShadow( color: AppColors.primaryShadow.withValues(alpha: 0.22), blurRadius: 4, offset: const Offset(0, 2), ), ] : null, ), alignment: Alignment.center, child: Text( label, style: AppTypography.bodyMedium.copyWith( fontSize: 13, color: isSelected ? AppColors.surface : AppColors.surface.withValues(alpha: 0.85), fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, ), ), ), ); } } class _GenerateButton extends StatelessWidget { const _GenerateButton({ required this.onGenerate, this.isLoading = false, this.credits = '50', }); final VoidCallback onGenerate; final bool isLoading; final String credits; @override Widget build(BuildContext context) { return GestureDetector( onTap: isLoading ? null : onGenerate, child: Opacity( opacity: isLoading ? 0.6 : 1, child: Container( height: 56, decoration: BoxDecoration( color: AppColors.primaryGlassEmphasis, borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.primary.withValues(alpha: 0.45), width: 1, ), boxShadow: [ BoxShadow( color: AppColors.primaryShadow.withValues(alpha: 0.28), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (isLoading) const SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.surface, ), ) else Text( 'Generate', style: AppTypography.bodyMedium.copyWith( color: AppColors.surface, ), ), if (!isLoading) ...[ const SizedBox(width: AppSpacing.md), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: AppSpacing.xs, ), decoration: BoxDecoration( color: AppColors.surface.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( LucideIcons.sparkles, size: 16, color: AppColors.surface, ), const SizedBox(width: AppSpacing.xs), Text( credits, style: AppTypography.bodyRegular.copyWith( color: AppColors.surface, fontWeight: FontWeight.w600, ), ), ], ), ), ], ], ), ), ), ); } }