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:
2026-05-28 00:51:02 -04:00
parent 42a231e179
commit 38102df929
3 changed files with 150 additions and 3 deletions
@@ -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 {
@@ -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() }
}
}
@@ -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) }