Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions core-ui/src/main/kotlin/app/transitos/core/ui/FavoritesSkeleton.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -103,13 +105,19 @@ 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),
) { padding ->
AnimatedContent(
targetState = state,
transitionSpec = { fadeIn() togetherWith fadeOut() },
contentKey = { it::class },
label = "home-state",
) { current ->
Box(modifier = Modifier.padding(padding)) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ sealed interface HomeUiState {
val alerts: List<Alert>,
val savedRoutes: List<SavedRouteInfo> = 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
}
}

Expand All @@ -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
}
}
Expand All @@ -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<T1, T2, T3, T4>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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)
Expand Down
Loading