450 lines
14 KiB
Dart
450 lines
14 KiB
Dart
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';
|
||
}
|