import 'dart:developer' as developer; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; import '../../core/auth/auth_service.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.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}); final TaskItem? task; @override State createState() => _GenerateVideoScreenState(); } enum _Resolution { p480, p720 } class _GenerateVideoScreenState extends State { File? _selectedImage; _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'; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { developer.log( 'GenerateVideoScreen opened with task: ${widget.task}', name: 'GenerateVideoScreen', ); debugPrint('[GenerateVideoScreen] task: ${widget.task}'); }); } Future _showImageSourcePicker() async { 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) { _pickImage(source); } } Future _pickImage(ImageSource source) async { final picker = ImagePicker(); final picked = await picker.pickImage( source: source, imageQuality: 85, ); if (picked != null && mounted) { setState(() { _selectedImage = File(picked.path); }); } } Future _onGenerate() async { if (_selectedImage == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Please select an image first'), behavior: SnackBarBehavior.floating, ), ); } return; } setState(() => _isGenerating = true); try { await AuthService.loginComplete; final file = _selectedImage!; 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 createRes = await ImageApi.createTask( asset: userId, guild: filePath, allowance: false, cipher: widget.task?.taskType ?? '', congregation: widget.task?.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']; if (!mounted) return; Navigator.of(context) .pushReplacementNamed('/progress', arguments: taskId); } catch (e, st) { developer.log('Generate failed: $e', name: 'GenerateVideoScreen'); debugPrint('[GenerateVideoScreen] error: $e\n$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 Video', credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', showBackButton: true, onBack: () => Navigator.of(context).pop(), onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), ), ), body: SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _CreditsCard( credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', ), const SizedBox(height: AppSpacing.xxl), _UploadArea( selectedImage: _selectedImage, onUpload: _showImageSourcePicker, ), const SizedBox(height: AppSpacing.xxl), _ResolutionToggle( selected: _selectedResolution, onChanged: (r) => setState(() => _selectedResolution = r), ), const SizedBox(height: AppSpacing.xxl), _GenerateButton( onGenerate: _onGenerate, 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: [ 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, ), ), ], ), ], ), ); } } class _UploadArea extends StatelessWidget { const _UploadArea({ required this.selectedImage, required this.onUpload, }); final File? selectedImage; final VoidCallback onUpload; @override Widget build(BuildContext context) { return GestureDetector( onTap: onUpload, child: Container( height: 280, decoration: BoxDecoration( color: AppColors.surfaceAlt, borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.border, width: 2, ), ), clipBehavior: Clip.antiAlias, child: selectedImage != null ? Image.file( selectedImage!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, ) : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.image_plus, size: 48, color: AppColors.textMuted, ), const SizedBox(height: AppSpacing.lg), SizedBox( width: 280, child: Text( 'Please upload an image as the base for generation', textAlign: TextAlign.center, style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, ), ), ), ], ), ), ); } } 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 Row( children: [ Expanded( child: GestureDetector( onTap: () => onChanged(_Resolution.p480), child: Container( height: 48, decoration: BoxDecoration( color: selected == _Resolution.p480 ? AppColors.primary : AppColors.surfaceAlt, borderRadius: BorderRadius.circular(12), border: Border.all( color: selected == _Resolution.p480 ? AppColors.primary.withValues(alpha: 0.5) : AppColors.border, width: selected == _Resolution.p480 ? 1 : 2, ), ), alignment: Alignment.center, child: Text( '480P', style: AppTypography.bodyMedium.copyWith( color: selected == _Resolution.p480 ? AppColors.surface : AppColors.textSecondary, fontWeight: FontWeight.w600, ), ), ), ), ), const SizedBox(width: 12), Expanded( child: GestureDetector( onTap: () => onChanged(_Resolution.p720), child: Container( height: 48, decoration: BoxDecoration( color: selected == _Resolution.p720 ? AppColors.primary : AppColors.surfaceAlt, borderRadius: BorderRadius.circular(12), border: Border.all( color: selected == _Resolution.p720 ? AppColors.primary.withValues(alpha: 0.5) : AppColors.border, width: selected == _Resolution.p720 ? 1 : 2, ), ), alignment: Alignment.center, child: Text( '720P', style: AppTypography.bodyMedium.copyWith( color: selected == _Resolution.p720 ? AppColors.surface : AppColors.textSecondary, fontWeight: FontWeight.w600, ), ), ), ), ), ], ); } } 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 Video', 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: [ 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, ), ), ], ), ), ], ], ), ), ), ); } }