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'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Collapsing album-detail header shared by the album, local-album, and /// downloaded-album screens: full-bleed [background] (optionally blurred and /// scrimmed), bottom gradient, centered square cover, title, optional /// subtitle/meta/actions rows, and a circular back button. Content fades out /// below 30% expansion; the [title] fades into the toolbar instead. /// /// Colours come from a scheme derived from [paletteSource] (see [CoverPalette]) /// and are published to descendants through [HeaderPalette], so the header /// follows both the artwork and the app's light/dark mode instead of assuming a /// black backdrop with white text. class AlbumDetailHeader extends StatelessWidget { const AlbumDetailHeader({ super.key, required this.title, required this.expandedHeight, required this.showTitleInAppBar, required this.background, this.paletteSource, this.blurAndScrimBackground = true, this.coverBuilder, this.subtitle, this.meta, this.actions, this.appBarActions, this.appBarTitle, this.leading, this.backgroundColor, this.immersive = false, this.squareArtwork = true, }); final String title; final double expandedHeight; final bool showTitleInAppBar; /// Full-bleed background content (cover image, motion banner, placeholder). final Widget background; /// Cover URL or local path the header palette is derived from. Null keeps the /// app's own colour scheme. final String? paletteSource; /// Blur the background and dim it 35%; disable for motion banners and /// placeholder backgrounds. final bool blurAndScrimBackground; /// Builds the centered square cover for the given side length; null hides /// the cover block (the header wraps it in the shared shadow + rounding). final Widget Function(BuildContext context, double coverSize)? coverBuilder; /// Artist line under the title (plain text or a clickable artist widget). final Widget? subtitle; /// "•"-joined meta row under the subtitle. final Widget? meta; /// Action row (play/shuffle, download-all, ...) under the meta row. final Widget? actions; /// Extra toolbar actions (top-right). final List? appBarActions; /// Toolbar title when it should differ from [title] (e.g. a selection /// count); defaults to [title]. final String? appBarTitle; /// Replaces the default circular back button. final Widget? leading; final Color? backgroundColor; final bool immersive; /// Ordinary cover art stays 1:1; provider-supplied banners may fill a header. final bool squareArtwork; /// Shrinks long titles so up to three lines fit the header. double _titleFontSize() { final length = title.trim().length; if (length > 45) return 18; if (length > 30) return 21; return 24; } @override Widget build(BuildContext context) { if (context.isMornye && (coverBuilder != null || immersive)) { final scheme = Theme.of(context).colorScheme; return HeaderPalette( scheme: scheme, child: Builder( builder: (context) => SliverMainAxisGroup(slivers: buildSlivers(context)), ), ); } return CoverPaletteBuilder( imageSource: paletteSource, builder: (context, headerScheme) => _buildAppBar(context, headerScheme), ); } /// Keep the toolbar at the collection scroll-view level so it stays pinned /// after the dynamically sized artwork and metadata have scrolled away. List buildSlivers(BuildContext context) { if (!context.isMornye || (coverBuilder == null && !immersive)) { return [this]; } final scheme = Theme.of(context).colorScheme; final edgeInset = detailHeaderEdgeInset(context); final artworkSize = MediaQuery.sizeOf(context).width.clamp(0.0, 440.0); 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), ), ), 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: [ Positioned.fill( child: squareArtwork ? Align( alignment: Alignment.topCenter, child: SizedBox.square( dimension: artworkSize, child: artwork, ), ) : artwork, ), Column( children: [ SizedBox(height: detailsTop), details, ], ), ], ), ), ]; } 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; // iOS does not add horizontal safe-area padding in portrait. Give toolbar // controls and header content an explicit inset so circular actions do not // sit against the glass edge on either iPhone or iPad. Android retains its // existing spacing. final iosEdgeInset = detailHeaderEdgeInset(context); // Scrim and gradient are drawn from the palette surface instead of black, // so a light theme gets a light header with dark text and a dark theme // keeps the familiar dark treatment — both tinted by the artwork. final scrimColor = headerScheme.surface; final onHeader = headerScheme.onSurface; return SliverAppBar( expandedHeight: expandedHeight, pinned: true, stretch: true, backgroundColor: backgroundColor ?? headerScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: tokens.motionFast, opacity: showTitleInAppBar ? 1.0 : 0.0, child: Text( appBarTitle ?? title, style: TextStyle( color: onHeader, fontWeight: FontWeight.w600, fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), flexibleSpace: LayoutBuilder( builder: (context, constraints) { final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ if (plainArtworkHeader) ColoredBox(color: headerScheme.surface) else if (blurAndScrimBackground) ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: background, ) else background, if (!plainArtworkHeader && blurAndScrimBackground) ColoredBox(color: scrimColor.withValues(alpha: 0.4)), if (!plainArtworkHeader) Positioned( left: 0, right: 0, bottom: 0, height: expandedHeight * 0.65, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ scrimColor.withValues(alpha: 0), scrimColor.withValues(alpha: 0.92), ], ), ), ), ), Positioned( left: 20 + iosEdgeInset, right: 20 + iosEdgeInset, bottom: 40, child: AnimatedOpacity( duration: tokens.motionFast, opacity: showContent ? 1.0 : 0.0, child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (coverBuilder != null) ...[ Builder( builder: (context) { final coverSize = (constraints.maxWidth * 0.5) .clamp(150.0, 210.0) .toDouble(); return Container( width: coverSize, height: coverSize, decoration: BoxDecoration( borderRadius: BorderRadius.circular( plainArtworkHeader ? tokens.radiusCover : tokens.radiusControl, ), boxShadow: [ BoxShadow( color: headerScheme.shadow.withValues( alpha: 0.45, ), blurRadius: 24, offset: const Offset(0, 8), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular( tokens.radiusControl, ), child: coverBuilder!(context, coverSize), ), ); }, ), const SizedBox(height: 20), ], Text( title, style: TextStyle( color: onHeader, fontSize: _titleFontSize(), fontWeight: FontWeight.bold, height: 1.2, ), textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis, ), if (subtitle != null) ...[ const SizedBox(height: 6), subtitle!, ], if (meta != null) ...[ const SizedBox(height: 12), meta!, ], if (actions != null) ...[ const SizedBox(height: 16), actions!, ], ], ), ), ), ], ), stretchModes: const [StretchMode.zoomBackground], ); }, ), leadingWidth: kToolbarHeight + iosEdgeInset, leading: Padding( padding: EdgeInsets.only(left: iosEdgeInset), child: leading ?? IconButton.filledTonal( tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Icon( context.isMornye ? Icons.chevron_left : Icons.arrow_back, ), style: IconButton.styleFrom( minimumSize: Size.square(tokens.minTouchTarget), backgroundColor: headerScheme.surfaceContainerHigh.withValues( alpha: 0.75, ), foregroundColor: headerScheme.onSurfaceVariant, ), onPressed: () => Navigator.pop(context), ), ), actionsPadding: EdgeInsets.only(right: iosEdgeInset), actions: appBarActions, ); } } /// 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 { const AlbumPlayActions({ super.key, required this.playLabel, required this.shuffleTooltip, required this.onPlay, required this.onShuffle, }); final String playLabel; final String shuffleTooltip; final VoidCallback onPlay; final VoidCallback onShuffle; @override Widget build(BuildContext context) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); if (context.isMornye) { return Row( children: [ Expanded( child: HeaderFilledButton( icon: CupertinoIcons.play_fill, label: playLabel, onPressed: onPlay, ), ), const SizedBox(width: 12), Expanded( child: _MornyeHeaderButton( icon: CupertinoIcons.shuffle, label: shuffleTooltip, onPressed: onShuffle, ), ), ], ); } return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: FilledButton.icon( onPressed: onPlay, icon: const Icon(Icons.play_arrow, size: 20), label: Text( playLabel, maxLines: 1, overflow: TextOverflow.ellipsis, ), style: FilledButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, minimumSize: Size(0, tokens.minTouchTarget), shape: const StadiumBorder(), ), ), ), SizedBox(width: tokens.gapMd), IconButton.filledTonal( tooltip: shuffleTooltip, onPressed: onShuffle, icon: const Icon(Icons.shuffle), style: IconButton.styleFrom( minimumSize: Size.square(tokens.minTouchTarget), backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.8), foregroundColor: scheme.onSecondaryContainer, ), ), ], ); } } /// Primary action pill in a detail header (download all, play all). Reads its /// colours from [HeaderPalette] so it works on light and dark headers alike. class HeaderFilledButton extends StatelessWidget { const HeaderFilledButton({ super.key, required this.icon, required this.label, required this.onPressed, this.tonal = false, }); final IconData icon; final String label; final VoidCallback? onPressed; final bool tonal; @override Widget build(BuildContext context) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); if (context.isMornye) { return _MornyeHeaderButton( icon: mornyeIconFor(icon), label: label, onPressed: onPressed, prominent: !tonal, ); } return FilledButton.icon( onPressed: onPressed, icon: Icon(icon, size: 18), label: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), style: FilledButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, disabledBackgroundColor: scheme.primary.withValues(alpha: 0.4), disabledForegroundColor: scheme.onPrimary.withValues(alpha: 0.7), minimumSize: Size(0, tokens.minTouchTarget), shape: const StadiumBorder(), ), ); } } /// Circular header icon button (add-to-playlist, love-all, ...). Sized to the /// minimum touch target and tinted from the [HeaderPalette]. class HeaderCircleButton extends ConsumerWidget { const HeaderCircleButton({ super.key, required this.icon, required this.tooltip, required this.onPressed, this.iconColor, this.tonal = false, this.glassTintColor, this.glassTintOpacity, this.buttonSize, this.iconSize = 22, }); final IconData icon; final String tooltip; final VoidCallback? onPressed; /// Overrides the palette foreground, e.g. to mark an active "loved" state. final Color? iconColor; /// Uses the same translucent fill as the local album's Play/Shuffle pills. final bool tonal; /// 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) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); if (context.isMornye) { if (tonal) { return Tooltip( message: tooltip, child: SizedBox.square( dimension: buttonSize ?? 44, child: CupertinoButton( padding: EdgeInsets.zero, 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: iconSize, color: onPressed == null ? scheme.onSurfaceVariant : iconColor ?? scheme.primary, ), ), ), ); } final blurEnabled = !ref.watch(lowEndDeviceProvider) || ref.watch(backdropBlurEnabledProvider); final button = IconButton( onPressed: onPressed, tooltip: tooltip, icon: Icon(mornyeIconFor(icon), size: iconSize), style: IconButton.styleFrom( minimumSize: Size.square(buttonSize ?? 44), foregroundColor: iconColor ?? scheme.onSurface, ), ); return Center( widthFactor: 1, child: MornyeGlass.navigation( radius: (buttonSize ?? 48) / 2, tintColor: glassTintColor ?? (scheme.brightness == Brightness.dark ? Colors.black : Colors.white), tintOpacity: glassTintOpacity ?? (scheme.brightness == Brightness.dark ? 0.45 : 0.55), blurEnabled: blurEnabled, child: button, ), ); } return IconButton.filledTonal( onPressed: onPressed, icon: Icon(icon, size: iconSize), tooltip: tooltip, style: IconButton.styleFrom( minimumSize: Size.square(buttonSize ?? tokens.minTouchTarget), backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.7), foregroundColor: iconColor ?? scheme.onSurfaceVariant, ), ); } } class _MornyeHeaderButton extends StatelessWidget { const _MornyeHeaderButton({ required this.icon, required this.label, required this.onPressed, this.prominent = false, }); final IconData icon; final String label; final VoidCallback? onPressed; final bool prominent; @override Widget build(BuildContext context) { final scheme = HeaderPalette.of(context); final foreground = onPressed == null ? scheme.onSurfaceVariant : prominent ? scheme.onPrimary : scheme.primary; return ConstrainedBox( constraints: const BoxConstraints(minHeight: 50), child: CupertinoButton( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), borderRadius: BorderRadius.circular(28), color: prominent ? scheme.primary : scheme.onSurface.withValues( alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06, ), onPressed: onPressed, child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 20, color: foreground), const SizedBox(width: 8), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: foreground, ), ), ), ], ), ), ); } } /// "•"-joined row of [HeaderMetaItem]s shown under the header subtitle /// (track count, duration, quality, ...). class HeaderMetaRow extends StatelessWidget { const HeaderMetaRow({super.key, required this.items}); final List items; @override Widget build(BuildContext context) { final scheme = HeaderPalette.of(context); final parts = []; for (final item in items) { if (parts.isNotEmpty) { parts.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( '•', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), ), ), ); } parts.add(item); } return Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, runSpacing: 4, children: parts, ); } } /// Single meta label (optional leading icon) for [HeaderMetaRow]. class HeaderMetaItem extends StatelessWidget { const HeaderMetaItem(this.label, {super.key, this.icon}); final String label; final IconData? icon; @override Widget build(BuildContext context) { final scheme = HeaderPalette.of(context); final textStyle = TextStyle( color: context.isMornye ? scheme.onSurfaceVariant : scheme.onSurface, fontSize: 13, fontWeight: FontWeight.w500, ); if (icon == null) return Text(label, style: textStyle); return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 15, color: scheme.onSurface), const SizedBox(width: 4), Text(label, style: textStyle), ], ); } }