diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 96e2ae1..c60df57 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -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); diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index cda704b..694fd53 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -215,44 +215,66 @@ class _GenerateVideoScreenState extends State { @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), - child: TopNavBar( - title: 'Generate', - showBackButton: true, - onBack: () => Navigator.of(context).pop(), + 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( - videoUrl: widget.task?.previewVideoUrl, - imageUrl: widget.task?.previewImageUrl, - ), - ], + _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 { AppSpacing.screenPaddingLarge, ), child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const Spacer(), if (_hasVideo) - _ResolutionToggle( - selected: _selectedResolution, - onChanged: (r) => setState(() => _selectedResolution = r), + Center( + child: _ResolutionToggle( + selected: _selectedResolution, + onChanged: (r) => + setState(() => _selectedResolution = r), + ), ), if (_hasVideo) const SizedBox(height: AppSpacing.xxl), _GenerateButton( @@ -285,65 +310,9 @@ class _GenerateVideoScreenState extends State { } } -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,207 +391,86 @@ 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, + return Stack( + fit: StackFit.expand, + children: [ + 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.cover, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!), + ), ), ), - clipBehavior: Clip.antiAlias, - child: _AspectRatioImage( + ) + else if (hasVideo && hasImage) + Positioned.fill( + child: CachedNetworkImage( imageUrl: widget.imageUrl!, - maxWidth: constraints.maxWidth, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => const _BgLoadingPlaceholder(), + errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), ), - ); - }, - ); - } - - // 视频模式: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, - alignment: Alignment.center, - children: [ - if (isReady) - SizedBox.expand( - child: FittedBox( - fit: BoxFit.contain, - child: SizedBox( - width: _controller!.value.size.width, - height: _controller!.value.size.height, - child: VideoPlayer(_controller!), - ), - ), - ) - else if (hasImage) - CachedNetworkImage( - imageUrl: widget.imageUrl!, - fit: BoxFit.contain, - width: double.infinity, - height: double.infinity, - placeholder: (_, __) => - const _LoadingOverlay(isLoading: true), - errorWidget: (_, __, ___) => - const _LoadingOverlay(isLoading: false), - ) - else - const _LoadingOverlay(isLoading: false), - if (hasVideo && !isReady) - const Positioned.fill( - child: _LoadingOverlay(isLoading: true), - ), - ], - ), - ); - }, + ) + else if (hasVideo) + const Positioned.fill(child: _BgLoadingPlaceholder()) + else + 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(); - } +class _BgLoadingPlaceholder extends StatelessWidget { + const _BgLoadingPlaceholder(); @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), + return Container( + color: AppColors.textPrimary, + alignment: Alignment.center, + child: const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.surface, ), ), ); } } -class _LoadingOverlay extends StatelessWidget { - const _LoadingOverlay({this.isLoading = true}); - - final bool isLoading; +class _BgErrorPlaceholder extends StatelessWidget { + const _BgErrorPlaceholder(); @override Widget build(BuildContext context) { return Container( - color: AppColors.surfaceAlt, + color: AppColors.textPrimary, alignment: Alignment.center, - child: isLoading - ? const SizedBox( - width: 40, - height: 40, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, - ), - ) - : const Icon( - LucideIcons.video, - size: 48, - color: AppColors.textMuted, - ), + 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), ), diff --git a/lib/shared/widgets/credits_badge.dart b/lib/shared/widgets/credits_badge.dart index 2576b9f..5cc7f6e 100644 --- a/lib/shared/widgets/credits_badge.dart +++ b/lib/shared/widgets/credits_badge.dart @@ -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,28 +35,35 @@ class CreditsBadge extends StatelessWidget { vertical: AppSpacing.sm, ), decoration: BoxDecoration( - color: AppColors.primaryLight, + color: capsule, borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: AppColors.primaryShadow.withValues(alpha: 0.13), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], + 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, + offset: const Offset(0, 2), + ), + ], ), 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, ), ), ], diff --git a/lib/shared/widgets/top_nav_bar.dart b/lib/shared/widgets/top_nav_bar.dart index 23330a0..ddc3aa4 100644 --- a/lib/shared/widgets/top_nav_bar.dart +++ b/lib/shared/widgets/top_nav_bar.dart @@ -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,24 +23,35 @@ 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: [ - BoxShadow( - color: AppColors.shadowLight, - blurRadius: 8, - offset: Offset(0, 2), - ), - ], + decoration: BoxDecoration( + color: backgroundColor, + boxShadow: backgroundColor.a < 0.02 + ? null + : const [ + BoxShadow( + color: AppColors.shadowLight, + blurRadius: 8, + offset: Offset(0, 2), + ), + ], ), child: Row( children: [ @@ -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), ],