优化:生成图片页面

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 primaryLight = Color(0x338B5CF6); // #8B5CF620
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
static const Color background = Color(0xFFFAFAFA);

View File

@ -215,44 +215,66 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
@override
Widget build(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
final creditsDisplay =
UserCreditsData.of(context)?.creditsDisplay ?? '--';
return Scaffold(
backgroundColor: AppColors.background,
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
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(
title: 'Generate',
credits: creditsDisplay,
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
showBackButton: true,
onBack: () => Navigator.of(context).pop(),
backgroundColor: Colors.transparent,
foregroundColor: AppColors.surface,
),
),
body: Column(
),
),
body: Stack(
fit: StackFit.expand,
children: [
Expanded(
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(
_GenerateFullScreenBackground(
videoUrl: widget.task?.previewVideoUrl,
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(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
@ -261,13 +283,16 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
AppSpacing.screenPaddingLarge,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
if (_hasVideo)
_ResolutionToggle(
Center(
child: _ResolutionToggle(
selected: _selectedResolution,
onChanged: (r) => setState(() => _selectedResolution = r),
onChanged: (r) =>
setState(() => _selectedResolution = r),
),
),
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton(
@ -285,65 +310,9 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
}
}
class _CreditsCard extends StatelessWidget {
const _CreditsCard({required this.credits});
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({
/// / [BoxFit.cover]
class _GenerateFullScreenBackground extends StatefulWidget {
const _GenerateFullScreenBackground({
this.videoUrl,
this.imageUrl,
});
@ -352,10 +321,12 @@ class _VideoPreviewArea extends StatefulWidget {
final String? imageUrl;
@override
State<_VideoPreviewArea> createState() => _VideoPreviewAreaState();
State<_GenerateFullScreenBackground> createState() =>
_GenerateFullScreenBackgroundState();
}
class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
class _GenerateFullScreenBackgroundState
extends State<_GenerateFullScreenBackground> {
VideoPlayerController? _controller;
@override
@ -374,7 +345,7 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
}
@override
void didUpdateWidget(covariant _VideoPreviewArea oldWidget) {
void didUpdateWidget(covariant _GenerateFullScreenBackground oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoUrl != widget.videoUrl) {
_controller?.dispose();
@ -394,11 +365,15 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
try {
final file = await DefaultCacheManager().getSingleFile(url);
if (!mounted) return;
final controller = VideoPlayerController.file(file);
final controller = VideoPlayerController.file(
file,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
await controller.initialize();
if (!mounted) return;
await controller.setVolume(1.0);
await controller.setLooping(true);
await controller.play();
controller.setLooping(true);
if (mounted) {
setState(() {
_controller = controller;
@ -416,206 +391,85 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
// =
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(
return Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
if (isReady)
SizedBox.expand(
if (!hasVideo && hasImage)
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(
fit: BoxFit.contain,
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
),
),
)
else if (hasImage)
CachedNetworkImage(
else if (hasVideo && hasImage)
Positioned.fill(
child: CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.contain,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) =>
const _LoadingOverlay(isLoading: true),
errorWidget: (_, __, ___) =>
const _LoadingOverlay(isLoading: false),
placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
),
)
else if (hasVideo)
const Positioned.fill(child: _BgLoadingPlaceholder())
else
const _LoadingOverlay(isLoading: false),
if (hasVideo && !isReady)
const Positioned.fill(
child: _LoadingOverlay(isLoading: true),
),
const Positioned.fill(child: _BgErrorPlaceholder()),
],
),
);
},
);
}
}
/// =
class _AspectRatioImage extends StatefulWidget {
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;
class _BgLoadingPlaceholder extends StatelessWidget {
const _BgLoadingPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.surfaceAlt,
color: AppColors.textPrimary,
alignment: Alignment.center,
child: isLoading
? const SizedBox(
child: const SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
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',
style: AppTypography.bodyMedium.copyWith(
fontSize: 14,
color: AppColors.textPrimary,
color: AppColors.surface,
fontWeight: FontWeight.w600,
),
),
@ -685,14 +539,22 @@ class _ResolutionOption extends StatelessWidget {
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
color: isSelected
? AppColors.primaryGlass
: AppColors.surface.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(18),
border:
isSelected ? null : Border.all(color: AppColors.border, width: 1),
border: isSelected
? 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(
color: AppColors.primaryShadow.withValues(alpha: 0.19),
color: AppColors.primaryShadow.withValues(alpha: 0.22),
blurRadius: 4,
offset: const Offset(0, 2),
),
@ -704,7 +566,9 @@ class _ResolutionOption extends StatelessWidget {
label,
style: AppTypography.bodyMedium.copyWith(
fontSize: 13,
color: isSelected ? AppColors.surface : AppColors.textSecondary,
color: isSelected
? AppColors.surface
: AppColors.surface.withValues(alpha: 0.85),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
@ -733,11 +597,15 @@ class _GenerateButton extends StatelessWidget {
child: Container(
height: 56,
decoration: BoxDecoration(
color: AppColors.primary,
color: AppColors.primaryGlassEmphasis,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.45),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
color: AppColors.primaryShadow.withValues(alpha: 0.28),
blurRadius: 8,
offset: const Offset(0, 2),
),

View File

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

View File

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