diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 7a6ac1b6..bda39145 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -22,6 +25,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -122,6 +126,8 @@ class _ArtistScreenState extends ConsumerState bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); final PageController _popularPageController = PageController(); + final SelectionOverlayController _selectionOverlay = + SelectionOverlayController(); int _popularCurrentPage = 0; bool _isFetchingDiscography = false; @@ -253,6 +259,7 @@ class _ArtistScreenState extends ConsumerState @override void dispose() { + _selectionOverlay.dispose(); _scrollController.removeListener(_onScroll); _scrollController.dispose(); _popularPageController.dispose(); @@ -260,7 +267,10 @@ class _ArtistScreenState extends ConsumerState } Future _fetchDiscography() async { - setState(() => _isLoadingDiscography = true); + setState(() { + _isLoadingDiscography = true; + _error = null; + }); try { List albums; List? releases; @@ -360,6 +370,7 @@ class _ArtistScreenState extends ConsumerState _headerImageUrl = finalHeaderImage; _headerVideoUrl = finalHeaderVideo; _monthlyListeners = finalListeners; + _error = null; _isLoadingDiscography = false; }); } @@ -465,6 +476,14 @@ class _ArtistScreenState extends ConsumerState final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty; + if (isSelectionMode || _selectionOverlay.isVisible) { + final bottomPadding = MediaQuery.paddingOf(context).bottom; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _syncSelectionOverlay(albums: albums, bottomPadding: bottomPadding); + }); + } + return PopScope( canPop: !isSelectionMode, onPopInvokedWithResult: (didPop, result) { @@ -505,6 +524,7 @@ class _ArtistScreenState extends ConsumerState child: ErrorCard( error: _error!, colorScheme: colorScheme, + onRetry: _fetchDiscography, ), ), ), @@ -553,14 +573,31 @@ class _ArtistScreenState extends ConsumerState SliverToBoxAdapter(child: SizedBox(height: bottomInset)), ], ), - if (isSelectionMode) - _buildSelectionBar(context, colorScheme, albums), ], ), ), ); } + void _syncSelectionOverlay({ + required List albums, + required double bottomPadding, + }) { + if (!isSelectionMode) { + _selectionOverlay.hide(); + return; + } + _selectionOverlay.show( + context, + (overlayContext) => _buildSelectionBar( + overlayContext, + Theme.of(overlayContext).colorScheme, + albums, + bottomPadding, + ), + ); + } + @override void exitSelectionMode() { HapticFeedback.lightImpact(); @@ -583,8 +620,10 @@ class _ArtistScreenState extends ConsumerState BuildContext context, ColorScheme colorScheme, List allAlbums, + double bottomPadding, ) { - final allSelected = selectedIds.length == allAlbums.length; + final allSelected = + selectedIds.length == allAlbums.length && allAlbums.isNotEmpty; final selectedCount = selectedIds.length; final selectedAlbums = allAlbums .where((a) => selectedIds.contains(a.id)) @@ -593,171 +632,34 @@ class _ArtistScreenState extends ConsumerState 0, (sum, a) => sum + a.totalTracks, ); - final textScale = MediaQuery.textScalerOf(context).scale(1.0); - final compactLayout = - MediaQuery.sizeOf(context).width < 430 || textScale > 1.15; - return Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 8, - offset: const Offset(0, -2), + return SelectionBottomBar( + selectedCount: selectedCount, + allSelected: allSelected, + onClose: exitSelectionMode, + onToggleSelectAll: allSelected + ? _deselectAll + : () => selectAll(allAlbums.map((a) => a.id)), + bottomPadding: bottomPadding, + allSelectedLabel: context.l10n.tracksCount(totalTracks), + tapToSelectLabel: selectedCount > 0 + ? context.l10n.tracksCount(totalTracks) + : context.l10n.discographySelectAlbumsSubtitle, + children: [ + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _downloadSelectedAlbums(context, selectedAlbums) + : null, + icon: const Icon(Icons.download, size: 18), + label: Text(context.l10n.discographyDownloadSelected), + ), ), ], ), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: compactLayout - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - onPressed: exitSelectionMode, - icon: const Icon(Icons.close), - tooltip: context.l10n.dialogCancel, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.discographySelectedCount( - selectedCount, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - if (selectedCount > 0) - Text( - context.l10n.tracksCount(totalTracks), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: allSelected - ? _deselectAll - : () => selectAll(allAlbums.map((a) => a.id)), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - allSelected - ? context.l10n.actionDeselect - : context.l10n.actionSelectAll, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton( - onPressed: selectedCount > 0 - ? () => _downloadSelectedAlbums( - context, - selectedAlbums, - ) - : null, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - context.l10n.discographyDownloadSelected, - ), - ), - ), - ), - ], - ), - ], - ) - : Row( - children: [ - IconButton( - onPressed: exitSelectionMode, - icon: const Icon(Icons.close), - tooltip: context.l10n.dialogCancel, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.discographySelectedCount( - selectedCount, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - if (selectedCount > 0) - Text( - context.l10n.tracksCount(totalTracks), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - TextButton( - onPressed: allSelected - ? _deselectAll - : () => selectAll(allAlbums.map((a) => a.id)), - child: Text( - allSelected - ? context.l10n.actionDeselect - : context.l10n.actionSelectAll, - ), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: selectedCount > 0 - ? () => _downloadSelectedAlbums( - context, - selectedAlbums, - ) - : null, - icon: const Icon(Icons.download, size: 18), - label: Text(context.l10n.discographyDownloadSelected), - ), - ], - ), - ), - ), - ), + ], ); } @@ -782,24 +684,13 @@ class _ArtistScreenState extends ConsumerState context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (context) => SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), + const AppSheetHandle(), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Row( diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index dee16b91..c453d88d 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -30,8 +30,6 @@ extension _ArtistScreenSections on _ArtistScreenState { headerVideoUrl.isNotEmpty && Uri.tryParse(headerVideoUrl)?.hasAuthority == true; - final isDark = Theme.of(context).brightness == Brightness.dark; - String? listenersText; final listeners = _monthlyListeners ?? widget.monthlyListeners; if (listeners != null && listeners > 0) { @@ -52,6 +50,37 @@ extension _ArtistScreenSections on _ArtistScreenState { ), ); + return CoverPaletteBuilder( + imageSource: hasValidImage ? imageUrl : null, + builder: (context, headerScheme) => _buildArtistAppBar( + context, + colorScheme, + headerScheme, + albums: albums, + hasDiscography: hasDiscography, + imageUrl: imageUrl, + hasValidImage: hasValidImage, + headerVideoUrl: headerVideoUrl, + hasMotionBanner: hasMotionBanner, + listenersText: listenersText, + isFavoriteArtist: isFavoriteArtist, + ), + ); + } + + Widget _buildArtistAppBar( + BuildContext context, + ColorScheme colorScheme, + ColorScheme headerScheme, { + required List albums, + required bool hasDiscography, + required String? imageUrl, + required bool hasValidImage, + required String? headerVideoUrl, + required bool hasMotionBanner, + required String? listenersText, + required bool isFavoriteArtist, + }) { return SliverAppBar( expandedHeight: hasDiscography ? 420 : 380, pinned: true, @@ -79,10 +108,10 @@ extension _ArtistScreenSections on _ArtistScreenState { children: [ if (hasMotionBanner) MotionHeaderBanner( - videoUrl: headerVideoUrl, + videoUrl: headerVideoUrl!, fallback: hasValidImage ? CachedCoverImage( - imageUrl: imageUrl, + imageUrl: imageUrl!, fit: BoxFit.cover, alignment: Alignment.topCenter, memCacheWidth: 800, @@ -109,7 +138,7 @@ extension _ArtistScreenSections on _ArtistScreenState { ) else if (hasValidImage) CachedCoverImage( - imageUrl: imageUrl, + imageUrl: imageUrl!, fit: BoxFit.cover, alignment: Alignment.topCenter, memCacheWidth: 800, @@ -139,12 +168,10 @@ extension _ArtistScreenSections on _ArtistScreenState { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.3), - Colors.black.withValues(alpha: 0.7), - isDark - ? colorScheme.surface - : Colors.black.withValues(alpha: 0.85), + headerScheme.surface.withValues(alpha: 0), + headerScheme.surface.withValues(alpha: 0.35), + headerScheme.surface.withValues(alpha: 0.75), + headerScheme.surface, ], stops: const [0.0, 0.5, 0.75, 1.0], ), @@ -167,14 +194,7 @@ extension _ArtistScreenSections on _ArtistScreenState { style: Theme.of(context).textTheme.headlineLarge ?.copyWith( fontWeight: FontWeight.bold, - color: Colors.white, - shadows: [ - Shadow( - offset: const Offset(0, 1), - blurRadius: 4, - color: Colors.black.withValues(alpha: 0.5), - ), - ], + color: headerScheme.onSurface, ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -185,16 +205,7 @@ extension _ArtistScreenSections on _ArtistScreenState { listenersText, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: Colors.white, - shadows: [ - Shadow( - offset: const Offset(0, 1), - blurRadius: 2, - color: Colors.black.withValues( - alpha: 0.5, - ), - ), - ], + color: headerScheme.onSurfaceVariant, ), ), ], @@ -206,8 +217,8 @@ extension _ArtistScreenSections on _ArtistScreenState { Container( width: 52, height: 52, - decoration: const BoxDecoration( - color: Colors.white, + decoration: BoxDecoration( + color: headerScheme.primaryContainer, shape: BoxShape.circle, ), child: IconButton( @@ -219,8 +230,8 @@ extension _ArtistScreenSections on _ArtistScreenState { size: 26, ), color: isFavoriteArtist - ? colorScheme.error - : Colors.black87, + ? headerScheme.error + : headerScheme.onPrimaryContainer, tooltip: isFavoriteArtist ? context.l10n.artistOptionRemoveFromFavorites : context.l10n.artistOptionAddToFavorites, @@ -232,8 +243,8 @@ extension _ArtistScreenSections on _ArtistScreenState { Container( width: 52, height: 52, - decoration: const BoxDecoration( - color: Colors.white, + decoration: BoxDecoration( + color: headerScheme.primaryContainer, shape: BoxShape.circle, ), child: IconButton( @@ -243,7 +254,7 @@ extension _ArtistScreenSections on _ArtistScreenState { albums, ), icon: const Icon(Icons.download_rounded, size: 26), - color: Colors.black87, + color: headerScheme.onPrimaryContainer, tooltip: context.l10n.discographyDownload, ), ), @@ -255,31 +266,17 @@ extension _ArtistScreenSections on _ArtistScreenState { ), stretchModes: const [StretchMode.zoomBackground], ), - leading: IconButton( + leading: HeaderCircleButton( + icon: Icons.arrow_back, tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.arrow_back, color: Colors.white), - ), onPressed: () => Navigator.pop(context), ), actions: [ Padding( padding: const EdgeInsets.only(right: 8), - child: IconButton( + child: HeaderCircleButton( + icon: Icons.open_in_new_rounded, tooltip: context.l10n.openInOtherServices, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.open_in_new_rounded, color: Colors.white), - ), onPressed: () => _showShareSheet(context), ), ), diff --git a/lib/theme/cover_palette.dart b/lib/theme/cover_palette.dart new file mode 100644 index 00000000..2ca03cce --- /dev/null +++ b/lib/theme/cover_palette.dart @@ -0,0 +1,184 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; + +/// Colour scheme derived from cover art, used to theme detail-screen headers. +/// +/// The headers used to hardcode `Colors.white` text over a `Colors.black` +/// scrim, so they looked identical in light and dark mode and ignored dynamic +/// colour entirely — and white-on-pale-cover was hard to read. Deriving a +/// scheme from the artwork keeps the header tinted by the album while still +/// following the app's brightness, and guarantees the on-colours contrast with +/// whatever surface ends up behind them. +class CoverPalette { + const CoverPalette._(); + + /// Quantizing an image is not cheap, so results are memoized per + /// URL+brightness. Bounded because a long library-browsing session would + /// otherwise keep every visited album's scheme alive. + static final Map _cache = {}; + static final List _cacheOrder = []; + static const int _maxEntries = 32; + + static bool _isNetworkSource(String source) => + source.startsWith('http://') || source.startsWith('https://'); + + /// Includes the local file version so replacing artwork at the same path + /// cannot reuse a palette derived from the previous image. + static String cacheKeyFor(String source, Brightness brightness) { + var versionedSource = source; + if (!_isNetworkSource(source)) { + try { + final stat = File(source).statSync(); + if (stat.type != FileSystemEntityType.notFound) { + versionedSource = + '$source|${stat.modified.microsecondsSinceEpoch}|${stat.size}'; + } + } catch (_) { + // Resolution below will return null for inaccessible local files. + } + } + return '$versionedSource|${brightness.name}'; + } + + /// Cached scheme for [source], or null when it has not been resolved yet. + static ColorScheme? peek(String source, Brightness brightness) => + _cache[cacheKeyFor(source, brightness)]; + + static ColorScheme? _peekByKey(String key) => _cache[key]; + + /// Resolves the scheme for [source] (a network URL or a local file path). + /// Returns null when the image cannot be decoded. + static Future resolve( + String source, + Brightness brightness, { + String? cacheKey, + }) async { + final key = cacheKey ?? cacheKeyFor(source, brightness); + final cached = _cache[key]; + if (cached != null) return cached; + + final ImageProvider provider; + if (_isNetworkSource(source)) { + provider = cachedCoverImageProvider(source); + } else { + final file = File(source); + if (!file.existsSync()) return null; + provider = FileImage(file); + } + + try { + final scheme = await ColorScheme.fromImageProvider( + provider: provider, + brightness: brightness, + ); + _cache[key] = scheme; + _cacheOrder.add(key); + while (_cacheOrder.length > _maxEntries) { + _cache.remove(_cacheOrder.removeAt(0)); + } + return scheme; + } catch (_) { + // Unreachable URL, unsupported format, decode failure: callers fall back + // to the app scheme. + return null; + } + } +} + +/// Exposes the header's effective [ColorScheme] to descendants. +/// +/// Header sub-widgets (meta rows, circle buttons, play actions) live in the +/// screens' own build methods, so they cannot be handed the palette directly; +/// they read it from here and fall back to the app scheme when absent. +class HeaderPalette extends InheritedWidget { + const HeaderPalette({super.key, required this.scheme, required super.child}); + + final ColorScheme scheme; + + static ColorScheme of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.scheme ?? + Theme.of(context).colorScheme; + + @override + bool updateShouldNotify(HeaderPalette oldWidget) => + scheme != oldWidget.scheme; +} + +/// Resolves [imageSource] into a [ColorScheme] and rebuilds when it arrives. +class CoverPaletteBuilder extends StatefulWidget { + const CoverPaletteBuilder({ + super.key, + required this.imageSource, + required this.builder, + }); + + /// Cover URL or local path. Null disables palette extraction. + final String? imageSource; + + final Widget Function(BuildContext context, ColorScheme scheme) builder; + + @override + State createState() => _CoverPaletteBuilderState(); +} + +class _CoverPaletteBuilderState extends State { + ColorScheme? _scheme; + String? _resolvedKey; + int _resolveGeneration = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _maybeResolve(); + } + + @override + void didUpdateWidget(CoverPaletteBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + // Re-check local file identity even when its path did not change. Metadata + // editing can replace cover bytes in place. + _maybeResolve(); + } + + void _maybeResolve() { + final source = widget.imageSource; + final brightness = Theme.of(context).brightness; + if (source == null || source.isEmpty) { + _resolveGeneration++; + _resolvedKey = null; + _scheme = null; + return; + } + final key = CoverPalette.cacheKeyFor(source, brightness); + if (_resolvedKey == key) return; + + final requestGeneration = ++_resolveGeneration; + _resolvedKey = key; + + final cached = CoverPalette._peekByKey(key); + if (cached != null) { + _scheme = cached; + return; + } + _scheme = null; + + CoverPalette.resolve(source, brightness, cacheKey: key).then((scheme) { + if (!mounted) return; + if (_resolveGeneration != requestGeneration || _resolvedKey != key) { + return; + } + setState(() => _scheme = scheme); + }); + } + + @override + Widget build(BuildContext context) { + final scheme = _scheme ?? Theme.of(context).colorScheme; + return HeaderPalette( + scheme: scheme, + child: widget.builder(context, scheme), + ); + } +} diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 31c64e69..3e7cc7db 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -1,12 +1,19 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/cover_palette.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, @@ -14,6 +21,7 @@ class AlbumDetailHeader extends StatelessWidget { required this.expandedHeight, required this.showTitleInAppBar, required this.background, + this.paletteSource, this.blurAndScrimBackground = true, this.coverBuilder, this.subtitle, @@ -32,6 +40,10 @@ class AlbumDetailHeader extends StatelessWidget { /// 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; @@ -71,21 +83,33 @@ class AlbumDetailHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + return CoverPaletteBuilder( + imageSource: paletteSource, + builder: (context, headerScheme) => _buildAppBar(context, headerScheme), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme headerScheme) { + final tokens = context.tokens; + // 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 ?? colorScheme.surface, + backgroundColor: backgroundColor ?? headerScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: tokens.motionFast, opacity: showTitleInAppBar ? 1.0 : 0.0, child: Text( appBarTitle ?? title, style: TextStyle( - color: colorScheme.onSurface, + color: onHeader, fontWeight: FontWeight.w600, fontSize: 16, ), @@ -113,20 +137,20 @@ class AlbumDetailHeader extends StatelessWidget { else background, if (blurAndScrimBackground) - Container(color: Colors.black.withValues(alpha: 0.35)), + ColoredBox(color: scrimColor.withValues(alpha: 0.4)), Positioned( left: 0, right: 0, bottom: 0, height: expandedHeight * 0.65, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.85), + scrimColor.withValues(alpha: 0), + scrimColor.withValues(alpha: 0.92), ], ), ), @@ -137,7 +161,7 @@ class AlbumDetailHeader extends StatelessWidget { right: 20, bottom: 40, child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), + duration: tokens.motionFast, opacity: showContent ? 1.0 : 0.0, child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -153,10 +177,12 @@ class AlbumDetailHeader extends StatelessWidget { width: coverSize, height: coverSize, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular( + tokens.radiusControl, + ), boxShadow: [ BoxShadow( - color: Colors.black.withValues( + color: headerScheme.shadow.withValues( alpha: 0.45, ), blurRadius: 24, @@ -165,7 +191,9 @@ class AlbumDetailHeader extends StatelessWidget { ], ), child: ClipRRect( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular( + tokens.radiusControl, + ), child: coverBuilder!(context, coverSize), ), ); @@ -176,7 +204,7 @@ class AlbumDetailHeader extends StatelessWidget { Text( title, style: TextStyle( - color: Colors.white, + color: onHeader, fontSize: _titleFontSize(), fontWeight: FontWeight.bold, height: 1.2, @@ -209,15 +237,15 @@ class AlbumDetailHeader extends StatelessWidget { ), leading: leading ?? - IconButton( + IconButton.filledTonal( tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, + icon: const Icon(Icons.arrow_back), + style: IconButton.styleFrom( + minimumSize: Size.square(tokens.minTouchTarget), + backgroundColor: headerScheme.surfaceContainerHigh.withValues( + alpha: 0.75, ), - child: const Icon(Icons.arrow_back, color: Colors.white), + foregroundColor: headerScheme.onSurfaceVariant, ), onPressed: () => Navigator.pop(context), ), @@ -226,8 +254,8 @@ class AlbumDetailHeader extends StatelessWidget { } } -/// White "play" pill + translucent shuffle circle used by the local and -/// downloaded album headers. +/// 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, @@ -244,6 +272,8 @@ class AlbumPlayActions extends StatelessWidget { @override Widget build(BuildContext context) { + final tokens = context.tokens; + final scheme = HeaderPalette.of(context); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -257,25 +287,22 @@ class AlbumPlayActions extends StatelessWidget { overflow: TextOverflow.ellipsis, ), style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + minimumSize: Size(0, tokens.minTouchTarget), + shape: const StadiumBorder(), ), ), ), - const SizedBox(width: 12), - Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: IconButton( - tooltip: shuffleTooltip, - onPressed: onShuffle, - icon: const Icon(Icons.shuffle, color: Colors.white), + 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, ), ), ], @@ -283,46 +310,76 @@ class AlbumPlayActions extends StatelessWidget { } } -/// 48x48 translucent-white circle icon button used in album/playlist headers -/// (add-to-playlist, love-all, ...). +/// 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, + }); + + final IconData icon; + final String label; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final scheme = HeaderPalette.of(context); + 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 StatelessWidget { const HeaderCircleButton({ super.key, required this.icon, required this.tooltip, required this.onPressed, - this.iconColor = Colors.white, + this.iconColor, }); final IconData icon; final String tooltip; final VoidCallback? onPressed; - final Color iconColor; + + /// Overrides the palette foreground, e.g. to mark an active "loved" state. + final Color? iconColor; @override Widget build(BuildContext context) { - return Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.15), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 1, - ), - ), - child: IconButton( - onPressed: onPressed, - icon: Icon(icon, size: 22, color: iconColor), - tooltip: tooltip, - padding: EdgeInsets.zero, + final tokens = context.tokens; + final scheme = HeaderPalette.of(context); + return IconButton.filledTonal( + onPressed: onPressed, + icon: Icon(icon, size: 22), + tooltip: tooltip, + style: IconButton.styleFrom( + minimumSize: Size.square(tokens.minTouchTarget), + backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.7), + foregroundColor: iconColor ?? scheme.onSurfaceVariant, ), ); } } -/// "•"-joined row of white [HeaderMetaItem]s shown under the header subtitle +/// "•"-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}); @@ -331,15 +388,16 @@ class HeaderMetaRow extends StatelessWidget { @override Widget build(BuildContext context) { + final scheme = HeaderPalette.of(context); final parts = []; for (final item in items) { if (parts.isNotEmpty) { parts.add( - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( '•', - style: TextStyle(color: Colors.white70, fontSize: 12), + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), ), ), ); @@ -356,7 +414,7 @@ class HeaderMetaRow extends StatelessWidget { } } -/// Single white meta label (optional leading icon) for [HeaderMetaRow]. +/// Single meta label (optional leading icon) for [HeaderMetaRow]. class HeaderMetaItem extends StatelessWidget { const HeaderMetaItem(this.label, {super.key, this.icon}); @@ -365,8 +423,9 @@ class HeaderMetaItem extends StatelessWidget { @override Widget build(BuildContext context) { - const textStyle = TextStyle( - color: Colors.white, + final scheme = HeaderPalette.of(context); + final textStyle = TextStyle( + color: scheme.onSurface, fontSize: 13, fontWeight: FontWeight.w500, ); @@ -374,7 +433,7 @@ class HeaderMetaItem extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 15, color: Colors.white), + Icon(icon, size: 15, color: scheme.onSurface), const SizedBox(width: 4), Text(label, style: textStyle), ],