Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/TransitScreen.kt +263 −205 Original line number Diff line number Diff line Loading @@ -30,6 +30,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize Loading @@ -54,7 +55,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue Loading Loading @@ -115,7 +115,23 @@ fun TransitScreenContent( bottom = 36.dp ) ) { // Departures section DeparturesHeader(viewModel, coroutineScope, isRefreshingDepartures.value) DeparturesContent( didLoadingFail.value, isLoading.value, departures, onRouteClicked, use24HourFormat ) } } @Composable private fun DeparturesHeader( viewModel: TransitScreenViewModel, coroutineScope: kotlinx.coroutines.CoroutineScope, isRefreshing: Boolean, ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Loading @@ -129,7 +145,7 @@ fun TransitScreenContent( ) // Refresh button for departures IconButton(onClick = { coroutineScope.launch { viewModel.refreshData() } }) { if (isRefreshingDepartures.value) { if (isRefreshing) { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) Loading @@ -141,15 +157,24 @@ fun TransitScreenContent( } } } } if (didLoadingFail.value) { @Composable private fun DeparturesContent( didLoadingFail: Boolean, isLoading: Boolean, departures: List<StopTime>, onRouteClicked: (Place) -> Unit, use24HourFormat: Boolean, ) { if (didLoadingFail) { Text( modifier = Modifier .fillMaxWidth() .padding(16.dp), text = stringResource(string.failed_to_load_departures) ) } else if (isLoading.value && departures.isEmpty()) { } else if (isLoading && departures.isEmpty()) { Text( modifier = Modifier .fillMaxWidth() Loading Loading @@ -177,8 +202,6 @@ fun TransitScreenContent( ) } } } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable Loading @@ -187,18 +210,33 @@ fun TransitScreenRouteDepartures( ) { // Group departures by route name val departuresByRoute = stopTimes.groupBy { it.routeShortName } val transitStopString = stringResource(string.transit_stop) departuresByRoute.forEach { (routeName, departures) -> // Group departures by headsign within each route val departuresByHeadsign = departures.groupBy { it.headsign }.map { (key, value) -> (key to value.take(1)) }.toMap() val headsigns = departuresByHeadsign.keys.toList().sorted() val (departuresByHeadsign, headsigns, places) = prepareHeadsignData( departures, transitStopString ) TransitRouteItem( routeName, departures, departuresByHeadsign, headsigns, places, onRouteClicked, use24HourFormat ) } } val transitStopString = stringResource(string.transit_stop) val places = remember(departures) { departuresByHeadsign.map { (key, value) -> key to value.firstOrNull()?.let { departure -> private fun prepareHeadsignData( departures: List<StopTime>, transitStopString: String ): Triple<Map<String, List<StopTime>>, List<String>, Map<String, Place?>> { val departuresByHeadsign = departures.groupBy { it.headsign }.mapValues { it.value.take(1) } val headsigns = departuresByHeadsign.keys.sorted() val places = departuresByHeadsign.mapValues { (key, value) -> value.firstOrNull()?.let { departure -> Place( name = departure.place.name, description = transitStopString, Loading @@ -207,10 +245,23 @@ fun TransitScreenRouteDepartures( transitStopId = departure.place.stopId, ) } }.toMap() } return Triple(departuresByHeadsign, headsigns, places) } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable private fun TransitRouteItem( routeName: String, departures: List<StopTime>, departuresByHeadsign: Map<String, List<StopTime>>, headsigns: List<String>, places: Map<String, Place?>, onRouteClicked: (Place) -> Unit, use24HourFormat: Boolean, ) { val pagerState = rememberPagerState(pageCount = { headsigns.size }) Box( modifier = Modifier .fillMaxWidth() Loading @@ -223,24 +274,7 @@ fun TransitScreenRouteDepartures( Card(modifier = Modifier.padding(bottom = dimensionResource(dimen.padding_minor))) { Box { // Route name at top Row( modifier = Modifier.padding(dimensionResource(dimen.padding_minor)) ) { val routeColor = departures.firstOrNull()?.parseRouteColor() ?: MaterialTheme.colorScheme.surfaceVariant Text( text = stringResource(string.square_char), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = routeColor ) Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor))) Text( text = routeName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold ) } RouteNameHeader(routeName, departures) Column(modifier = Modifier.fillMaxWidth()) { // Page indicator Loading @@ -255,10 +289,44 @@ fun TransitScreenRouteDepartures( val departuresForHeadsign = departuresByHeadsign[selectedHeadsign] ?: emptyList() val soonestDeparture = departuresForHeadsign.minByOrNull { val dep = it.place.departure ?: it.place.scheduledDeparture dep ?: "" it.place.departure ?: it.place.scheduledDeparture ?: "" } ?: return@HorizontalPager DepartureItem(selectedHeadsign, soonestDeparture, use24HourFormat) } } } } } } @Composable private fun RouteNameHeader(routeName: String, departures: List<StopTime>) { Row(modifier = Modifier.padding(dimensionResource(dimen.padding_minor))) { val routeColor = departures.firstOrNull()?.parseRouteColor() ?: MaterialTheme.colorScheme.surfaceVariant Text( text = stringResource(string.square_char), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = routeColor ) Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor))) Text( text = routeName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold ) } } @OptIn(ExperimentalTime::class) @Composable private fun DepartureItem( selectedHeadsign: String, soonestDeparture: StopTime, use24HourFormat: Boolean ) { Row( modifier = Modifier .fillMaxWidth() Loading Loading @@ -287,36 +355,33 @@ fun TransitScreenRouteDepartures( ) } // Right side: departure time val bestDepartureTime = formatDepartureTime( soonestDeparture, use24HourFormat = use24HourFormat ) DepartureTimeDisplay(soonestDeparture, use24HourFormat) } } @Composable private fun RowScope.DepartureTimeDisplay(soonestDeparture: StopTime, use24HourFormat: Boolean) { val bestDepartureTime = formatDepartureTime(soonestDeparture, use24HourFormat = use24HourFormat) val containerContent = @Composable { Row( modifier = Modifier.padding( dimensionResource(dimen.padding), ), modifier = Modifier.padding(dimensionResource(dimen.padding)), verticalAlignment = Alignment.CenterVertically ) { if (soonestDeparture.realTime) { val infiniteTransition = rememberInfiniteTransition(label = "alpha animation") val infiniteTransition = rememberInfiniteTransition(label = "alpha animation") val animatedAlpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween( durationMillis = 750, easing = LinearEasing ), repeatMode = RepeatMode.Reverse animation = tween(durationMillis = 750, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "alpha" ) Text( modifier = Modifier.padding(end = 4.dp), text = stringResource(string.live_indicator_short), color = MaterialTheme.colorScheme.onSurface.copy( alpha = animatedAlpha ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = animatedAlpha), style = MaterialTheme.typography.headlineSmall ) } Loading @@ -340,10 +405,3 @@ fun TransitScreenRouteDepartures( containerContent() } } } } } } } } } Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/TransitScreen.kt +263 −205 Original line number Diff line number Diff line Loading @@ -30,6 +30,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize Loading @@ -54,7 +55,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue Loading Loading @@ -115,7 +115,23 @@ fun TransitScreenContent( bottom = 36.dp ) ) { // Departures section DeparturesHeader(viewModel, coroutineScope, isRefreshingDepartures.value) DeparturesContent( didLoadingFail.value, isLoading.value, departures, onRouteClicked, use24HourFormat ) } } @Composable private fun DeparturesHeader( viewModel: TransitScreenViewModel, coroutineScope: kotlinx.coroutines.CoroutineScope, isRefreshing: Boolean, ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Loading @@ -129,7 +145,7 @@ fun TransitScreenContent( ) // Refresh button for departures IconButton(onClick = { coroutineScope.launch { viewModel.refreshData() } }) { if (isRefreshingDepartures.value) { if (isRefreshing) { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) Loading @@ -141,15 +157,24 @@ fun TransitScreenContent( } } } } if (didLoadingFail.value) { @Composable private fun DeparturesContent( didLoadingFail: Boolean, isLoading: Boolean, departures: List<StopTime>, onRouteClicked: (Place) -> Unit, use24HourFormat: Boolean, ) { if (didLoadingFail) { Text( modifier = Modifier .fillMaxWidth() .padding(16.dp), text = stringResource(string.failed_to_load_departures) ) } else if (isLoading.value && departures.isEmpty()) { } else if (isLoading && departures.isEmpty()) { Text( modifier = Modifier .fillMaxWidth() Loading Loading @@ -177,8 +202,6 @@ fun TransitScreenContent( ) } } } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable Loading @@ -187,18 +210,33 @@ fun TransitScreenRouteDepartures( ) { // Group departures by route name val departuresByRoute = stopTimes.groupBy { it.routeShortName } val transitStopString = stringResource(string.transit_stop) departuresByRoute.forEach { (routeName, departures) -> // Group departures by headsign within each route val departuresByHeadsign = departures.groupBy { it.headsign }.map { (key, value) -> (key to value.take(1)) }.toMap() val headsigns = departuresByHeadsign.keys.toList().sorted() val (departuresByHeadsign, headsigns, places) = prepareHeadsignData( departures, transitStopString ) TransitRouteItem( routeName, departures, departuresByHeadsign, headsigns, places, onRouteClicked, use24HourFormat ) } } val transitStopString = stringResource(string.transit_stop) val places = remember(departures) { departuresByHeadsign.map { (key, value) -> key to value.firstOrNull()?.let { departure -> private fun prepareHeadsignData( departures: List<StopTime>, transitStopString: String ): Triple<Map<String, List<StopTime>>, List<String>, Map<String, Place?>> { val departuresByHeadsign = departures.groupBy { it.headsign }.mapValues { it.value.take(1) } val headsigns = departuresByHeadsign.keys.sorted() val places = departuresByHeadsign.mapValues { (key, value) -> value.firstOrNull()?.let { departure -> Place( name = departure.place.name, description = transitStopString, Loading @@ -207,10 +245,23 @@ fun TransitScreenRouteDepartures( transitStopId = departure.place.stopId, ) } }.toMap() } return Triple(departuresByHeadsign, headsigns, places) } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable private fun TransitRouteItem( routeName: String, departures: List<StopTime>, departuresByHeadsign: Map<String, List<StopTime>>, headsigns: List<String>, places: Map<String, Place?>, onRouteClicked: (Place) -> Unit, use24HourFormat: Boolean, ) { val pagerState = rememberPagerState(pageCount = { headsigns.size }) Box( modifier = Modifier .fillMaxWidth() Loading @@ -223,24 +274,7 @@ fun TransitScreenRouteDepartures( Card(modifier = Modifier.padding(bottom = dimensionResource(dimen.padding_minor))) { Box { // Route name at top Row( modifier = Modifier.padding(dimensionResource(dimen.padding_minor)) ) { val routeColor = departures.firstOrNull()?.parseRouteColor() ?: MaterialTheme.colorScheme.surfaceVariant Text( text = stringResource(string.square_char), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = routeColor ) Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor))) Text( text = routeName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold ) } RouteNameHeader(routeName, departures) Column(modifier = Modifier.fillMaxWidth()) { // Page indicator Loading @@ -255,10 +289,44 @@ fun TransitScreenRouteDepartures( val departuresForHeadsign = departuresByHeadsign[selectedHeadsign] ?: emptyList() val soonestDeparture = departuresForHeadsign.minByOrNull { val dep = it.place.departure ?: it.place.scheduledDeparture dep ?: "" it.place.departure ?: it.place.scheduledDeparture ?: "" } ?: return@HorizontalPager DepartureItem(selectedHeadsign, soonestDeparture, use24HourFormat) } } } } } } @Composable private fun RouteNameHeader(routeName: String, departures: List<StopTime>) { Row(modifier = Modifier.padding(dimensionResource(dimen.padding_minor))) { val routeColor = departures.firstOrNull()?.parseRouteColor() ?: MaterialTheme.colorScheme.surfaceVariant Text( text = stringResource(string.square_char), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = routeColor ) Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor))) Text( text = routeName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold ) } } @OptIn(ExperimentalTime::class) @Composable private fun DepartureItem( selectedHeadsign: String, soonestDeparture: StopTime, use24HourFormat: Boolean ) { Row( modifier = Modifier .fillMaxWidth() Loading Loading @@ -287,36 +355,33 @@ fun TransitScreenRouteDepartures( ) } // Right side: departure time val bestDepartureTime = formatDepartureTime( soonestDeparture, use24HourFormat = use24HourFormat ) DepartureTimeDisplay(soonestDeparture, use24HourFormat) } } @Composable private fun RowScope.DepartureTimeDisplay(soonestDeparture: StopTime, use24HourFormat: Boolean) { val bestDepartureTime = formatDepartureTime(soonestDeparture, use24HourFormat = use24HourFormat) val containerContent = @Composable { Row( modifier = Modifier.padding( dimensionResource(dimen.padding), ), modifier = Modifier.padding(dimensionResource(dimen.padding)), verticalAlignment = Alignment.CenterVertically ) { if (soonestDeparture.realTime) { val infiniteTransition = rememberInfiniteTransition(label = "alpha animation") val infiniteTransition = rememberInfiniteTransition(label = "alpha animation") val animatedAlpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween( durationMillis = 750, easing = LinearEasing ), repeatMode = RepeatMode.Reverse animation = tween(durationMillis = 750, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "alpha" ) Text( modifier = Modifier.padding(end = 4.dp), text = stringResource(string.live_indicator_short), color = MaterialTheme.colorScheme.onSurface.copy( alpha = animatedAlpha ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = animatedAlpha), style = MaterialTheme.typography.headlineSmall ) } Loading @@ -340,10 +405,3 @@ fun TransitScreenRouteDepartures( containerContent() } } } } } } } } }