petsHero-AI/lib/features/generate_video/generation_result_screen.dart
2026-03-29 19:54:56 +08:00

731 lines
22 KiB
Dart

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:gal/gal.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart';
import '../../core/api/services/feedback_api.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart';
import '../../features/gallery/models/gallery_task_item.dart';
import '../../shared/widgets/top_nav_bar.dart';
/// Video Generation Result screen - matches Pencil cFA4T
class GenerationResultScreen extends StatefulWidget {
const GenerationResultScreen({super.key, this.mediaItem});
final GalleryMediaItem? mediaItem;
@override
State<GenerationResultScreen> createState() => _GenerationResultScreenState();
}
class _GenerationResultScreenState extends State<GenerationResultScreen> {
VideoPlayerController? _videoController;
bool _saving = false;
bool _videoLoading = true;
String? _videoLoadError;
String? get _videoUrl => widget.mediaItem?.videoUrl;
String? get _imageUrl => widget.mediaItem?.imageUrl;
@override
void initState() {
super.initState();
if (_videoUrl != null) {
_initVideoFromCache();
}
}
Future<void> _initVideoFromCache() async {
if (_videoUrl == null) return;
try {
final file = await DefaultCacheManager().getSingleFile(_videoUrl!);
if (!mounted) return;
final controller = VideoPlayerController.file(file);
await controller.initialize();
if (!mounted) return;
await controller.setLooping(true);
await controller.play();
if (!mounted) return;
setState(() {
_videoController = controller;
_videoLoading = false;
});
} catch (e) {
if (mounted) {
setState(() {
_videoLoading = false;
_videoLoadError = e.toString();
});
}
}
}
@override
void dispose() {
_videoController?.dispose();
super.dispose();
}
Future<void> _saveToAlbum() async {
if (widget.mediaItem == null) return;
setState(() => _saving = true);
try {
final hasAccess = await Gal.hasAccess(toAlbum: true);
if (!hasAccess) {
final granted = await Gal.requestAccess(toAlbum: true);
if (!granted) {
throw Exception('Photo library access denied');
}
}
if (_videoUrl != null) {
final tempDir = await getTemporaryDirectory();
final file = File(
'${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4');
final response = await http.get(Uri.parse(_videoUrl!));
if (response.statusCode != 200) {
throw Exception('Failed to download video');
}
await file.writeAsBytes(response.bodyBytes);
await Gal.putVideo(file.path);
await file.delete();
} else if (_imageUrl != null) {
final tempDir = await getTemporaryDirectory();
final file = File(
'${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg');
final response = await http.get(Uri.parse(_imageUrl!));
if (response.statusCode != 200) {
throw Exception('Failed to download image');
}
await file.writeAsBytes(response.bodyBytes);
await Gal.putImage(file.path);
await file.delete();
}
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Saved to photo library')),
);
}
} catch (e) {
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Save failed: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: TopNavBar(
title: 'Ready',
showBackButton: true,
onBack: () => Navigator.of(context).pop(),
),
),
body: widget.mediaItem == null
? const Center(
child: Text(
'No media',
style: TextStyle(color: AppColors.textSecondary),
),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_MediaDisplay(
videoUrl: _videoUrl,
imageUrl: _imageUrl,
videoController: _videoController,
videoLoading: _videoUrl != null ? _videoLoading : false,
videoLoadError: _videoLoadError,
),
const SizedBox(height: AppSpacing.xxl),
_DownloadButton(
onDownload: _saving ? null : _saveToAlbum,
saving: _saving,
)
],
),
),
);
}
}
class _MediaDisplay extends StatelessWidget {
const _MediaDisplay({
this.videoUrl,
this.imageUrl,
this.videoController,
this.videoLoading = false,
this.videoLoadError,
});
final String? videoUrl;
final String? imageUrl;
final VideoPlayerController? videoController;
final bool videoLoading;
final String? videoLoadError;
@override
Widget build(BuildContext context) {
return Container(
height: 360,
decoration: BoxDecoration(
color: AppColors.textPrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
fit: StackFit.expand,
children: [
videoUrl != null && videoController != null
? _VideoPlayer(
controller: videoController!,
)
: videoUrl != null && videoLoading
? Container(
color: AppColors.textPrimary,
alignment: Alignment.center,
child: const CircularProgressIndicator(
color: AppColors.surface,
),
)
: videoUrl != null && videoLoadError != null
? Container(
color: AppColors.textPrimary,
alignment: Alignment.center,
child: const Text(
'Load failed',
style: TextStyle(color: AppColors.surface),
),
)
: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => _Placeholder(),
errorWidget: (_, __, ___) => _Placeholder(),
)
: _Placeholder(),
Positioned(
top: 16,
right: 20,
child: GestureDetector(
onTap: () => _ReportDialog.show(context),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Icon(
LucideIcons.triangle_alert,
size: 24,
color: AppColors.surface,
),
),
),
),
Positioned(
bottom: 20,
right: 20,
child: Text(
'PetsHero AI',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface.withValues(alpha: 0.6),
fontSize: 12,
),
),
),
],
),
),
);
}
}
class _VideoPlayer extends StatefulWidget {
const _VideoPlayer({required this.controller});
final VideoPlayerController controller;
@override
State<_VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<_VideoPlayer> {
@override
void initState() {
super.initState();
widget.controller.addListener(_listener);
}
@override
void dispose() {
widget.controller.removeListener(_listener);
super.dispose();
}
void _listener() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
if (!widget.controller.value.isInitialized) {
return Container(
color: AppColors.textPrimary,
child: const Center(
child: CircularProgressIndicator(color: AppColors.surface),
),
);
}
return GestureDetector(
onTap: () {
if (widget.controller.value.isPlaying) {
widget.controller.pause();
} else {
widget.controller.play();
}
},
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: widget.controller.value.size.width > 0
? widget.controller.value.size.width
: 16,
height: widget.controller.value.size.height > 0
? widget.controller.value.size.height
: 9,
child: VideoPlayer(widget.controller),
),
),
);
}
}
class _Placeholder extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.textPrimary,
alignment: Alignment.center,
child: Text(
'Your work is ready',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface.withValues(alpha: 0.6),
),
),
);
}
}
class _DownloadButton extends StatelessWidget {
const _DownloadButton({
required this.onDownload,
this.saving = false,
});
final VoidCallback? onDownload;
final bool saving;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onDownload,
child: Container(
height: 52,
decoration: BoxDecoration(
color: saving ? AppColors.surfaceAlt : AppColors.primary,
borderRadius: BorderRadius.circular(14),
boxShadow: saving
? null
: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (saving)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textSecondary,
),
)
else
const Icon(LucideIcons.download,
size: 20, color: AppColors.surface),
const SizedBox(width: AppSpacing.md),
Text(
saving ? 'Saving...' : 'Save to Album',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface,
),
),
],
),
),
);
}
}
/// Report dialog - matches Pencil 5qKUB
class _ReportDialog extends StatefulWidget {
const _ReportDialog({required this.parentContext});
final BuildContext parentContext;
static void show(BuildContext context) {
showDialog<void>(
context: context,
barrierColor: Colors.black54,
builder: (_) => _ReportDialog(parentContext: context),
);
}
@override
State<_ReportDialog> createState() => _ReportDialogState();
}
class _ReportDialogState extends State<_ReportDialog> {
final _controller = TextEditingController();
File? _pickedImage;
final _picker = ImagePicker();
bool _submitting = false;
String? _errorText;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _pickImage() async {
final x = await _picker.pickImage(source: ImageSource.gallery);
if (x != null && mounted) {
setState(() => _pickedImage = File(x.path));
}
}
Future<void> _submit() async {
final content = _controller.text.trim();
if (content.isEmpty) {
setState(() => _errorText = 'Please describe the issue');
return;
}
if (_pickedImage == null) {
setState(() => _errorText = 'Please upload an image');
return;
}
setState(() {
_errorText = null;
_submitting = true;
});
try {
final file = _pickedImage!;
final ext = file.path.split('.').last.toLowerCase();
final contentType = ext == 'png'
? 'image/png'
: ext == 'gif'
? 'image/gif'
: 'image/jpeg';
final fileName =
'feedback_${DateTime.now().millisecondsSinceEpoch}.$ext';
final presignedRes =
await FeedbackApi.getUploadPresignedUrl(fileName: fileName);
if (!presignedRes.isSuccess || presignedRes.data == null) {
throw Exception(
presignedRes.msg.isNotEmpty ? presignedRes.msg : 'Failed to get upload URL');
}
final data = presignedRes.data as Map<String, dynamic>;
final uploadUrl = data['shed'] as String?;
final filePath = data['hunt'] as String?;
if (uploadUrl == null ||
uploadUrl.isEmpty ||
filePath == null ||
filePath.isEmpty) {
throw Exception('Invalid presigned URL response');
}
final bytes = await file.readAsBytes();
final uploadResponse = await http.put(
Uri.parse(uploadUrl),
headers: {'Content-Type': contentType},
body: bytes,
);
if (uploadResponse.statusCode < 200 ||
uploadResponse.statusCode >= 300) {
throw Exception('Upload failed: ${uploadResponse.statusCode}');
}
final submitRes = await FeedbackApi.submit(
fileUrls: [filePath],
content: content,
contentType: 'text/plain',
);
if (!submitRes.isSuccess) {
throw Exception(
submitRes.msg.isNotEmpty ? submitRes.msg : 'Failed to submit report');
}
if (!mounted) return;
Navigator.of(context).pop();
if (widget.parentContext.mounted) {
ScaffoldMessenger.of(widget.parentContext).showSnackBar(
const SnackBar(
content: Text('Report submitted'),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (mounted) {
setState(() => _errorText = e.toString().replaceAll('Exception: ', ''));
}
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24),
child: Container(
width: 342,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Report',
style: AppTypography.bodyMedium.copyWith(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const SizedBox(
width: 40,
height: 40,
child: Icon(
LucideIcons.x,
size: 24,
color: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 20),
Container(
height: 120,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: TextField(
controller: _controller,
maxLines: null,
decoration: const InputDecoration(
hintText: 'Describe the issue...',
hintStyle: TextStyle(color: AppColors.textMuted),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
),
),
const SizedBox(height: 20),
GestureDetector(
onTap: _pickImage,
child: Container(
height: 120,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFD4D4D8),
width: 2,
),
),
child: _pickedImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(
_pickedImage!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.image_plus,
size: 32,
color: AppColors.textMuted,
),
const SizedBox(height: 8),
Text(
'Tap to upload image',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
],
),
),
),
if (_errorText != null) ...[
const SizedBox(height: 12),
Text(
_errorText!,
style: TextStyle(
color: AppColors.accentOrange,
fontSize: 14,
),
),
],
const SizedBox(height: 20),
GestureDetector(
onTap: _submitting ? null : _submit,
child: Container(
height: 52,
decoration: BoxDecoration(
color: _submitting
? AppColors.textMuted
: const Color(0xFF7C3AED),
borderRadius: BorderRadius.circular(14),
),
alignment: Alignment.center,
child: _submitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.surface,
),
)
: Text(
'Submit',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
}
class _ShareButton extends StatelessWidget {
const _ShareButton({required this.onShare});
final VoidCallback onShare;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onShare,
child: Container(
height: 52,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: const [
BoxShadow(
color: AppColors.shadowLight,
blurRadius: 6,
offset: Offset(0, 2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.share_2, size: 20, color: AppColors.primary),
const SizedBox(width: AppSpacing.md),
Text(
'Share',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.textPrimary,
),
),
],
),
),
);
}
}