Преглед изворни кода

首页骨架屏:替换占位提示为EmptyMessage,模块加载骨架屏及状态切换动画

HapeLee пре 5 месеци
родитељ
комит
d3d107a4f8

+ 197 - 172
app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt

@@ -1,9 +1,15 @@
 package io.legado.app.ui.main.homepage
 package io.legado.app.ui.main.homepage
 
 
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
@@ -63,6 +69,7 @@ import io.legado.app.ui.main.homepage.modules.ButtonGroupModule
 import io.legado.app.ui.main.homepage.modules.CardModule
 import io.legado.app.ui.main.homepage.modules.CardModule
 import io.legado.app.ui.main.homepage.modules.GridModule
 import io.legado.app.ui.main.homepage.modules.GridModule
 import io.legado.app.ui.main.homepage.modules.GridRankingModule
 import io.legado.app.ui.main.homepage.modules.GridRankingModule
+import io.legado.app.ui.main.homepage.modules.HomepageModuleSkeleton
 import io.legado.app.ui.main.homepage.modules.RankingModule
 import io.legado.app.ui.main.homepage.modules.RankingModule
 import io.legado.app.ui.main.homepage.modules.WaterfallItem
 import io.legado.app.ui.main.homepage.modules.WaterfallItem
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
@@ -75,8 +82,8 @@ import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcon
-import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
@@ -198,9 +205,10 @@ fun HomepageScreen(
                 .padding(paddingValues),
                 .padding(paddingValues),
         ) {
         ) {
             if (selectedSets.isEmpty()) {
             if (selectedSets.isEmpty()) {
-                Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
-                    AppText(stringResource(R.string.homepage_no_source_sets_selected))
-                }
+                EmptyMessage(
+                    messageResId = R.string.homepage_no_source_sets_selected,
+                    modifier = Modifier.fillMaxSize(),
+                )
             } else {
             } else {
                 HorizontalPager(
                 HorizontalPager(
                     state = pagerState,
                     state = pagerState,
@@ -394,100 +402,103 @@ private fun ModuleList(
 
 
                 // 2. 内容正文
                 // 2. 内容正文
                 when (val state = moduleUi.state) {
                 when (val state = moduleUi.state) {
-                    is ModuleLoadState.Loading -> {
-                        item(
-                            key = "loading_${moduleUi.globalId}",
-                            span = StaggeredGridItemSpan.FullLine
-                        ) {
-                            Box(
-                                modifier = Modifier
-                                    .fillMaxWidth()
-                                    .height(120.dp),
-                                contentAlignment = Alignment.Center,
-                            ) {
-                                AppCircularProgressIndicator()
-                            }
-                        }
-                    }
-
+                    is ModuleLoadState.Loading,
                     is ModuleLoadState.Error -> {
                     is ModuleLoadState.Error -> {
                         item(
                         item(
-                            key = "error_${moduleUi.globalId}",
+                            key = "status_${moduleUi.globalId}",
                             span = StaggeredGridItemSpan.FullLine
                             span = StaggeredGridItemSpan.FullLine
                         ) {
                         ) {
-                            Column(
-                                modifier = Modifier
-                                    .fillMaxWidth(),
-                                horizontalAlignment = Alignment.CenterHorizontally
-                            ) {
-                                GlassCard(
-                                    onClick = { onErrorClick(state.message) },
-                                    containerColor = LegadoTheme.colorScheme.errorContainer.copy(
-                                        alpha = 0.6f
-                                    ),
-                                ) {
-                                    Column(
-                                        modifier = Modifier.fillMaxWidth()
-                                    ) {
-
-                                        Row(
-                                            modifier = Modifier
-                                                .fillMaxWidth()
-                                                .padding(
-                                                    horizontal = 16.dp,
-                                                    vertical = 16.dp
-                                                ),
-                                            verticalAlignment = Alignment.CenterVertically,
-                                            horizontalArrangement = Arrangement.spacedBy(12.dp)
-                                        ) {
-
-                                            AppIcon(
-                                                imageVector = Icons.Outlined.Info,
-                                                contentDescription = null,
-                                                tint = LegadoTheme.colorScheme.error
-                                            )
-
-                                            AppText(
-                                                text = state.message,
-                                                color = LegadoTheme.colorScheme.error,
-                                                style = LegadoTheme.typography.bodySmall,
-                                                modifier = Modifier.weight(1f),
-                                                maxLines = 2,
-                                                overflow = TextOverflow.Ellipsis,
-                                            )
-                                        }
-
-                                        HorizontalDivider(
-                                            color = LegadoTheme.colorScheme.error.copy(alpha = 0.3f)
+                            AnimatedContent(
+                                targetState = state,
+                                transitionSpec = {
+                                    fadeIn(tween(300)) togetherWith fadeOut(tween(300))
+                                },
+                                contentKey = { it::class },
+                            ) { targetState ->
+                                when (targetState) {
+                                    is ModuleLoadState.Loading -> {
+                                        HomepageModuleSkeleton(
+                                            type = moduleUi.type,
+                                            modifier = Modifier.fillMaxWidth(),
                                         )
                                         )
+                                    }
 
 
-                                        Box(
-                                            modifier = Modifier
-                                                .fillMaxWidth()
-                                                .clickable {
-                                                    viewModel.retryModule(moduleUi.globalId)
-                                                }
-                                                .padding(vertical = 10.dp),
-                                            contentAlignment = Alignment.Center
+                                    is ModuleLoadState.Error -> {
+                                        Column(
+                                            modifier = Modifier.fillMaxWidth(),
+                                            horizontalAlignment = Alignment.CenterHorizontally
                                         ) {
                                         ) {
-                                            Row(
-                                                horizontalArrangement = Arrangement.spacedBy(8.dp),
-                                                verticalAlignment = Alignment.CenterVertically
+                                            GlassCard(
+                                                onClick = { onErrorClick(targetState.message) },
+                                                containerColor = LegadoTheme.colorScheme.errorContainer.copy(
+                                                    alpha = 0.6f
+                                                ),
                                             ) {
                                             ) {
-                                                AppIcon(
-                                                    imageVector = Icons.Default.Refresh,
-                                                    contentDescription = null,
-                                                    tint = LegadoTheme.colorScheme.error
-                                                )
-
-                                                AppText(
-                                                    text = "重试",
-                                                    color = LegadoTheme.colorScheme.error,
-                                                    style = LegadoTheme.typography.labelMedium
-                                                )
+                                                Column(
+                                                    modifier = Modifier.fillMaxWidth()
+                                                ) {
+                                                    Row(
+                                                        modifier = Modifier
+                                                            .fillMaxWidth()
+                                                            .padding(
+                                                                horizontal = 16.dp,
+                                                                vertical = 16.dp
+                                                            ),
+                                                        verticalAlignment = Alignment.CenterVertically,
+                                                        horizontalArrangement = Arrangement.spacedBy(12.dp)
+                                                    ) {
+                                                        AppIcon(
+                                                            imageVector = Icons.Outlined.Info,
+                                                            contentDescription = null,
+                                                            tint = LegadoTheme.colorScheme.error
+                                                        )
+
+                                                        AppText(
+                                                            text = targetState.message,
+                                                            color = LegadoTheme.colorScheme.error,
+                                                            style = LegadoTheme.typography.bodySmall,
+                                                            modifier = Modifier.weight(1f),
+                                                            maxLines = 2,
+                                                            overflow = TextOverflow.Ellipsis,
+                                                        )
+                                                    }
+
+                                                    HorizontalDivider(
+                                                        color = LegadoTheme.colorScheme.error.copy(alpha = 0.3f)
+                                                    )
+
+                                                    Box(
+                                                        modifier = Modifier
+                                                            .fillMaxWidth()
+                                                            .clickable {
+                                                                viewModel.retryModule(moduleUi.globalId)
+                                                            }
+                                                            .padding(vertical = 10.dp),
+                                                        contentAlignment = Alignment.Center
+                                                    ) {
+                                                        Row(
+                                                            horizontalArrangement = Arrangement.spacedBy(8.dp),
+                                                            verticalAlignment = Alignment.CenterVertically
+                                                        ) {
+                                                            AppIcon(
+                                                                imageVector = Icons.Default.Refresh,
+                                                                contentDescription = null,
+                                                                tint = LegadoTheme.colorScheme.error
+                                                            )
+
+                                                            AppText(
+                                                                text = "重试",
+                                                                color = LegadoTheme.colorScheme.error,
+                                                                style = LegadoTheme.typography.labelMedium
+                                                            )
+                                                        }
+                                                    }
+                                                }
                                             }
                                             }
                                         }
                                         }
                                     }
                                     }
+
+                                    else -> {}
                                 }
                                 }
                             }
                             }
                         }
                         }
@@ -520,19 +531,21 @@ private fun ModuleList(
                                         item.book.bookUrl,
                                         item.book.bookUrl,
                                         "home:${moduleUi.globalId}:waterfall:$index"
                                         "home:${moduleUi.globalId}:waterfall:$index"
                                     )
                                     )
-                                    WaterfallItem(
-                                        item = item,
-                                        onClick = {
-                                            viewModel.onBookClick(
-                                                item.book,
-                                                sharedCoverKey
-                                            )
-                                        },
-                                        onLongClick = onBookLongClick,
-                                        sharedTransitionScope = sharedTransitionScope,
-                                        animatedVisibilityScope = animatedVisibilityScope,
-                                        sharedCoverKey = sharedCoverKey,
-                                    )
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        WaterfallItem(
+                                            item = item,
+                                            onClick = {
+                                                viewModel.onBookClick(
+                                                    item.book,
+                                                    sharedCoverKey
+                                                )
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKey = sharedCoverKey,
+                                        )
+                                    }
                                 }
                                 }
 
 
                                 item(
                                 item(
@@ -556,20 +569,22 @@ private fun ModuleList(
                                         item.book.bookUrl,
                                         item.book.bookUrl,
                                         "home:${moduleUi.globalId}:infinite:$index"
                                         "home:${moduleUi.globalId}:infinite:$index"
                                     )
                                     )
-                                    SearchBookGridItem(
-                                        book = item.book,
-                                        shelfState = item.shelfState,
-                                        onClick = {
-                                            viewModel.onBookClick(
-                                                item.book,
-                                                sharedCoverKey
-                                            )
-                                        },
-                                        onLongClick = onBookLongClick,
-                                        sharedTransitionScope = sharedTransitionScope,
-                                        animatedVisibilityScope = animatedVisibilityScope,
-                                        sharedCoverKey = sharedCoverKey
-                                    )
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        SearchBookGridItem(
+                                            book = item.book,
+                                            shelfState = item.shelfState,
+                                            onClick = {
+                                                viewModel.onBookClick(
+                                                    item.book,
+                                                    sharedCoverKey
+                                                )
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKey = sharedCoverKey
+                                        )
+                                    }
                                 }
                                 }
 
 
                                 item(
                                 item(
@@ -592,19 +607,21 @@ private fun ModuleList(
                                     key = "content_${moduleUi.globalId}",
                                     key = "content_${moduleUi.globalId}",
                                     span = StaggeredGridItemSpan.FullLine
                                     span = StaggeredGridItemSpan.FullLine
                                 ) {
                                 ) {
-                                    GridModule(
-                                        books = state.books,
-                                        onClick = { book, sharedCoverKey ->
-                                            viewModel.onBookClick(book, sharedCoverKey)
-                                        },
-                                        onLongClick = onBookLongClick,
-                                        modifier = Modifier.fillMaxWidth(),
-                                        columns = columns,
-                                        maxRows = rows,
-                                        sharedTransitionScope = sharedTransitionScope,
-                                        animatedVisibilityScope = animatedVisibilityScope,
-                                        sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
-                                    )
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        GridModule(
+                                            books = state.books,
+                                            onClick = { book, sharedCoverKey ->
+                                                viewModel.onBookClick(book, sharedCoverKey)
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            modifier = Modifier.fillMaxWidth(),
+                                            columns = columns,
+                                            maxRows = rows,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
+                                        )
+                                    }
                                 }
                                 }
                             }
                             }
 
 
@@ -615,17 +632,19 @@ private fun ModuleList(
                                             key = "content_${moduleUi.globalId}",
                                             key = "content_${moduleUi.globalId}",
                                             span = StaggeredGridItemSpan.FullLine
                                             span = StaggeredGridItemSpan.FullLine
                                         ) {
                                         ) {
-                                            BannerModule(
-                                                books = state.books,
-                                                onClick = { book, sharedCoverKey ->
-                                                    viewModel.onBookClick(book, sharedCoverKey)
-                                                },
-                                                onLongClick = onBookLongClick,
-                                                modifier = Modifier.fillMaxWidth(),
-                                                sharedTransitionScope = sharedTransitionScope,
-                                                animatedVisibilityScope = animatedVisibilityScope,
-                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
-                                            )
+                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                                BannerModule(
+                                                    books = state.books,
+                                                    onClick = { book, sharedCoverKey ->
+                                                        viewModel.onBookClick(book, sharedCoverKey)
+                                                    },
+                                                    onLongClick = onBookLongClick,
+                                                    modifier = Modifier.fillMaxWidth(),
+                                                    sharedTransitionScope = sharedTransitionScope,
+                                                    animatedVisibilityScope = animatedVisibilityScope,
+                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
+                                                )
+                                            }
                                         }
                                         }
                                     }
                                     }
 
 
