feat(android): seed headers on detail-screen Loading state (audit v3 Bug-8)
AlbumDetail / ArtistDetail / PlaylistDetail previously rendered only the AppBar title from the navigation seed during Loading, with a bare skeleton body. Now each Loading branch renders the full header from the seed when present — cover + title + artist/description + count — above the skeleton track rows / album grid, so the screen reads as itself instantly instead of skeleton-then-pop. Falls back to the plain skeleton when no seed (e.g. deep links, track-row navigations that only carry an id). Action buttons (Play / Shuffle / Like / Regenerate) are intentionally omitted from the seeded header — they need the loaded track list. Added @file:Suppress(TooManyFunctions) to AlbumDetailScreen (now at the 11-function cap with the new SeededAlbumLoading helper). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
@@ -120,7 +122,8 @@ private fun AlbumDetailStateContent(
|
||||
) {
|
||||
Crossfade(targetState = state, label = "album-detail") { s ->
|
||||
when (s) {
|
||||
is AlbumDetailUiState.Loading -> SkeletonTrackList()
|
||||
is AlbumDetailUiState.Loading ->
|
||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||
is AlbumDetailUiState.Error -> EmptyState(
|
||||
title = "Couldn't load album",
|
||||
body = s.message,
|
||||
@@ -370,6 +373,53 @@ private fun SkeletonTrackList() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading body that renders the album header from the navigation seed
|
||||
* (cover + title + artist + count) so the screen reads as itself
|
||||
* immediately, with skeleton track rows below until the fetch lands.
|
||||
* Action buttons are omitted — they need the real track list.
|
||||
*/
|
||||
@Composable
|
||||
private fun SeededAlbumLoading(seed: AlbumRef) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AlbumCover(seed)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = seed.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (seed.artistName.isNotEmpty()) {
|
||||
Text(
|
||||
text = seed.artistName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = trackCountLine(seed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
items(SKELETON_TRACK_ROWS) { SkeletonTrackRow() }
|
||||
}
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60
|
||||
|
||||
private fun formatDuration(seconds: Int): String {
|
||||
|
||||
+50
-1
@@ -89,7 +89,12 @@ fun ArtistDetailScreen(
|
||||
) {
|
||||
Crossfade(targetState = state, label = "artist-detail") { s ->
|
||||
when (s) {
|
||||
is ArtistDetailUiState.Loading -> SkeletonArtistAlbumsGrid()
|
||||
is ArtistDetailUiState.Loading ->
|
||||
if (s.seed != null) {
|
||||
SeededArtistLoading(s.seed)
|
||||
} else {
|
||||
SkeletonArtistAlbumsGrid()
|
||||
}
|
||||
is ArtistDetailUiState.Error -> EmptyState(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
@@ -258,3 +263,47 @@ private fun SkeletonArtistAlbumsGrid() {
|
||||
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading body that renders the artist header from the navigation
|
||||
* seed (avatar + name + album count) with a skeleton album grid
|
||||
* below until the fetch lands. Play / Like omitted — they need the
|
||||
* loaded detail.
|
||||
*/
|
||||
@Composable
|
||||
private fun SeededArtistLoading(seed: ArtistRef) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ArtistAvatar(seed)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = seed.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (seed.albumCount > 0) {
|
||||
Text(
|
||||
text = albumCountLine(seed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(SKELETON_ARTIST_GRID_TILES) { SkeletonAlbumTile() }
|
||||
}
|
||||
}
|
||||
|
||||
+49
-1
@@ -299,7 +299,8 @@ private fun PlaylistDetailContent(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
|
||||
is PlaylistDetailUiState.Loading -> SkeletonPlaylistTrackList()
|
||||
is PlaylistDetailUiState.Loading ->
|
||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
||||
is PlaylistDetailUiState.Success -> {
|
||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||
@@ -553,6 +554,53 @@ private fun SkeletonPlaylistTrackList() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading body that renders the playlist header from the navigation
|
||||
* seed (cover + name + description + count) with skeleton track rows
|
||||
* below. Play / Shuffle / Regenerate omitted — they need the loaded
|
||||
* track list.
|
||||
*/
|
||||
@Composable
|
||||
private fun SeededPlaylistLoading(seed: PlaylistRef) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlaylistCover(seed)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = seed.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (seed.description.isNotEmpty()) {
|
||||
Text(
|
||||
text = seed.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${seed.trackCount} tracks",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
items(SKELETON_PLAYLIST_ROWS) { SkeletonTrackRow() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
||||
var retried by remember { mutableStateOf(false) }
|
||||
|
||||
Reference in New Issue
Block a user