From 9a622442636ec1c509a44501b9fa8ce5549b5f45 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:20:54 +0700 Subject: [PATCH] feat(library): refine album and artist artwork headers Raise album details and artist actions over the artwork, soften the lower artist banner, and derive action colors from the artist logo. Match loading skeletons to the updated header layouts and preserve scrolling and large text behavior. --- lib/screens/album_screen.dart | 1 - lib/screens/artist_screen.dart | 1 + lib/screens/artist_screen_widgets.dart | 70 +++--- lib/screens/home_tab_widgets.dart | 13 +- lib/widgets/album_detail_header.dart | 307 ++++++++++++++----------- lib/widgets/animation_utils.dart | 117 +++++++--- lib/widgets/mornye_artist_header.dart | 286 ++++++++++++++++------- test/mornye_detail_headers_test.dart | 189 ++++++++++++++- 8 files changed, 697 insertions(+), 287 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index cb55e744..3b5d7130 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -540,7 +540,6 @@ class _AlbumScreenState extends ConsumerState child: HeaderFilledButton( icon: CupertinoIcons.arrow_down_circle_fill, label: context.l10n.dialogDownload, - tonal: true, onPressed: tracks.isEmpty ? null : () => _downloadAll(context), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index b8133bee..8e8feb98 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -608,6 +608,7 @@ class _ArtistScreenState extends ConsumerState return MornyeArtistSurface( imageSource: _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl, + logoSource: _headerLogoUrl ?? widget.headerLogoUrl, child: Builder(builder: _buildPage), ); } diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index ab022524..9c15b77b 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -76,10 +76,45 @@ extension _ArtistScreenSections on _ArtistScreenState { actions: isSelectionMode ? const [] : [ + HeaderCircleButton( + icon: CupertinoIcons.square_arrow_up, + buttonSize: 50, + iconSize: 24, + iconColor: colorScheme.primary, + glassTintColor: Colors.white, + glassTintOpacity: 0.10, + tooltip: context.l10n.openInOtherServices, + onPressed: () => _showShareSheet(context), + ), + if (_isLoadingDiscography) + const ShimmerLoading( + child: SkeletonBox(width: 74, height: 74, borderRadius: 37), + ) + else if (hasDiscography) + CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(37), + color: colorScheme.primary, + onPressed: () => + _showDiscographyOptions(context, colorScheme, albums), + child: SizedBox.square( + dimension: 74, + child: Semantics( + label: context.l10n.discographyDownload, + child: Icon( + CupertinoIcons.arrow_down, + size: 34, + color: colorScheme.onPrimary, + ), + ), + ), + ), HeaderCircleButton( icon: isFavoriteArtist - ? CupertinoIcons.heart_fill - : CupertinoIcons.heart, + ? CupertinoIcons.star_fill + : CupertinoIcons.star, + buttonSize: 50, + iconSize: 24, iconColor: colorScheme.primary, glassTintColor: Colors.white, glassTintOpacity: 0.10, @@ -88,37 +123,6 @@ extension _ArtistScreenSections on _ArtistScreenState { : context.l10n.artistOptionAddToFavorites, onPressed: () => _toggleFavoriteArtist(context), ), - if (_isLoadingDiscography) - const ShimmerLoading( - child: SkeletonBox(width: 60, height: 60, borderRadius: 30), - ) - else if (hasDiscography) - CupertinoButton( - padding: EdgeInsets.zero, - borderRadius: BorderRadius.circular(30), - color: colorScheme.primary, - onPressed: () => - _showDiscographyOptions(context, colorScheme, albums), - child: SizedBox.square( - dimension: 60, - child: Semantics( - label: context.l10n.discographyDownload, - child: Icon( - CupertinoIcons.arrow_down, - size: 26, - color: colorScheme.onPrimary, - ), - ), - ), - ), - HeaderCircleButton( - icon: CupertinoIcons.ellipsis, - iconColor: colorScheme.primary, - glassTintColor: Colors.white, - glassTintOpacity: 0.10, - tooltip: context.l10n.openInOtherServices, - onPressed: () => _showShareSheet(context), - ), ], ).buildSlivers(context); } diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index a3d22398..aa0c148e 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -864,21 +864,12 @@ class _ArtistLoadingScaffold extends StatelessWidget { : const ShimmerLoading( child: SkeletonBox( width: double.infinity, - height: 342, + height: double.infinity, borderRadius: 0, ), ), actions: const [ - ShimmerLoading( - child: Row( - spacing: 24, - children: [ - SkeletonBox(width: 44, height: 44, borderRadius: 22), - SkeletonBox(width: 60, height: 60, borderRadius: 30), - SkeletonBox(width: 44, height: 44, borderRadius: 22), - ], - ), - ), + ShimmerLoading(child: ArtistHeaderActionsSkeleton()), ], ).buildSlivers(context), const SliverToBoxAdapter( diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 889b0721..be904dde 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderProxySliver; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; @@ -121,136 +122,159 @@ class AlbumDetailHeader extends StatelessWidget { final scheme = Theme.of(context).colorScheme; final edgeInset = detailHeaderEdgeInset(context); final artworkSize = MediaQuery.sizeOf(context).width.clamp(0.0, 440.0); - // Keep the artwork and text in normal sliver flow: Dynamic Type and long - // album names must grow the header instead of overflowing a fixed height. - return [ - // Only the toolbar needs safe-area padding. Reading it on the caller's - // context rebuilds all header slivers as the navigation bar folds. - Builder( - builder: (context) => SliverAppBar( - pinned: true, - expandedHeight: immersive - ? squareArtwork - ? artworkSize - MediaQuery.paddingOf(context).top - : MediaQuery.sizeOf(context).width.clamp(280.0, 440.0) - : null, - backgroundColor: scheme.surface, - surfaceTintColor: Colors.transparent, - flexibleSpace: immersive - ? FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: Stack( - fit: StackFit.expand, - children: [ - if (squareArtwork) - Align( - alignment: Alignment.topCenter, - child: SizedBox.square( - dimension: artworkSize, - child: background, - ), - ) - else - background, - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0, 0.52, 1], - colors: [ - Colors.black.withValues(alpha: 0.12), - scheme.surface.withValues(alpha: 0), - scheme.surface, - ], - ), - ), - ), - ], - ), - ) - : null, - title: AnimatedOpacity( - opacity: showTitleInAppBar || appBarTitle != null ? 1 : 0, - duration: const Duration(milliseconds: 180), - child: Text( - appBarTitle ?? title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - ), - leadingWidth: kToolbarHeight + edgeInset, - leading: Padding( - padding: EdgeInsets.only(left: edgeInset), - child: - leading ?? - HeaderCircleButton( - icon: CupertinoIcons.chevron_back, - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.pop(context), - ), - ), - actionsPadding: EdgeInsets.only(right: edgeInset), - actions: appBarActions, + final details = _buildMornyeDetails(context); + Widget toolbar({bool collapsed = true}) => SliverAppBar( + pinned: true, + backgroundColor: collapsed ? scheme.surface : Colors.transparent, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + title: AnimatedOpacity( + opacity: + showTitleInAppBar || appBarTitle != null || (immersive && collapsed) + ? 1 + : 0, + duration: const Duration(milliseconds: 180), + child: Text( + appBarTitle ?? title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), ), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB(20, immersive ? 0 : 20, 20, 28), - child: Column( + leadingWidth: kToolbarHeight + edgeInset, + leading: Padding( + padding: EdgeInsets.only(left: edgeInset), + child: + leading ?? + HeaderCircleButton( + icon: CupertinoIcons.chevron_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + actionsPadding: EdgeInsets.only(right: edgeInset), + actions: appBarActions, + ); + if (immersive) { + final detailsTop = (artworkSize - 20).clamp(0.0, double.infinity); + final artwork = Stack( + fit: StackFit.expand, + children: [ + background, + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.48, 1], + colors: [ + Colors.black.withValues(alpha: 0.12), + scheme.surface.withValues(alpha: 0), + scheme.surface, + ], + ), + ), + ), + ], + ); + return [ + // The toolbar paints above the header without reserving a second + // status-bar inset. Metadata still has its natural, scrollable height. + SliverLayoutBuilder( + builder: (context, constraints) { + final toolbarHeight = + kToolbarHeight + MediaQuery.paddingOf(context).top; + return _AlbumOverlayToolbar( + child: toolbar( + collapsed: + constraints.scrollOffset >= detailsTop - toolbarHeight, + ), + ); + }, + ), + SliverToBoxAdapter( + child: Stack( children: [ - if (!immersive) - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: AspectRatio( - aspectRatio: 1, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.16), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: LayoutBuilder( - builder: (context, constraints) => - coverBuilder!(context, constraints.maxWidth), + Positioned.fill( + child: squareArtwork + ? Align( + alignment: Alignment.topCenter, + child: SizedBox.square( + dimension: artworkSize, + child: artwork, ), - ), - ), - ), - ), - if (!immersive) const SizedBox(height: 22), - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontSize: 22, - fontWeight: FontWeight.w700, - ), + ) + : artwork, + ), + Column( + children: [ + SizedBox(height: detailsTop), + details, + ], ), - if (subtitle != null) ...[const SizedBox(height: 5), subtitle!], - if (meta != null) ...[const SizedBox(height: 5), meta!], - if (actions != null) ...[ - const SizedBox(height: 24), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: actions!, - ), - ], ], ), ), - ), + ]; + } + return [ + Builder(builder: (_) => toolbar()), + SliverToBoxAdapter(child: details), ]; } + Widget _buildMornyeDetails(BuildContext context) => Padding( + padding: EdgeInsets.fromLTRB(20, immersive ? 0 : 20, 20, 28), + child: Column( + children: [ + if (!immersive) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.16), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: LayoutBuilder( + builder: (context, constraints) => + coverBuilder!(context, constraints.maxWidth), + ), + ), + ), + ), + ), + if (!immersive) const SizedBox(height: 22), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[const SizedBox(height: 5), subtitle!], + if (meta != null) ...[const SizedBox(height: 5), meta!], + if (actions != null) ...[ + SizedBox(height: immersive ? 16 : 24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: actions!, + ), + ], + ], + ), + ); + Widget _buildAppBar(BuildContext context, ColorScheme headerScheme) { final tokens = context.tokens; final plainArtworkHeader = context.isMornye && coverBuilder != null; @@ -434,6 +458,30 @@ class AlbumDetailHeader extends StatelessWidget { } } +/// Keeps the pinned toolbar above the artwork without shifting the artwork or +/// metadata down by the toolbar's safe-area height. +class _AlbumOverlayToolbar extends SingleChildRenderObjectWidget { + const _AlbumOverlayToolbar({required super.child}); + + @override + RenderProxySliver createRenderObject(BuildContext context) => + _RenderAlbumOverlayToolbar(); +} + +class _RenderAlbumOverlayToolbar extends RenderProxySliver { + @override + void performLayout() { + super.performLayout(); + final childGeometry = geometry!; + if (childGeometry.scrollOffsetCorrection != null) return; + geometry = childGeometry.copyWith( + scrollExtent: 0, + layoutExtent: 0, + cacheExtent: 0, + ); + } +} + /// Primary "play" pill + shuffle circle used by the local and downloaded album /// headers. Colours follow the [HeaderPalette]. class AlbumPlayActions extends StatelessWidget { @@ -462,7 +510,6 @@ class AlbumPlayActions extends StatelessWidget { icon: CupertinoIcons.play_fill, label: playLabel, onPressed: onPlay, - tonal: true, ), ), const SizedBox(width: 12), @@ -568,6 +615,8 @@ class HeaderCircleButton extends ConsumerWidget { this.tonal = false, this.glassTintColor, this.glassTintOpacity, + this.buttonSize, + this.iconSize = 22, }); final IconData icon; @@ -583,6 +632,8 @@ class HeaderCircleButton extends ConsumerWidget { /// Optional tint for controls floating over artwork-colored surfaces. final Color? glassTintColor; final double? glassTintOpacity; + final double? buttonSize; + final double iconSize; @override Widget build(BuildContext context, WidgetRef ref) { @@ -593,17 +644,17 @@ class HeaderCircleButton extends ConsumerWidget { return Tooltip( message: tooltip, child: SizedBox.square( - dimension: 44, + dimension: buttonSize ?? 44, child: CupertinoButton( padding: EdgeInsets.zero, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular((buttonSize ?? 48) / 2), color: scheme.onSurface.withValues( alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06, ), onPressed: onPressed, child: Icon( mornyeIconFor(icon), - size: 22, + size: iconSize, color: onPressed == null ? scheme.onSurfaceVariant : iconColor ?? scheme.primary, @@ -618,16 +669,16 @@ class HeaderCircleButton extends ConsumerWidget { final button = IconButton( onPressed: onPressed, tooltip: tooltip, - icon: Icon(mornyeIconFor(icon), size: 22), + icon: Icon(mornyeIconFor(icon), size: iconSize), style: IconButton.styleFrom( - minimumSize: const Size.square(44), + minimumSize: Size.square(buttonSize ?? 44), foregroundColor: iconColor ?? scheme.onSurface, ), ); return Center( widthFactor: 1, child: MornyeGlass.navigation( - radius: 24, + radius: (buttonSize ?? 48) / 2, tintColor: glassTintColor ?? (scheme.brightness == Brightness.dark @@ -643,10 +694,10 @@ class HeaderCircleButton extends ConsumerWidget { } return IconButton.filledTonal( onPressed: onPressed, - icon: Icon(icon, size: 22), + icon: Icon(icon, size: iconSize), tooltip: tooltip, style: IconButton.styleFrom( - minimumSize: Size.square(tokens.minTouchTarget), + minimumSize: Size.square(buttonSize ?? tokens.minTouchTarget), backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.7), foregroundColor: iconColor ?? scheme.onSurfaceVariant, ), diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 45f22215..4e37a512 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -381,24 +381,36 @@ class _CollectionHeaderSkeleton extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final coverSize = constraints.maxWidth.clamp(0.0, 440.0); - return Column( + return Stack( children: [ - ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) => const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [0, 0.52, 1], - colors: [Colors.white, Colors.white, Colors.transparent], - ).createShader(bounds), - child: SkeletonBox( - width: coverSize, - height: coverSize, - borderRadius: 0, + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.48, 1], + colors: [Colors.white, Colors.white, Colors.transparent], + ).createShader(bounds), + child: SkeletonBox( + width: coverSize, + height: coverSize, + borderRadius: 0, + ), + ), ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), + padding: EdgeInsets.fromLTRB( + 20, + (coverSize - 20).clamp(0.0, double.infinity), + 20, + 28, + ), child: Column( children: [ const FractionallySizedBox( @@ -422,7 +434,7 @@ class _CollectionHeaderSkeleton extends StatelessWidget { ], const SizedBox(height: 5), const SkeletonBox(width: 140, height: 16, borderRadius: 4), - const SizedBox(height: 24), + const SizedBox(height: 16), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: const Row( @@ -494,6 +506,23 @@ class _CollectionHeaderSkeleton extends StatelessWidget { } } +/// Mirrors the larger primary action and two smaller artist header actions. +class ArtistHeaderActionsSkeleton extends StatelessWidget { + const ArtistHeaderActionsSkeleton({super.key}); + + @override + Widget build(BuildContext context) => const Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + SkeletonBox(width: 50, height: 50, borderRadius: 25), + SkeletonBox(width: 74, height: 74, borderRadius: 37), + SkeletonBox(width: 50, height: 50, borderRadius: 25), + ], + ); +} + /// Artist screen skeleton shown below the SliverAppBar header while the /// discography loads: optional cover placeholder, "Popular" section, and the /// horizontal album sections. @@ -519,24 +548,48 @@ class ArtistScreenSkeleton extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showCoverHeader) ...[ - const SkeletonBox( - width: double.infinity, - height: 342, - borderRadius: 0, + if (showCoverHeader) + LayoutBuilder( + builder: (context, constraints) => Stack( + children: [ + Positioned.fill( + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.48, 1], + colors: [ + Colors.white, + Colors.white, + Colors.transparent, + ], + ).createShader(bounds), + child: const SkeletonBox( + width: double.infinity, + height: double.infinity, + borderRadius: 0, + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + 24, + (constraints.maxWidth * 0.76).clamp(240.0, 340.0), + 24, + 32, + ), + child: const Column( + children: [ + Center(child: SkeletonBox(width: 220, height: 36)), + SizedBox(height: 20), + ArtistHeaderActionsSkeleton(), + ], + ), + ), + ], + ), ), - const Center(child: SkeletonBox(width: 220, height: 36)), - const SizedBox(height: 16), - const Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 24, - children: [ - SkeletonBox(width: 44, height: 44, borderRadius: 22), - SkeletonBox(width: 60, height: 60, borderRadius: 30), - SkeletonBox(width: 44, height: 44, borderRadius: 22), - ], - ), - ], Padding( padding: const EdgeInsets.fromLTRB(20, 28, 20, 0), child: Container( diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart index b9c3186a..094db34e 100644 --- a/lib/widgets/mornye_artist_header.dart +++ b/lib/widgets/mornye_artist_header.dart @@ -16,10 +16,12 @@ class MornyeArtistSurface extends StatefulWidget { super.key, required this.imageSource, required this.child, + this.logoSource, this.neutralActions = false, }); final String? imageSource; + final String? logoSource; final Widget child; /// Album and playlist actions use white controls over the artwork tint. @@ -46,13 +48,30 @@ class _MornyeArtistSurfaceState extends State { child: CoverPaletteBuilder( imageSource: widget.imageSource, builder: (context, palette) { - // Retain the artwork's hue with a muted, slightly lifted surface. - // Large backgrounds need less saturation than the cover's accent. - final dominant = HSLColor.fromColor(palette.primary); - final surface = dominant - .withSaturation((dominant.saturation * 0.45).clamp(0.0, 0.22)) - .withLightness(0.34) - .toColor(); + final source = widget.imageSource; + final dominant = HSLColor.fromColor( + source == null + ? Colors.grey + : CoverPalette.sourceColor(source, Brightness.dark) ?? + Colors.grey, + ); + // Averaging a portrait mixes its highlights and foliage into gray. + // Artist pages use the extracted accent at a deeper tone instead; + // album surfaces and monochrome artwork keep their neutral shading. + final useAccent = + !widget.neutralActions && dominant.saturation >= 0.08; + final accent = HSLColor.fromColor(palette.primary); + final surface = useAccent + ? accent + .withSaturation((accent.saturation * 0.9).clamp(0.0, 0.88)) + .withLightness(0.14 + dominant.lightness * 0.05) + .toColor() + : dominant + .withSaturation( + (dominant.saturation * 0.45).clamp(0.0, 0.22), + ) + .withLightness(0.24 + dominant.lightness * 0.20) + .toColor(); final route = ModalRoute.of(context); if (route != null) { ShellNavigationService.setChromeBrightness( @@ -62,12 +81,34 @@ class _MornyeArtistSurfaceState extends State { surface: surface, ); } - return Theme( - data: darkTheme.copyWith( - scaffoldBackgroundColor: surface, - colorScheme: darkTheme.colorScheme.copyWith( - primary: widget.neutralActions ? Colors.white : palette.primary, - onPrimary: widget.neutralActions ? surface : palette.onPrimary, + return CoverPaletteBuilder( + imageSource: widget.neutralActions ? null : widget.logoSource, + builder: (context, _) { + final logo = widget.logoSource; + // Use the logo's original pixels, including white lettering, + // without Material's pastel accent mapping. + final logoColor = logo == null + ? null + : CoverPalette.sourceColor(logo, Brightness.dark); + final primary = widget.neutralActions + ? Colors.white + : logoColor ?? palette.primary; + final primaryLuminance = primary.computeLuminance(); + final surfaceContrast = + (primaryLuminance + 0.05) / + (surface.computeLuminance() + 0.05); + final onPrimary = widget.neutralActions + ? Colors.black + : logoColor == null + ? palette.onPrimary + : surfaceContrast >= 4.5 + ? surface + : primaryLuminance > 0.179 + ? Colors.black + : Colors.white; + final scheme = darkTheme.colorScheme.copyWith( + primary: primary, + onPrimary: onPrimary, onSurfaceVariant: widget.neutralActions ? Colors.white70 : null, surface: surface, surfaceContainer: Color.alphaBlend( @@ -78,9 +119,15 @@ class _MornyeArtistSurfaceState extends State { Colors.white.withValues(alpha: 0.12), surface, ), - ), - ), - child: widget.child, + ); + return Theme( + data: darkTheme.copyWith( + scaffoldBackgroundColor: surface, + colorScheme: scheme, + ), + child: HeaderPalette(scheme: scheme, child: widget.child), + ); + }, ); }, ), @@ -115,70 +162,121 @@ class MornyeArtistHeader extends StatelessWidget { List buildSlivers(BuildContext context) { final surface = Theme.of(context).colorScheme.surface; return [ - // Padding changes while the shell folds. Keep that dependency on the - // toolbar so callers do not rebuild the artist's entire discography. - Builder( - builder: (context) => SliverAppBar( - pinned: true, - expandedHeight: 342 - MediaQuery.paddingOf(context).top, - backgroundColor: surface, - surfaceTintColor: Colors.transparent, - leadingWidth: 64, - leading: Padding( - padding: const EdgeInsets.only(left: 12), - child: HeaderCircleButton( - icon: Icons.arrow_back, - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => Navigator.pop(context), + // Keep safe-area dependencies inside this sliver: folding the shell + // navigation must not rebuild the artist's entire discography. + SliverLayoutBuilder( + builder: (context, constraints) { + final width = constraints.crossAxisExtent; + final textWidth = (width - 48).clamp(0.0, double.infinity); + final textScaler = MediaQuery.textScalerOf(context); + double textHeight(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: DefaultTextStyle.of(context).style.merge(style), + ), + textDirection: Directionality.of(context), + textScaler: textScaler, + textAlign: TextAlign.center, + )..layout(maxWidth: textWidth); + final height = painter.height; + painter.dispose(); + return height; + } + + final nameHeight = textHeight( + name, + const TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ); + final hasLogo = logoUrl?.trim().isNotEmpty == true; + final identityHeight = hasLogo + ? nameHeight.clamp(96.0, double.infinity) + : nameHeight; + final listenersHeight = listeners == null + ? 0.0 + : 7 + textHeight(listeners!, const TextStyle(fontSize: 13)); + final photoHeight = (width * 0.76).clamp(240.0, 340.0); + final expandedHeight = + photoHeight + + identityHeight + + listenersHeight + + (actions.isEmpty ? 0 : 94) + + 32; + return SliverAppBar( + pinned: true, + expandedHeight: expandedHeight - MediaQuery.paddingOf(context).top, + backgroundColor: surface, + surfaceTintColor: Colors.transparent, + leadingWidth: 64, + leading: Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), ), - ), - title: AnimatedOpacity( - opacity: showTitle ? 1 : 0, - duration: const Duration(milliseconds: 180), - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + title: AnimatedOpacity( + opacity: showTitle ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), ), - ), - flexibleSpace: FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: _ArtistCollapsingArtwork( - surface: surface, - child: artwork, - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), - child: Column( - children: [ - _buildIdentity(), - if (listeners != null) ...[ - const SizedBox(height: 7), - Text( - listeners!, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Colors.white.withValues(alpha: 0.7), + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + _ArtistCollapsingArtwork(surface: surface, child: artwork), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: identityHeight, + child: Center(child: _buildIdentity()), + ), + if (listeners != null) ...[ + const SizedBox(height: 7), + Text( + listeners!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + if (actions.isNotEmpty) ...[ + const SizedBox(height: 20), + SizedBox( + height: 74, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 24, + children: actions, + ), + ), + ], + ], + ), + ), ), - ), - ], - if (actions.isNotEmpty) ...[ - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 24, - children: actions, - ), - ], - ], - ), - ), + ], + ), + ), + ); + }, ), ]; } @@ -204,7 +302,7 @@ class MornyeArtistHeader extends StatelessWidget { fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, imageBuilder: (_, provider) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320, maxHeight: 124), + constraints: const BoxConstraints(maxWidth: 260, maxHeight: 96), child: Image(image: provider, fit: BoxFit.contain), ), placeholder: (_, _) => fallback, @@ -262,15 +360,45 @@ class _ArtistCollapsingArtwork extends ConsumerWidget { child: RepaintBoundary(child: child), ), ), + if (blurEnabled && fade < 1) + Positioned.fill( + child: IgnorePointer( + child: Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + heightFactor: 0.48, + widthFactor: 1, + child: ClipRect( + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.white], + stops: [0, 0.7], + ).createShader(bounds), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: ColoredBox( + color: surface.withValues(alpha: 0.08), + ), + ), + ), + ), + ), + ), + ), + ), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - stops: const [0, 0.52, 1], + stops: const [0, 0.48, 0.82, 1], colors: [ Colors.black.withValues(alpha: 0.15), surface.withValues(alpha: 0), + surface.withValues(alpha: 0.55), surface, ], ), diff --git a/test/mornye_detail_headers_test.dart b/test/mornye_detail_headers_test.dart index 325e740b..119243a5 100644 --- a/test/mornye_detail_headers_test.dart +++ b/test/mornye_detail_headers_test.dart @@ -1,12 +1,111 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; void main() { + testWidgets( + 'local and online album actions share the neutral cover surface', + (tester) async { + final directory = Directory.systemTemp.createTempSync('album-surface-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final cover = File('${directory.path}/cover.png'); + await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawColor(Colors.white, BlendMode.src); + canvas.drawRect( + const Rect.fromLTWH(50, 0, 50, 100), + Paint()..color = const Color(0xff02020f), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(100, 100); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + await cover.writeAsBytes(bytes!.buffer.asUint8List()); + await CoverPalette.resolve(cover.path, Brightness.dark); + }); + for (final online in [false, true]) { + final label = online ? 'Download' : 'Play'; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: MornyeArtistSurface( + imageSource: cover.path, + neutralActions: true, + child: CollectionScaffold( + scrollController: scrollController, + isSelectionMode: false, + onExitSelectionMode: () {}, + bottomInset: 0, + appBar: AlbumDetailHeader( + title: 'Monochrome album', + expandedHeight: 400, + showTitleInAppBar: false, + immersive: true, + background: const ColoredBox(color: Colors.white), + actions: online + ? HeaderFilledButton( + icon: CupertinoIcons.arrow_down_circle_fill, + label: label, + onPressed: () {}, + ) + : AlbumPlayActions( + playLabel: label, + shuffleTooltip: 'Shuffle', + onPlay: () {}, + onShuffle: () {}, + ), + ), + slivers: const [], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final action = find.text(label); + final context = tester.element(action); + final scheme = Theme.of(context).colorScheme; + expect(scheme.surface.r, scheme.surface.g); + expect(scheme.surface.g, scheme.surface.b); + expect(HeaderPalette.of(context), scheme); + expect(tester.widget(action).style?.color, Colors.black); + expect( + tester + .widget( + find.ancestor( + of: action, + matching: find.byType(CupertinoButton), + ), + ) + .color, + Colors.white, + ); + if (!online) { + expect( + tester.widget(find.text('Shuffle')).style?.color, + Colors.white, + ); + } + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox()); + }, + ); + testWidgets('unavailable artist logo keeps the name and actions readable', ( tester, ) async { @@ -37,13 +136,20 @@ void main() { expect( find .descendant( - of: find.byType(SliverToBoxAdapter), + of: find.byType(FlexibleSpaceBar), matching: find.text('Example Artist'), ) .hitTestable(), findsOneWidget, ); expect(find.text('Artist action').hitTestable(), findsOneWidget); + final background = tester.getRect( + find.byKey(const ValueKey('artist-artwork-fade')), + ); + final action = tester.getRect(find.text('Artist action')); + expect(background.contains(action.center), isTrue); + expect(background.bottom - action.bottom, inInclusiveRange(32, 60)); + expect(action.center.dy, lessThan(480)); expect(tester.takeException(), isNull); }); @@ -99,18 +205,19 @@ void main() { expect(fade(), inExclusiveRange(0, 1)); expect(find.text('Artist action').hitTestable(), findsOneWidget); - controller.jumpTo(210); + controller.jumpTo(340); await tester.pumpAndSettle(); expect(fade(), 1); expect(filter().enabled, isFalse); expect(TickerMode.valuesOf(artwork).enabled, isFalse); - expect(find.text('Artist action').hitTestable(), findsOneWidget); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); controller.jumpTo(0); await tester.pumpAndSettle(); expect(fade(), 0); expect(filter().enabled, isFalse); expect(TickerMode.valuesOf(artwork).enabled, isTrue); + expect(find.text('Artist action').hitTestable(), findsOneWidget); expect(tester.element(find.byKey(artworkKey)), same(artwork)); expect(tester.takeException(), isNull); }); @@ -186,6 +293,13 @@ void main() { tester.getSize(find.byKey(const ValueKey('album-artwork'))), const Size(390, 390), ); + final albumTitle = find + .text( + 'A long album title that must wrap without hiding any of its controls', + ) + .last; + expect(tester.getTopLeft(albumTitle).dy, 370); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); await tester.ensureVisible(find.text('Play')); await tester.pumpAndSettle(); await tester.tap(find.text('Play')); @@ -203,6 +317,67 @@ void main() { } } + testWidgets('album banner extends behind the raised details and actions', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.reset); + final controller = ScrollController(); + addTearDown(controller.dispose); + var downloads = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: CollectionScaffold( + scrollController: controller, + isSelectionMode: false, + onExitSelectionMode: () {}, + bottomInset: 0, + appBar: AlbumDetailHeader( + title: 'Album title', + expandedHeight: 400, + showTitleInAppBar: false, + immersive: true, + squareArtwork: false, + background: const ColoredBox( + key: ValueKey('album-banner'), + color: Colors.orange, + ), + subtitle: const Text('Artist name'), + meta: const Text('2026 ยท Lossless'), + actions: HeaderFilledButton( + icon: CupertinoIcons.arrow_down_circle_fill, + label: 'Download', + onPressed: () => downloads++, + ), + ), + slivers: const [SliverToBoxAdapter(child: SizedBox(height: 2000))], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Album title').last).dy, 370); + final banner = tester.getRect(find.byKey(const ValueKey('album-banner'))); + final action = tester.getRect(find.text('Download')); + expect(banner.top, 0); + expect(banner.contains(action.bottomRight), isTrue); + expect(action.center.dy, lessThan(520)); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); + await tester.tap(find.text('Download')); + expect(downloads, 1); + controller.jumpTo(1000); + await tester.pumpAndSettle(); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.text('Download').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets( 'artist identity grows with text and retains navigation after scroll', (tester) async { @@ -216,6 +391,12 @@ void main() { ProviderScope( child: MaterialApp( theme: MornyeTheme.build(Brightness.dark), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), home: Builder( builder: (context) => Scaffold( body: CustomScrollView( @@ -242,6 +423,8 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -600)); + await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Favorite')); expect(favorites, 1); controller.jumpTo(1000);