@@ -634,17 +653,19 @@ private fun ModuleList(
                                             key = "content_${moduleUi.globalId}",
                                             key = "content_${moduleUi.globalId}",
                                             span = StaggeredGridItemSpan.FullLine
                                             span = StaggeredGridItemSpan.FullLine
                                         ) {
                                         ) {
-                                            RankingModule(
-                                                books = state.books,
-                                                onClick = { book, sharedCoverKey ->
-                                                    viewModel.onBookClick(book, sharedCoverKey)
-                                                },
-                                                onLongClick = onBookLongClick,
-                                                modifier = Modifier.fillMaxWidth(),
-                                                sharedTransitionScope = sharedTransitionScope,
-                                                animatedVisibilityScope = animatedVisibilityScope,
-                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
-                                            )
+                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                                RankingModule(
+                                                    books = state.books,
+                                                    onClick = { book, sharedCoverKey ->
+                                                        viewModel.onBookClick(book, sharedCoverKey)
+                                                    },
+                                                    onLongClick = onBookLongClick,
+                                                    modifier = Modifier.fillMaxWidth(),
+                                                    sharedTransitionScope = sharedTransitionScope,
+                                                    animatedVisibilityScope = animatedVisibilityScope,
+                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
+                                                )
+                                            }
                                         }
                                         }
                                     }
                                     }
 
 
