FunyMeeAI/lib/features/generate/generate_result_screen.dart
2026-04-13 22:25:08 +08:00

450 lines
14 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 '../../design/pencil_theme.dart';
import '../../widgets/resilient_network_video.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> {
bool _saving = false;
bool get _hasUrl {
final u = widget.resultUrl.trim();
return u.startsWith('http://') || u.startsWith('https://');
}
bool get _isVideo => _urlLooksLikeVideo(widget.resultUrl);
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),
],
),
),
),
),
),
Positioned(
top: 0,
right: 0,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.only(top: 6, right: 14),
child: Text(
'FunyMeeAI',
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.35,
color: Colors.white.withValues(alpha: 0.72),
shadows: const [
Shadow(
blurRadius: 8,
color: Color(0x99000000),
offset: Offset(0, 1),
),
],
),
),
),
),
),
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) {
final u = widget.resultUrl.trim();
return ResilientNetworkVideoCover(
key: ValueKey<String>(u),
url: u,
loadingWidget: const ColoredBox(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white54),
),
),
failedWidget: ColoredBox(
color: PencilTheme.stone900,
child: Center(
child: Icon(
Icons.videocam_off_outlined,
size: 48,
color: Colors.white54,
),
),
),
);
}
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';
}