Files
SpotiFLAC-Mobile/lib/widgets/album_detail_header.dart
T
zarzet 9a62244263 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.
2026-09-25 01:20:54 +07:00

826 lines
27 KiB
Dart

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<Widget>? 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<Widget> 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<Widget> items;
@override
Widget build(BuildContext context) {
final scheme = HeaderPalette.of(context);
final parts = <Widget>[];
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),
],
);
}
}