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)