@@ -653,18 +674,20 @@ private fun ModuleList(
                                             key = "content_${moduleUi.globalId}",
                                             key = "content_${moduleUi.globalId}",
                                             span = StaggeredGridItemSpan.FullLine
                                             span = StaggeredGridItemSpan.FullLine
                                         ) {
                                         ) {
-                                            GridRankingModule(
-                                                books = state.books,
-                                                onClick = { book, sharedCoverKey ->
-                                                    viewModel.onBookClick(book, sharedCoverKey)
-                                                },
-                                                onLongClick = onBookLongClick,
-                                                modifier = Modifier.fillMaxWidth(),
-                                                rows = config["layout_rows"]?.toIntOrNull() ?: 4,
-                                                sharedTransitionScope = sharedTransitionScope,
-                                                animatedVisibilityScope = animatedVisibilityScope,
-                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
-                                            )
+                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                                GridRankingModule(
+                                                    books = state.books,
+                                                    onClick = { book, sharedCoverKey ->
+                                                        viewModel.onBookClick(book, sharedCoverKey)
+                                                    },
+                                                    onLongClick = onBookLongClick,
+                                                    modifier = Modifier.fillMaxWidth(),
+                                                    rows = config["layout_rows"]?.toIntOrNull() ?: 4,
+                                                    sharedTransitionScope = sharedTransitionScope,
+                                                    animatedVisibilityScope = animatedVisibilityScope,
+                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
+                                                )
+                                            }
                                         }
                                         }
                                     }
                                     }
 
 
