From 5d7165a61020f720bb84bfbb64c1e983a9830438 Mon Sep 17 00:00:00 2001 From: IgnacioLD Date: Fri, 31 Jul 2026 12:34:44 +0200 Subject: [PATCH] Add shimmer loading state for favorites on Home Favorites previously flashed the empty state then popped in: the stops catalog emits an empty list before its first network fetch, so the favorites list appeared empty then suddenly filled. Show shimmer placeholder cards while favorites are pending instead. Also fixes pull-to-refresh getting stuck: - The polling loop consumed refresh signals without breaking its wait, so arrivals only re-fetched on the 30s cadence. The loop now breaks on signal for an immediate fetch. - AnimatedContent was keyed on the whole state, recreating PullToRefreshBox mid-gesture. Keyed it on the state kind instead. - The spinner now dismisses on a fixed timer instead of waiting on data arrival (which can be deduped and leave it stuck). Removes a double bottom system-bar inset on the nested Home Scaffold that cut into the list. Signed-off-by: IgnacioLD --- .../transitos/core/ui/FavoritesSkeleton.kt | 65 +++++++++++++++++++ .../app/transitos/feature/home/HomeScreen.kt | 18 ++++- .../app/transitos/feature/home/HomeUiState.kt | 6 ++ .../transitos/feature/home/HomeViewModel.kt | 18 ++++- .../metrovalencia/MetrovalenciaRepository.kt | 7 +- 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/core-ui/src/main/kotlin/app/transitos/core/ui/FavoritesSkeleton.kt b/core-ui/src/main/kotlin/app/transitos/core/ui/FavoritesSkeleton.kt index 3451118..9ee69c0 100644 --- a/core-ui/src/main/kotlin/app/transitos/core/ui/FavoritesSkeleton.kt +++ b/core-ui/src/main/kotlin/app/transitos/core/ui/FavoritesSkeleton.kt @@ -3,14 +3,22 @@ package com.glossostudio.transitos.core.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.glossostudio.transitos.core.design.theme.LocalSpacing @@ -57,3 +65,60 @@ fun FavoritesSkeleton( } } } + +/** + * A single favorite-stop card rendered as a shimmering skeleton. Mirrors the + * layout of [FavoriteStopCard] (header + a few arrival rows) so the placeholder + * occupies the same space the real card will, avoiding layout jump when data + * arrives. Used by the Home screen while favorites resolve. + * + * @param rowCount number of arrival-row placeholders to draw. + */ +@Composable +fun FavoriteStopCardSkeleton( + modifier: Modifier = Modifier, + rowCount: Int = 3, +) { + val spacing = LocalSpacing.current + val badgeShape = RoundedCornerShape(36.dp * 0.28f) + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = spacing.lg, vertical = spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + SkeletonBlock(modifier = Modifier.fillMaxWidth(0.5f), height = 22.dp) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(spacing.xs)) + repeat(rowCount) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = spacing.sm, vertical = spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.md), + ) { + SkeletonBlock( + modifier = Modifier.size(36.dp), + height = 36.dp, + shape = badgeShape, + ) + SkeletonBlock( + modifier = Modifier.weight(1f), + height = 18.dp, + ) + SkeletonBlock( + modifier = Modifier.width(48.dp), + height = 28.dp, + ) + } + } + } + } +} diff --git a/feature-home/src/main/kotlin/app/transitos/feature/home/HomeScreen.kt b/feature-home/src/main/kotlin/app/transitos/feature/home/HomeScreen.kt index c5eadf3..ddb69b6 100644 --- a/feature-home/src/main/kotlin/app/transitos/feature/home/HomeScreen.kt +++ b/feature-home/src/main/kotlin/app/transitos/feature/home/HomeScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -59,6 +60,7 @@ import com.glossostudio.transitos.core.ui.AlertsSection import com.glossostudio.transitos.core.ui.EmptyState import com.glossostudio.transitos.core.ui.ErrorState import com.glossostudio.transitos.core.ui.FavoriteStopCard +import com.glossostudio.transitos.core.ui.FavoriteStopCardSkeleton import com.glossostudio.transitos.core.ui.FavoritesSkeleton import com.glossostudio.transitos.core.ui.SectionHeader import com.glossostudio.transitos.core.ui.R as coreUiR @@ -103,6 +105,11 @@ internal fun HomeScreen( onNavigateToSettings = onNavigateToSettings, ) }, + // The host Scaffold (TransitOSApp) already insets content above the + // bottom navigation bar, so this nested Scaffold must not re-apply the + // system bar insets — otherwise the bottom inset is counted twice and + // eats into the list. + contentWindowInsets = WindowInsets(0, 0, 0, 0), modifier = modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), @@ -110,6 +117,7 @@ internal fun HomeScreen( AnimatedContent( targetState = state, transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentKey = { it::class }, label = "home-state", ) { current -> Box(modifier = Modifier.padding(padding)) { @@ -211,7 +219,15 @@ private fun HomeContent( item { SectionHeader(favoritesTitle) } - if (state.favorites.isEmpty()) { + if (state.isLoading) { + items(listOf(0, 1), key = { "skeleton-$it" }) { + FavoriteStopCardSkeleton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = spacing.screenGutter), + ) + } + } else if (state.favorites.isEmpty()) { item { EmptyState( icon = Icons.Outlined.BookmarkAdd, diff --git a/feature-home/src/main/kotlin/app/transitos/feature/home/HomeUiState.kt b/feature-home/src/main/kotlin/app/transitos/feature/home/HomeUiState.kt index 4765cd4..ee1cb76 100644 --- a/feature-home/src/main/kotlin/app/transitos/feature/home/HomeUiState.kt +++ b/feature-home/src/main/kotlin/app/transitos/feature/home/HomeUiState.kt @@ -13,6 +13,12 @@ sealed interface HomeUiState { val alerts: List, val savedRoutes: List = emptyList(), val isRefreshing: Boolean = false, + /** + * True while the user has favorited stops but their arrivals haven't + * resolved yet (catalog/arrivals still loading). Drives the shimmer + * placeholders so favorites don't appear to "pop in". + */ + val isLoading: Boolean = false, ) : HomeUiState data class Error(val error: AppError) : HomeUiState diff --git a/feature-home/src/main/kotlin/app/transitos/feature/home/HomeViewModel.kt b/feature-home/src/main/kotlin/app/transitos/feature/home/HomeViewModel.kt index 5731aa2..b080e9a 100644 --- a/feature-home/src/main/kotlin/app/transitos/feature/home/HomeViewModel.kt +++ b/feature-home/src/main/kotlin/app/transitos/feature/home/HomeViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -67,7 +68,12 @@ class HomeViewModel( ) } arrivalsFlow.map { favs -> - HomeUiState.Ready(favorites = favs, alerts = alerts, savedRoutes = routeInfos) + HomeUiState.Ready( + favorites = favs, + alerts = alerts, + savedRoutes = routeInfos, + isLoading = favs.isEmpty() && favIds.isNotEmpty(), + ) } } @@ -77,6 +83,11 @@ class HomeViewModel( viewModelScope.launch { _isRefreshing.value = true repository.refresh() + // The refresh signal already kicked off an immediate background + // re-fetch. Show the spinner for a brief, fixed beat for feedback + // then dismiss — never waiting on data arrival, which can be + // delayed/deduped and would make the spinner feel stuck. + delay(REFRESH_MIN_MS) _isRefreshing.value = false } } @@ -99,6 +110,11 @@ class HomeViewModel( routeFavorites.renameRoute(routeId, label) } } + + private companion object { + /** How long the pull-to-refresh spinner stays up for feedback. */ + const val REFRESH_MIN_MS = 600L + } } private data class Four( diff --git a/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt b/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt index 233472f..6f42919 100644 --- a/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt +++ b/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt @@ -130,7 +130,10 @@ class MetrovalenciaRepository( while (true) { val remaining = deadline - nowMs() if (remaining <= 0L) break - withTimeoutOrNull(remaining) { arrivalsRefreshSignal.receive() } + // A refresh signal breaks the wait early so the next + // fetch runs immediately instead of waiting for the + // full poll interval. + if (withTimeoutOrNull(remaining) { arrivalsRefreshSignal.receive() } != null) break } } } @@ -238,7 +241,7 @@ class MetrovalenciaRepository( while (true) { val remaining = deadline - nowMs() if (remaining <= 0L) break - withTimeoutOrNull(remaining) { refreshSignal.receive() } + if (withTimeoutOrNull(remaining) { refreshSignal.receive() } != null) break } } else { delay(intervalMs)