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)) + } +}