From 43ee8f9a392741f28afa1a14a04d85bfff1bfd4e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 14:18:02 -0400 Subject: [PATCH] feat(android): skeleton tiles + Home cross-fade reveal (audit v2 #16, slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/home/ui/HomeScreen.kt | 88 ++++++++--- .../minstrel/shared/widgets/Skeletons.kt | 143 ++++++++++++++++++ 2 files changed, 210 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/Skeletons.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index 2bbc5187..0b90b4d0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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() } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/Skeletons.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/Skeletons.kt new file mode 100644 index 00000000..5fe31a88 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/Skeletons.kt @@ -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)) + } +}