731 lines
22 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|