优化:生成图片页面

This commit is contained in:
ivan 2026-03-29 20:41:45 +08:00
parent 20d43b4ae4
commit 846dd5e9f5
4 changed files with 217 additions and 309 deletions

View File

@ -6,6 +6,10 @@ abstract final class AppColors {
static const Color primary = Color(0xFF8B5CF6); static const Color primary = Color(0xFF8B5CF6);
static const Color primaryLight = Color(0x338B5CF6); // #8B5CF620 static const Color primaryLight = Color(0x338B5CF6); // #8B5CF620
static const Color primaryShadow = Color(0x338B5CF6); // #8B5CF620 for shadow static const Color primaryShadow = Color(0x338B5CF6); // #8B5CF620 for shadow
/// chip [surface]
static Color get primaryGlass => primary.withValues(alpha: 0.58);
///
static Color get primaryGlassEmphasis => primary.withValues(alpha: 0.72);
// Neutrals // Neutrals
static const Color background = Color(0xFFFAFAFA); static const Color background = Color(0xFFFAFAFA);

View File

@ -215,44 +215,66 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
final creditsDisplay =
UserCreditsData.of(context)?.creditsDisplay ?? '--';
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(56), preferredSize: Size.fromHeight(topInset + 56),
child: Container(
padding: EdgeInsets.only(top: topInset),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.72),
Colors.black.withValues(alpha: 0.35),
Colors.black.withValues(alpha: 0.0),
],
stops: const [0.0, 0.55, 1.0],
),
),
child: SizedBox(
height: 56,
child: TopNavBar( child: TopNavBar(
title: 'Generate', title: 'Generate',
credits: creditsDisplay,
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: () => Navigator.of(context).pop(),
backgroundColor: Colors.transparent,
foregroundColor: AppColors.surface,
), ),
), ),
body: Column( ),
),
body: Stack(
fit: StackFit.expand,
children: [ children: [
Expanded( _GenerateFullScreenBackground(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CreditsCard(
credits:
UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
const SizedBox(height: AppSpacing.xxl),
_VideoPreviewArea(
videoUrl: widget.task?.previewVideoUrl, videoUrl: widget.task?.previewVideoUrl,
imageUrl: widget.task?.previewImageUrl, imageUrl: widget.task?.previewImageUrl,
), ),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.12),
Colors.black.withValues(alpha: 0.55),
], ],
stops: const [0.45, 1.0],
),
), ),
), ),
), ),
SafeArea( SafeArea(
top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge,
@ -261,13 +283,16 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Spacer(),
if (_hasVideo) if (_hasVideo)
_ResolutionToggle( Center(
child: _ResolutionToggle(
selected: _selectedResolution, selected: _selectedResolution,
onChanged: (r) => setState(() => _selectedResolution = r), onChanged: (r) =>
setState(() => _selectedResolution = r),
),
), ),
if (_hasVideo) const SizedBox(height: AppSpacing.xxl), if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton( _GenerateButton(
@ -285,65 +310,9 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
} }
} }
class _CreditsCard extends StatelessWidget { /// / [BoxFit.cover]
const _CreditsCard({required this.credits}); class _GenerateFullScreenBackground extends StatefulWidget {
const _GenerateFullScreenBackground({
final String credits;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xxl,
vertical: AppSpacing.xl,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.5),
),
boxShadow: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
const Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
const SizedBox(width: AppSpacing.md),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Available Credits',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface.withValues(alpha: 0.8),
),
),
Text(
credits,
style: AppTypography.bodyLarge.copyWith(
fontSize: 32,
fontWeight: FontWeight.w700,
color: AppColors.surface,
),
),
],
),
],
),
);
}
}
/// Video preview area - video URL from card click. Auto-load and play on init.
/// Video fit: contain (no crop). Loading animation until ready.
class _VideoPreviewArea extends StatefulWidget {
const _VideoPreviewArea({
this.videoUrl, this.videoUrl,
this.imageUrl, this.imageUrl,
}); });
@ -352,10 +321,12 @@ class _VideoPreviewArea extends StatefulWidget {
final String? imageUrl; final String? imageUrl;
@override @override
State<_VideoPreviewArea> createState() => _VideoPreviewAreaState(); State<_GenerateFullScreenBackground> createState() =>
_GenerateFullScreenBackgroundState();
} }
class _VideoPreviewAreaState extends State<_VideoPreviewArea> { class _GenerateFullScreenBackgroundState
extends State<_GenerateFullScreenBackground> {
VideoPlayerController? _controller; VideoPlayerController? _controller;
@override @override
@ -374,7 +345,7 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
} }
@override @override
void didUpdateWidget(covariant _VideoPreviewArea oldWidget) { void didUpdateWidget(covariant _GenerateFullScreenBackground oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.videoUrl != widget.videoUrl) { if (oldWidget.videoUrl != widget.videoUrl) {
_controller?.dispose(); _controller?.dispose();
@ -394,11 +365,15 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
try { try {
final file = await DefaultCacheManager().getSingleFile(url); final file = await DefaultCacheManager().getSingleFile(url);
if (!mounted) return; if (!mounted) return;
final controller = VideoPlayerController.file(file); final controller = VideoPlayerController.file(
file,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
await controller.initialize(); await controller.initialize();
if (!mounted) return; if (!mounted) return;
await controller.setVolume(1.0);
await controller.setLooping(true);
await controller.play(); await controller.play();
controller.setLooping(true);
if (mounted) { if (mounted) {
setState(() { setState(() {
_controller = controller; _controller = controller;
@ -416,206 +391,85 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty; final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
// = return Stack(
if (!hasVideo && hasImage) {
return LayoutBuilder(
builder: (context, constraints) {
return Container(
width: constraints.maxWidth,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.border,
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: _AspectRatioImage(
imageUrl: widget.imageUrl!,
maxWidth: constraints.maxWidth,
),
);
},
);
}
// aspect ratio 16:9
final aspectRatio = isReady &&
_controller!.value.size.width > 0 &&
_controller!.value.size.height > 0
? _controller!.value.size.width / _controller!.value.size.height
: 16 / 9;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = width / aspectRatio;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.border,
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
alignment: Alignment.center,
children: [ children: [
if (isReady) if (!hasVideo && hasImage)
SizedBox.expand( Positioned.fill(
child: CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
),
)
else if (hasVideo && isReady)
Positioned.fill(
child: ClipRect(
child: FittedBox( child: FittedBox(
fit: BoxFit.contain, fit: BoxFit.cover,
child: SizedBox( child: SizedBox(
width: _controller!.value.size.width, width: _controller!.value.size.width,
height: _controller!.value.size.height, height: _controller!.value.size.height,
child: VideoPlayer(_controller!), child: VideoPlayer(_controller!),
), ),
), ),
),
) )
else if (hasImage) else if (hasVideo && hasImage)
CachedNetworkImage( Positioned.fill(
child: CachedNetworkImage(
imageUrl: widget.imageUrl!, imageUrl: widget.imageUrl!,
fit: BoxFit.contain, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
placeholder: (_, __) => placeholder: (_, __) => const _BgLoadingPlaceholder(),
const _LoadingOverlay(isLoading: true), errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
errorWidget: (_, __, ___) => ),
const _LoadingOverlay(isLoading: false),
) )
else if (hasVideo)
const Positioned.fill(child: _BgLoadingPlaceholder())
else else
const _LoadingOverlay(isLoading: false), const Positioned.fill(child: _BgErrorPlaceholder()),
if (hasVideo && !isReady)
const Positioned.fill(
child: _LoadingOverlay(isLoading: true),
),
], ],
),
);
},
); );
} }
} }
/// = class _BgLoadingPlaceholder extends StatelessWidget {
class _AspectRatioImage extends StatefulWidget { const _BgLoadingPlaceholder();
const _AspectRatioImage({
required this.imageUrl,
required this.maxWidth,
});
final String imageUrl;
final double maxWidth;
@override
State<_AspectRatioImage> createState() => _AspectRatioImageState();
}
class _AspectRatioImageState extends State<_AspectRatioImage> {
double? _aspectRatio;
ImageStream? _stream;
late ImageStreamListener _listener;
@override
void initState() {
super.initState();
_listener = ImageStreamListener(_onImageLoaded, onError: _onImageError);
_resolveImage();
}
@override
void didUpdateWidget(covariant _AspectRatioImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_stream?.removeListener(_listener);
_aspectRatio = null;
_resolveImage();
}
}
void _resolveImage() {
final provider = CachedNetworkImageProvider(widget.imageUrl);
_stream = provider.resolve(const ImageConfiguration());
_stream!.addListener(_listener);
}
void _onImageLoaded(ImageInfo info, bool sync) {
if (!mounted) return;
final w = info.image.width.toDouble();
final h = info.image.height.toDouble();
if (w > 0 && h > 0) {
setState(() => _aspectRatio = w / h);
}
}
void _onImageError(dynamic exception, StackTrace? stackTrace) {
if (mounted) setState(() => _aspectRatio = 1);
}
@override
void dispose() {
_stream?.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
final ratio = _aspectRatio ?? 1;
final height = widget.maxWidth / ratio;
return SizedBox(
width: widget.maxWidth,
height: height,
child: CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.contain,
width: widget.maxWidth,
height: height,
placeholder: (_, __) => SizedBox(
width: widget.maxWidth,
height: widget.maxWidth,
child: const _LoadingOverlay(isLoading: true),
),
errorWidget: (_, __, ___) => SizedBox(
width: widget.maxWidth,
height: widget.maxWidth,
child: const _LoadingOverlay(isLoading: false),
),
),
);
}
}
class _LoadingOverlay extends StatelessWidget {
const _LoadingOverlay({this.isLoading = true});
final bool isLoading;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
color: AppColors.surfaceAlt, color: AppColors.textPrimary,
alignment: Alignment.center, alignment: Alignment.center,
child: isLoading child: const SizedBox(
? const SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: AppColors.primary, color: AppColors.surface,
), ),
) ),
: const Icon( );
LucideIcons.video, }
size: 48, }
color: AppColors.textMuted,
class _BgErrorPlaceholder extends StatelessWidget {
const _BgErrorPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.textPrimary,
alignment: Alignment.center,
child: Icon(
LucideIcons.image_off,
size: 56,
color: AppColors.surface.withValues(alpha: 0.45),
), ),
); );
} }
@ -644,7 +498,7 @@ class _ResolutionToggle extends StatelessWidget {
'Resolution', 'Resolution',
style: AppTypography.bodyMedium.copyWith( style: AppTypography.bodyMedium.copyWith(
fontSize: 14, fontSize: 14,
color: AppColors.textPrimary, color: AppColors.surface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@ -685,14 +539,22 @@ class _ResolutionOption extends StatelessWidget {
height: 36, height: 36,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? AppColors.primary : AppColors.surfaceAlt, color: isSelected
? AppColors.primaryGlass
: AppColors.surface.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: border: isSelected
isSelected ? null : Border.all(color: AppColors.border, width: 1), ? Border.all(
color: AppColors.primary.withValues(alpha: 0.42),
width: 1)
: Border.all(
color: AppColors.surface.withValues(alpha: 0.45),
width: 1,
),
boxShadow: isSelected boxShadow: isSelected
? [ ? [
BoxShadow( BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.19), color: AppColors.primaryShadow.withValues(alpha: 0.22),
blurRadius: 4, blurRadius: 4,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
@ -704,7 +566,9 @@ class _ResolutionOption extends StatelessWidget {
label, label,
style: AppTypography.bodyMedium.copyWith( style: AppTypography.bodyMedium.copyWith(
fontSize: 13, fontSize: 13,
color: isSelected ? AppColors.surface : AppColors.textSecondary, color: isSelected
? AppColors.surface
: AppColors.surface.withValues(alpha: 0.85),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
), ),
), ),
@ -733,11 +597,15 @@ class _GenerateButton extends StatelessWidget {
child: Container( child: Container(
height: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary, color: AppColors.primaryGlassEmphasis,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.45),
width: 1,
),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25), color: AppColors.primaryShadow.withValues(alpha: 0.28),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),

View File

@ -10,13 +10,23 @@ class CreditsBadge extends StatelessWidget {
super.key, super.key,
required this.credits, required this.credits,
this.onTap, this.onTap,
this.foregroundColor,
this.capsuleColor,
}); });
final String credits; final String credits;
final VoidCallback? onTap; final VoidCallback? onTap;
/// [AppColors.primary]
final Color? foregroundColor;
/// [AppColors.primaryLight]
final Color? capsuleColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fg = foregroundColor ?? AppColors.primary;
final capsule = capsuleColor ?? AppColors.primaryLight;
final lightOnDarkNav = foregroundColor != null;
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
@ -25,9 +35,17 @@ class CreditsBadge extends StatelessWidget {
vertical: AppSpacing.sm, vertical: AppSpacing.sm,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primaryLight, color: capsule,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: [ boxShadow: lightOnDarkNav
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.28),
blurRadius: 6,
offset: const Offset(0, 2),
),
]
: [
BoxShadow( BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.13), color: AppColors.primaryShadow.withValues(alpha: 0.13),
blurRadius: 6, blurRadius: 6,
@ -38,15 +56,14 @@ class CreditsBadge extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(LucideIcons.sparkles, Icon(LucideIcons.sparkles, size: 16, color: fg),
size: 16, color: AppColors.primary),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Text( Text(
credits, credits,
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.primary, color: fg,
), ),
), ),
], ],

