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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user