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:image_picker/image_picker.dart'; 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/models/task_item.dart'; import '../../shared/widgets/top_nav_bar.dart'; import '../../core/api/services/image_api.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}'); }); } /// 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 source = await showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(LucideIcons.image), title: const Text('Choose from gallery'), onTap: () => Navigator.pop(context, ImageSource.gallery), ), ListTile( leading: const Icon(LucideIcons.camera), title: const Text('Take photo'), onTap: () => Navigator.pop(context, ImageSource.camera), ), ], ), ), ); if (source == null || !mounted) return; final picker = ImagePicker(); final picked = await picker.pickImage( source: source, imageQuality: 85, ); if (picked == null || !mounted) return; final file = File(picked.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) { return Scaffold( backgroundColor: AppColors.background, appBar: PreferredSize( preferredSize: const Size.fromHeight(56), child: TopNavBar( title: 'Generate', showBackButton: true, onBack: () => Navigator.of(context).pop(), ), ), body: Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge, AppSpacing.lg, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _CreditsCard( credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', ), const SizedBox(height: AppSpacing.xxl), _VideoPreviewArea( videoUrl: widget.task?.previewVideoUrl, imageUrl: widget.task?.previewImageUrl, ), ], ), ), ), SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.screenPaddingLarge, AppSpacing.lg, AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_hasVideo) _ResolutionToggle( selected: _selectedResolution, onChanged: (r) => setState(() => _selectedResolution = r), ), if (_hasVideo) const SizedBox(height: AppSpacing.xxl), _GenerateButton( onGenerate: _onGenerateButtonTap, isLoading: _isGenerating, credits: _currentCredits.toString(), ), ], ), ), ), ], ), ); } } class _CreditsCard extends StatelessWidget { const _CreditsCard({required this.credits}); final String credits; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xxl, vertical: AppSpacing.xl, ), decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.primary.withValues(alpha: 0.5), ), boxShadow: [ BoxShadow( color: AppColors.primaryShadow.withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( children: [ const Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface), const SizedBox(width: AppSpacing.md), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Available Credits', style: AppTypography.bodyRegular.copyWith( color: AppColors.surface.withValues(alpha: 0.8), ), ), Text( credits, style: AppTypography.bodyLarge.copyWith( fontSize: 32, fontWeight: FontWeight.w700, color: AppColors.surface, ), ), ], ), ], ), ); } } /// Video preview area - video URL from card click. Auto-load and play on init. /// Video fit: contain (no crop). Loading animation until ready. class _VideoPreviewArea extends StatefulWidget { const _VideoPreviewArea({ this.videoUrl, this.imageUrl, }); final String? videoUrl; final String? imageUrl; @override State<_VideoPreviewArea> createState() => _VideoPreviewAreaState(); } class _VideoPreviewAreaState extends State<_VideoPreviewArea> { 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 _VideoPreviewArea 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); await controller.initialize(); if (!mounted) return; await controller.play(); controller.setLooping(true); 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; // 图片模式:宽度=组件宽度,高度按图片宽高比自适应 if (!hasVideo && hasImage) { return LayoutBuilder( builder: (context, constraints) { return Container( width: constraints.maxWidth, decoration: BoxDecoration( color: AppColors.surfaceAlt, borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.border, width: 1, ), ), clipBehavior: Clip.antiAlias, child: _AspectRatioImage( imageUrl: widget.imageUrl!, maxWidth: constraints.maxWidth, ), ); }, ); } // 视频模式:aspect ratio 来自视频或 16:9 占位 final aspectRatio = isReady && _controller!.value.size.width > 0 && _controller!.value.size.height > 0 ? _controller!.value.size.width / _controller!.value.size.height : 16 / 9; return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final height = width / aspectRatio; return Container( width: width, height: height, decoration: BoxDecoration( color: AppColors.surfaceAlt, borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.border, width: 1, ), ), clipBehavior: Clip.antiAlias, child: Stack( fit: StackFit.expand, alignment: Alignment.center, children: [ if (isReady) SizedBox.expand( child: FittedBox( fit: BoxFit.contain, child: SizedBox( width: _controller!.value.size.width, height: _controller!.value.size.height, child: VideoPlayer(_controller!), ), ), ) else if (hasImage) CachedNetworkImage( imageUrl: widget.imageUrl!, fit: BoxFit.contain, width: double.infinity, height: double.infinity, placeholder: (_, __) => const _LoadingOverlay(isLoading: true), errorWidget: (_, __, ___) => const _LoadingOverlay(isLoading: false), ) else const _LoadingOverlay(isLoading: false), if (hasVideo && !isReady) const Positioned.fill( child: _LoadingOverlay(isLoading: true), ), ], ), ); }, ); } } /// 图片展示:宽度=组件宽度,高度按图片宽高比自适应 class _AspectRatioImage extends StatefulWidget { const _AspectRatioImage({ required this.imageUrl, required this.maxWidth, }); final String imageUrl; final double maxWidth; @override State<_AspectRatioImage> createState() => _AspectRatioImageState(); } class _AspectRatioImageState extends State<_AspectRatioImage> { double? _aspectRatio; ImageStream? _stream; late ImageStreamListener _listener; @override void initState() { super.initState(); _listener = ImageStreamListener(_onImageLoaded, onError: _onImageError); _resolveImage(); } @override void didUpdateWidget(covariant _AspectRatioImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.imageUrl != widget.imageUrl) { _stream?.removeListener(_listener); _aspectRatio = null; _resolveImage(); } } void _resolveImage() { final provider = CachedNetworkImageProvider(widget.imageUrl); _stream = provider.resolve(const ImageConfiguration()); _stream!.addListener(_listener); } void _onImageLoaded(ImageInfo info, bool sync) { if (!mounted) return; final w = info.image.width.toDouble(); final h = info.image.height.toDouble(); if (w > 0 && h > 0) { setState(() => _aspectRatio = w / h); } } void _onImageError(dynamic exception, StackTrace? stackTrace) { if (mounted) setState(() => _aspectRatio = 1); } @override void dispose() { _stream?.removeListener(_listener); super.dispose(); } @override Widget build(BuildContext context) { final ratio = _aspectRatio ?? 1; final height = widget.maxWidth / ratio; return SizedBox( width: widget.maxWidth, height: height, child: CachedNetworkImage( imageUrl: widget.imageUrl, fit: BoxFit.contain, width: widget.maxWidth, height: height, placeholder: (_, __) => SizedBox( width: widget.maxWidth, height: widget.maxWidth, child: const _LoadingOverlay(isLoading: true), ), errorWidget: (_, __, ___) => SizedBox( width: widget.maxWidth, height: widget.maxWidth, child: const _LoadingOverlay(isLoading: false), ), ), ); } } class _LoadingOverlay extends StatelessWidget { const _LoadingOverlay({this.isLoading = true}); final bool isLoading; @override Widget build(BuildContext context) { return Container( color: AppColors.surfaceAlt, alignment: Alignment.center, child: isLoading ? const SizedBox( width: 40, height: 40, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.primary, ), ) : const Icon( LucideIcons.video, size: 48, color: AppColors.textMuted, ), ); } } /// 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.textPrimary, 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.primary : AppColors.surfaceAlt, borderRadius: BorderRadius.circular(18), border: isSelected ? null : Border.all(color: AppColors.border, width: 1), boxShadow: isSelected ? [ BoxShadow( color: AppColors.primaryShadow.withValues(alpha: 0.19), 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.textSecondary, 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.primary, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: AppColors.primaryShadow.withValues(alpha: 0.25), 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, ), ), ], ), ), ], ], ), ), ), ); } }