@@ -673,17 +696,19 @@ private fun ModuleList(
                                             key = "content_${moduleUi.globalId}",
                                             key = "content_${moduleUi.globalId}",
                                             span = StaggeredGridItemSpan.FullLine
                                             span = StaggeredGridItemSpan.FullLine
                                         ) {
                                         ) {
-                                            CardModule(
-                                                books = state.books,
-                                                onClick = { book, sharedCoverKey ->
-                                                    viewModel.onBookClick(book, sharedCoverKey)
-                                                },
-                                                onLongClick = onBookLongClick,
-                                                modifier = Modifier.fillMaxWidth(),
-                                                sharedTransitionScope = sharedTransitionScope,
-                                                animatedVisibilityScope = animatedVisibilityScope,
-                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
-                                            )
+                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                                CardModule(
+                                                    books = state.books,
+                                                    onClick = { book, sharedCoverKey ->
+                                                        viewModel.onBookClick(book, sharedCoverKey)
+                                                    },
+                                                    onLongClick = onBookLongClick,
+                                                    modifier = Modifier.fillMaxWidth(),
+                                                    sharedTransitionScope = sharedTransitionScope,
+                                                    animatedVisibilityScope = animatedVisibilityScope,
+                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
+                                                )
+                                            }
                                         }
                                         }
                                     }
                                     }
 
 

