mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
862 lines
29 KiB
Dart
862 lines
29 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/widgets/expressive_button.dart';
|
|
import 'package:spotiflac_android/widgets/expressive_icon_button.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: _AlbumStretchArtwork(child: artwork),
|
|
),
|
|
)
|
|
: _AlbumStretchArtwork(child: 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,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AlbumStretchArtwork extends StatelessWidget {
|
|
const _AlbumStretchArtwork({required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (MediaQuery.disableAnimationsOf(context)) return child;
|
|
final position = Scrollable.of(context).position;
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) => AnimatedBuilder(
|
|
animation: position,
|
|
child: RepaintBoundary(child: child),
|
|
builder: (context, artwork) {
|
|
final overscroll = position.hasContentDimensions
|
|
? (position.minScrollExtent - position.pixels).clamp(
|
|
0.0,
|
|
double.infinity,
|
|
)
|
|
: 0.0;
|
|
final height = constraints.maxHeight;
|
|
final scale = height > 0 ? 1 + overscroll / height : 1.0;
|
|
// The viewport moves the details down during a pull. Keep the image
|
|
// at the top and enlarge it without changing the scrollable layout.
|
|
return Transform.translate(
|
|
offset: Offset(0, -overscroll),
|
|
child: Transform.scale(
|
|
scale: scale,
|
|
alignment: Alignment.topCenter,
|
|
child: artwork,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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: ExpressiveButton(
|
|
onPressed: onPlay,
|
|
icon: const Icon(Icons.play_arrow, size: 20),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: scheme.primary,
|
|
foregroundColor: scheme.onPrimary,
|
|
minimumSize: Size(0, tokens.minTouchTarget),
|
|
shape: const StadiumBorder(),
|
|
),
|
|
child: Text(
|
|
playLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: tokens.gapMd),
|
|
ExpressiveIconButton(
|
|
tooltip: shuffleTooltip,
|
|
onPressed: onShuffle,
|
|
icon: const Icon(Icons.shuffle),
|
|
size: 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 ExpressiveButton(
|
|
onPressed: onPressed,
|
|
icon: Icon(icon, size: 18),
|
|
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(),
|
|
),
|
|
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 ExpressiveIconButton(
|
|
onPressed: onPressed,
|
|
icon: Icon(icon, size: iconSize),
|
|
tooltip: tooltip,
|
|
size: 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),
|
|
],
|
|
);
|
|
}
|
|
}
|