feat(details): add adaptive cover palettes

This commit is contained in:
zarzet
2026-07-29 17:12:15 +07:00
parent dc4c75363c
commit 1443be6a4f
4 changed files with 428 additions and 297 deletions
+68 -177
View File
@@ -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<ArtistScreen>
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<ArtistScreen>
@override
void dispose() {
_selectionOverlay.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_popularPageController.dispose();
@@ -260,7 +267,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
}
Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true);
setState(() {
_isLoadingDiscography = true;
_error = null;
});
try {
List<ArtistAlbum> albums;
List<ArtistAlbum>? releases;
@@ -360,6 +370,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
_headerImageUrl = finalHeaderImage;
_headerVideoUrl = finalHeaderVideo;
_monthlyListeners = finalListeners;
_error = null;
_isLoadingDiscography = false;
});
}
@@ -465,6 +476,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
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<ArtistScreen>
child: ErrorCard(
error: _error!,
colorScheme: colorScheme,
onRetry: _fetchDiscography,
),
),
),
@@ -553,14 +573,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
if (isSelectionMode)
_buildSelectionBar(context, colorScheme, albums),
],
),
),
);
}
void _syncSelectionOverlay({
required List<ArtistAlbum> 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<ArtistScreen>
BuildContext context,
ColorScheme colorScheme,
List<ArtistAlbum> 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<ArtistScreen>
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<ArtistScreen>
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(
+51 -54
View File
@@ -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<ArtistAlbum> 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),
),
),