442 lines
13 KiB
Dart
442 lines
13 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:path_provider/path_provider.dart';
|
|
import 'package:video_player/video_player.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;
|
|
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: () {
|
|
// TODO: Report action
|
|
},
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|