+ 405 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/SkeletonPlaceholders.kt

@@ -0,0 +1,405 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+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.aspectRatio
+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.lazy.LazyRow
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.legado.app.domain.model.HomepageModuleType
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.GlassCard
+
+@Composable
+private fun rememberShimmerBrush(): Brush {
+    val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
+    val offset by infiniteTransition.animateFloat(
+        initialValue = -1f,
+        targetValue = 2f,
+        animationSpec = infiniteRepeatable(
+            animation = tween(durationMillis = 1200, easing = LinearEasing),
+            repeatMode = RepeatMode.Restart,
+        ),
+        label = "shimmerOffset",
+    )
+
+    val colorScheme = LegadoTheme.colorScheme
+    val colors = remember(colorScheme) {
+        listOf(
+            colorScheme.surfaceContainerHighest,
+            colorScheme.surfaceContainerHigh,
+            colorScheme.surfaceContainerHighest,
+        )
+    }
+
+    return Brush.linearGradient(
+        colors = colors,
+        start = Offset(offset * 300f, 0f),
+        end = Offset(offset * 300f + 300f, 0f),
+    )
+}
+
+@Composable
+private fun SkeletonBox(
+    modifier: Modifier = Modifier,
+    cornerRadius: Dp = 8.dp,
+) {
+    Box(
+        modifier = modifier
+            .clip(RoundedCornerShape(cornerRadius))
+            .background(rememberShimmerBrush())
+    )
+}
+
+// ── Waterfall skeleton ──
+
+@Composable
+fun WaterfallSkeletonItem(modifier: Modifier = Modifier) {
+    GlassCard(
+        modifier = modifier,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column {
+            SkeletonBox(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(180.dp),
+                cornerRadius = 16.dp,
+            )
+            Column(
+                modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
+            ) {
+                SkeletonBox(
+                    modifier = Modifier
+                        .fillMaxWidth(0.8f)
+                        .height(14.dp),
+                )
+                Spacer(modifier = Modifier.height(6.dp))
+                SkeletonBox(
+                    modifier = Modifier
+                        .fillMaxWidth(0.5f)
+                        .height(10.dp),
+                )
+                Spacer(modifier = Modifier.height(8.dp))
+                SkeletonBox(
+                    modifier = Modifier
+                        .fillMaxWidth(0.6f)
+                        .height(10.dp),
+                )
+                Spacer(modifier = Modifier.height(4.dp))
+                Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+                    SkeletonBox(
+                        modifier = Modifier
+                            .width(40.dp)
+                            .height(18.dp),
+                        cornerRadius = 4.dp,
+                    )
+                    SkeletonBox(
+                        modifier = Modifier
+                            .width(52.dp)
+                            .height(18.dp),
+                        cornerRadius = 4.dp,
+                    )
+                }
+            }
+        }
+    }
+}
+
+// ── Grid skeleton ──
+
+@Composable
+fun GridSkeletonItem(modifier: Modifier = Modifier) {
+    Column(modifier = modifier) {
+        SkeletonBox(
+            modifier = Modifier
+                .fillMaxWidth()
+                .aspectRatio(5f / 7f),
+            cornerRadius = 4.dp,
+        )
+        Spacer(modifier = Modifier.height(4.dp))
+        SkeletonBox(
+            modifier = Modifier
+                .fillMaxWidth(0.8f)
+                .height(12.dp),
+        )
+    }
+}
+
+@Composable
+fun GridSkeletonModule(
+    modifier: Modifier = Modifier,
+    columns: Int = 3,
+    rows: Int = 2,
+) {
+    Column(
+        modifier = modifier,
+        verticalArrangement = Arrangement.spacedBy(4.dp),
+    ) {
+        repeat(rows) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                repeat(columns) {
+                    GridSkeletonItem(modifier = Modifier.weight(1f))
+                }
+            }
+        }
+    }
+}
+
+// ── Banner skeleton ──
+
+@Composable
+fun BannerSkeletonModule(
+    modifier: Modifier = Modifier,
+    itemCount: Int = 6,
+) {
+    LazyRow(
+        modifier = modifier,
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        items(itemCount) {
+            SkeletonBox(
+                modifier = Modifier
+                    .width(96.dp)
+                    .aspectRatio(5f / 7f),
+                cornerRadius = 12.dp,
+            )
+        }
+    }
+}
+
+// ── Card skeleton ──
+
+@Composable
+fun CardSkeletonItem(modifier: Modifier = Modifier) {
+    Column(
+        modifier = modifier
+            .width(120.dp)
+            .clip(RoundedCornerShape(16.dp))
+            .background(LegadoTheme.colorScheme.surfaceContainerLow),
+    ) {
+        SkeletonBox(
+            modifier = Modifier
+                .fillMaxWidth()
+                .aspectRatio(5f / 7f),
+            cornerRadius = 0.dp,
+        )
+        Column(modifier = Modifier.padding(8.dp)) {
+            SkeletonBox(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(14.dp),
+            )
+            Spacer(modifier = Modifier.height(4.dp))
+            SkeletonBox(
+                modifier = Modifier
+                    .fillMaxWidth(0.7f)
+                    .height(14.dp),
+            )
+        }
+    }
+}
+
+@Composable
+fun CardSkeletonModule(
+    modifier: Modifier = Modifier,
+    itemCount: Int = 5,
+) {
+    LazyRow(
+        modifier = modifier,
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        items(itemCount) {
+            CardSkeletonItem()
+        }
+    }
+}
+
+// ── Ranking skeleton ──
+
+@Composable
+fun RankingSkeletonModule(
+    modifier: Modifier = Modifier,
+    rows: Int = 5,
+) {
+    GlassCard(
+        modifier = modifier,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        cornerRadius = 16.dp,
+    ) {
+        Column(modifier = Modifier.padding(top = 12.dp)) {
+            repeat(rows) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(horizontal = 4.dp, vertical = 4.dp),
+                    verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+                ) {
+                    SkeletonBox(
+                        modifier = Modifier
+                            .size(18.dp),
+                    )
+                    Spacer(modifier = Modifier.width(14.dp))
+                    SkeletonBox(
+                        modifier = Modifier
+                            .width(52.dp)
+                            .aspectRatio(5f / 7f),
+                    )
+                    Spacer(modifier = Modifier.width(8.dp))
+                    Column(modifier = Modifier.weight(1f)) {
+                        SkeletonBox(
+                            modifier = Modifier
+                                .fillMaxWidth(0.7f)
+                                .height(14.dp),
+                        )
+                        Spacer(modifier = Modifier.height(4.dp))
+                        SkeletonBox(
+                            modifier = Modifier
+                                .fillMaxWidth(0.4f)
+                                .height(10.dp),
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+// ── GridRanking skeleton ──
+
+@Composable
+fun GridRankingSkeletonModule(
+    modifier: Modifier = Modifier,
+    rows: Int = 4,
+) {
+    GlassCard(
+        modifier = modifier,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        cornerRadius = 20.dp,
+    ) {
+        Column(modifier = Modifier.padding(12.dp)) {
+            repeat(rows) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(vertical = 4.dp),
+                    verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+                ) {
+                    SkeletonBox(
+                        modifier = Modifier
+                            .width(48.dp)
+                            .aspectRatio(5f / 7f),
+                    )
+                    Spacer(modifier = Modifier.width(8.dp))
+                    SkeletonBox(
+                        modifier = Modifier
+                            .size(18.dp),
+                    )
+                    Spacer(modifier = Modifier.width(4.dp))
+                    Column(modifier = Modifier.weight(1f)) {
+                        SkeletonBox(
+                            modifier = Modifier
+                                .fillMaxWidth(0.8f)
+                                .height(14.dp),
+                        )
+                        Spacer(modifier = Modifier.height(4.dp))
+                        SkeletonBox(
+                            modifier = Modifier
+                                .fillMaxWidth(0.5f)
+                                .height(10.dp),
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+// ── Dispatcher ──
+
+@Composable
+fun HomepageModuleSkeleton(
+    type: HomepageModuleType,
+    modifier: Modifier = Modifier,
+    columns: Int = 3,
+    rows: Int = 2,
+) {
+    when (type) {
+        HomepageModuleType.Waterfall -> {
+            Column(
+                modifier = modifier,
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                repeat(2) {
+                    WaterfallSkeletonItem(modifier = Modifier.fillMaxWidth())
+                }
+            }
+        }
+
+        HomepageModuleType.InfiniteGrid -> {
+            GridSkeletonModule(
+                modifier = modifier,
+                columns = columns,
+                rows = rows,
+            )
+        }
+
+        HomepageModuleType.Grid -> {
+            GridSkeletonModule(
+                modifier = modifier,
+                columns = columns,
+                rows = rows,
+            )
+        }
+
+        HomepageModuleType.Banner -> {
+            BannerSkeletonModule(modifier = modifier)
+        }
+
+        HomepageModuleType.Card -> {
+            CardSkeletonModule(modifier = modifier)
+        }
+
+        HomepageModuleType.Ranking -> {
+            RankingSkeletonModule(modifier = modifier)
+        }
+
+        HomepageModuleType.GridRanking -> {
+            GridRankingSkeletonModule(modifier = modifier)
+        }
+
+        HomepageModuleType.ButtonGroup -> {
+            // ButtonGroup loads instantly (no network), no skeleton needed
+        }
+
+        HomepageModuleType.Unknown -> {}
+    }
+}