FunyMeeAI/lib/features/generate/generate_result_screen.dart
2026-04-10 15:36:08 +08:00

516 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gal/gal.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http;
import 'package:video_player/video_player.dart';
import '../../design/pencil_theme.dart';
import '../report/report_screen.dart';
/// Generate result: full-bleed media (clipped), top nav, bottom save + report (design `2SyyL`).
///
/// **Why match [HomeScreen]s Scaffold:** with `extendBody: true`, Material
/// wraps the body and adjusts [MediaQueryData.padding] for the subtree (see
/// `scaffold.dart` `_BodyBuilder`), unlike the default used on the home page.
/// That diverged from full-bleed behavior; this screen keeps `extendBody` false.
class GenerateResultScreen extends StatefulWidget {
const GenerateResultScreen({
super.key,
required this.taskId,
required this.resultUrl,
});
final String taskId;
final String resultUrl;
@override
State<GenerateResultScreen> createState() => _GenerateResultScreenState();
}
class _GenerateResultScreenState extends State<GenerateResultScreen> {
VideoPlayerController? _video;
bool _videoInitFailed = false;
bool _saving = false;
bool get _hasUrl {
final u = widget.resultUrl.trim();
return u.startsWith('http://') || u.startsWith('https://');
}
bool get _isVideo => _urlLooksLikeVideo(widget.resultUrl);
@override
void initState() {
super.initState();
if (_hasUrl && _isVideo) {
_initVideo();
}
}
Future<void> _initVideo() async {
final uri = Uri.tryParse(widget.resultUrl.trim());
if (uri == null) {
setState(() => _videoInitFailed = true);
return;
}
final c = VideoPlayerController.networkUrl(uri)
..setLooping(true)
..setVolume(1);
try {
await c.initialize();
await c.play();
if (!mounted) {
await c.dispose();
return;
}
setState(() => _video = c);
} catch (_) {
await c.dispose();
if (mounted) setState(() => _videoInitFailed = true);
}
}
@override
void dispose() {
_video?.dispose();
super.dispose();
}
Future<void> _saveToGallery() async {
if (!_hasUrl || _saving) return;
setState(() => _saving = true);
try {
final ok = await Gal.hasAccess();
if (!ok) {
await Gal.requestAccess();
}
final uri = Uri.parse(widget.resultUrl.trim());
final res = await http.get(uri);
if (res.statusCode < 200 || res.statusCode >= 300) {
throw HttpException('HTTP ${res.statusCode}');
}
final ext = _isVideo ? _guessVideoExt(widget.resultUrl) : '.jpg';
final file = File(
'${Directory.systemTemp.path}/funymee_${widget.taskId}$ext',
);
await file.writeAsBytes(res.bodyBytes);
if (_isVideo) {
await Gal.putVideo(file.path);
} else {
await Gal.putImage(file.path);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Saved to Photos',
style: GoogleFonts.inter(fontWeight: FontWeight.w600),
),
),
);
} on GalException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.type.message)));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Save failed: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
void _openReport() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ReportScreen(taskId: widget.taskId),
),
);
}
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.black,
systemNavigationBarIconBrightness: Brightness.light,
),
child: Scaffold(
backgroundColor: Colors.black,
resizeToAvoidBottomInset: false,
body: Stack(
fit: StackFit.expand,
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: MediaQuery.removeViewPadding(
context: context,
removeTop: true,
removeBottom: true,
child: MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
child: ClipRect(child: _buildBackdrop()),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: 140,
child: MediaQuery.removeViewPadding(
context: context,
removeTop: true,
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.55),
Colors.black.withValues(alpha: 0),
],
),
),
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(2, 0, 14, 0),
child: SizedBox(
height: 56,
child: Row(
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => Navigator.of(context).pop(),
borderRadius: BorderRadius.circular(14),
child: const SizedBox(
width: 44,
height: 44,
child: Icon(
Icons.chevron_left_rounded,
size: 26,
color: Colors.white,
shadows: [
Shadow(
blurRadius: 8,
color: Color(0x99000000),
offset: Offset(0, 1),
),
],
),
),
),
),
Expanded(
child: Center(
child: Text(
'Save',
style: GoogleFonts.inter(
fontSize: 19,
fontWeight: FontWeight.w700,
fontStyle: FontStyle.italic,
color: Colors.white,
shadows: const [
Shadow(
blurRadius: 8,
color: Color(0x66000000),
offset: Offset(0, 1),
),
],
),
),
),
),
const SizedBox(width: 44),
],
),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: _ResultBottomBar(
bottomInset: bottomInset,
saving: _saving,
onSave: _saveToGallery,
onReport: _openReport,
),
),
],
),
),
);
}
Widget _buildBackdrop() {
if (!_hasUrl) {
return ColoredBox(
color: PencilTheme.stone900,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'The result is not ready yet. Check History later.\nTask: ${widget.taskId}',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
color: Colors.white70,
fontSize: 15,
height: 1.4,
),
),
),
),
);
}
if (_isVideo) {
if (_videoInitFailed) {
return ColoredBox(
color: PencilTheme.stone900,
child: Center(
child: Icon(
Icons.videocam_off_outlined,
size: 48,
color: Colors.white54,
),
),
);
}
if (_video == null) {
return const ColoredBox(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white54),
),
);
}
final c = _video!;
if (!c.value.isInitialized) {
return const ColoredBox(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white54),
),
);
}
// Match [home_screen] `_HomeItemVideoBackground`: viewport cw×ch, FittedBox.cover, clip overflow.
return LayoutBuilder(
builder: (context, constraints) {
final cw = constraints.maxWidth;
final ch = constraints.maxHeight;
final w = c.value.size.width;
final h = c.value.size.height;
if (w <= 0 ||
h <= 0 ||
!cw.isFinite ||
!ch.isFinite ||
cw <= 0 ||
ch <= 0) {
return const ColoredBox(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white54),
),
);
}
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: ClipRect(
child: SizedBox(
width: cw,
height: ch,
child: FittedBox(
fit: BoxFit.cover,
alignment: Alignment.center,
clipBehavior: Clip.none,
child: SizedBox(
width: w,
height: h,
child: VideoPlayer(c),
),
),
),
),
),
],
);
},
);
}
return SizedBox.expand(
child: CachedNetworkImage(
imageUrl: widget.resultUrl.trim(),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
progressIndicatorBuilder: (_, _, _) => const ColoredBox(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white54),
),
),
errorWidget: (_, _, _) => ColoredBox(
color: PencilTheme.stone900,
child: Center(
child: Icon(Icons.broken_image, size: 48, color: Colors.white54),
),
),
),
);
}
}
/// Bottom actions: gold CTA + hint + report on transparent background (no white bar).
class _ResultBottomBar extends StatelessWidget {
const _ResultBottomBar({
required this.bottomInset,
required this.saving,
required this.onSave,
required this.onReport,
});
final double bottomInset;
final bool saving;
final VoidCallback onSave;
final VoidCallback onReport;
static const _goldGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFEAB308), Color(0xFFCA8A04)],
);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 34 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: saving ? null : onSave,
borderRadius: BorderRadius.circular(999),
child: Ink(
height: 54,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
gradient: _goldGradient,
boxShadow: const [
BoxShadow(
color: Color(0x40B45309),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: Center(
child: saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
'Save to Photos',
style: GoogleFonts.inter(
fontSize: 17,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
),
),
const SizedBox(height: 12),
Material(
color: Colors.transparent,
child: InkWell(
onTap: onReport,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.flag_outlined,
size: 18,
color: Colors.white.withValues(alpha: 0.88),
),
const SizedBox(width: 6),
Text(
'Report',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.88),
shadows: const [
Shadow(
blurRadius: 6,
color: Color(0x80000000),
offset: Offset(0, 1),
),
],
),
),
],
),
),
),
),
],
),
);
}
}
bool _urlLooksLikeVideo(String url) {
if (url.isEmpty) return false;
final lower = url.toLowerCase();
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv', '.avi'];
return hints.any((h) => lower.contains(h));
}
String _guessVideoExt(String url) {
final lower = url.toLowerCase();
if (lower.contains('.webm')) return '.webm';
if (lower.contains('.mov')) return '.mov';
if (lower.contains('.m3u8')) return '.mp4';
return '.mp4';
}