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);