feat(android): skeleton tiles + Home cross-fade reveal (audit v2 #16, slice 1)

Adds Skeletons.kt with five primitives: SkeletonBox (pulsing
surfaceVariant fill, the building block), SkeletonAlbumTile,
SkeletonArtistTile, SkeletonTrackRow, SkeletonSectionHeader. Each
matches the geometry of the real card it stands in for so the
reveal doesn't reflow.

Wires HomeScreen as the first consumer: replaces the cold-load
CircularProgressIndicator with a HomeSkeletonContent LazyColumn
(section header + horizontal album row × 2 + section header +
horizontal artist row), and wraps the state machine in a Crossfade
so Loading → Success cross-fades instead of snapping.

Library + AlbumDetail / ArtistDetail / PlaylistDetail still use
the centered spinner; those land in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:18:02 -04:00
parent f21f53d04a
commit 43ee8f9a39
2 changed files with 210 additions and 21 deletions
@@ -1,5 +1,8 @@
@file:Suppress("TooManyFunctions") // Compose screen + section helper composables + skeletons
package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -12,9 +15,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -54,6 +57,9 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
@@ -180,32 +186,72 @@ fun HomeScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
HomeUiState.Loading -> LoadingCentered()
HomeUiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server settings, " +
"then come back here for system playlists and recommendations.",
)
is HomeUiState.Error -> EmptyState(
title = "Couldn't load home",
body = s.message,
)
is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
)
Crossfade(targetState = state, label = "home-state") { s ->
when (s) {
HomeUiState.Loading -> HomeSkeletonContent()
HomeUiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is HomeUiState.Error -> EmptyState(
title = "Couldn't load home",
body = s.message,
)
is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
)
}
}
}
}
}
/**
* Skeleton stand-in shown during cold-load. Mirrors the success
* layout's section structure (Playlists carousel → Recently added
* carousel → Rediscover albums carousel → Last played artists
* carousel) so the Crossfade reveal doesn't reflow.
*/
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
private fun HomeSkeletonContent() {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
item { SkeletonSectionHeader() }
item { SkeletonAlbumRow() }
item { SkeletonSectionHeader() }
item { SkeletonAlbumRow() }
item { SkeletonSectionHeader() }
item { SkeletonArtistRow() }
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
}
}
private const val SKELETON_TILES_PER_ROW = 5
@Composable
private fun SkeletonAlbumRow() {
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp),
) {
items(SKELETON_TILES_PER_ROW) { SkeletonAlbumTile() }
}
}
@Composable
private fun SkeletonArtistRow() {
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp),
) {
items(SKELETON_TILES_PER_ROW) { SkeletonArtistTile() }
}
}
@@ -0,0 +1,143 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* Skeleton placeholders for cold-load states. Mirror Flutter's
* `SkeletonAlbumTile` / `SkeletonArtistTile` / `_CompactTrackSkeleton`
* — same geometry as the real cards so the layout doesn't reflow when
* real data lands and the screen-level Crossfade reveals.
*/
private const val PULSE_MIN_ALPHA = 0.3f
private const val PULSE_MAX_ALPHA = 0.55f
private const val PULSE_DURATION_MS = 800
/**
* Solid-color box with a slow alpha pulse. Building block for the
* tile-shape composites below. Mirrors the surfaceVariant fill the
* real cards use as their cover-loading background.
*/
@Composable
fun SkeletonBox(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm),
) {
val transition = rememberInfiniteTransition(label = "skeleton-pulse")
val alpha by transition.animateFloat(
initialValue = PULSE_MIN_ALPHA,
targetValue = PULSE_MAX_ALPHA,
animationSpec = infiniteRepeatable(
animation = tween(PULSE_DURATION_MS, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "skeleton-alpha",
)
Box(
modifier = modifier
.clip(shape)
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
)
}
/**
* Stand-in for AlbumCard / PlaylistCard (same 176dp×144dp cover +
* 2 caption lines geometry).
*/
@Composable
fun SkeletonAlbumTile(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(176.dp)
.padding(horizontal = 8.dp),
) {
SkeletonBox(modifier = Modifier.size(144.dp))
Spacer(Modifier.height(8.dp))
SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp))
Spacer(Modifier.height(4.dp))
SkeletonBox(modifier = Modifier.width(96.dp).height(12.dp))
}
}
/**
* Stand-in for ArtistCard (128dp circular cover + 1 name line).
*/
@Composable
fun SkeletonArtistTile(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(144.dp)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SkeletonBox(
modifier = Modifier.size(128.dp),
shape = CircleShape,
)
Spacer(Modifier.height(8.dp))
SkeletonBox(modifier = Modifier.width(96.dp).height(14.dp))
}
}
/**
* Stand-in for a compact track row (28dp cover thumb + title + subtitle).
*/
@Composable
fun SkeletonTrackRow(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SkeletonBox(modifier = Modifier.size(40.dp))
Column(
modifier = Modifier
.weight(1f, fill = true),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp))
SkeletonBox(modifier = Modifier.width(120.dp).height(12.dp))
}
}
}
/** Section-heading skeleton (~40% width title bar). */
@Composable
fun SkeletonSectionHeader(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
SkeletonBox(modifier = Modifier.width(160.dp).height(20.dp))
}
}