View File

@ -14,6 +14,8 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
this.showBackButton = false, this.showBackButton = false,
this.onBack, this.onBack,
this.onCreditsTap, this.onCreditsTap,
this.backgroundColor = AppColors.surface,
this.foregroundColor,
}); });
final String title; final String title;
@ -21,18 +23,29 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
final bool showBackButton; final bool showBackButton;
final VoidCallback? onBack; final VoidCallback? onBack;
final VoidCallback? onCreditsTap; final VoidCallback? onCreditsTap;
/// [Colors.transparent]
final Color backgroundColor;
/// [AppColors.textPrimary]
final Color? foregroundColor;
@override @override
Size get preferredSize => const Size.fromHeight(56); Size get preferredSize => const Size.fromHeight(56);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fg = foregroundColor ?? AppColors.textPrimary;
final titleStyle = foregroundColor != null
? AppTypography.navTitle.copyWith(color: foregroundColor)
: AppTypography.navTitle;
return Container( return Container(
height: 56, height: 56,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
decoration: const BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: backgroundColor,
boxShadow: [ boxShadow: backgroundColor.a < 0.02
? null
: const [
BoxShadow( BoxShadow(
color: AppColors.shadowLight, color: AppColors.shadowLight,
blurRadius: 8, blurRadius: 8,
@ -46,14 +59,14 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
GestureDetector( GestureDetector(
onTap: onBack ?? () => Navigator.of(context).pop(), onTap: onBack ?? () => Navigator.of(context).pop(),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: const SizedBox( child: SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: Center( child: Center(
child: Icon( child: Icon(
LucideIcons.arrow_left, LucideIcons.arrow_left,
size: 24, size: 24,
color: AppColors.textPrimary, color: fg,
), ),
), ),
), ),
@ -63,19 +76,25 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
? Center( ? Center(
child: Text( child: Text(
title, title,
style: AppTypography.navTitle, style: titleStyle,
), ),
) )
: Align( : Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
title, title,
style: AppTypography.navTitle, style: titleStyle,
), ),
), ),
), ),
if (credits != null) if (credits != null)
CreditsBadge(credits: credits!, onTap: onCreditsTap) CreditsBadge(
credits: credits!,
onTap: onCreditsTap,
foregroundColor: foregroundColor,
capsuleColor:
foregroundColor?.withValues(alpha: 0.22),
)
else else
const SizedBox(width: 40), const SizedBox(width: 40),
], ],