From eec6d95a4cccf60647026898325ce77671357ce9 Mon Sep 17 00:00:00 2001 From: bryantran24 <158430748+bryantran24@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:09:24 -0500 Subject: [PATCH 01/13] Stop etas ui --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 197 +++++++++++++----- .../shuttletracker/ui/maps/MapsViewModel.kt | 4 +- .../shuttletracker/ui/maps/StopEtaContent.kt | 191 +++++++++++++++++ 3 files changed, 334 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 2a07c8a..6773b59 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -12,32 +12,33 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.LocationDisabled import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetValue +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -62,6 +63,7 @@ import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.MapStyleOptions +import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.Circle import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapProperties @@ -79,6 +81,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation +import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta import edu.rpi.shuttletracker.ui.theme.VehicleColors import edu.rpi.shuttletracker.ui.util.CheckResponseError import kotlinx.coroutines.launch @@ -91,33 +94,51 @@ fun MapsScreen( viewModel: MapsViewModel = hiltViewModel(), ) { val mapsUiState = viewModel.mapsUiState.collectAsStateWithLifecycle().value - val snackbarHostState = remember { SnackbarHostState() } - val sheetState = - rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded, - ) - val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) + var selectedStopKey by remember { mutableStateOf(null) } + var selectedStop by remember { mutableStateOf(null) } + var selectedVehicleId by remember { mutableStateOf(null) } - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = 160.dp, - sheetDragHandle = { BottomSheetDefaults.DragHandle() }, - sheetContainerColor = MaterialTheme.colorScheme.surface, - sheetShadowElevation = 10.dp, - sheetContent = { - val showDetails by remember(scaffoldState.bottomSheetState) { - derivedStateOf { - scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded || - scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded - } - } + val cameraPositionState = + rememberCameraPositionState { + position = + CameraPosition.fromLatLngZoom( + LatLng(42.73068146020498, -73.67619731950525), + 14.3f, + ) + } - ScheduleSheetContent( - schedule = mapsUiState.schedule, - showDetails = showDetails, - ) + val scope = rememberCoroutineScope() + + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = true, + onClick = { /* already on home/map */ }, + icon = { Icon(Icons.Outlined.Home, contentDescription = null) }, + label = { Text("Home") }, + ) + NavigationBarItem( + selected = false, + onClick = { /* TODO open ModalBottomSheet later */ }, + icon = { Icon(Icons.Outlined.StopCircle, contentDescription = null) }, + label = { Text("Stops") }, + ) + NavigationBarItem( + selected = false, + onClick = { navigator.navigate(ScheduleScreenDestination()) }, + icon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, + label = { Text("Schedule") }, + ) + NavigationBarItem( + selected = false, + onClick = { navigator.navigate(SettingsScreenDestination()) }, + icon = { Icon(Icons.Outlined.Settings, contentDescription = null) }, + label = { Text("Settings") }, + ) + } }, snackbarHost = { // finds errors when requesting data to server @@ -131,14 +152,56 @@ fun MapsScreen( SnackbarHost(hostState = snackbarHostState) }, ) { padding -> + Box(Modifier.fillMaxSize()) { + ShuttleMap( + mapsUiState = mapsUiState, + padding = padding, + onScheduleClick = { navigator.navigate(ScheduleScreenDestination()) }, + onSettingsClick = { navigator.navigate(SettingsScreenDestination()) }, + onToggleMapTypeClick = { viewModel.toggleMapType() }, + cameraPositionState = cameraPositionState, + selectedStopKey = selectedStopKey, + selectedStop = selectedStop, + onStopSelected = { stopKey, stop -> + selectedStopKey = stopKey + selectedStop = stop + }, + ) - ShuttleMap( - mapsUiState = mapsUiState, - padding = padding, - onScheduleClick = { navigator.navigate(ScheduleScreenDestination()) }, - onSettingsClick = { navigator.navigate(SettingsScreenDestination()) }, - onToggleMapTypeClick = { viewModel.toggleMapType() }, - ) + EtaOverlayCard( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(padding) + .padding(horizontal = 12.dp, vertical = 10.dp), + selectedStopKey = selectedStopKey, + selectedStop = selectedStop, + vehicleStopEtas = mapsUiState.vehicleStopEtas, + vehicleLocations = mapsUiState.vehicleLocations, + onClearStop = { + selectedStopKey = null + selectedStop = null + selectedVehicleId = null + }, + onEtaChipClick = { vehicleId -> + selectedVehicleId = vehicleId + val loc = mapsUiState.vehicleLocations[vehicleId] ?: return@EtaOverlayCard + scope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + CameraPosition + .Builder() + .target(loc.latLng()) + .zoom(maxOf(cameraPositionState.position.zoom, 16f)) + .tilt(0f) + .build(), + ), + 700, + ) + } + }, + ) + } } } @@ -160,6 +223,10 @@ private fun ShuttleMap( onScheduleClick: () -> Unit, onSettingsClick: () -> Unit, onToggleMapTypeClick: () -> Unit, + cameraPositionState: CameraPositionState, + selectedStopKey: String?, + selectedStop: Stop?, + onStopSelected: (stopKey: String, stop: Stop) -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -174,17 +241,6 @@ private fun ShuttleMap( ) } - // keeps track of where the camera currently is - val cameraPositionState = - rememberCameraPositionState { - position = - CameraPosition.fromLatLngZoom( - LatLng(42.73068146020498, -73.67619731950525), - 14.3f, - ) - } - - var selectedStop by remember { mutableStateOf(null) } val isDark = mapsUiState.themeMode.isDarkTheme(isSystemInDarkTheme()) Box(modifier = Modifier.fillMaxSize()) { @@ -220,11 +276,11 @@ private fun ShuttleMap( ) { // creates the stops mapsUiState.routes.forEach { (_, route) -> - route.stopDetails.forEach { (_, stop) -> + route.stopDetails.forEach { (stopKey, stop) -> StopMarker( stop = stop, - selected = stop.name == selectedStop?.name, - onSelected = { selectedStop = it }, + selected = stopKey == selectedStopKey || stop.name == selectedStop?.name, + onSelected = { onStopSelected(stopKey, stop) }, ) } } @@ -264,7 +320,6 @@ private fun ShuttleMap( MapButtonsOverlay( modifier = Modifier - .statusBarsPadding() .padding(padding) .padding(horizontal = 10.dp), isMyLocationEnabled = isLocationPermissionGranted, @@ -415,10 +470,10 @@ private fun MapButtonsOverlay( // ActionButton(icon = Icons.Outlined.Schedule) { // onScheduleClick() // } - - ActionButton(icon = Icons.Outlined.Settings) { - onSettingsClick() - } +// +// ActionButton(icon = Icons.Outlined.Settings) { +// onSettingsClick() +// } } // Right side Column( @@ -483,3 +538,33 @@ private fun ActionButton( } } } + +@Composable +fun EtaOverlayCard( + modifier: Modifier = Modifier, + selectedStopKey: String?, + selectedStop: Stop?, + vehicleStopEtas: Map, + vehicleLocations: Map, + onClearStop: () -> Unit, + onEtaChipClick: (vehicleId: String) -> Unit, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(18.dp), + tonalElevation = 2.dp, + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surface, + ) { + StopEtaContent( + modifier = Modifier.padding(vertical = 2.dp), + selectedStopKey = selectedStopKey, + selectedStop = selectedStop, + vehicleStopEtas = vehicleStopEtas, + vehicleLocations = vehicleLocations, + showDetails = false, + onClearStop = onClearStop, + onEtaChipClick = onEtaChipClick, + ) + } +} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt index 5dff16e..eedc96e 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt @@ -38,7 +38,7 @@ class MapsViewModel init { loadAll() observeVehicleLocations() -// observeVehicleEtas() + observeVehicleEtas() loadPreferences() } @@ -80,7 +80,7 @@ class MapsViewModel private fun observeVehicleEtas() { apiRepository - .observeVehicleEtas(pollMs = 30_000L) + .observeVehicleEtas(pollMs = 5_000L) .flowOn(Dispatchers.IO) .onEach { response -> readApiResponse(response) { etas -> diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt new file mode 100644 index 0000000..37d8cad --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt @@ -0,0 +1,191 @@ +package edu.rpi.shuttletracker.ui.maps + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import edu.rpi.shuttletracker.data.models.Stop +import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation +import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime + +@Composable +fun StopEtaContent( + modifier: Modifier = Modifier, + selectedStopKey: String?, + selectedStop: Stop?, + vehicleStopEtas: Map, + vehicleLocations: Map, + showDetails: Boolean, + onClearStop: () -> Unit, + onEtaChipClick: (vehicleId: String) -> Unit, +) { + val stopTitle = selectedStop?.name ?: "Tap a stop to see etas" + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + StopEtaHeader(stopTitle, onClearStop) + + if (selectedStopKey == null) { + return + } + + val etas = + remember(selectedStopKey, vehicleStopEtas, vehicleLocations) { + buildVehicleEtas( + selectedStopKey, + vehicleStopEtas, + vehicleLocations, + ) + } + + val now = Instant.now() + + val visibleEtas = + etas + .filter { Duration.between(now, it.etaInstant).toMinutes() >= -5 } + .take(if (showDetails) 8 else 3) + + if (visibleEtas.isEmpty()) { + EmptyState("No ETAs found") + return + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items(visibleEtas, key = { it.vehicleId }) { eta -> + EtaChip( + eta = eta, + onClick = { onEtaChipClick(eta.vehicleId) }, + ) + } + } + + Text( + text = "Note: ETAs may be off by a few minutes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp), + ) + } +} + +@Composable +private fun StopEtaHeader( + title: String, + onClearStop: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + + Box( + modifier = + Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(50), + ).clickable { onClearStop() }, + contentAlignment = Alignment.Center, + ) { + Text("✕") + } + } +} + +@Composable +private fun EmptyState(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp), + ) +} + +@Composable +private fun EtaChip( + eta: VehicleEta, + onClick: () -> Unit, +) { + val now = Instant.now() + val mins = Duration.between(now, eta.etaInstant).toMinutes() + + val etaText = + when { + mins <= 0 -> "now" + else -> "${mins}m" + } + + Text( + text = "Shuttle ${eta.vehicleLabel} • $etaText", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(999.dp), + ).clickable { onClick() } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) +} + +// Data + +private data class VehicleEta( + val vehicleId: String, + val vehicleLabel: String, + val etaInstant: Instant, +) + +private fun buildVehicleEtas( + stopKey: String, + vehicleStopEtas: Map, + vehicleLocations: Map, +): List = + vehicleStopEtas + .mapNotNull { (vehicleId, etaData) -> + val rawTime = etaData.stopTimes[stopKey] ?: return@mapNotNull null + val instant = rawTime.toInstantOrNull() ?: return@mapNotNull null + + val vehicle = vehicleLocations[vehicleId] + + VehicleEta( + vehicleId = vehicleId, + vehicleLabel = vehicle?.name.orEmpty(), + etaInstant = instant, + ) + }.sortedBy { it.etaInstant } + +private fun String.toInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() From f99bf395cca33a016659c495aa342ecd309cbb93 Mon Sep 17 00:00:00 2001 From: gujars Date: Thu, 26 Feb 2026 18:44:27 -0500 Subject: [PATCH 02/13] change layout edit the bottom bar, hide the x when stop not selected --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 19 +- .../shuttletracker/ui/maps/StopEtaContent.kt | 30 +- build/reports/problems/problems-report.html | 663 ++++++++++++++++++ 3 files changed, 682 insertions(+), 30 deletions(-) create mode 100644 build/reports/problems/problems-report.html diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 6773b59..2d7ad13 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Layers -import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.LocationDisabled import androidx.compose.material.icons.outlined.MyLocation @@ -114,12 +113,6 @@ fun MapsScreen( Scaffold( bottomBar = { NavigationBar { - NavigationBarItem( - selected = true, - onClick = { /* already on home/map */ }, - icon = { Icon(Icons.Outlined.Home, contentDescription = null) }, - label = { Text("Home") }, - ) NavigationBarItem( selected = false, onClick = { /* TODO open ModalBottomSheet later */ }, @@ -132,12 +125,6 @@ fun MapsScreen( icon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, label = { Text("Schedule") }, ) - NavigationBarItem( - selected = false, - onClick = { navigator.navigate(SettingsScreenDestination()) }, - icon = { Icon(Icons.Outlined.Settings, contentDescription = null) }, - label = { Text("Settings") }, - ) } }, snackbarHost = { @@ -471,9 +458,9 @@ private fun MapButtonsOverlay( // onScheduleClick() // } // -// ActionButton(icon = Icons.Outlined.Settings) { -// onSettingsClick() -// } + ActionButton(icon = Icons.Outlined.Settings) { + onSettingsClick() + } } // Right side Column( diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt index 37d8cad..3014abe 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt @@ -44,7 +44,7 @@ fun StopEtaContent( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - StopEtaHeader(stopTitle, onClearStop) + StopEtaHeader(stopTitle, onClearStop, selectedStop) if (selectedStopKey == null) { return @@ -70,7 +70,6 @@ fun StopEtaContent( EmptyState("No ETAs found") return } - LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp), @@ -98,6 +97,7 @@ fun StopEtaContent( private fun StopEtaHeader( title: String, onClearStop: () -> Unit, + stopSelected: Stop?, ) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), @@ -108,18 +108,20 @@ private fun StopEtaHeader( text = title, style = MaterialTheme.typography.titleMedium, ) - - Box( - modifier = - Modifier - .size(28.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(50), - ).clickable { onClearStop() }, - contentAlignment = Alignment.Center, - ) { - Text("✕") + // show exit "x" if stop selected + if (stopSelected != null) { + Box( + modifier = + Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(50), + ).clickable { onClearStop() }, + contentAlignment = Alignment.Center, + ) { + Text("✕") + } } } } diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html new file mode 100644 index 0000000..8b49f86 --- /dev/null +++ b/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + From 2db521acdd97c6ce52b1ce21e6e172af012d9f91 Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Thu, 26 Feb 2026 20:15:25 -0500 Subject: [PATCH 03/13] Ignore Gradle problem reports --- .gitignore | 1 + build/reports/problems/problems-report.html | 663 -------------------- 2 files changed, 1 insertion(+), 663 deletions(-) delete mode 100644 build/reports/problems/problems-report.html diff --git a/.gitignore b/.gitignore index 6efb2fe..2f2303c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ google_maps_api.xml /build/intermediates/lint-cache/maven.google/com/google/.DS_Store /app/src/main/res/values/.DS_Store /build/intermediates/lint-cache/maven.google/com/google/android/.DS_Store +/build/reports/ diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html deleted file mode 100644 index 8b49f86..0000000 --- a/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - From 56495f3aa5f85cdd18e8b9e4eb63b6e85b8b729a Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Fri, 27 Feb 2026 00:11:34 -0500 Subject: [PATCH 04/13] Consistent height with eta and not --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 1 - .../shuttletracker/ui/maps/StopEtaContent.kt | 54 +++++++++---------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 2d7ad13..89693cc 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -549,7 +549,6 @@ fun EtaOverlayCard( selectedStop = selectedStop, vehicleStopEtas = vehicleStopEtas, vehicleLocations = vehicleLocations, - showDetails = false, onClearStop = onClearStop, onEtaChipClick = onEtaChipClick, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt index 3014abe..29c906b 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt @@ -34,14 +34,13 @@ fun StopEtaContent( selectedStop: Stop?, vehicleStopEtas: Map, vehicleLocations: Map, - showDetails: Boolean, onClearStop: () -> Unit, onEtaChipClick: (vehicleId: String) -> Unit, ) { val stopTitle = selectedStop?.name ?: "Tap a stop to see etas" Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { StopEtaHeader(stopTitle, onClearStop, selectedStop) @@ -64,23 +63,31 @@ fun StopEtaContent( val visibleEtas = etas .filter { Duration.between(now, it.etaInstant).toMinutes() >= -5 } - .take(if (showDetails) 8 else 3) if (visibleEtas.isEmpty()) { - EmptyState("No ETAs found") - return - } - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items(visibleEtas, key = { it.vehicleId }) { eta -> - EtaChip( - eta = eta, - onClick = { onEtaChipClick(eta.vehicleId) }, - ) + Text( + text = "No ETAs found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } else { + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items(visibleEtas, key = { it.vehicleId }) { eta -> + EtaChip( + eta = eta, + now = now, + onClick = { onEtaChipClick(eta.vehicleId) }, + ) + } } } @@ -126,24 +133,13 @@ private fun StopEtaHeader( } } -@Composable -private fun EmptyState(text: String) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 6.dp), - ) -} - @Composable private fun EtaChip( eta: VehicleEta, + now: Instant, onClick: () -> Unit, ) { - val now = Instant.now() val mins = Duration.between(now, eta.etaInstant).toMinutes() - val etaText = when { mins <= 0 -> "now" From bd8b199fa97f5a52e693cd9cc32ac6f19e8efd79 Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Fri, 27 Feb 2026 12:29:07 -0500 Subject: [PATCH 05/13] Add stops sheet --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 44 ++- .../shuttletracker/ui/maps/MapsViewModel.kt | 7 +- .../shuttletracker/ui/maps/StopEtaContent.kt | 75 ++++- .../ui/maps/StopsSheetContent.kt | 302 ++++++++++++++++++ 4 files changed, 413 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 89693cc..1771f8b 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -36,12 +37,14 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -84,6 +87,7 @@ import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta import edu.rpi.shuttletracker.ui.theme.VehicleColors import edu.rpi.shuttletracker.ui.util.CheckResponseError import kotlinx.coroutines.launch +import java.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Destination(start = true) @@ -99,6 +103,9 @@ fun MapsScreen( var selectedStop by remember { mutableStateOf(null) } var selectedVehicleId by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showStopsSheet by rememberSaveable { mutableStateOf(false) } + val cameraPositionState = rememberCameraPositionState { position = @@ -115,7 +122,7 @@ fun MapsScreen( NavigationBar { NavigationBarItem( selected = false, - onClick = { /* TODO open ModalBottomSheet later */ }, + onClick = { showStopsSheet = true }, icon = { Icon(Icons.Outlined.StopCircle, contentDescription = null) }, label = { Text("Stops") }, ) @@ -165,6 +172,7 @@ fun MapsScreen( selectedStop = selectedStop, vehicleStopEtas = mapsUiState.vehicleStopEtas, vehicleLocations = mapsUiState.vehicleLocations, + lastEtasUpdatedAt = mapsUiState.lastEtasUpdatedAt, onClearStop = { selectedStopKey = null selectedStop = null @@ -188,6 +196,38 @@ fun MapsScreen( } }, ) + + if (showStopsSheet) { + ModalBottomSheet( + onDismissRequest = { showStopsSheet = false }, + sheetState = sheetState, + ) { + StopSheetContent( + routes = mapsUiState.routes, + vehicleStopEtas = mapsUiState.vehicleStopEtas, + showDetails = true, + onStopClick = { stopKey, stop -> + selectedStopKey = stopKey + selectedStop = stop + selectedVehicleId = null + showStopsSheet = false + scope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + CameraPosition + .Builder() + .target(stop.latLng()) + .zoom(maxOf(cameraPositionState.position.zoom, 16f)) + .tilt(0f) + .build(), + ), + 1000, + ) + } + }, + ) + } + } } } } @@ -533,6 +573,7 @@ fun EtaOverlayCard( selectedStop: Stop?, vehicleStopEtas: Map, vehicleLocations: Map, + lastEtasUpdatedAt: Instant?, onClearStop: () -> Unit, onEtaChipClick: (vehicleId: String) -> Unit, ) { @@ -549,6 +590,7 @@ fun EtaOverlayCard( selectedStop = selectedStop, vehicleStopEtas = vehicleStopEtas, vehicleLocations = vehicleLocations, + lastEtasUpdatedAt = lastEtasUpdatedAt, onClearStop = onClearStop, onEtaChipClick = onEtaChipClick, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt index eedc96e..43a099a 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.Instant import javax.inject.Inject @HiltViewModel @@ -85,7 +86,10 @@ class MapsViewModel .onEach { response -> readApiResponse(response) { etas -> _mapsUiState.update { - it.copy(vehicleStopEtas = etas) + it.copy( + vehicleStopEtas = etas, + lastEtasUpdatedAt = Instant.now(), + ) } } }.launchIn(viewModelScope) @@ -198,4 +202,5 @@ data class MapsUiState( val totalAnnouncements: Int = -1, val themeMode: ThemeMode = ThemeMode.System, val mapType: MapType = MapType.NORMAL, + val lastEtasUpdatedAt: Instant? = null, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt index 29c906b..5b67588 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt @@ -20,12 +20,17 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import java.time.Duration import java.time.Instant import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit @Composable fun StopEtaContent( @@ -34,6 +39,7 @@ fun StopEtaContent( selectedStop: Stop?, vehicleStopEtas: Map, vehicleLocations: Map, + lastEtasUpdatedAt: Instant?, onClearStop: () -> Unit, onEtaChipClick: (vehicleId: String) -> Unit, ) { @@ -43,7 +49,7 @@ fun StopEtaContent( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - StopEtaHeader(stopTitle, onClearStop, selectedStop) + StopEtaHeader(stopTitle, onClearStop, selectedStop, lastEtasUpdatedAt) if (selectedStopKey == null) { return @@ -64,7 +70,7 @@ fun StopEtaContent( etas .filter { Duration.between(now, it.etaInstant).toMinutes() >= -5 } - if (visibleEtas.isEmpty()) { + if (visibleEtas.isEmpty() || stopTitle == "Blitman") { Text( text = "No ETAs found", style = MaterialTheme.typography.bodyMedium, @@ -105,7 +111,13 @@ private fun StopEtaHeader( title: String, onClearStop: () -> Unit, stopSelected: Stop?, + lastEtasUpdatedAt: Instant?, ) { + val updatedText = + updatedAgoFlow(lastEtasUpdatedAt) + .collectAsStateWithLifecycle(initialValue = "") + .value + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -115,19 +127,32 @@ private fun StopEtaHeader( text = title, style = MaterialTheme.typography.titleMedium, ) - // show exit "x" if stop selected + if (stopSelected != null) { - Box( - modifier = - Modifier - .size(28.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(50), - ).clickable { onClearStop() }, - contentAlignment = Alignment.Center, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text("✕") + if (updatedText.isNotBlank()) { + Text( + text = updatedText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Box( + modifier = + Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(50), + ).clickable { onClearStop() }, + contentAlignment = Alignment.Center, + ) { + Text("✕") + } } } } @@ -187,3 +212,27 @@ private fun buildVehicleEtas( }.sortedBy { it.etaInstant } private fun String.toInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() + +fun updatedAgoFlow(lastUpdatedAt: Instant?): Flow = + flow { + if (lastUpdatedAt == null) { + emit("") + return@flow + } + + while (true) { + val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val duration = Duration.between(lastUpdatedAt, now) + + val secs = duration.seconds.coerceAtLeast(0) + val text = + when { + secs < 5 -> "" + secs < 60 -> "Updated ${secs}s ago" + else -> "Updated ${secs / 60}m ago" + } + + emit(text) + delay(1000) + } + } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt new file mode 100644 index 0000000..75c95ec --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt @@ -0,0 +1,302 @@ +package edu.rpi.shuttletracker.ui.maps + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import edu.rpi.shuttletracker.R +import edu.rpi.shuttletracker.data.models.Route +import edu.rpi.shuttletracker.data.models.Stop +import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime + +@Composable +fun StopSheetContent( + routes: Map, + vehicleStopEtas: Map, + showDetails: Boolean, + onStopClick: (stopKey: String, stop: Stop) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().fillMaxHeight(.86f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.bottom_sheet_peek_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Text( + text = stringResource(R.string.bottom_sheet_peek_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + + if (!showDetails) return + + HorizontalDivider(Modifier.fillMaxWidth(), DividerDefaults.Thickness) + + if (routes.isEmpty()) { + EmptyState(R.string.no_schedule_found) + return + } + + StopsDetailsContent( + routes = routes, + vehicleStopEtas = vehicleStopEtas, + onStopClick = onStopClick, + ) + } +} + +@Composable +private fun StopsDetailsContent( + routes: Map, + vehicleStopEtas: Map, + onStopClick: (stopKey: String, stop: Stop) -> Unit, +) { + val allowedRoutes = setOf("NORTH", "WEST") + val routeKeys = remember(routes) { allowedRoutes.toList() } + + var selectedRouteKey by remember(routeKeys) { + mutableStateOf(routeKeys.firstOrNull()) + } + + if (routeKeys.isEmpty() || selectedRouteKey == null) { + EmptyState(R.string.no_schedule_found) + return + } + + RouteSelector( + routes = routeKeys, + selectedRoute = selectedRouteKey, + onSelect = { selectedRouteKey = it }, + ) + + HorizontalDivider(Modifier, DividerDefaults.Thickness) + + val stopRows = + remember(selectedRouteKey, routes, vehicleStopEtas) { + val route = routes[selectedRouteKey] ?: return@remember emptyList() + buildStopRowsForRoute(route, vehicleStopEtas) + } + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + items(stopRows, key = { it.stopKey }) { row -> + StopEtaRow( + stopName = row.stop.name, + etaLabels = row.etaLabels, + onClick = { onStopClick(row.stopKey, row.stop) }, + ) + } + } +} + +@Composable +private fun RouteSelector( + routes: List, + selectedRoute: String?, + onSelect: (String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(0.9f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + routes.forEach { dir -> + RouteTab( + label = + stringResource( + R.string.route_label_format, + dir + .lowercase() + .replaceFirstChar { it.titlecase() }, + ), + route = dir, + selectedRoute = selectedRoute, + onRouteSelected = onSelect, + modifier = Modifier.weight(1f).padding(bottom = 8.dp), + ) + } + } +} + +@Composable +private fun RouteTab( + label: String, + route: String, + selectedRoute: String?, + onRouteSelected: (String) -> Unit, + modifier: Modifier, +) { + val selected = route == selectedRoute + + Surface( + onClick = { onRouteSelected(route) }, + shape = RoundedCornerShape(12.dp), + tonalElevation = if (selected) 2.dp else 0.dp, + color = + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + modifier = modifier, + ) { + Text( + text = label, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge, + color = + if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +// List content + +@Composable +private fun StopEtaRow( + stopName: String, + etaLabels: List, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stopName, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (etaLabels.isEmpty()) { + Text( + text = "—", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + etaLabels.forEach { label -> + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(999.dp), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + } + + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + thickness = 0.5.dp, + ) + } +} + +@Composable +private fun EmptyState(textRes: Int) { + Box( + Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text(stringResource(textRes)) + } +} + +// Data helpers + +private data class StopRow( + val stopKey: String, + val stop: Stop, + val etaLabels: List, +) + +private fun buildStopRowsForRoute( + route: Route, + vehicleStopEtas: Map, +): List { + val now = Instant.now() + + return route.stopDetails.map { (stopKey, stop) -> + val nextMins = + vehicleStopEtas + .mapNotNull { (_, etaData) -> + val raw = etaData.stopTimes[stopKey] ?: return@mapNotNull null + val etaInstant = raw.toInstantOrNull() ?: return@mapNotNull null + Duration.between(now, etaInstant).toMinutes() + }.filter { it >= -1 } + .sorted() + .take(2) + + val labels = + nextMins.map { m -> + when { + m <= 0 -> "now" + else -> "${m}m" + } + } + + StopRow( + stopKey = stopKey, + stop = stop, + etaLabels = labels, + ) + } +} + +private fun String.toInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() From 71d8ff391a809ba7f7282c1c61eb6f2fd104846c Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Mon, 9 Mar 2026 21:43:20 -0400 Subject: [PATCH 06/13] Add velocities api --- .../rpi/shuttletracker/data/models/Vehicle.kt | 110 ++++++++++++++++++ .../data/models/vehicle/VehicleLocation.kt | 57 --------- .../data/models/vehicle/VehicleStopEta.kt | 8 -- .../shuttletracker/data/network/ApiHelper.kt | 7 +- .../data/network/ApiHelperImpl.kt | 8 +- .../shuttletracker/data/network/ApiService.kt | 8 +- .../data/repositories/ApiRepository.kt | 8 ++ .../shuttletracker/ui/maps/MapsViewModel.kt | 66 ++++++----- 8 files changed, 171 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt delete mode 100644 app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleLocation.kt delete mode 100644 app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleStopEta.kt diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt new file mode 100644 index 0000000..e9dc9d3 --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt @@ -0,0 +1,110 @@ +package edu.rpi.shuttletracker.data.models + +import com.google.android.gms.maps.model.LatLng +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import java.util.Locale +import kotlin.String + +data class Vehicle( + val id: String, + val name: String, + // from locations + val latitude: Double, + val longitude: Double, + val speedMph: Double, + val timestamp: String, + // from velocities + val routeName: String?, + val isAtStop: Boolean?, + val currentStop: String?, + // from etas + val stopTimes: Map, +) { + /** + * Turns the date stored into a time of a generalized time ago from current + * updates once per second if subscribed to + * */ + fun getTimeAgo(): Flow { + val busInstant = + OffsetDateTime.parse(timestamp).toInstant() + + return flow { + while (true) { + val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val duration = Duration.between(busInstant, now) + + emit(formatDuration(duration) + " ago") + delay(1000) + } + } + } + + // Pretty "xh ym zs" formatter (avoids relying on Duration.toString()) + private fun formatDuration(d: Duration): String { + var secs = d.seconds + val h = secs / 3600 + secs %= 3600 + val m = secs / 60 + secs %= 60 + val s = secs + return buildString { + if (h > 0) append("${h}h ") + if (m > 0 || h > 0) append("${m}m ") + append("${s}s") + }.trim().lowercase(Locale.ROOT) + } + + fun latLng() = LatLng(latitude, longitude) +} + +data class VehicleLocation( + val name: String, + val latitude: Double, + val longitude: Double, + @SerializedName("speed_mph") val speedMph: Double, + val timestamp: String, +) + +data class VehicleStopEta( + @SerializedName("stop_times") val stopTimes: Map, + @SerializedName("timestamp") val timestamp: String, +) + +data class VehicleVelocities( + @SerializedName("route_name") val routeName: String, + @SerializedName("is_at_stop") val isAtStop: Boolean, + @SerializedName("current_stop") val currentStop: String?, +) + +object VehicleMerger { + fun merge( + locations: Map, + velocities: Map = emptyMap(), + etas: Map = emptyMap(), + ): List = + locations + .map { (vehicleId, location) -> + val velocity = velocities[vehicleId] + val eta = etas[vehicleId] + + Vehicle( + id = vehicleId, + name = location.name, + latitude = location.latitude, + longitude = location.longitude, + speedMph = location.speedMph, + timestamp = location.timestamp, + routeName = velocity?.routeName, + isAtStop = velocity?.isAtStop, + currentStop = velocity?.currentStop, + stopTimes = eta?.stopTimes ?: emptyMap(), + ) + }.sortedBy { it.name } +} diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleLocation.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleLocation.kt deleted file mode 100644 index 325fff4..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleLocation.kt +++ /dev/null @@ -1,57 +0,0 @@ -package edu.rpi.shuttletracker.data.models.vehicle - -import com.google.android.gms.maps.model.LatLng -import com.google.gson.annotations.SerializedName -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import java.time.Duration -import java.time.Instant -import java.time.OffsetDateTime -import java.time.temporal.ChronoUnit -import java.util.Locale - -data class VehicleLocation( - val name: String, - val latitude: Double, - val longitude: Double, - @SerializedName("speed_mph") val speedMph: Double, - @SerializedName("route_name") val routeName: String, - @SerializedName("timestamp") val date: String, -) { - /** - * Turns the date stored into a time of a generalized time ago from current - * updates once per second if subscribed to - * */ - fun getTimeAgo(): Flow { - val busInstant = - OffsetDateTime.parse(date).toInstant() - - return flow { - while (true) { - val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) - val duration = Duration.between(busInstant, now) - - emit(formatDuration(duration) + " ago") - delay(1000) - } - } - } - - // Pretty "xh ym zs" formatter (avoids relying on Duration.toString()) - private fun formatDuration(d: Duration): String { - var secs = d.seconds - val h = secs / 3600 - secs %= 3600 - val m = secs / 60 - secs %= 60 - val s = secs - return buildString { - if (h > 0) append("${h}h ") - if (m > 0 || h > 0) append("${m}m ") - append("${s}s") - }.trim().lowercase(Locale.ROOT) - } - - fun latLng() = LatLng(latitude, longitude) -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleStopEta.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleStopEta.kt deleted file mode 100644 index dabb2ee..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleStopEta.kt +++ /dev/null @@ -1,8 +0,0 @@ -package edu.rpi.shuttletracker.data.models.vehicle - -import com.google.gson.annotations.SerializedName - -data class VehicleStopEta( - @SerializedName("stop_times") val stopTimes: Map, - @SerializedName("timestamp") val timestamp: String, -) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt index 07a22e6..b901b95 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt @@ -7,14 +7,17 @@ import edu.rpi.shuttletracker.data.models.Announcement import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities interface ApiHelper { suspend fun getVehicleLocations(): NetworkResponse, ErrorResponse> suspend fun getVehicleEtas(): NetworkResponse, ErrorResponse> + suspend fun getVehicleVelocities(): NetworkResponse, ErrorResponse> + suspend fun getRoutes(): NetworkResponse, ErrorResponse> suspend fun getAnnouncements(): NetworkResponse, ErrorResponse> diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt index 4cd13ac..6594351 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt @@ -7,8 +7,9 @@ import edu.rpi.shuttletracker.data.models.Announcement import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities import javax.inject.Inject class ApiHelperImpl @@ -22,6 +23,9 @@ class ApiHelperImpl override suspend fun getVehicleEtas(): NetworkResponse, ErrorResponse> = apiService.getVehicleEtas() + override suspend fun getVehicleVelocities(): NetworkResponse, ErrorResponse> = + apiService.getVehicleVelocities() + override suspend fun getRoutes(): NetworkResponse, ErrorResponse> = apiService.getRoutes() override suspend fun getAnnouncements(): NetworkResponse, ErrorResponse> = diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt index 58de0e9..5c8f4ab 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt @@ -7,8 +7,9 @@ import edu.rpi.shuttletracker.data.models.Announcement import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -20,6 +21,9 @@ interface ApiService { @GET("etas") suspend fun getVehicleEtas(): NetworkResponse, ErrorResponse> + @GET("velocities") + suspend fun getVehicleVelocities(): NetworkResponse, ErrorResponse> + @GET("routes") suspend fun getRoutes(): NetworkResponse, ErrorResponse> diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt b/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt index 083c1f3..0f9469e 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt @@ -36,6 +36,14 @@ class ApiRepository } } + fun observeVehicleVelocities(pollMs: Long = 30_000L) = + flow { + while (currentCoroutineContext().isActive) { + emit(apiHelper.getVehicleVelocities()) + delay(pollMs) + } + } + suspend fun getRoutes() = apiHelper.getRoutes() suspend fun getAnnouncements() = apiHelper.getAnnouncements() diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt index 43a099a..b291298 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt @@ -9,14 +9,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.Vehicle +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleMerger +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities import edu.rpi.shuttletracker.data.repositories.ApiRepository import edu.rpi.shuttletracker.data.repositories.UserPreferencesRepository import edu.rpi.shuttletracker.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -38,8 +42,7 @@ class MapsViewModel init { loadAll() - observeVehicleLocations() - observeVehicleEtas() + observeVehicles() loadPreferences() } @@ -66,31 +69,35 @@ class MapsViewModel loadAll() } - private fun observeVehicleLocations() { - apiRepository - .observeVehicleLocations(pollMs = 5_000L) - .flowOn(Dispatchers.IO) - .onEach { response -> - readApiResponse(response) { buses -> - _mapsUiState.update { - it.copy(vehicleLocations = buses) - } - } - }.launchIn(viewModelScope) - } + private fun observeVehicles() { + combine( + apiRepository.observeVehicleLocations(pollMs = 5_000L), + apiRepository.observeVehicleEtas(pollMs = 5_000L), + apiRepository.observeVehicleVelocities(pollMs = 5_000L), + ) { locationsResponse, etasResponse, velocitiesResponse -> + Triple(locationsResponse, etasResponse, velocitiesResponse) + }.flowOn(Dispatchers.IO) + .onEach { (locationsResponse, etasResponse, velocitiesResponse) -> + var locations: Map = emptyMap() + var etas: Map = emptyMap() + var velocities: Map = emptyMap() + + readApiResponse(locationsResponse) { locations = it } + readApiResponse(etasResponse) { etas = it } + readApiResponse(velocitiesResponse) { velocities = it } + + val vehicles = + VehicleMerger.merge( + locations = locations, + velocities = velocities, + etas = etas, + ) - private fun observeVehicleEtas() { - apiRepository - .observeVehicleEtas(pollMs = 5_000L) - .flowOn(Dispatchers.IO) - .onEach { response -> - readApiResponse(response) { etas -> - _mapsUiState.update { - it.copy( - vehicleStopEtas = etas, - lastEtasUpdatedAt = Instant.now(), - ) - } + _mapsUiState.update { + it.copy( + vehicles = vehicles, + lastEtasUpdatedAt = Instant.now(), + ) } }.launchIn(viewModelScope) } @@ -191,8 +198,7 @@ class MapsViewModel * */ @Immutable data class MapsUiState( - val vehicleLocations: Map = emptyMap(), - val vehicleStopEtas: Map = emptyMap(), + val vehicles: List = emptyList(), val routes: Map = emptyMap(), val schedule: Schedule? = null, val networkError: NetworkResponse.NetworkError<*, ErrorResponse>? = null, From e8e333e498bc2276b3ae10164a71fd853191aebf Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Tue, 10 Mar 2026 00:14:35 -0400 Subject: [PATCH 07/13] Improve eta filtering --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 71 ++++---- .../shuttletracker/ui/maps/StopEtaContent.kt | 118 ++++++++++---- .../ui/maps/StopsSheetContent.kt | 151 ++++++++++++++---- 3 files changed, 253 insertions(+), 87 deletions(-) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 1771f8b..0be0d10 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -81,9 +81,9 @@ import com.ramcosta.composedestinations.generated.destinations.ScheduleScreenDes import com.ramcosta.composedestinations.generated.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import edu.rpi.shuttletracker.R +import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.Vehicle import edu.rpi.shuttletracker.ui.theme.VehicleColors import edu.rpi.shuttletracker.ui.util.CheckResponseError import kotlinx.coroutines.launch @@ -103,8 +103,8 @@ fun MapsScreen( var selectedStop by remember { mutableStateOf(null) } var selectedVehicleId by remember { mutableStateOf(null) } + var showSheet by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var showStopsSheet by rememberSaveable { mutableStateOf(false) } val cameraPositionState = rememberCameraPositionState { @@ -122,10 +122,11 @@ fun MapsScreen( NavigationBar { NavigationBarItem( selected = false, - onClick = { showStopsSheet = true }, + onClick = { showSheet = true }, icon = { Icon(Icons.Outlined.StopCircle, contentDescription = null) }, label = { Text("Stops") }, ) + NavigationBarItem( selected = false, onClick = { navigator.navigate(ScheduleScreenDestination()) }, @@ -156,6 +157,7 @@ fun MapsScreen( cameraPositionState = cameraPositionState, selectedStopKey = selectedStopKey, selectedStop = selectedStop, + selectedVehicleId = selectedVehicleId, onStopSelected = { stopKey, stop -> selectedStopKey = stopKey selectedStop = stop @@ -170,8 +172,8 @@ fun MapsScreen( .padding(horizontal = 12.dp, vertical = 10.dp), selectedStopKey = selectedStopKey, selectedStop = selectedStop, - vehicleStopEtas = mapsUiState.vehicleStopEtas, - vehicleLocations = mapsUiState.vehicleLocations, + routes = mapsUiState.routes, + vehicles = mapsUiState.vehicles, lastEtasUpdatedAt = mapsUiState.lastEtasUpdatedAt, onClearStop = { selectedStopKey = null @@ -180,13 +182,13 @@ fun MapsScreen( }, onEtaChipClick = { vehicleId -> selectedVehicleId = vehicleId - val loc = mapsUiState.vehicleLocations[vehicleId] ?: return@EtaOverlayCard + val vehicle = mapsUiState.vehicles.firstOrNull { it.id == vehicleId } ?: return@EtaOverlayCard scope.launch { cameraPositionState.animate( CameraUpdateFactory.newCameraPosition( CameraPosition .Builder() - .target(loc.latLng()) + .target(vehicle.latLng()) .zoom(maxOf(cameraPositionState.position.zoom, 16f)) .tilt(0f) .build(), @@ -197,20 +199,19 @@ fun MapsScreen( }, ) - if (showStopsSheet) { + if (showSheet) { ModalBottomSheet( - onDismissRequest = { showStopsSheet = false }, + onDismissRequest = { showSheet = false }, sheetState = sheetState, ) { StopSheetContent( routes = mapsUiState.routes, - vehicleStopEtas = mapsUiState.vehicleStopEtas, + vehicles = mapsUiState.vehicles, showDetails = true, onStopClick = { stopKey, stop -> selectedStopKey = stopKey selectedStop = stop - selectedVehicleId = null - showStopsSheet = false + showSheet = false scope.launch { cameraPositionState.animate( CameraUpdateFactory.newCameraPosition( @@ -253,6 +254,7 @@ private fun ShuttleMap( cameraPositionState: CameraPositionState, selectedStopKey: String?, selectedStop: Stop?, + selectedVehicleId: String?, onStopSelected: (stopKey: String, stop: Stop) -> Unit, ) { val context = LocalContext.current @@ -313,9 +315,10 @@ private fun ShuttleMap( } // creates the vehicle markers - mapsUiState.vehicleLocations.values.forEach { + mapsUiState.vehicles.forEach { vehicle -> VehicleMarker( - vehicleLocation = it, + vehicle = vehicle, + selected = vehicle.id == selectedVehicleId, ) } @@ -428,17 +431,28 @@ private fun StopMarker( * Creates a marker for a vehicle * */ @Composable -private fun VehicleMarker(vehicleLocation: VehicleLocation) { +private fun VehicleMarker( + vehicle: Vehicle, + selected: Boolean, +) { val context = LocalContext.current - val markerState = rememberUpdatedMarkerState(position = vehicleLocation.latLng()) + val markerState = rememberUpdatedMarkerState(position = vehicle.latLng()) // every time the vehicle changes, update the position of the marker - LaunchedEffect(vehicleLocation) { - markerState.position = vehicleLocation.latLng() + LaunchedEffect(vehicle) { + markerState.position = vehicle.latLng() + } + + LaunchedEffect(selected) { + if (selected) { + markerState.showInfoWindow() + } else { + markerState.hideInfoWindow() + } } val vehicleColor = - when (vehicleLocation.routeName) { + when (vehicle.routeName) { "NORTH" -> VehicleColors.North "WEST" -> VehicleColors.West else -> VehicleColors.Default @@ -450,10 +464,13 @@ private fun VehicleMarker(vehicleLocation: VehicleLocation) { } // gets vehicle speed and last time it updated - val lastUpdatedAgoText = vehicleLocation.getTimeAgo().collectAsStateWithLifecycle(initialValue = "").value + val timeAgoFlow = remember(vehicle.timestamp) { vehicle.getTimeAgo() } + val lastUpdatedAgoText = + timeAgoFlow.collectAsStateWithLifecycle(initialValue = "").value + val snippetText = buildString { - append(stringResource(R.string.vehicle_speed, vehicleLocation.speedMph)) + append(stringResource(R.string.vehicle_speed, vehicle.speedMph)) if (lastUpdatedAgoText.isNotBlank()) { append(" • ") append(lastUpdatedAgoText) @@ -462,7 +479,7 @@ private fun VehicleMarker(vehicleLocation: VehicleLocation) { Marker( state = markerState, - title = stringResource(R.string.vehicle_number, vehicleLocation.name), + title = stringResource(R.string.vehicle_number, vehicle.name), icon = icon, snippet = snippetText, anchor = Offset(0.5f, 0.5f), @@ -571,8 +588,8 @@ fun EtaOverlayCard( modifier: Modifier = Modifier, selectedStopKey: String?, selectedStop: Stop?, - vehicleStopEtas: Map, - vehicleLocations: Map, + routes: Map, + vehicles: List, lastEtasUpdatedAt: Instant?, onClearStop: () -> Unit, onEtaChipClick: (vehicleId: String) -> Unit, @@ -588,8 +605,8 @@ fun EtaOverlayCard( modifier = Modifier.padding(vertical = 2.dp), selectedStopKey = selectedStopKey, selectedStop = selectedStop, - vehicleStopEtas = vehicleStopEtas, - vehicleLocations = vehicleLocations, + routes = routes, + vehicles = vehicles, lastEtasUpdatedAt = lastEtasUpdatedAt, onClearStop = onClearStop, onEtaChipClick = onEtaChipClick, diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt index 5b67588..2162a11 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt @@ -16,14 +16,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.Vehicle import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -37,8 +39,8 @@ fun StopEtaContent( modifier: Modifier = Modifier, selectedStopKey: String?, selectedStop: Stop?, - vehicleStopEtas: Map, - vehicleLocations: Map, + routes: Map, + vehicles: List, lastEtasUpdatedAt: Instant?, onClearStop: () -> Unit, onEtaChipClick: (vehicleId: String) -> Unit, @@ -49,28 +51,53 @@ fun StopEtaContent( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - StopEtaHeader(stopTitle, onClearStop, selectedStop, lastEtasUpdatedAt) + StopEtaHeader( + title = stopTitle, + onClearStop = onClearStop, + stopSelected = selectedStop, + lastEtasUpdatedAt = lastEtasUpdatedAt, + ) + + if (selectedStopKey == null) return@Column + + val lastSeenStopIndexByVehicle = remember { mutableStateMapOf() } + + LaunchedEffect(vehicles, routes) { + vehicles.forEach { vehicle -> + val routeKey = vehicle.routeName ?: return@forEach + val route = routes[routeKey] ?: return@forEach + val currentStopName = vehicle.currentStop ?: return@forEach + + val matchedIndex = + route.stops.indexOfFirst { stopKey -> + route.stopDetails[stopKey]?.name.equals(currentStopName, ignoreCase = true) + } - if (selectedStopKey == null) { - return + if (matchedIndex == -1) return@forEach + + val previousIndex = lastSeenStopIndexByVehicle[vehicle.id] + if (previousIndex == null || matchedIndex > previousIndex) { + lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex + } + } } val etas = - remember(selectedStopKey, vehicleStopEtas, vehicleLocations) { + remember(selectedStopKey, vehicles, routes, lastSeenStopIndexByVehicle.toMap()) { buildVehicleEtas( - selectedStopKey, - vehicleStopEtas, - vehicleLocations, + stopKey = selectedStopKey, + routes = routes, + vehicles = vehicles, + lastSeenStopIndexByVehicle = lastSeenStopIndexByVehicle, ) } val now = Instant.now() val visibleEtas = - etas - .filter { Duration.between(now, it.etaInstant).toMinutes() >= -5 } + etas.filter { Duration.between(now, it.etaInstant).toMinutes() >= -5 } - if (visibleEtas.isEmpty() || stopTitle == "Blitman") { + if (visibleEtas.isEmpty()) { Text( text = "No ETAs found", style = MaterialTheme.typography.bodyMedium, @@ -87,7 +114,10 @@ fun StopEtaContent( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - items(visibleEtas, key = { it.vehicleId }) { eta -> + items( + items = visibleEtas, + key = { it.vehicleId }, + ) { eta -> EtaChip( eta = eta, now = now, @@ -119,7 +149,10 @@ private fun StopEtaHeader( .value Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -167,7 +200,7 @@ private fun EtaChip( val mins = Duration.between(now, eta.etaInstant).toMinutes() val etaText = when { - mins <= 0 -> "now" + mins <= 0 -> "${mins}m" else -> "${mins}m" } @@ -194,22 +227,51 @@ private data class VehicleEta( private fun buildVehicleEtas( stopKey: String, - vehicleStopEtas: Map, - vehicleLocations: Map, -): List = - vehicleStopEtas - .mapNotNull { (vehicleId, etaData) -> - val rawTime = etaData.stopTimes[stopKey] ?: return@mapNotNull null - val instant = rawTime.toInstantOrNull() ?: return@mapNotNull null + routes: Map, + vehicles: List, + lastSeenStopIndexByVehicle: Map, +): List { + val now = Instant.now() - val vehicle = vehicleLocations[vehicleId] + return vehicles + .asSequence() + .mapNotNull { vehicle -> + val routeKey = vehicle.routeName + val route = routeKey?.let(routes::get) + + val rawTime = vehicle.stopTimes[stopKey] ?: return@mapNotNull null + val etaInstant = rawTime.toInstantOrNull() ?: return@mapNotNull null + + if (route != null) { + val candidateIndex = route.stops.indexOf(stopKey) + if (candidateIndex != -1) { + val lastSeenIndex = lastSeenStopIndexByVehicle[vehicle.id] + + // Only show ETAs for stops after the last recorded stop index + if (lastSeenIndex != null && candidateIndex <= lastSeenIndex) { + return@mapNotNull null + } + } + + // If the bus is currently at the first stop, hide old ETAs + val firstStopKey = route.stops.firstOrNull() + val firstStopName = firstStopKey?.let { route.stopDetails[it]?.name } + val isCurrentlyAtFirstStop = + firstStopName != null && vehicle.currentStop == firstStopName + + if (isCurrentlyAtFirstStop && etaInstant.isBefore(now)) { + return@mapNotNull null + } + } VehicleEta( - vehicleId = vehicleId, - vehicleLabel = vehicle?.name.orEmpty(), - etaInstant = instant, + vehicleId = vehicle.id, + vehicleLabel = vehicle.name, + etaInstant = etaInstant, ) }.sortedBy { it.etaInstant } + .toList() +} private fun String.toInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt index 75c95ec..4a579a5 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt @@ -18,7 +18,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -30,7 +32,7 @@ import androidx.compose.ui.unit.dp import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.Vehicle import java.time.Duration import java.time.Instant import java.time.OffsetDateTime @@ -38,12 +40,15 @@ import java.time.OffsetDateTime @Composable fun StopSheetContent( routes: Map, - vehicleStopEtas: Map, + vehicles: List, showDetails: Boolean, onStopClick: (stopKey: String, stop: Stop) -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth().fillMaxHeight(.86f), + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(.86f), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -59,18 +64,21 @@ fun StopSheetContent( modifier = Modifier.padding(bottom = 8.dp), ) - if (!showDetails) return + if (!showDetails) return@Column - HorizontalDivider(Modifier.fillMaxWidth(), DividerDefaults.Thickness) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = DividerDefaults.Thickness, + ) if (routes.isEmpty()) { EmptyState(R.string.no_schedule_found) - return + return@Column } StopsDetailsContent( routes = routes, - vehicleStopEtas = vehicleStopEtas, + vehicles = vehicles, onStopClick = onStopClick, ) } @@ -79,11 +87,17 @@ fun StopSheetContent( @Composable private fun StopsDetailsContent( routes: Map, - vehicleStopEtas: Map, + vehicles: List, onStopClick: (stopKey: String, stop: Stop) -> Unit, ) { val allowedRoutes = setOf("NORTH", "WEST") - val routeKeys = remember(routes) { allowedRoutes.toList() } + + val routeKeys = + remember(routes) { + routes.keys + .filter { it in allowedRoutes } + .sorted() + } var selectedRouteKey by remember(routeKeys) { mutableStateOf(routeKeys.firstOrNull()) @@ -94,25 +108,70 @@ private fun StopsDetailsContent( return } + val lastSeenStopIndexByVehicle = remember { mutableStateMapOf() } + + LaunchedEffect(vehicles, routes) { + vehicles.forEach { vehicle -> + val routeKey = vehicle.routeName ?: return@forEach + val route = routes[routeKey] ?: return@forEach + val currentStopName = vehicle.currentStop ?: return@forEach + + val matchedIndex = + route.stops.indexOfFirst { stopKey -> + route.stopDetails[stopKey]?.name.equals(currentStopName, ignoreCase = true) + } + + if (matchedIndex == -1) return@forEach + + val previousIndex = lastSeenStopIndexByVehicle[vehicle.id] + val lastRouteIndex = route.stops.lastIndex + + when { + previousIndex == null -> { + lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex + } + + matchedIndex > previousIndex -> { + lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex + } + + previousIndex >= lastRouteIndex - 1 && matchedIndex <= 1 -> { + lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex + } + } + } + } + RouteSelector( routes = routeKeys, selectedRoute = selectedRouteKey, onSelect = { selectedRouteKey = it }, ) - HorizontalDivider(Modifier, DividerDefaults.Thickness) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = DividerDefaults.Thickness, + ) val stopRows = - remember(selectedRouteKey, routes, vehicleStopEtas) { + remember(selectedRouteKey, routes, vehicles, lastSeenStopIndexByVehicle.toMap()) { val route = routes[selectedRouteKey] ?: return@remember emptyList() - buildStopRowsForRoute(route, vehicleStopEtas) + buildStopRowsForRoute( + route = route, + vehicles = vehicles, + routeKey = selectedRouteKey!!, + lastSeenStopIndexByVehicle = lastSeenStopIndexByVehicle, + ) } LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp), ) { - items(stopRows, key = { it.stopKey }) { row -> + items( + items = stopRows, + key = { it.stopKey }, + ) { row -> StopEtaRow( stopName = row.stop.name, etaLabels = row.etaLabels, @@ -138,14 +197,15 @@ private fun RouteSelector( label = stringResource( R.string.route_label_format, - dir - .lowercase() - .replaceFirstChar { it.titlecase() }, + dir.lowercase().replaceFirstChar { it.titlecase() }, ), route = dir, selectedRoute = selectedRoute, onRouteSelected = onSelect, - modifier = Modifier.weight(1f).padding(bottom = 8.dp), + modifier = + Modifier + .weight(1f) + .padding(bottom = 8.dp), ) } } @@ -206,7 +266,10 @@ private fun StopEtaRow( .clickable { onClick() }, ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -249,17 +312,16 @@ private fun StopEtaRow( @Composable private fun EmptyState(textRes: Int) { Box( - Modifier - .fillMaxWidth() - .padding(24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), contentAlignment = Alignment.Center, ) { - Text(stringResource(textRes)) + Text(text = stringResource(textRes)) } } -// Data helpers - private data class StopRow( val stopKey: String, val stop: Stop, @@ -268,26 +330,51 @@ private data class StopRow( private fun buildStopRowsForRoute( route: Route, - vehicleStopEtas: Map, + vehicles: List, + routeKey: String, + lastSeenStopIndexByVehicle: Map, ): List { val now = Instant.now() return route.stopDetails.map { (stopKey, stop) -> + val candidateIndex = route.stops.indexOf(stopKey) + val nextMins = - vehicleStopEtas - .mapNotNull { (_, etaData) -> - val raw = etaData.stopTimes[stopKey] ?: return@mapNotNull null - val etaInstant = raw.toInstantOrNull() ?: return@mapNotNull null + vehicles + .asSequence() + .filter { it.routeName == routeKey } + .mapNotNull { vehicle -> + if (candidateIndex != -1) { + val lastSeenIndex = lastSeenStopIndexByVehicle[vehicle.id] + if (lastSeenIndex != null && candidateIndex <= lastSeenIndex) { + return@mapNotNull null + } + } + + val rawEta = vehicle.stopTimes[stopKey] ?: return@mapNotNull null + val etaInstant = rawEta.toInstantOrNull() ?: return@mapNotNull null + + val firstStopKey = route.stops.firstOrNull() + val firstStopName = firstStopKey?.let { route.stopDetails[it]?.name } + val isCurrentlyAtFirstStop = + firstStopName != null && + vehicle.currentStop.equals(firstStopName, ignoreCase = true) + + if (isCurrentlyAtFirstStop && etaInstant.isBefore(now)) { + return@mapNotNull null + } + Duration.between(now, etaInstant).toMinutes() }.filter { it >= -1 } .sorted() .take(2) + .toList() val labels = - nextMins.map { m -> + nextMins.map { mins -> when { - m <= 0 -> "now" - else -> "${m}m" + mins <= 0 -> "now" + else -> "${mins}m" } } From a0e99b26c4ff4fd529eaa0359a44b5eebfcbac06 Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Tue, 10 Mar 2026 19:31:46 -0400 Subject: [PATCH 08/13] Refactor files and improve eta filtering --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 57 +++-- .../shuttletracker/ui/maps/MapsViewModel.kt | 96 +++++++-- .../{ => components}/ScheduleSheetContent.kt | 3 +- .../maps/{ => components}/StopEtaContent.kt | 160 +++----------- .../{ => components}/StopsSheetContent.kt | 166 ++------------- .../maps/{ => components}/SvgVehicleMarker.kt | 2 +- .../shuttletracker/ui/maps/utils/EtaUtils.kt | 200 ++++++++++++++++++ 7 files changed, 362 insertions(+), 322 deletions(-) rename app/src/main/java/edu/rpi/shuttletracker/ui/maps/{ => components}/ScheduleSheetContent.kt (99%) rename app/src/main/java/edu/rpi/shuttletracker/ui/maps/{ => components}/StopEtaContent.kt (55%) rename app/src/main/java/edu/rpi/shuttletracker/ui/maps/{ => components}/StopsSheetContent.kt (59%) rename app/src/main/java/edu/rpi/shuttletracker/ui/maps/{ => components}/SvgVehicleMarker.kt (98%) create mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 0be0d10..2d70870 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -81,13 +81,15 @@ import com.ramcosta.composedestinations.generated.destinations.ScheduleScreenDes import com.ramcosta.composedestinations.generated.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.data.models.Vehicle +import edu.rpi.shuttletracker.ui.maps.components.StopEtaContent +import edu.rpi.shuttletracker.ui.maps.components.StopSheetContent +import edu.rpi.shuttletracker.ui.maps.components.getVehicleMarkerDescriptor +import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi import edu.rpi.shuttletracker.ui.theme.VehicleColors import edu.rpi.shuttletracker.ui.util.CheckResponseError import kotlinx.coroutines.launch -import java.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Destination(start = true) @@ -99,7 +101,6 @@ fun MapsScreen( val mapsUiState = viewModel.mapsUiState.collectAsStateWithLifecycle().value val snackbarHostState = remember { SnackbarHostState() } - var selectedStopKey by remember { mutableStateOf(null) } var selectedStop by remember { mutableStateOf(null) } var selectedVehicleId by remember { mutableStateOf(null) } @@ -155,11 +156,11 @@ fun MapsScreen( onSettingsClick = { navigator.navigate(SettingsScreenDestination()) }, onToggleMapTypeClick = { viewModel.toggleMapType() }, cameraPositionState = cameraPositionState, - selectedStopKey = selectedStopKey, + selectedStopKey = mapsUiState.selectedStopKey, selectedStop = selectedStop, selectedVehicleId = selectedVehicleId, onStopSelected = { stopKey, stop -> - selectedStopKey = stopKey + viewModel.setSelectedStop(stopKey) selectedStop = stop }, ) @@ -170,13 +171,12 @@ fun MapsScreen( .align(Alignment.BottomCenter) .padding(padding) .padding(horizontal = 12.dp, vertical = 10.dp), - selectedStopKey = selectedStopKey, - selectedStop = selectedStop, - routes = mapsUiState.routes, - vehicles = mapsUiState.vehicles, + title = selectedStop?.name ?: "Tap a stop to see etas", + selectedStopEtas = mapsUiState.selectedStopEtas, lastEtasUpdatedAt = mapsUiState.lastEtasUpdatedAt, + stopSelected = selectedStop != null, onClearStop = { - selectedStopKey = null + viewModel.setSelectedStop(null) selectedStop = null selectedVehicleId = null }, @@ -205,11 +205,15 @@ fun MapsScreen( sheetState = sheetState, ) { StopSheetContent( - routes = mapsUiState.routes, - vehicles = mapsUiState.vehicles, + routeKeys = mapsUiState.allowedRouteKeys, + selectedRouteKey = mapsUiState.selectedRouteKey, + stopRows = mapsUiState.stopRows, showDetails = true, + onRouteSelected = { routeKey -> + viewModel.setSelectedRoute(routeKey) + }, onStopClick = { stopKey, stop -> - selectedStopKey = stopKey + viewModel.setSelectedStop(stopKey) selectedStop = stop showSheet = false scope.launch { @@ -233,17 +237,6 @@ fun MapsScreen( } } -/** - * Creates the map displaying everything - * - * @param mapsUiState: The UI state of the view from the view-model - * @param padding: Padding needed for the map content padding - * to close/open the stop bottom sheet - * @param onScheduleClick: Callback invoked when the user taps the Schedule button - * @param onSettingsClick: Callback invoked when the user taps the Settings button - * @param onToggleMapTypeClick: Callback invoked when user taps the MapType button - * - * */ @Composable private fun ShuttleMap( mapsUiState: MapsUiState, @@ -586,11 +579,10 @@ private fun ActionButton( @Composable fun EtaOverlayCard( modifier: Modifier = Modifier, - selectedStopKey: String?, - selectedStop: Stop?, - routes: Map, - vehicles: List, - lastEtasUpdatedAt: Instant?, + title: String, + selectedStopEtas: List, + lastEtasUpdatedAt: java.time.Instant?, + stopSelected: Boolean, onClearStop: () -> Unit, onEtaChipClick: (vehicleId: String) -> Unit, ) { @@ -603,11 +595,10 @@ fun EtaOverlayCard( ) { StopEtaContent( modifier = Modifier.padding(vertical = 2.dp), - selectedStopKey = selectedStopKey, - selectedStop = selectedStop, - routes = routes, - vehicles = vehicles, + title = title, + selectedStopEtas = selectedStopEtas, lastEtasUpdatedAt = lastEtasUpdatedAt, + stopSelected = stopSelected, onClearStop = onClearStop, onEtaChipClick = onEtaChipClick, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt index b291298..271cd46 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt @@ -16,6 +16,9 @@ import edu.rpi.shuttletracker.data.models.VehicleStopEta import edu.rpi.shuttletracker.data.models.VehicleVelocities import edu.rpi.shuttletracker.data.repositories.ApiRepository import edu.rpi.shuttletracker.data.repositories.UserPreferencesRepository +import edu.rpi.shuttletracker.ui.maps.utils.EtaUtils +import edu.rpi.shuttletracker.ui.maps.utils.StopRowUi +import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi import edu.rpi.shuttletracker.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -31,15 +34,17 @@ import javax.inject.Inject @HiltViewModel class MapsViewModel + // represents the ui state of the view @Inject constructor( private val apiRepository: ApiRepository, private val userPreferencesRepository: UserPreferencesRepository, ) : ViewModel() { - // represents the ui state of the view private val _mapsUiState = MutableStateFlow(MapsUiState()) val mapsUiState: StateFlow = _mapsUiState + private var lastSeenStopIndexByVehicle: Map = emptyMap() + init { loadAll() observeVehicles() @@ -51,9 +56,6 @@ class MapsViewModel if (mapsUiState.value.schedule == null) loadSchedule() } - /** - * sets all the errors to none - * */ fun clearErrors() { _mapsUiState.update { it.copy( @@ -69,6 +71,16 @@ class MapsViewModel loadAll() } + fun setSelectedStop(stopKey: String?) { + _mapsUiState.update { it.copy(selectedStopKey = stopKey) } + recomputeDerivedState() + } + + fun setSelectedRoute(routeKey: String?) { + _mapsUiState.update { it.copy(selectedRouteKey = routeKey) } + recomputeDerivedState() + } + private fun observeVehicles() { combine( apiRepository.observeVehicleLocations(pollMs = 5_000L), @@ -99,18 +111,18 @@ class MapsViewModel lastEtasUpdatedAt = Instant.now(), ) } + + recomputeDerivedState() }.launchIn(viewModelScope) } - /** - * Loads all possible routes and maps the API response - * */ private fun loadRoutes() { viewModelScope.launch { readApiResponse(apiRepository.getRoutes()) { routes -> _mapsUiState.update { it.copy(routes = routes) } + recomputeDerivedState() } } } @@ -126,7 +138,6 @@ class MapsViewModel } private fun loadPreferences() { - // gets user preference for dark mode userPreferencesRepository .getThemeMode() .flowOn(Dispatchers.Default) @@ -166,9 +177,64 @@ class MapsViewModel updateMapType(next) } - /** - * Reads the network response and maps it to correct place - * */ + private fun recomputeDerivedState() { + val state = _mapsUiState.value + val routes = state.routes + val vehicles = state.vehicles + + lastSeenStopIndexByVehicle = + EtaUtils.updateLastSeenStopIndices( + vehicles, + routes, + lastSeenStopIndexByVehicle, + ) + + val allowedRouteKeys = + routes.keys + .filter { it in setOf("NORTH", "WEST") } + .sorted() + + val resolvedSelectedRouteKey = + when { + state.selectedRouteKey in allowedRouteKeys -> state.selectedRouteKey + else -> allowedRouteKeys.firstOrNull() + } + + val selectedStopEtas = + state.selectedStopKey?.let { stopKey -> + EtaUtils.buildSelectedStopEtas( + stopKey, + routes, + vehicles, + lastSeenStopIndexByVehicle, + ) + } ?: emptyList() + + val stopRows = + resolvedSelectedRouteKey?.let { routeKey -> + val route = routes[routeKey] + if (route != null) { + EtaUtils.buildStopRowsForRoute( + route, + vehicles, + routeKey, + lastSeenStopIndexByVehicle, + ) + } else { + emptyList() + } + } ?: emptyList() + + _mapsUiState.update { + it.copy( + allowedRouteKeys = allowedRouteKeys, + selectedRouteKey = resolvedSelectedRouteKey, + selectedStopEtas = selectedStopEtas, + stopRows = stopRows, + ) + } + } + private fun readApiResponse( response: NetworkResponse, success: (body: T) -> Unit, @@ -193,9 +259,6 @@ class MapsViewModel } } -/** - * Representation of the screen - * */ @Immutable data class MapsUiState( val vehicles: List = emptyList(), @@ -209,4 +272,9 @@ data class MapsUiState( val themeMode: ThemeMode = ThemeMode.System, val mapType: MapType = MapType.NORMAL, val lastEtasUpdatedAt: Instant? = null, + val selectedStopKey: String? = null, + val selectedRouteKey: String? = null, + val selectedStopEtas: List = emptyList(), + val stopRows: List = emptyList(), + val allowedRouteKeys: List = emptyList(), ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/ScheduleSheetContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheetContent.kt similarity index 99% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/ScheduleSheetContent.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheetContent.kt index e8b0b12..24caeac 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/ScheduleSheetContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheetContent.kt @@ -1,4 +1,4 @@ -package edu.rpi.shuttletracker.ui.maps +package edu.rpi.shuttletracker.ui.maps.components import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -39,6 +39,7 @@ import java.time.LocalTime import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Locale +import kotlin.collections.iterator // Header / Peak diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaContent.kt similarity index 55% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaContent.kt index 2162a11..8f3a0e9 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopEtaContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaContent.kt @@ -1,4 +1,4 @@ -package edu.rpi.shuttletracker.ui.maps +package edu.rpi.shuttletracker.ui.maps.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -16,88 +16,63 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import edu.rpi.shuttletracker.data.models.Route -import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.data.models.Vehicle +import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import java.time.Duration import java.time.Instant -import java.time.OffsetDateTime import java.time.temporal.ChronoUnit @Composable fun StopEtaContent( modifier: Modifier = Modifier, - selectedStopKey: String?, - selectedStop: Stop?, - routes: Map, - vehicles: List, + title: String, + selectedStopEtas: List, lastEtasUpdatedAt: Instant?, + stopSelected: Boolean, onClearStop: () -> Unit, onEtaChipClick: (vehicleId: String) -> Unit, ) { - val stopTitle = selectedStop?.name ?: "Tap a stop to see etas" + StopEtaContainer( + modifier = modifier, + title = title, + selectedStopEtas = selectedStopEtas, + lastEtasUpdatedAt = lastEtasUpdatedAt, + stopSelected = stopSelected, + onClearStop = onClearStop, + onEtaChipClick = onEtaChipClick, + ) +} +@Composable +private fun StopEtaContainer( + modifier: Modifier = Modifier, + title: String, + selectedStopEtas: List, + lastEtasUpdatedAt: Instant?, + stopSelected: Boolean, + onClearStop: () -> Unit, + onEtaChipClick: (vehicleId: String) -> Unit, +) { Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { StopEtaHeader( - title = stopTitle, + title = title, onClearStop = onClearStop, - stopSelected = selectedStop, + stopSelected = stopSelected, lastEtasUpdatedAt = lastEtasUpdatedAt, ) - if (selectedStopKey == null) return@Column - - val lastSeenStopIndexByVehicle = remember { mutableStateMapOf() } - - LaunchedEffect(vehicles, routes) { - vehicles.forEach { vehicle -> - val routeKey = vehicle.routeName ?: return@forEach - val route = routes[routeKey] ?: return@forEach - val currentStopName = vehicle.currentStop ?: return@forEach - - val matchedIndex = - route.stops.indexOfFirst { stopKey -> - route.stopDetails[stopKey]?.name.equals(currentStopName, ignoreCase = true) - } - - if (matchedIndex == -1) return@forEach - - val previousIndex = lastSeenStopIndexByVehicle[vehicle.id] - if (previousIndex == null || matchedIndex > previousIndex) { - lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex - } - } - } - - val etas = - remember(selectedStopKey, vehicles, routes, lastSeenStopIndexByVehicle.toMap()) { - buildVehicleEtas( - stopKey = selectedStopKey, - routes = routes, - vehicles = vehicles, - lastSeenStopIndexByVehicle = lastSeenStopIndexByVehicle, - ) - } - - val now = Instant.now() - - val visibleEtas = - etas.filter { Duration.between(now, it.etaInstant).toMinutes() >= -5 } + if (!stopSelected) return@Column - if (visibleEtas.isEmpty()) { + if (selectedStopEtas.isEmpty()) { Text( text = "No ETAs found", style = MaterialTheme.typography.bodyMedium, @@ -115,12 +90,11 @@ fun StopEtaContent( verticalAlignment = Alignment.CenterVertically, ) { items( - items = visibleEtas, + items = selectedStopEtas, key = { it.vehicleId }, ) { eta -> EtaChip( eta = eta, - now = now, onClick = { onEtaChipClick(eta.vehicleId) }, ) } @@ -140,7 +114,7 @@ fun StopEtaContent( private fun StopEtaHeader( title: String, onClearStop: () -> Unit, - stopSelected: Stop?, + stopSelected: Boolean, lastEtasUpdatedAt: Instant?, ) { val updatedText = @@ -161,7 +135,7 @@ private fun StopEtaHeader( style = MaterialTheme.typography.titleMedium, ) - if (stopSelected != null) { + if (stopSelected) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -193,19 +167,11 @@ private fun StopEtaHeader( @Composable private fun EtaChip( - eta: VehicleEta, - now: Instant, + eta: VehicleEtaUi, onClick: () -> Unit, ) { - val mins = Duration.between(now, eta.etaInstant).toMinutes() - val etaText = - when { - mins <= 0 -> "${mins}m" - else -> "${mins}m" - } - Text( - text = "Shuttle ${eta.vehicleLabel} • $etaText", + text = "Shuttle ${eta.vehicleLabel} • ${eta.etaText}", style = MaterialTheme.typography.bodyMedium, modifier = Modifier @@ -217,64 +183,6 @@ private fun EtaChip( ) } -// Data - -private data class VehicleEta( - val vehicleId: String, - val vehicleLabel: String, - val etaInstant: Instant, -) - -private fun buildVehicleEtas( - stopKey: String, - routes: Map, - vehicles: List, - lastSeenStopIndexByVehicle: Map, -): List { - val now = Instant.now() - - return vehicles - .asSequence() - .mapNotNull { vehicle -> - val routeKey = vehicle.routeName - val route = routeKey?.let(routes::get) - - val rawTime = vehicle.stopTimes[stopKey] ?: return@mapNotNull null - val etaInstant = rawTime.toInstantOrNull() ?: return@mapNotNull null - - if (route != null) { - val candidateIndex = route.stops.indexOf(stopKey) - if (candidateIndex != -1) { - val lastSeenIndex = lastSeenStopIndexByVehicle[vehicle.id] - - // Only show ETAs for stops after the last recorded stop index - if (lastSeenIndex != null && candidateIndex <= lastSeenIndex) { - return@mapNotNull null - } - } - - // If the bus is currently at the first stop, hide old ETAs - val firstStopKey = route.stops.firstOrNull() - val firstStopName = firstStopKey?.let { route.stopDetails[it]?.name } - val isCurrentlyAtFirstStop = - firstStopName != null && vehicle.currentStop == firstStopName - - if (isCurrentlyAtFirstStop && etaInstant.isBefore(now)) { - return@mapNotNull null - } - } - - VehicleEta( - vehicleId = vehicle.id, - vehicleLabel = vehicle.name, - etaInstant = etaInstant, - ) - }.sortedBy { it.etaInstant } - .toList() -} - -private fun String.toInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() - fun updatedAgoFlow(lastUpdatedAt: Instant?): Flow = flow { if (lastUpdatedAt == null) { diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopsSheetContent.kt similarity index 59% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopsSheetContent.kt index 4a579a5..777b32d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/StopsSheetContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopsSheetContent.kt @@ -1,4 +1,4 @@ -package edu.rpi.shuttletracker.ui.maps +package edu.rpi.shuttletracker.ui.maps.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,30 +18,22 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.data.models.Vehicle -import java.time.Duration -import java.time.Instant -import java.time.OffsetDateTime +import edu.rpi.shuttletracker.ui.maps.utils.StopRowUi @Composable fun StopSheetContent( - routes: Map, - vehicles: List, + routeKeys: List, + selectedRouteKey: String?, + stopRows: List, showDetails: Boolean, + onRouteSelected: (String) -> Unit, onStopClick: (stopKey: String, stop: Stop) -> Unit, ) { Column( @@ -71,14 +63,16 @@ fun StopSheetContent( thickness = DividerDefaults.Thickness, ) - if (routes.isEmpty()) { + if (routeKeys.isEmpty()) { EmptyState(R.string.no_schedule_found) return@Column } StopsDetailsContent( - routes = routes, - vehicles = vehicles, + routeKeys = routeKeys, + selectedRouteKey = selectedRouteKey, + stopRows = stopRows, + onRouteSelected = onRouteSelected, onStopClick = onStopClick, ) } @@ -86,66 +80,21 @@ fun StopSheetContent( @Composable private fun StopsDetailsContent( - routes: Map, - vehicles: List, + routeKeys: List, + selectedRouteKey: String?, + stopRows: List, + onRouteSelected: (String) -> Unit, onStopClick: (stopKey: String, stop: Stop) -> Unit, ) { - val allowedRoutes = setOf("NORTH", "WEST") - - val routeKeys = - remember(routes) { - routes.keys - .filter { it in allowedRoutes } - .sorted() - } - - var selectedRouteKey by remember(routeKeys) { - mutableStateOf(routeKeys.firstOrNull()) - } - if (routeKeys.isEmpty() || selectedRouteKey == null) { EmptyState(R.string.no_schedule_found) return } - val lastSeenStopIndexByVehicle = remember { mutableStateMapOf() } - - LaunchedEffect(vehicles, routes) { - vehicles.forEach { vehicle -> - val routeKey = vehicle.routeName ?: return@forEach - val route = routes[routeKey] ?: return@forEach - val currentStopName = vehicle.currentStop ?: return@forEach - - val matchedIndex = - route.stops.indexOfFirst { stopKey -> - route.stopDetails[stopKey]?.name.equals(currentStopName, ignoreCase = true) - } - - if (matchedIndex == -1) return@forEach - - val previousIndex = lastSeenStopIndexByVehicle[vehicle.id] - val lastRouteIndex = route.stops.lastIndex - - when { - previousIndex == null -> { - lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex - } - - matchedIndex > previousIndex -> { - lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex - } - - previousIndex >= lastRouteIndex - 1 && matchedIndex <= 1 -> { - lastSeenStopIndexByVehicle[vehicle.id] = matchedIndex - } - } - } - } - RouteSelector( routes = routeKeys, selectedRoute = selectedRouteKey, - onSelect = { selectedRouteKey = it }, + onSelect = onRouteSelected, ) HorizontalDivider( @@ -153,17 +102,6 @@ private fun StopsDetailsContent( thickness = DividerDefaults.Thickness, ) - val stopRows = - remember(selectedRouteKey, routes, vehicles, lastSeenStopIndexByVehicle.toMap()) { - val route = routes[selectedRouteKey] ?: return@remember emptyList() - buildStopRowsForRoute( - route = route, - vehicles = vehicles, - routeKey = selectedRouteKey!!, - lastSeenStopIndexByVehicle = lastSeenStopIndexByVehicle, - ) - } - LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp), @@ -175,7 +113,9 @@ private fun StopsDetailsContent( StopEtaRow( stopName = row.stop.name, etaLabels = row.etaLabels, - onClick = { onStopClick(row.stopKey, row.stop) }, + onClick = { + onStopClick(row.stopKey, row.stop) + }, ) } } @@ -251,8 +191,6 @@ private fun RouteTab( } } -// List content - @Composable private fun StopEtaRow( stopName: String, @@ -321,69 +259,3 @@ private fun EmptyState(textRes: Int) { Text(text = stringResource(textRes)) } } - -private data class StopRow( - val stopKey: String, - val stop: Stop, - val etaLabels: List, -) - -private fun buildStopRowsForRoute( - route: Route, - vehicles: List, - routeKey: String, - lastSeenStopIndexByVehicle: Map, -): List { - val now = Instant.now() - - return route.stopDetails.map { (stopKey, stop) -> - val candidateIndex = route.stops.indexOf(stopKey) - - val nextMins = - vehicles - .asSequence() - .filter { it.routeName == routeKey } - .mapNotNull { vehicle -> - if (candidateIndex != -1) { - val lastSeenIndex = lastSeenStopIndexByVehicle[vehicle.id] - if (lastSeenIndex != null && candidateIndex <= lastSeenIndex) { - return@mapNotNull null - } - } - - val rawEta = vehicle.stopTimes[stopKey] ?: return@mapNotNull null - val etaInstant = rawEta.toInstantOrNull() ?: return@mapNotNull null - - val firstStopKey = route.stops.firstOrNull() - val firstStopName = firstStopKey?.let { route.stopDetails[it]?.name } - val isCurrentlyAtFirstStop = - firstStopName != null && - vehicle.currentStop.equals(firstStopName, ignoreCase = true) - - if (isCurrentlyAtFirstStop && etaInstant.isBefore(now)) { - return@mapNotNull null - } - - Duration.between(now, etaInstant).toMinutes() - }.filter { it >= -1 } - .sorted() - .take(2) - .toList() - - val labels = - nextMins.map { mins -> - when { - mins <= 0 -> "now" - else -> "${mins}m" - } - } - - StopRow( - stopKey = stopKey, - stop = stop, - etaLabels = labels, - ) - } -} - -private fun String.toInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/SvgVehicleMarker.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/SvgVehicleMarker.kt similarity index 98% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/SvgVehicleMarker.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/SvgVehicleMarker.kt index c5de2fc..eac9027 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/SvgVehicleMarker.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/SvgVehicleMarker.kt @@ -1,4 +1,4 @@ -package edu.rpi.shuttletracker.ui.maps +package edu.rpi.shuttletracker.ui.maps.components import android.content.Context import android.graphics.Bitmap diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt new file mode 100644 index 0000000..1ce1ab9 --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt @@ -0,0 +1,200 @@ +package edu.rpi.shuttletracker.ui.maps.utils + +import androidx.compose.runtime.Immutable +import edu.rpi.shuttletracker.data.models.Route +import edu.rpi.shuttletracker.data.models.Stop +import edu.rpi.shuttletracker.data.models.Vehicle +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime + +private const val PREVIOUS_LOOP_ETA_MAX_AGE_SECONDS = 300L + +@Immutable +data class VehicleEtaUi( + val vehicleId: String, + val vehicleLabel: String, + val etaText: String, + val etaInstant: Instant, +) + +@Immutable +data class StopRowUi( + val stopKey: String, + val stop: Stop, + val etaLabels: List, +) + +object EtaUtils { + fun updateLastSeenStopIndices( + vehicles: List, + routesByName: Map, + previousStopIndexByVehicleId: Map, + ): Map { + val updatedStopIndexByVehicleId = previousStopIndexByVehicleId.toMutableMap() + + vehicles.forEach { vehicle -> + val route = vehicle.routeName?.let(routesByName::get) ?: return@forEach + val currentStopIndex = findCurrentStopIndex(vehicle, route) ?: return@forEach + + val previousStopIndex = updatedStopIndexByVehicleId[vehicle.id] + + when { + previousStopIndex == null -> { + updatedStopIndexByVehicleId[vehicle.id] = currentStopIndex + } + currentStopIndex > previousStopIndex -> { + updatedStopIndexByVehicleId[vehicle.id] = currentStopIndex + } + currentStopIndex == 0 && previousStopIndex > 0 -> { + // Bus looped back to the beginning of the route + updatedStopIndexByVehicleId[vehicle.id] = 0 + } + } + } + + return updatedStopIndexByVehicleId + } + + fun buildSelectedStopEtas( + selectedStopKey: String, + routesByName: Map, + vehicles: List, + lastSeenStopIndexByVehicleId: Map, + now: Instant = Instant.now(), + ): List { + return vehicles + .asSequence() + .mapNotNull { vehicle -> + val route = vehicle.routeName?.let(routesByName::get) ?: return@mapNotNull null + val stopIndex = route.stops.indexOf(selectedStopKey) + if (stopIndex == -1) return@mapNotNull null + + val lastSeenStopIndex = lastSeenStopIndexByVehicleId[vehicle.id] + if (!shouldShowStopForVehicle(vehicle, route, stopIndex, lastSeenStopIndex)) { + return@mapNotNull null + } + + val etaInstant = vehicle.stopTimes[selectedStopKey]?.toInstantOrNull() ?: return@mapNotNull null + if (shouldHideOldEtaAtRouteStart(vehicle, route, etaInstant, now)) { + return@mapNotNull null + } + + VehicleEtaUi( + vehicleId = vehicle.id, + vehicleLabel = vehicle.name, + etaText = formatEtaText(now, etaInstant), + etaInstant = etaInstant, + ) + }.sortedBy { it.etaInstant } + .toList() + } + + fun buildStopRowsForRoute( + route: Route, + vehicles: List, + routeName: String, + lastSeenStopIndexByVehicleId: Map, + now: Instant = Instant.now(), + maxEtasPerStop: Int = 2, + ): List { + return route.stops.mapNotNull { stopKey -> + val stop = route.stopDetails[stopKey] ?: return@mapNotNull null + val stopIndex = route.stops.indexOf(stopKey) + + val etaLabels = + vehicles + .asSequence() + .filter { it.routeName == routeName } + .mapNotNull { vehicle -> + val lastSeenStopIndex = lastSeenStopIndexByVehicleId[vehicle.id] + if (!shouldShowStopForVehicle(vehicle, route, stopIndex, lastSeenStopIndex)) { + return@mapNotNull null + } + + val etaInstant = vehicle.stopTimes[stopKey]?.toInstantOrNull() ?: return@mapNotNull null + if (shouldHideOldEtaAtRouteStart(vehicle, route, etaInstant, now)) { + return@mapNotNull null + } + + Duration.between(now, etaInstant).toMinutes() + }.sorted() + .take(maxEtasPerStop) + .map(::formatEtaMinutes) + .toList() + + StopRowUi( + stopKey = stopKey, + stop = stop, + etaLabels = etaLabels, + ) + } + } + + fun shouldShowStopForVehicle( + vehicle: Vehicle, + route: Route, + candidateStopIndex: Int, + lastSeenStopIndex: Int?, + ): Boolean { + val currentStopIndex = findCurrentStopIndex(vehicle, route) + val isCurrentStop = currentStopIndex != null && candidateStopIndex == currentStopIndex + + if (lastSeenStopIndex != null) { + if (candidateStopIndex < lastSeenStopIndex) return false + + // If this is the same stop as the last seen stop, only show it + // when the vehicle still reports that stop as current. + if (candidateStopIndex == lastSeenStopIndex && !isCurrentStop) { + return false + } + } + + return true + } + + fun findCurrentStopIndex( + vehicle: Vehicle, + route: Route, + ): Int? { + val currentStopKey = vehicle.currentStop ?: return null + + val stopIndex = + route.stops.indexOfFirst { routeStopKey -> + routeStopKey.equals(currentStopKey, ignoreCase = true) + } + + return stopIndex.takeIf { it != -1 } + } + + fun formatEtaText( + now: Instant, + etaInstant: Instant, + ): String { + val minutesUntilArrival = Duration.between(now, etaInstant).toMinutes() + return formatEtaMinutes(minutesUntilArrival) + } + + private fun formatEtaMinutes(minutesUntilArrival: Long): String = + when { + minutesUntilArrival <= 0 -> "${minutesUntilArrival}m" + else -> "${minutesUntilArrival}m" + } + + private fun shouldHideOldEtaAtRouteStart( + vehicle: Vehicle, + route: Route, + etaInstant: Instant, + now: Instant, + ): Boolean { + val firstStopKey = route.stops.firstOrNull() ?: return false + val isCurrentlyAtFirstStop = + vehicle.currentStop?.equals(firstStopKey, ignoreCase = true) == true + + return isCurrentlyAtFirstStop && + etaInstant.isBefore(now.minusSeconds(PREVIOUS_LOOP_ETA_MAX_AGE_SECONDS)) + } + + private fun String.toInstantOrNull(): Instant? = + runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() +} From 9a0ca147f26039f195b7080fbaf590d70fbe45c4 Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Tue, 10 Mar 2026 22:20:47 -0400 Subject: [PATCH 09/13] refactor: separate ui and util functions --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 90 +++-- ...heduleSheetContent.kt => ScheduleSheet.kt} | 284 +++++++------- .../{StopEtaContent.kt => StopEtaCard.kt} | 4 +- .../{StopsSheetContent.kt => StopSheet.kt} | 49 ++- .../shuttletracker/ui/maps/utils/EtaUtils.kt | 18 +- .../ui/maps/utils/ScheduleUtils.kt | 112 ++++++ .../ui/schedule/ScheduleScreen.kt | 349 ------------------ .../ui/schedule/ScheduleViewModel.kt | 109 ------ app/src/main/res/values/strings.xml | 31 +- 9 files changed, 384 insertions(+), 662 deletions(-) rename app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/{ScheduleSheetContent.kt => ScheduleSheet.kt} (58%) rename app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/{StopEtaContent.kt => StopEtaCard.kt} (98%) rename app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/{StopsSheetContent.kt => StopSheet.kt} (85%) create mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt delete mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleScreen.kt delete mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleViewModel.kt diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 2d70870..bae1aa5 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -77,14 +76,14 @@ import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.ScheduleScreenDestination import com.ramcosta.composedestinations.generated.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.data.models.Vehicle +import edu.rpi.shuttletracker.ui.maps.components.ScheduleSheet import edu.rpi.shuttletracker.ui.maps.components.StopEtaContent -import edu.rpi.shuttletracker.ui.maps.components.StopSheetContent +import edu.rpi.shuttletracker.ui.maps.components.StopSheet import edu.rpi.shuttletracker.ui.maps.components.getVehicleMarkerDescriptor import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi import edu.rpi.shuttletracker.ui.theme.VehicleColors @@ -105,7 +104,9 @@ fun MapsScreen( var selectedVehicleId by remember { mutableStateOf(null) } var showSheet by rememberSaveable { mutableStateOf(false) } + var showScheduleSheet by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scheduleSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val cameraPositionState = rememberCameraPositionState { @@ -130,7 +131,7 @@ fun MapsScreen( NavigationBarItem( selected = false, - onClick = { navigator.navigate(ScheduleScreenDestination()) }, + onClick = { showScheduleSheet = true }, icon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, label = { Text("Schedule") }, ) @@ -152,7 +153,6 @@ fun MapsScreen( ShuttleMap( mapsUiState = mapsUiState, padding = padding, - onScheduleClick = { navigator.navigate(ScheduleScreenDestination()) }, onSettingsClick = { navigator.navigate(SettingsScreenDestination()) }, onToggleMapTypeClick = { viewModel.toggleMapType() }, cameraPositionState = cameraPositionState, @@ -170,7 +170,7 @@ fun MapsScreen( Modifier .align(Alignment.BottomCenter) .padding(padding) - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = 12.dp, vertical = 24.dp), title = selectedStop?.name ?: "Tap a stop to see etas", selectedStopEtas = mapsUiState.selectedStopEtas, lastEtasUpdatedAt = mapsUiState.lastEtasUpdatedAt, @@ -198,41 +198,42 @@ fun MapsScreen( } }, ) - - if (showSheet) { - ModalBottomSheet( - onDismissRequest = { showSheet = false }, - sheetState = sheetState, - ) { - StopSheetContent( - routeKeys = mapsUiState.allowedRouteKeys, - selectedRouteKey = mapsUiState.selectedRouteKey, - stopRows = mapsUiState.stopRows, - showDetails = true, - onRouteSelected = { routeKey -> - viewModel.setSelectedRoute(routeKey) - }, - onStopClick = { stopKey, stop -> - viewModel.setSelectedStop(stopKey) - selectedStop = stop - showSheet = false - scope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition - .Builder() - .target(stop.latLng()) - .zoom(maxOf(cameraPositionState.position.zoom, 16f)) - .tilt(0f) - .build(), - ), - 1000, - ) - } - }, - ) - } - } + StopSheet( + show = showSheet, + sheetState = sheetState, + routeKeys = mapsUiState.allowedRouteKeys, + selectedRouteKey = mapsUiState.selectedRouteKey, + stopRows = mapsUiState.stopRows, + onDismiss = { showSheet = false }, + onRouteSelected = { routeKey -> + viewModel.setSelectedRoute(routeKey) + }, + onStopClick = { stopKey, stop -> + viewModel.setSelectedStop(stopKey) + selectedStop = stop + showSheet = false + scope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + CameraPosition + .Builder() + .target(stop.latLng()) + .zoom(maxOf(cameraPositionState.position.zoom, 16f)) + .tilt(0f) + .build(), + ), + 1000, + ) + } + }, + ) + ScheduleSheet( + show = showScheduleSheet, + sheetState = scheduleSheetState, + schedule = mapsUiState.schedule, + routesByName = mapsUiState.routes, + onDismiss = { showScheduleSheet = false }, + ) } } } @@ -241,7 +242,6 @@ fun MapsScreen( private fun ShuttleMap( mapsUiState: MapsUiState, padding: PaddingValues, - onScheduleClick: () -> Unit, onSettingsClick: () -> Unit, onToggleMapTypeClick: () -> Unit, cameraPositionState: CameraPositionState, @@ -347,7 +347,6 @@ private fun ShuttleMap( .padding(horizontal = 10.dp), isMyLocationEnabled = isLocationPermissionGranted, mapTypeIcon = mapTypeIcon, - onScheduleClick = onScheduleClick, onSettingsClick = onSettingsClick, onRecenterClick = { LocationServices @@ -489,7 +488,6 @@ private fun MapButtonsOverlay( modifier: Modifier = Modifier, isMyLocationEnabled: Boolean, mapTypeIcon: ImageVector, - onScheduleClick: () -> Unit, onSettingsClick: () -> Unit, onRecenterClick: () -> Unit, onToggleMapTypeClick: () -> Unit, @@ -504,10 +502,6 @@ private fun MapButtonsOverlay( .align(Alignment.TopStart), verticalArrangement = Arrangement.spacedBy(10.dp), ) { -// ActionButton(icon = Icons.Outlined.Schedule) { -// onScheduleClick() -// } -// ActionButton(icon = Icons.Outlined.Settings) { onSettingsClick() } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheetContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt similarity index 58% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheetContent.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt index 24caeac..1d0bbd9 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheetContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt @@ -1,8 +1,8 @@ package edu.rpi.shuttletracker.ui.maps.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -11,17 +11,22 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SheetState import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,19 +39,39 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.DayOfWeek +import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import java.time.LocalTime -import java.time.format.DateTimeFormatter +import edu.rpi.shuttletracker.ui.maps.utils.StopTimeInfo +import edu.rpi.shuttletracker.ui.maps.utils.consolidatedTimes +import edu.rpi.shuttletracker.ui.maps.utils.routesForDay import java.util.Calendar -import java.util.Locale -import kotlin.collections.iterator -// Header / Peak +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleSheet( + show: Boolean, + sheetState: SheetState, + schedule: Schedule?, + routesByName: Map, + onDismiss: () -> Unit, +) { + if (!show) return + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + ScheduleSheetContent( + schedule = schedule, + routesByName = routesByName, + ) + } +} @Composable -fun ScheduleSheetContent( +private fun ScheduleSheetContent( schedule: Schedule?, - showDetails: Boolean, + routesByName: Map, ) { Column( modifier = @@ -54,33 +79,50 @@ fun ScheduleSheetContent( .fillMaxWidth() .fillMaxHeight(.86f), horizontalAlignment = Alignment.CenterHorizontally, + ) { + ScheduleHeader() + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = DividerDefaults.Thickness, + ) + + when (schedule) { + null -> EmptyState(R.string.no_schedule_found) + else -> ScheduleDetailsContent(schedule = schedule, routesByName = routesByName) + } + } +} + +@Composable +private fun ScheduleHeader() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stringResource(R.string.bottom_sheet_peek_title), + text = stringResource(R.string.schedule_title), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 4.dp), ) Text( - text = stringResource(R.string.bottom_sheet_peek_subtitle), + text = stringResource(R.string.schedule_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), + textAlign = TextAlign.Center, ) - - if (!showDetails) return - - HorizontalDivider(Modifier.fillMaxWidth(), DividerDefaults.Thickness) - - when (schedule) { - null -> EmptyState(R.string.no_schedule_found) - else -> ScheduleDetailsContent(schedule) - } } } @Composable -private fun ScheduleDetailsContent(schedule: Schedule) { +private fun ScheduleDetailsContent( + schedule: Schedule, + routesByName: Map, +) { var selectedDay by remember { mutableStateOf(DayOfWeek.fromToday()) } val routes = @@ -111,9 +153,14 @@ private fun ScheduleDetailsContent(schedule: Schedule) { HorizontalDivider(Modifier, DividerDefaults.Thickness) val times = - remember(selectedDay, selectedRoute, schedule) { - val dir = selectedRoute ?: return@remember emptyList() - consolidatedTimes(dir, selectedDay, schedule) + remember(selectedDay, selectedRoute, schedule, routesByName) { + val routeName = selectedRoute ?: return@remember emptyList() + consolidatedTimes( + routeName = routeName, + day = selectedDay, + schedule = schedule, + routesByName = routesByName, + ) } if (times.isEmpty()) { @@ -121,18 +168,49 @@ private fun ScheduleDetailsContent(schedule: Schedule) { return } + var expandedRowKey by remember(selectedDay, selectedRoute) { + mutableStateOf(null) + } + + val listState = rememberLazyListState() + + val now = Calendar.getInstance() + val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + + val scrollIndex = + remember(times) { + times.indexOfFirst { it.minutesOfDay >= nowMinutes }.let { index -> + if (index == -1) 0 else index + } + } + + LaunchedEffect(times, scrollIndex) { + if (times.isNotEmpty()) { + listState.scrollToItem(scrollIndex) + } + } + LazyColumn( + state = listState, modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp), ) { - items(times, key = { it.vehicleName + it.time + it.route }) { item -> - ScheduleTimeRow(time = item.time, vehicleName = item.vehicleName) + items(times, key = { it.vehicleName + it.departureTime + it.routeName }) { item -> + val rowKey = item.vehicleName + item.departureTime + item.routeName + + ScheduleTimeRow( + time = item.departureTime, + vehicleName = item.vehicleName, + expanded = expandedRowKey == rowKey, + stopTimes = item.stopTimes, + onToggleExpanded = { + expandedRowKey = if (expandedRowKey == rowKey) null else rowKey + }, + ) } } } -// Selectors - @Composable private fun DaySelector( selectedDay: DayOfWeek, @@ -175,14 +253,15 @@ private fun RouteSelector( label = stringResource( R.string.route_label_format, - dir - .lowercase() - .replaceFirstChar { it.titlecase() }, + dir.lowercase().replaceFirstChar { it.titlecase() }, ), route = dir, selectedRoute = selectedRoute, onRouteSelected = onSelect, - modifier = Modifier.weight(1f).padding(bottom = 8.dp), + modifier = + Modifier + .weight(1f) + .padding(bottom = 8.dp), ) } } @@ -228,18 +307,20 @@ private fun RouteTab( } } -// List content - @Composable private fun ScheduleTimeRow( time: String, vehicleName: String, + expanded: Boolean, + stopTimes: List, + onToggleExpanded: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() + .clickable { onToggleExpanded() } .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -262,6 +343,49 @@ private fun ScheduleTimeRow( color = tagColor, ) } + + Text( + text = if (expanded) "∨" else ">", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) + } + + if (expanded) { + if (stopTimes.isEmpty()) { + Text( + text = "No stop times available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) + } else { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + stopTimes.forEach { stopTime -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stopTime.stopName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + + Text( + text = stopTime.time, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } } HorizontalDivider( @@ -271,18 +395,6 @@ private fun ScheduleTimeRow( } } -@Composable -private fun EmptyState(textRes: Int) { - Box( - Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text(stringResource(textRes)) - } -} - private fun vehicleTagColor( vehicleName: String, defaultColor: Color, @@ -294,83 +406,3 @@ private fun vehicleTagColor( else -> defaultColor } } - -// Data helpers - -/** - * Collects all unique directions (e.g. "NORTH", "WEST") running on a given day. - */ -private fun routesForDay( - day: DayOfWeek, - data: Schedule, -): List { - val scheduleMap = data.scheduleMapFor(day) - val dirs = mutableSetOf() - - for ((_, times) in scheduleMap) { - for (pair in times) { - if (pair.size > 1) dirs.add(pair[1]) - } - } - return dirs.sorted() -} - -private data class TimeInfo( - val time: String, - val route: String, - val vehicleName: String, - val minutesOfDay: Int, -) - -/** - * Parses a time string into minutes since midnight (or null if invalid). - */ -private fun parseMinutesOfDay(timeStr: String): Int? = - runCatching { - val t = - LocalTime.parse( - timeStr.trim(), - DateTimeFormatter.ofPattern("h:mm a", Locale.US), - ) - t.hour * 60 + t.minute - }.getOrNull() - -/** - * Flattens, filters, and sorts upcoming departures for one route on a given day. - */ -private fun consolidatedTimes( - route: String, - day: DayOfWeek, - data: Schedule, -): List { - val scheduleMap = data.scheduleMapFor(day) - - val now = Calendar.getInstance() - val isToday = DayOfWeek.fromToday() == day - val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) - - val out = mutableListOf() - - for ((vehicleName, times) in scheduleMap) { - for (pair in times) { - if (pair.size <= 1) continue - - val timeStr = pair[0] - val routeStr = pair[1] - if (routeStr != route) continue - - val minutes = parseMinutesOfDay(timeStr) ?: continue - if (isToday && minutes < nowMinutes) continue - - out += - TimeInfo( - time = timeStr, - route = routeStr, - vehicleName = vehicleName, - minutesOfDay = minutes, - ) - } - } - - return out.sortedBy { it.minutesOfDay } -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt similarity index 98% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaContent.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt index 8f3a0e9..484d8db 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt @@ -18,8 +18,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -102,7 +104,7 @@ private fun StopEtaContainer( } Text( - text = "Note: ETAs may be off by a few minutes.", + text = stringResource(R.string.eta_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp), diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopsSheetContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt similarity index 85% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopsSheetContent.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt index 777b32d..33197c0 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopsSheetContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt @@ -13,8 +13,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,13 +29,41 @@ import androidx.compose.ui.unit.dp import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.ui.maps.utils.StopRowUi +import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaLabel +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun StopSheetContent( +fun StopSheet( + show: Boolean, + sheetState: SheetState, + routeKeys: List, + selectedRouteKey: String?, + stopRows: List, + onDismiss: () -> Unit, + onRouteSelected: (String) -> Unit, + onStopClick: (stopKey: String, stop: Stop) -> Unit, +) { + if (!show) return + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + StopSheetContent( + routeKeys = routeKeys, + selectedRouteKey = selectedRouteKey, + stopRows = stopRows, + onRouteSelected = onRouteSelected, + onStopClick = onStopClick, + ) + } +} + +@Composable +private fun StopSheetContent( routeKeys: List, selectedRouteKey: String?, stopRows: List, - showDetails: Boolean, onRouteSelected: (String) -> Unit, onStopClick: (stopKey: String, stop: Stop) -> Unit, ) { @@ -44,20 +75,18 @@ fun StopSheetContent( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stringResource(R.string.bottom_sheet_peek_title), + text = stringResource(R.string.stop_etas_title), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 4.dp), ) Text( - text = stringResource(R.string.bottom_sheet_peek_subtitle), + text = stringResource(R.string.stop_etas_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 8.dp), ) - if (!showDetails) return@Column - HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = DividerDefaults.Thickness, @@ -194,7 +223,7 @@ private fun RouteTab( @Composable private fun StopEtaRow( stopName: String, - etaLabels: List, + etaLabels: List, onClick: () -> Unit, ) { Column( @@ -224,13 +253,13 @@ private fun StopEtaRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - etaLabels.forEach { label -> + etaLabels.forEach { eta -> Surface( color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(999.dp), ) { Text( - text = label, + text = "${eta.vehicleLabel} • ${eta.minutes}m", modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), style = MaterialTheme.typography.labelLarge, ) @@ -248,7 +277,7 @@ private fun StopEtaRow( } @Composable -private fun EmptyState(textRes: Int) { +fun EmptyState(textRes: Int) { Box( modifier = Modifier diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt index 1ce1ab9..cd29712 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt @@ -22,7 +22,13 @@ data class VehicleEtaUi( data class StopRowUi( val stopKey: String, val stop: Stop, - val etaLabels: List, + val etaLabels: List, +) + +@Immutable +data class VehicleEtaLabel( + val vehicleLabel: String, + val minutes: Long, ) object EtaUtils { @@ -117,10 +123,14 @@ object EtaUtils { return@mapNotNull null } - Duration.between(now, etaInstant).toMinutes() - }.sorted() + val minutesUntilArrival = Duration.between(now, etaInstant).toMinutes() + + VehicleEtaLabel( + vehicleLabel = vehicle.name, + minutes = minutesUntilArrival, + ) + }.sortedBy { it.minutes } .take(maxEtasPerStop) - .map(::formatEtaMinutes) .toList() StopRowUi( diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt new file mode 100644 index 0000000..d74f704 --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt @@ -0,0 +1,112 @@ +package edu.rpi.shuttletracker.ui.maps.utils + +import edu.rpi.shuttletracker.data.models.DayOfWeek +import edu.rpi.shuttletracker.data.models.Route +import edu.rpi.shuttletracker.data.models.Schedule +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator + +fun routesForDay( + day: DayOfWeek, + schedule: Schedule, +): List { + val scheduleMap = schedule.scheduleMapFor(day) + val directions = mutableSetOf() + + for ((_, times) in scheduleMap) { + for (pair in times) { + if (pair.size > 1) directions.add(pair[1]) + } + } + + return directions.sorted() +} + +data class StopTimeInfo( + val stopName: String, + val time: String, +) + +data class TimeInfo( + val departureTime: String, + val routeName: String, + val vehicleName: String, + val minutesOfDay: Int, + val stopTimes: List, +) + +fun parseLocalTime(timeText: String): LocalTime? = + runCatching { + LocalTime.parse( + timeText.trim(), + DateTimeFormatter.ofPattern("h:mm a", Locale.US), + ) + }.getOrNull() + +fun parseMinutesOfDay(timeText: String): Int? = parseLocalTime(timeText)?.let { it.hour * 60 + it.minute } + +fun formatLocalTime(time: LocalTime): String = time.format(DateTimeFormatter.ofPattern("h:mm a", Locale.US)) + +fun buildStopTimesForDeparture( + routeName: String, + departureTime: String, + routesByName: Map, +): List { + val route = routesByName[routeName] ?: return emptyList() + val departureLocalTime = parseLocalTime(departureTime) ?: return emptyList() + + return route.stops.mapNotNull { stopKey -> + val stop = route.stopDetails[stopKey] ?: return@mapNotNull null + val stopTime = departureLocalTime.plusMinutes(stop.offset.toLong()) + + StopTimeInfo( + stopName = stop.name, + time = formatLocalTime(stopTime), + ) + } +} + +/** + * Flattens, filters, and sorts upcoming departures for one route on a given day. + */ +fun consolidatedTimes( + routeName: String, + day: DayOfWeek, + schedule: Schedule, + routesByName: Map, +): List { + val scheduleMap = schedule.scheduleMapFor(day) + val out = mutableListOf() + + for ((vehicleName, times) in scheduleMap) { + for (pair in times) { + if (pair.size <= 1) continue + + val departureTime = pair[0] + val scheduledRouteName = pair[1] + if (scheduledRouteName != routeName) continue + + val minutesOfDay = parseMinutesOfDay(departureTime) ?: continue + + out += + TimeInfo( + departureTime = departureTime, + routeName = scheduledRouteName, + vehicleName = vehicleName, + minutesOfDay = minutesOfDay, + stopTimes = + buildStopTimesForDeparture( + routeName = scheduledRouteName, + departureTime = departureTime, + routesByName = routesByName, + ), + ) + } + } + + return out.sortedBy { it.minutesOfDay } +} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleScreen.kt deleted file mode 100644 index 6126801..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleScreen.kt +++ /dev/null @@ -1,349 +0,0 @@ -package edu.rpi.shuttletracker.ui.schedule - -import androidx.compose.foundation.border -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.window.core.layout.WindowSizeClass -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.data.models.Route -import edu.rpi.shuttletracker.ui.util.CheckResponseError -import edu.rpi.shuttletracker.ui.util.LabeledDropdown -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Calendar -import java.util.Locale - -@OptIn(ExperimentalMaterial3Api::class) -@Destination -@Composable -fun ScheduleScreen( - navigator: DestinationsNavigator, - viewModel: ScheduleViewModel = hiltViewModel(), - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, -) { - val scheduleUiState = viewModel.scheduleUiState.collectAsStateWithLifecycle().value - val routes = scheduleUiState.routes - - // Checks if height < 480 dp - val useHorizontalLayout = - !windowSizeClass - .isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND) - - val days = listOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") - val allowedRoutes = setOf("NORTH", "WEST") - - // Route dropdown values - val routeDropdownItems = remember(routes) { allowedRoutes.toList() } - var selectedRoute by remember(routeDropdownItems) { mutableStateOf(routeDropdownItems.first()) } - if (selectedRoute !in routeDropdownItems) selectedRoute = routeDropdownItems.first() - - val selectedStop = "All Stops" - - // Weekday dropdown values - val todayName = remember { days[Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 1] } - var selectedDay by remember { mutableStateOf(todayName) } - val dayIndex = remember(selectedDay) { days.indexOf(selectedDay).takeIf { it >= 0 } ?: 0 } - - // Gets schedule base times for the selected day and route (north/west) - val selectedRouteTimes: List = - remember(selectedDay, selectedRoute, scheduleUiState.schedule) { - val routeSchedule = scheduleUiState.schedule.getOrNull(dayIndex) - when (selectedRoute) { - "NORTH" -> routeSchedule?.north ?: emptyList() - "WEST" -> routeSchedule?.west ?: emptyList() - else -> emptyList() - } - } - Scaffold( - snackbarHost = { - CheckResponseError( - scheduleUiState.networkError, - scheduleUiState.serverError, - scheduleUiState.unknownError, - ignoreErrorRequest = { viewModel.clearErrors() }, - retryErrorRequest = { - viewModel.clearErrors() - viewModel.loadAll() - }, - ) - }, - topBar = { - TopAppBar( - title = { Text("Schedule") }, - navigationIcon = { - IconButton(onClick = { navigator.popBackStack() }) { - Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - if (useHorizontalLayout) { - Row( - modifier = - Modifier - .padding(padding) - .padding(horizontal = 16.dp, vertical = 12.dp) - .fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Column( - modifier = - Modifier - .widthIn(min = 260.dp, max = 360.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Controls( - days = days, - selectedDay = selectedDay, - onDay = { selectedDay = it }, - routeItems = routeDropdownItems, - selectedRoute = selectedRoute, - onRoute = { selectedRoute = it }, - ) - } - - Box( - modifier = - Modifier - .fillMaxWidth() - .border(width = 1.dp, color = MaterialTheme.colorScheme.onPrimaryContainer) - .padding(8.dp), - ) { - ScheduleScroll( - selectedRouteTimes = selectedRouteTimes, - selectedStop = selectedStop, - routeData = routes[selectedRoute], - ) - } - } - } else { - Column( - modifier = - Modifier - .padding(padding) - .padding(horizontal = 16.dp, vertical = 12.dp) - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Controls( - days = days, - selectedDay = selectedDay, - onDay = { selectedDay = it }, - routeItems = routeDropdownItems, - selectedRoute = selectedRoute, - onRoute = { selectedRoute = it }, - ) - Box( - modifier = - Modifier - .fillMaxWidth() - .border(width = 1.dp, color = MaterialTheme.colorScheme.onPrimaryContainer) - .padding(8.dp), - ) { - ScheduleScroll( - selectedRouteTimes = selectedRouteTimes, - selectedStop = selectedStop, - routeData = routes[selectedRoute], - ) - } - } - } - } -} - -@Composable -private fun Controls( - days: List, - selectedDay: String, - onDay: (String) -> Unit, - routeItems: List, - selectedRoute: String, - onRoute: (String) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - routeItems.forEachIndexed { index, option -> - SegmentedButton( - selected = selectedRoute == option, - onClick = { onRoute(option) }, - shape = SegmentedButtonDefaults.itemShape(index, routeItems.size), - label = { Text(option) }, - ) - } - } - LabeledDropdown( - label = "Weekday", - items = days, - selectedItem = selectedDay, - onItemSelected = onDay, - ) - } -} - -@Composable -private fun ScheduleScroll( - selectedRouteTimes: List, - selectedStop: String, - routeData: Route?, - centered: Boolean = false, -) { - val listState = rememberLazyListState() - val stops = routeData?.stopDetails?.values?.toList() ?: emptyList() - - val columnModifier = if (centered) Modifier.fillMaxSize() else Modifier - val columnHorizontal = if (centered) Alignment.CenterHorizontally else Alignment.Start - val columnVertical = if (centered) Arrangement.Center else Arrangement.Top - val textAlign = if (centered) TextAlign.Center else TextAlign.Start - - val reliableStops = listOf("All Stops", "Student Union") - - Column( - modifier = columnModifier, - horizontalAlignment = columnHorizontal, - verticalArrangement = columnVertical, - ) { - Text( - text = - if (selectedStop in reliableStops) { - stringResource(R.string.time_estimated_reliable) - } else { - stringResource(R.string.time_estimated_unreliable) - }, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), - ) - - if (selectedRouteTimes.isEmpty() || stops.isEmpty()) { - Text( - text = "Loading...", - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - ) - return - } - - val formatterIn = remember { DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) } - val formatterOut = remember { DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) } - - fun parseTime(timeStr: String): LocalTime = LocalTime.parse(timeStr.uppercase(Locale.getDefault()), formatterIn) - - val rowTimes = - remember(selectedRouteTimes, selectedStop, stops) { - if (selectedStop == "All Stops") { - selectedRouteTimes.map { baseStr -> - parseTime(baseStr) - } - } else { - val offset = stops.find { it.name == selectedStop }?.offset ?: 0 - selectedRouteTimes.map { baseStr -> parseTime(baseStr).plusMinutes(offset.toLong()) } - } - } - - val rowDisplay = - remember(rowTimes, selectedStop) { - rowTimes.map { time -> time.format(formatterOut) } - } - - // Auto-scroll to user time - LaunchedEffect(rowTimes, selectedStop) { - if (rowTimes.isEmpty()) return@LaunchedEffect - val now = LocalTime.now() - val firstUpcomingIndex = rowTimes.indexOfFirst { !it.isBefore(now) } - val scrollToIndex = if (firstUpcomingIndex >= 0) firstUpcomingIndex else rowTimes.lastIndex - if (scrollToIndex >= 0) listState.scrollToItem(scrollToIndex) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(rowDisplay) { line -> - if (selectedStop == "All Stops") { - Text( - text = "$line Student Union", - textAlign = textAlign, - modifier = - Modifier - .fillMaxWidth() - .padding( - start = 0.dp, - bottom = 4.dp, - ), - ) - stops - .filter { it.name != "Student Union" } - .forEach { stop -> - Text( - text = stop.name, - textAlign = textAlign, - modifier = - Modifier - .fillMaxWidth() - .padding( - start = if (!centered) 16.dp else 0.dp, - bottom = 2.dp, - ), - ) - } - } else { - Text( - text = line, - textAlign = textAlign, - modifier = - Modifier - .fillMaxWidth() - .padding( - bottom = 4.dp, - ), - ) - } - } - } - } -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleViewModel.kt deleted file mode 100644 index 22f19a4..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleViewModel.kt +++ /dev/null @@ -1,109 +0,0 @@ -package edu.rpi.shuttletracker.ui.schedule - -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.haroldadmin.cnradapter.NetworkResponse -import dagger.hilt.android.lifecycle.HiltViewModel -import edu.rpi.shuttletracker.data.models.AggregatedSchedule -import edu.rpi.shuttletracker.data.models.ErrorResponse -import edu.rpi.shuttletracker.data.models.Route -import edu.rpi.shuttletracker.data.repositories.ApiRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class ScheduleViewModel - @Inject - constructor( - private val apiRepository: ApiRepository, - ) : ViewModel() { - // represents the ui state of the view - private val _scheduleUiState = MutableStateFlow(ScheduleUIState()) - val scheduleUiState: StateFlow = _scheduleUiState - - init { - loadAll() - } - - fun loadAll() { - if (scheduleUiState.value.schedule.isEmpty()) { - getAggregatedSchedule() - } - if (scheduleUiState.value.routes.isEmpty()) { - getRoutes() - } - } - - /** - * sets all the errors to none - * */ - fun clearErrors() { - loadAll() - _scheduleUiState.update { - it.copy( - unknownError = null, - networkError = null, - serverError = null, - ) - } - } - - private fun getAggregatedSchedule() { - viewModelScope.launch { - readApiResponse(apiRepository.getAggregatedSchedule()) { response -> - _scheduleUiState.update { - it.copy(schedule = response) - } - } - } - } - - private fun getRoutes() { - viewModelScope.launch { - readApiResponse(apiRepository.getRoutes()) { routes -> - _scheduleUiState.update { - it.copy(routes = routes) - } - } - } - } - - /** - * Reads the network response and maps it to correct place - * */ - private fun readApiResponse( - response: NetworkResponse, - success: (body: T) -> Unit, - ) { - when (response) { - is NetworkResponse.Success -> success(response.body) - is NetworkResponse.ServerError -> - _scheduleUiState.update { - it.copy(serverError = response) - } - - is NetworkResponse.NetworkError -> - _scheduleUiState.update { - it.copy(networkError = response) - } - - is NetworkResponse.UnknownError -> - _scheduleUiState.update { - it.copy(unknownError = response) - } - } - } - } - -@Immutable -data class ScheduleUIState( - val schedule: List = listOf(), - val routes: Map = emptyMap(), - val networkError: NetworkResponse.NetworkError<*, ErrorResponse>? = null, - val serverError: NetworkResponse.ServerError<*, ErrorResponse>? = null, - val unknownError: NetworkResponse.UnknownError<*, ErrorResponse>? = null, -) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61df2c4..cdb3a4e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,13 +107,27 @@ Shuttle %1$s %.1f mph Unable to determine your location - "Student Union Shuttle Arrivals" - "Chasan stop available M–F, 7:00 AM – 5:30 PM" Select a route No schedule found No shuttles running %1$s Route + + + Student Union Schedule + Times are based on departures from the Student Union. + Stop times for other locations are precomputed and may not be fully accurate. + + + "Tap a stop to see etas" + "Note: ETAs may be off by a few minutes." + "No ETAs found" + + + "ETAs for each Stop" + "Chasan stop available M–F, 7:00 AM – 5:30 PM" + + I understand Network error @@ -149,18 +163,5 @@ Maximum stop distance - - - Effective from %1$s to %2$s - Monday - Tuesday - Wednesday - Thursday - Friday - Saturday - Sunday - Estimated Time - Estimated Time (do not rely on) - \ No newline at end of file From 9f9b1058b90adc54a701800b9c8bc2dcee958204 Mon Sep 17 00:00:00 2001 From: Bryan Tran Date: Wed, 11 Mar 2026 00:15:44 -0400 Subject: [PATCH 10/13] more refactoring and ui update --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 8 +- .../shuttletracker/ui/maps/MapsViewModel.kt | 11 +- .../ui/maps/components/ScheduleSheet.kt | 1 - .../ui/maps/components/StopEtaCard.kt | 27 +---- .../ui/maps/components/StopSheet.kt | 114 +++++++++--------- .../shuttletracker/ui/maps/utils/EtaUtils.kt | 84 ++++++++++++- .../ui/maps/utils/ScheduleUtils.kt | 12 +- app/src/main/res/values/strings.xml | 14 ++- 8 files changed, 168 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index bae1aa5..1cf51e4 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -82,7 +82,7 @@ import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.data.models.Vehicle import edu.rpi.shuttletracker.ui.maps.components.ScheduleSheet -import edu.rpi.shuttletracker.ui.maps.components.StopEtaContent +import edu.rpi.shuttletracker.ui.maps.components.StopEtaCard import edu.rpi.shuttletracker.ui.maps.components.StopSheet import edu.rpi.shuttletracker.ui.maps.components.getVehicleMarkerDescriptor import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi @@ -170,8 +170,8 @@ fun MapsScreen( Modifier .align(Alignment.BottomCenter) .padding(padding) - .padding(horizontal = 12.dp, vertical = 24.dp), - title = selectedStop?.name ?: "Tap a stop to see etas", + .padding(horizontal = 12.dp, vertical = 27.dp), + title = selectedStop?.name ?: stringResource(R.string.tap_indicator), selectedStopEtas = mapsUiState.selectedStopEtas, lastEtasUpdatedAt = mapsUiState.lastEtasUpdatedAt, stopSelected = selectedStop != null, @@ -587,7 +587,7 @@ fun EtaOverlayCard( shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface, ) { - StopEtaContent( + StopEtaCard( modifier = Modifier.padding(vertical = 2.dp), title = title, selectedStopEtas = selectedStopEtas, diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt index 271cd46..14aecf6 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.google.maps.android.compose.MapType import com.haroldadmin.cnradapter.NetworkResponse import dagger.hilt.android.lifecycle.HiltViewModel +import edu.rpi.shuttletracker.data.models.DayOfWeek import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule @@ -215,10 +216,12 @@ class MapsViewModel val route = routes[routeKey] if (route != null) { EtaUtils.buildStopRowsForRoute( - route, - vehicles, - routeKey, - lastSeenStopIndexByVehicle, + route = route, + vehicles = vehicles, + routeName = routeKey, + lastSeenStopIndexByVehicleId = lastSeenStopIndexByVehicle, + schedule = state.schedule, + day = DayOfWeek.fromToday(), ) } else { emptyList() diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt index 1d0bbd9..f0c3a9d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt @@ -374,7 +374,6 @@ private fun ScheduleTimeRow( Text( text = stopTime.stopName, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt index 484d8db..057b023 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt @@ -31,28 +31,7 @@ import java.time.Instant import java.time.temporal.ChronoUnit @Composable -fun StopEtaContent( - modifier: Modifier = Modifier, - title: String, - selectedStopEtas: List, - lastEtasUpdatedAt: Instant?, - stopSelected: Boolean, - onClearStop: () -> Unit, - onEtaChipClick: (vehicleId: String) -> Unit, -) { - StopEtaContainer( - modifier = modifier, - title = title, - selectedStopEtas = selectedStopEtas, - lastEtasUpdatedAt = lastEtasUpdatedAt, - stopSelected = stopSelected, - onClearStop = onClearStop, - onEtaChipClick = onEtaChipClick, - ) -} - -@Composable -private fun StopEtaContainer( +fun StopEtaCard( modifier: Modifier = Modifier, title: String, selectedStopEtas: List, @@ -76,7 +55,7 @@ private fun StopEtaContainer( if (selectedStopEtas.isEmpty()) { Text( - text = "No ETAs found", + text = stringResource(R.string.no_etas), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = @@ -173,7 +152,7 @@ private fun EtaChip( onClick: () -> Unit, ) { Text( - text = "Shuttle ${eta.vehicleLabel} • ${eta.etaText}", + text = stringResource(R.string.shuttle_eta_chip, eta.vehicleLabel, eta.etaText), style = MaterialTheme.typography.bodyMedium, modifier = Modifier diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt index 33197c0..3a2b701 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt @@ -74,79 +74,70 @@ private fun StopSheetContent( .fillMaxHeight(.86f), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = stringResource(R.string.stop_etas_title), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 4.dp), - ) - - Text( - text = stringResource(R.string.stop_etas_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) + StopsHeader() HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = DividerDefaults.Thickness, ) - if (routeKeys.isEmpty()) { + if (routeKeys.isEmpty() || selectedRouteKey == null) { EmptyState(R.string.no_schedule_found) return@Column } - StopsDetailsContent( - routeKeys = routeKeys, - selectedRouteKey = selectedRouteKey, - stopRows = stopRows, - onRouteSelected = onRouteSelected, - onStopClick = onStopClick, + RouteSelector( + routes = routeKeys, + selectedRoute = selectedRouteKey, + onSelect = onRouteSelected, + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = DividerDefaults.Thickness, ) + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + items( + items = stopRows, + key = { it.stopKey }, + ) { row -> + StopEtaRow( + stopName = row.stop.name, + etaLabels = row.etaLabels, + onClick = { + onStopClick(row.stopKey, row.stop) + }, + ) + } + } } } @Composable -private fun StopsDetailsContent( - routeKeys: List, - selectedRouteKey: String?, - stopRows: List, - onRouteSelected: (String) -> Unit, - onStopClick: (stopKey: String, stop: Stop) -> Unit, -) { - if (routeKeys.isEmpty() || selectedRouteKey == null) { - EmptyState(R.string.no_schedule_found) - return - } - - RouteSelector( - routes = routeKeys, - selectedRoute = selectedRouteKey, - onSelect = onRouteSelected, - ) - - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - thickness = DividerDefaults.Thickness, - ) - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(bottom = 24.dp), +private fun StopsHeader() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - items( - items = stopRows, - key = { it.stopKey }, - ) { row -> - StopEtaRow( - stopName = row.stop.name, - etaLabels = row.etaLabels, - onClick = { - onStopClick(row.stopKey, row.stop) - }, - ) - } + Text( + text = stringResource(R.string.stop_etas_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Text( + text = stringResource(R.string.stop_etas_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } } @@ -174,7 +165,7 @@ private fun RouteSelector( modifier = Modifier .weight(1f) - .padding(bottom = 8.dp), + .padding(vertical = 8.dp), ) } } @@ -259,7 +250,12 @@ private fun StopEtaRow( shape = RoundedCornerShape(999.dp), ) { Text( - text = "${eta.vehicleLabel} • ${eta.minutes}m", + text = + when { + eta.scheduledTime != null -> "Scheduled • ${eta.scheduledTime}" + eta.minutes != null -> "${eta.vehicleLabel} • ${eta.minutes}m" + else -> eta.vehicleLabel + }, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), style = MaterialTheme.typography.labelLarge, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt index cd29712..b5f6080 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt @@ -1,12 +1,18 @@ package edu.rpi.shuttletracker.ui.maps.utils import androidx.compose.runtime.Immutable +import edu.rpi.shuttletracker.data.models.DayOfWeek import edu.rpi.shuttletracker.data.models.Route +import edu.rpi.shuttletracker.data.models.Schedule import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.data.models.Vehicle import java.time.Duration import java.time.Instant +import java.time.LocalTime import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.Locale private const val PREVIOUS_LOOP_ETA_MAX_AGE_SECONDS = 300L @@ -28,7 +34,8 @@ data class StopRowUi( @Immutable data class VehicleEtaLabel( val vehicleLabel: String, - val minutes: Long, + val minutes: Long? = null, + val scheduledTime: String? = null, ) object EtaUtils { @@ -49,9 +56,11 @@ object EtaUtils { previousStopIndex == null -> { updatedStopIndexByVehicleId[vehicle.id] = currentStopIndex } + currentStopIndex > previousStopIndex -> { updatedStopIndexByVehicleId[vehicle.id] = currentStopIndex } + currentStopIndex == 0 && previousStopIndex > 0 -> { // Bus looped back to the beginning of the route updatedStopIndexByVehicleId[vehicle.id] = 0 @@ -101,6 +110,8 @@ object EtaUtils { vehicles: List, routeName: String, lastSeenStopIndexByVehicleId: Map, + schedule: Schedule?, + day: DayOfWeek, now: Instant = Instant.now(), maxEtasPerStop: Int = 2, ): List { @@ -118,7 +129,9 @@ object EtaUtils { return@mapNotNull null } - val etaInstant = vehicle.stopTimes[stopKey]?.toInstantOrNull() ?: return@mapNotNull null + val etaInstant = + vehicle.stopTimes[stopKey]?.toInstantOrNull() ?: return@mapNotNull null + if (shouldHideOldEtaAtRouteStart(vehicle, route, etaInstant, now)) { return@mapNotNull null } @@ -129,9 +142,22 @@ object EtaUtils { vehicleLabel = vehicle.name, minutes = minutesUntilArrival, ) - }.sortedBy { it.minutes } + }.sortedBy { it.minutes ?: Long.MAX_VALUE } .take(maxEtasPerStop) - .toList() + .toMutableList() + + val firstStopKey = route.stops.firstOrNull() + val isFirstStop = stopKey == firstStopKey + + if (isFirstStop && etaLabels.isEmpty() && schedule != null) { + etaLabels += + nextScheduledDepartures( + routeName = routeName, + day = day, + schedule = schedule, + maxCount = 2, + ) + } StopRowUi( stopKey = stopKey, @@ -207,4 +233,54 @@ object EtaUtils { private fun String.toInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() + + private fun nextScheduledDepartures( + routeName: String, + day: DayOfWeek, + schedule: Schedule, + maxCount: Int, + ): List { + val scheduleMap = schedule.scheduleMapFor(day) + + val now = Calendar.getInstance() + val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + + val formatter = DateTimeFormatter.ofPattern("h:mm a", Locale.US) + + val departures = + buildList { + for ((vehicleName, times) in scheduleMap) { + for (pair in times) { + if (pair.size <= 1) continue + + val timeStr = pair[0] + val scheduledRouteName = pair[1] + if (scheduledRouteName != routeName) continue + + val localTime = + runCatching { LocalTime.parse(timeStr.trim(), formatter) }.getOrNull() + ?: continue + + val minutesOfDay = localTime.hour * 60 + localTime.minute + if (minutesOfDay < nowMinutes) continue + + add( + Triple( + vehicleName, + timeStr, + minutesOfDay, + ), + ) + } + } + }.sortedBy { it.third } + .take(maxCount) + + return departures.map { (vehicleName, timeStr, _) -> + VehicleEtaLabel( + vehicleLabel = vehicleName, + scheduledTime = timeStr, + ) + } + } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt index d74f704..6e90e3f 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt @@ -47,7 +47,17 @@ fun parseLocalTime(timeText: String): LocalTime? = ) }.getOrNull() -fun parseMinutesOfDay(timeText: String): Int? = parseLocalTime(timeText)?.let { it.hour * 60 + it.minute } +fun parseMinutesOfDay(timeText: String): Int? = + parseLocalTime(timeText)?.let { time -> + val minutes = time.hour * 60 + time.minute + + // Moves after midnight departures at the end of the list + if (time.hour in 0..3) { + minutes + 24 * 60 + } else { + minutes + } + } fun formatLocalTime(time: LocalTime): String = time.format(DateTimeFormatter.ofPattern("h:mm a", Locale.US)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cdb3a4e..c7901df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,7 +91,7 @@ Go to settings to enable permissions To settings Not now - + Permission denied. Permission granted. Grant permissions @@ -119,13 +119,15 @@ Stop times for other locations are precomputed and may not be fully accurate. - "Tap a stop to see etas" - "Note: ETAs may be off by a few minutes." - "No ETAs found" + Tap a stop to see etas + Note: ETAs may be off by a few minutes. + No ETAs found + Shuttle %1$s • %2$s - "ETAs for each Stop" - "Chasan stop available M–F, 7:00 AM – 5:30 PM" + Stop ETAs + Chasan stop available M–F, 7:00 AM – 5:30 PM. + ETAs are still in development and may be inaccurate. From 375ef8db6f89536a4385f2142ae17f47b3871f2e Mon Sep 17 00:00:00 2001 From: bryantran24 <158430748+bryantran24@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:19:15 -0400 Subject: [PATCH 11/13] Remember route and auto expand row --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 3 + .../ui/maps/components/ScheduleSheet.kt | 45 +++++++-- .../ui/maps/utils/ScheduleUtils.kt | 99 +++++++++---------- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 88 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 1cf51e4..8caae9d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -102,6 +102,7 @@ fun MapsScreen( var selectedStop by remember { mutableStateOf(null) } var selectedVehicleId by remember { mutableStateOf(null) } + var selectedScheduleRoute by rememberSaveable { mutableStateOf(null) } var showSheet by rememberSaveable { mutableStateOf(false) } var showScheduleSheet by rememberSaveable { mutableStateOf(false) } @@ -232,6 +233,8 @@ fun MapsScreen( sheetState = scheduleSheetState, schedule = mapsUiState.schedule, routesByName = mapsUiState.routes, + selectedRoute = selectedScheduleRoute, + onSelectedRouteChange = { selectedScheduleRoute = it }, onDismiss = { showScheduleSheet = false }, ) } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt index f0c3a9d..ae52e4f 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt @@ -53,6 +53,8 @@ fun ScheduleSheet( sheetState: SheetState, schedule: Schedule?, routesByName: Map, + selectedRoute: String?, + onSelectedRouteChange: (String) -> Unit, onDismiss: () -> Unit, ) { if (!show) return @@ -64,6 +66,8 @@ fun ScheduleSheet( ScheduleSheetContent( schedule = schedule, routesByName = routesByName, + selectedRoute = selectedRoute, + onSelectedRouteChange = onSelectedRouteChange, ) } } @@ -72,6 +76,8 @@ fun ScheduleSheet( private fun ScheduleSheetContent( schedule: Schedule?, routesByName: Map, + selectedRoute: String?, + onSelectedRouteChange: (String) -> Unit, ) { Column( modifier = @@ -89,7 +95,13 @@ private fun ScheduleSheetContent( when (schedule) { null -> EmptyState(R.string.no_schedule_found) - else -> ScheduleDetailsContent(schedule = schedule, routesByName = routesByName) + else -> + ScheduleDetailsContent( + schedule = schedule, + routesByName = routesByName, + selectedRoute = selectedRoute, + onSelectedRouteChange = onSelectedRouteChange, + ) } } } @@ -122,6 +134,8 @@ private fun ScheduleHeader() { private fun ScheduleDetailsContent( schedule: Schedule, routesByName: Map, + selectedRoute: String?, + onSelectedRouteChange: (String) -> Unit, ) { var selectedDay by remember { mutableStateOf(DayOfWeek.fromToday()) } @@ -130,9 +144,12 @@ private fun ScheduleDetailsContent( routesForDay(selectedDay, schedule) } - var selectedRoute by remember(selectedDay, routes) { - mutableStateOf(routes.firstOrNull()) - } + val activeRoute = + when { + selectedRoute in routes -> selectedRoute + routes.isNotEmpty() -> routes.first() + else -> null + } DaySelector( selectedDay = selectedDay, @@ -146,15 +163,15 @@ private fun ScheduleDetailsContent( RouteSelector( routes = routes, - selectedRoute = selectedRoute, - onSelect = { selectedRoute = it }, + selectedRoute = activeRoute, + onSelect = onSelectedRouteChange, ) HorizontalDivider(Modifier, DividerDefaults.Thickness) val times = - remember(selectedDay, selectedRoute, schedule, routesByName) { - val routeName = selectedRoute ?: return@remember emptyList() + remember(selectedDay, activeRoute, schedule, routesByName) { + val routeName = activeRoute ?: return@remember emptyList() consolidatedTimes( routeName = routeName, day = selectedDay, @@ -168,7 +185,7 @@ private fun ScheduleDetailsContent( return } - var expandedRowKey by remember(selectedDay, selectedRoute) { + var expandedRowKey by remember(selectedDay, activeRoute) { mutableStateOf(null) } @@ -180,13 +197,21 @@ private fun ScheduleDetailsContent( val scrollIndex = remember(times) { times.indexOfFirst { it.minutesOfDay >= nowMinutes }.let { index -> - if (index == -1) 0 else index + if (index <= 0) 0 else index - 1 } } LaunchedEffect(times, scrollIndex) { if (times.isNotEmpty()) { + val autoExpandedItem = times[scrollIndex] + expandedRowKey = + autoExpandedItem.vehicleName + + autoExpandedItem.departureTime + + autoExpandedItem.routeName + listState.scrollToItem(scrollIndex) + } else { + expandedRowKey = null } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt index 6e90e3f..c9f43ea 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt @@ -10,21 +10,7 @@ import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.iterator -fun routesForDay( - day: DayOfWeek, - schedule: Schedule, -): List { - val scheduleMap = schedule.scheduleMapFor(day) - val directions = mutableSetOf() - - for ((_, times) in scheduleMap) { - for (pair in times) { - if (pair.size > 1) directions.add(pair[1]) - } - } - - return directions.sorted() -} +private val TIME_FORMATTER = DateTimeFormatter.ofPattern("h:mm a", Locale.US) data class StopTimeInfo( val stopName: String, @@ -39,45 +25,20 @@ data class TimeInfo( val stopTimes: List, ) -fun parseLocalTime(timeText: String): LocalTime? = - runCatching { - LocalTime.parse( - timeText.trim(), - DateTimeFormatter.ofPattern("h:mm a", Locale.US), - ) - }.getOrNull() - -fun parseMinutesOfDay(timeText: String): Int? = - parseLocalTime(timeText)?.let { time -> - val minutes = time.hour * 60 + time.minute +fun routesForDay( + day: DayOfWeek, + schedule: Schedule, +): List { + val scheduleMap = schedule.scheduleMapFor(day) + val directions = mutableSetOf() - // Moves after midnight departures at the end of the list - if (time.hour in 0..3) { - minutes + 24 * 60 - } else { - minutes + for ((_, times) in scheduleMap) { + for (pair in times) { + if (pair.size > 1) directions.add(pair[1]) } } -fun formatLocalTime(time: LocalTime): String = time.format(DateTimeFormatter.ofPattern("h:mm a", Locale.US)) - -fun buildStopTimesForDeparture( - routeName: String, - departureTime: String, - routesByName: Map, -): List { - val route = routesByName[routeName] ?: return emptyList() - val departureLocalTime = parseLocalTime(departureTime) ?: return emptyList() - - return route.stops.mapNotNull { stopKey -> - val stop = route.stopDetails[stopKey] ?: return@mapNotNull null - val stopTime = departureLocalTime.plusMinutes(stop.offset.toLong()) - - StopTimeInfo( - stopName = stop.name, - time = formatLocalTime(stopTime), - ) - } + return directions.sorted() } /** @@ -120,3 +81,41 @@ fun consolidatedTimes( return out.sortedBy { it.minutesOfDay } } + +fun buildStopTimesForDeparture( + routeName: String, + departureTime: String, + routesByName: Map, +): List { + val route = routesByName[routeName] ?: return emptyList() + val departureLocalTime = parseLocalTime(departureTime) ?: return emptyList() + + return route.stops.mapNotNull { stopKey -> + val stop = route.stopDetails[stopKey] ?: return@mapNotNull null + val stopTime = departureLocalTime.plusMinutes(stop.offset.toLong()) + + StopTimeInfo( + stopName = stop.name, + time = formatLocalTime(stopTime), + ) + } +} + +fun parseMinutesOfDay(timeText: String): Int? = + parseLocalTime(timeText)?.let { time -> + val minutes = time.hour * 60 + time.minute + + // Moves after midnight departures at the end of the list + if (time.hour in 0..3) { + minutes + 24 * 60 + } else { + minutes + } + } + +fun parseLocalTime(timeText: String): LocalTime? = + runCatching { + LocalTime.parse(timeText.trim(), TIME_FORMATTER) + }.getOrNull() + +fun formatLocalTime(time: LocalTime): String = time.format(TIME_FORMATTER) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97f75af..0effb22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,7 +116,7 @@ Student Union Schedule Times are based on departures from the Student Union. - Stop times for other locations are precomputed and may not be fully accurate. + Arrival times at other stops are estimated using fixed offsets and may not be exact. Tap a stop to see etas From bcae4873642dd2161ab68359569e4f3760f105d0 Mon Sep 17 00:00:00 2001 From: bryantran24 <158430748+bryantran24@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:03:32 -0400 Subject: [PATCH 12/13] Remove all eta stuff --- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 194 ++---------- .../shuttletracker/ui/maps/MapsViewModel.kt | 87 ------ .../ui/maps/components/ScheduleSheet.kt | 14 + .../ui/maps/components/StopEtaCard.kt | 189 ------------ .../ui/maps/components/StopSheet.kt | 286 ------------------ .../shuttletracker/ui/maps/utils/EtaUtils.kt | 286 ------------------ app/src/main/res/values/strings.xml | 13 - 7 files changed, 37 insertions(+), 1032 deletions(-) delete mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt delete mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt delete mode 100644 app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 8caae9d..27bf4a3 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.outlined.Layers @@ -21,7 +20,6 @@ import androidx.compose.material.icons.outlined.LocationDisabled import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button @@ -29,12 +27,9 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -64,7 +59,6 @@ import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.MapStyleOptions -import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.Circle import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapProperties @@ -82,10 +76,7 @@ import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.data.models.Vehicle import edu.rpi.shuttletracker.ui.maps.components.ScheduleSheet -import edu.rpi.shuttletracker.ui.maps.components.StopEtaCard -import edu.rpi.shuttletracker.ui.maps.components.StopSheet import edu.rpi.shuttletracker.ui.maps.components.getVehicleMarkerDescriptor -import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi import edu.rpi.shuttletracker.ui.theme.VehicleColors import edu.rpi.shuttletracker.ui.util.CheckResponseError import kotlinx.coroutines.launch @@ -100,46 +91,13 @@ fun MapsScreen( val mapsUiState = viewModel.mapsUiState.collectAsStateWithLifecycle().value val snackbarHostState = remember { SnackbarHostState() } - var selectedStop by remember { mutableStateOf(null) } - var selectedVehicleId by remember { mutableStateOf(null) } var selectedScheduleRoute by rememberSaveable { mutableStateOf(null) } - var showSheet by rememberSaveable { mutableStateOf(false) } var showScheduleSheet by rememberSaveable { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scheduleSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val cameraPositionState = - rememberCameraPositionState { - position = - CameraPosition.fromLatLngZoom( - LatLng(42.73068146020498, -73.67619731950525), - 14.3f, - ) - } - - val scope = rememberCoroutineScope() - Scaffold( - bottomBar = { - NavigationBar { - NavigationBarItem( - selected = false, - onClick = { showSheet = true }, - icon = { Icon(Icons.Outlined.StopCircle, contentDescription = null) }, - label = { Text("Stops") }, - ) - - NavigationBarItem( - selected = false, - onClick = { showScheduleSheet = true }, - icon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, - label = { Text("Schedule") }, - ) - } - }, snackbarHost = { - // finds errors when requesting data to server CheckResponseError( mapsUiState.networkError, mapsUiState.serverError, @@ -155,79 +113,10 @@ fun MapsScreen( mapsUiState = mapsUiState, padding = padding, onSettingsClick = { navigator.navigate(SettingsScreenDestination()) }, + onScheduleClick = { showScheduleSheet = true }, onToggleMapTypeClick = { viewModel.toggleMapType() }, - cameraPositionState = cameraPositionState, - selectedStopKey = mapsUiState.selectedStopKey, - selectedStop = selectedStop, - selectedVehicleId = selectedVehicleId, - onStopSelected = { stopKey, stop -> - viewModel.setSelectedStop(stopKey) - selectedStop = stop - }, ) - EtaOverlayCard( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(padding) - .padding(horizontal = 12.dp, vertical = 27.dp), - title = selectedStop?.name ?: stringResource(R.string.tap_indicator), - selectedStopEtas = mapsUiState.selectedStopEtas, - lastEtasUpdatedAt = mapsUiState.lastEtasUpdatedAt, - stopSelected = selectedStop != null, - onClearStop = { - viewModel.setSelectedStop(null) - selectedStop = null - selectedVehicleId = null - }, - onEtaChipClick = { vehicleId -> - selectedVehicleId = vehicleId - val vehicle = mapsUiState.vehicles.firstOrNull { it.id == vehicleId } ?: return@EtaOverlayCard - scope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition - .Builder() - .target(vehicle.latLng()) - .zoom(maxOf(cameraPositionState.position.zoom, 16f)) - .tilt(0f) - .build(), - ), - 700, - ) - } - }, - ) - StopSheet( - show = showSheet, - sheetState = sheetState, - routeKeys = mapsUiState.allowedRouteKeys, - selectedRouteKey = mapsUiState.selectedRouteKey, - stopRows = mapsUiState.stopRows, - onDismiss = { showSheet = false }, - onRouteSelected = { routeKey -> - viewModel.setSelectedRoute(routeKey) - }, - onStopClick = { stopKey, stop -> - viewModel.setSelectedStop(stopKey) - selectedStop = stop - showSheet = false - scope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition - .Builder() - .target(stop.latLng()) - .zoom(maxOf(cameraPositionState.position.zoom, 16f)) - .tilt(0f) - .build(), - ), - 1000, - ) - } - }, - ) ScheduleSheet( show = showScheduleSheet, sheetState = scheduleSheetState, @@ -246,17 +135,12 @@ private fun ShuttleMap( mapsUiState: MapsUiState, padding: PaddingValues, onSettingsClick: () -> Unit, + onScheduleClick: () -> Unit, onToggleMapTypeClick: () -> Unit, - cameraPositionState: CameraPositionState, - selectedStopKey: String?, - selectedStop: Stop?, - selectedVehicleId: String?, - onStopSelected: (stopKey: String, stop: Stop) -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - // can't show current location without location val isLocationPermissionGranted by remember { mutableStateOf( ActivityCompat.checkSelfPermission( @@ -266,12 +150,21 @@ private fun ShuttleMap( ) } + val cameraPositionState = + rememberCameraPositionState { + position = + CameraPosition.fromLatLngZoom( + LatLng(42.73068146020498, -73.67619731950525), + 14.3f, + ) + } + + var selectedStop by remember { mutableStateOf(null) } val isDark = mapsUiState.themeMode.isDarkTheme(isSystemInDarkTheme()) Box(modifier = Modifier.fillMaxSize()) { GoogleMap( modifier = Modifier.fillMaxSize(), - // makes sure the items drawn (current location and compass) are clickable contentPadding = padding, cameraPositionState = cameraPositionState, properties = @@ -292,33 +185,28 @@ private fun ShuttleMap( null }, ), - // removes the zoom control which was covered by the FAB uiSettings = MapUiSettings( zoomControlsEnabled = false, myLocationButtonEnabled = false, ), ) { - // creates the stops mapsUiState.routes.forEach { (_, route) -> - route.stopDetails.forEach { (stopKey, stop) -> + route.stopDetails.forEach { (_, stop) -> StopMarker( stop = stop, - selected = stopKey == selectedStopKey || stop.name == selectedStop?.name, - onSelected = { onStopSelected(stopKey, stop) }, + selected = stop.name == selectedStop?.name, + onSelected = { selectedStop = it }, ) } } - // creates the vehicle markers mapsUiState.vehicles.forEach { vehicle -> VehicleMarker( vehicle = vehicle, - selected = vehicle.id == selectedVehicleId, ) } - // draws the paths mapsUiState.routes.forEach { (_, route) -> val points = route.latLng() if (points.isNotEmpty()) { @@ -327,9 +215,8 @@ private fun ShuttleMap( color = Color( android.graphics.Color - .valueOf( - route.color.toColorInt(), - ).toArgb(), + .valueOf(route.color.toColorInt()) + .toArgb(), ), ) } @@ -351,6 +238,7 @@ private fun ShuttleMap( isMyLocationEnabled = isLocationPermissionGranted, mapTypeIcon = mapTypeIcon, onSettingsClick = onSettingsClick, + onScheduleClick = onScheduleClick, onRecenterClick = { LocationServices .getFusedLocationProviderClient(context) @@ -426,10 +314,7 @@ private fun StopMarker( * Creates a marker for a vehicle * */ @Composable -private fun VehicleMarker( - vehicle: Vehicle, - selected: Boolean, -) { +private fun VehicleMarker(vehicle: Vehicle) { val context = LocalContext.current val markerState = rememberUpdatedMarkerState(position = vehicle.latLng()) @@ -438,14 +323,6 @@ private fun VehicleMarker( markerState.position = vehicle.latLng() } - LaunchedEffect(selected) { - if (selected) { - markerState.showInfoWindow() - } else { - markerState.hideInfoWindow() - } - } - val vehicleColor = when (vehicle.routeName) { "NORTH" -> VehicleColors.North @@ -492,6 +369,7 @@ private fun MapButtonsOverlay( isMyLocationEnabled: Boolean, mapTypeIcon: ImageVector, onSettingsClick: () -> Unit, + onScheduleClick: () -> Unit, onRecenterClick: () -> Unit, onToggleMapTypeClick: () -> Unit, ) { @@ -508,6 +386,9 @@ private fun MapButtonsOverlay( ActionButton(icon = Icons.Outlined.Settings) { onSettingsClick() } + ActionButton(icon = Icons.Outlined.Schedule) { + onScheduleClick() + } } // Right side Column( @@ -572,32 +453,3 @@ private fun ActionButton( } } } - -@Composable -fun EtaOverlayCard( - modifier: Modifier = Modifier, - title: String, - selectedStopEtas: List, - lastEtasUpdatedAt: java.time.Instant?, - stopSelected: Boolean, - onClearStop: () -> Unit, - onEtaChipClick: (vehicleId: String) -> Unit, -) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(18.dp), - tonalElevation = 2.dp, - shadowElevation = 8.dp, - color = MaterialTheme.colorScheme.surface, - ) { - StopEtaCard( - modifier = Modifier.padding(vertical = 2.dp), - title = title, - selectedStopEtas = selectedStopEtas, - lastEtasUpdatedAt = lastEtasUpdatedAt, - stopSelected = stopSelected, - onClearStop = onClearStop, - onEtaChipClick = onEtaChipClick, - ) - } -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt index 14aecf6..add1f15 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import com.google.maps.android.compose.MapType import com.haroldadmin.cnradapter.NetworkResponse import dagger.hilt.android.lifecycle.HiltViewModel -import edu.rpi.shuttletracker.data.models.DayOfWeek import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule @@ -17,9 +16,6 @@ import edu.rpi.shuttletracker.data.models.VehicleStopEta import edu.rpi.shuttletracker.data.models.VehicleVelocities import edu.rpi.shuttletracker.data.repositories.ApiRepository import edu.rpi.shuttletracker.data.repositories.UserPreferencesRepository -import edu.rpi.shuttletracker.ui.maps.utils.EtaUtils -import edu.rpi.shuttletracker.ui.maps.utils.StopRowUi -import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi import edu.rpi.shuttletracker.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -30,7 +26,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.time.Instant import javax.inject.Inject @HiltViewModel @@ -44,8 +39,6 @@ class MapsViewModel private val _mapsUiState = MutableStateFlow(MapsUiState()) val mapsUiState: StateFlow = _mapsUiState - private var lastSeenStopIndexByVehicle: Map = emptyMap() - init { loadAll() observeVehicles() @@ -72,16 +65,6 @@ class MapsViewModel loadAll() } - fun setSelectedStop(stopKey: String?) { - _mapsUiState.update { it.copy(selectedStopKey = stopKey) } - recomputeDerivedState() - } - - fun setSelectedRoute(routeKey: String?) { - _mapsUiState.update { it.copy(selectedRouteKey = routeKey) } - recomputeDerivedState() - } - private fun observeVehicles() { combine( apiRepository.observeVehicleLocations(pollMs = 5_000L), @@ -109,11 +92,8 @@ class MapsViewModel _mapsUiState.update { it.copy( vehicles = vehicles, - lastEtasUpdatedAt = Instant.now(), ) } - - recomputeDerivedState() }.launchIn(viewModelScope) } @@ -123,7 +103,6 @@ class MapsViewModel _mapsUiState.update { it.copy(routes = routes) } - recomputeDerivedState() } } } @@ -178,66 +157,6 @@ class MapsViewModel updateMapType(next) } - private fun recomputeDerivedState() { - val state = _mapsUiState.value - val routes = state.routes - val vehicles = state.vehicles - - lastSeenStopIndexByVehicle = - EtaUtils.updateLastSeenStopIndices( - vehicles, - routes, - lastSeenStopIndexByVehicle, - ) - - val allowedRouteKeys = - routes.keys - .filter { it in setOf("NORTH", "WEST") } - .sorted() - - val resolvedSelectedRouteKey = - when { - state.selectedRouteKey in allowedRouteKeys -> state.selectedRouteKey - else -> allowedRouteKeys.firstOrNull() - } - - val selectedStopEtas = - state.selectedStopKey?.let { stopKey -> - EtaUtils.buildSelectedStopEtas( - stopKey, - routes, - vehicles, - lastSeenStopIndexByVehicle, - ) - } ?: emptyList() - - val stopRows = - resolvedSelectedRouteKey?.let { routeKey -> - val route = routes[routeKey] - if (route != null) { - EtaUtils.buildStopRowsForRoute( - route = route, - vehicles = vehicles, - routeName = routeKey, - lastSeenStopIndexByVehicleId = lastSeenStopIndexByVehicle, - schedule = state.schedule, - day = DayOfWeek.fromToday(), - ) - } else { - emptyList() - } - } ?: emptyList() - - _mapsUiState.update { - it.copy( - allowedRouteKeys = allowedRouteKeys, - selectedRouteKey = resolvedSelectedRouteKey, - selectedStopEtas = selectedStopEtas, - stopRows = stopRows, - ) - } - } - private fun readApiResponse( response: NetworkResponse, success: (body: T) -> Unit, @@ -274,10 +193,4 @@ data class MapsUiState( val totalAnnouncements: Int = -1, val themeMode: ThemeMode = ThemeMode.System, val mapType: MapType = MapType.NORMAL, - val lastEtasUpdatedAt: Instant? = null, - val selectedStopKey: String? = null, - val selectedRouteKey: String? = null, - val selectedStopEtas: List = emptyList(), - val stopRows: List = emptyList(), - val allowedRouteKeys: List = emptyList(), ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt index ae52e4f..5d975f5 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt @@ -3,6 +3,7 @@ package edu.rpi.shuttletracker.ui.maps.components import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -419,6 +420,19 @@ private fun ScheduleTimeRow( } } +@Composable +private fun EmptyState(textRes: Int) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = stringResource(textRes)) + } +} + private fun vehicleTagColor( vehicleName: String, defaultColor: Color, diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt deleted file mode 100644 index 057b023..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopEtaCard.kt +++ /dev/null @@ -1,189 +0,0 @@ -package edu.rpi.shuttletracker.ui.maps.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaUi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit - -@Composable -fun StopEtaCard( - modifier: Modifier = Modifier, - title: String, - selectedStopEtas: List, - lastEtasUpdatedAt: Instant?, - stopSelected: Boolean, - onClearStop: () -> Unit, - onEtaChipClick: (vehicleId: String) -> Unit, -) { - Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - StopEtaHeader( - title = title, - onClearStop = onClearStop, - stopSelected = stopSelected, - lastEtasUpdatedAt = lastEtasUpdatedAt, - ) - - if (!stopSelected) return@Column - - if (selectedStopEtas.isEmpty()) { - Text( - text = stringResource(R.string.no_etas), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - } else { - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items( - items = selectedStopEtas, - key = { it.vehicleId }, - ) { eta -> - EtaChip( - eta = eta, - onClick = { onEtaChipClick(eta.vehicleId) }, - ) - } - } - } - - Text( - text = stringResource(R.string.eta_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp), - ) - } -} - -@Composable -private fun StopEtaHeader( - title: String, - onClearStop: () -> Unit, - stopSelected: Boolean, - lastEtasUpdatedAt: Instant?, -) { - val updatedText = - updatedAgoFlow(lastEtasUpdatedAt) - .collectAsStateWithLifecycle(initialValue = "") - .value - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - ) - - if (stopSelected) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (updatedText.isNotBlank()) { - Text( - text = updatedText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Box( - modifier = - Modifier - .size(28.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(50), - ).clickable { onClearStop() }, - contentAlignment = Alignment.Center, - ) { - Text("✕") - } - } - } - } -} - -@Composable -private fun EtaChip( - eta: VehicleEtaUi, - onClick: () -> Unit, -) { - Text( - text = stringResource(R.string.shuttle_eta_chip, eta.vehicleLabel, eta.etaText), - style = MaterialTheme.typography.bodyMedium, - modifier = - Modifier - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(999.dp), - ).clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 8.dp), - ) -} - -fun updatedAgoFlow(lastUpdatedAt: Instant?): Flow = - flow { - if (lastUpdatedAt == null) { - emit("") - return@flow - } - - while (true) { - val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) - val duration = Duration.between(lastUpdatedAt, now) - - val secs = duration.seconds.coerceAtLeast(0) - val text = - when { - secs < 5 -> "" - secs < 60 -> "Updated ${secs}s ago" - else -> "Updated ${secs / 60}m ago" - } - - emit(text) - delay(1000) - } - } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt deleted file mode 100644 index 3a2b701..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/StopSheet.kt +++ /dev/null @@ -1,286 +0,0 @@ -package edu.rpi.shuttletracker.ui.maps.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DividerDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.ui.maps.utils.StopRowUi -import edu.rpi.shuttletracker.ui.maps.utils.VehicleEtaLabel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun StopSheet( - show: Boolean, - sheetState: SheetState, - routeKeys: List, - selectedRouteKey: String?, - stopRows: List, - onDismiss: () -> Unit, - onRouteSelected: (String) -> Unit, - onStopClick: (stopKey: String, stop: Stop) -> Unit, -) { - if (!show) return - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - StopSheetContent( - routeKeys = routeKeys, - selectedRouteKey = selectedRouteKey, - stopRows = stopRows, - onRouteSelected = onRouteSelected, - onStopClick = onStopClick, - ) - } -} - -@Composable -private fun StopSheetContent( - routeKeys: List, - selectedRouteKey: String?, - stopRows: List, - onRouteSelected: (String) -> Unit, - onStopClick: (stopKey: String, stop: Stop) -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight(.86f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - StopsHeader() - - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - thickness = DividerDefaults.Thickness, - ) - - if (routeKeys.isEmpty() || selectedRouteKey == null) { - EmptyState(R.string.no_schedule_found) - return@Column - } - - RouteSelector( - routes = routeKeys, - selectedRoute = selectedRouteKey, - onSelect = onRouteSelected, - ) - - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - thickness = DividerDefaults.Thickness, - ) - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(bottom = 24.dp), - ) { - items( - items = stopRows, - key = { it.stopKey }, - ) { row -> - StopEtaRow( - stopName = row.stop.name, - etaLabels = row.etaLabels, - onClick = { - onStopClick(row.stopKey, row.stop) - }, - ) - } - } - } -} - -@Composable -private fun StopsHeader() { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.stop_etas_title), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 4.dp), - ) - - Text( - text = stringResource(R.string.stop_etas_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } -} - -@Composable -private fun RouteSelector( - routes: List, - selectedRoute: String?, - onSelect: (String) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(0.9f), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - routes.forEach { dir -> - RouteTab( - label = - stringResource( - R.string.route_label_format, - dir.lowercase().replaceFirstChar { it.titlecase() }, - ), - route = dir, - selectedRoute = selectedRoute, - onRouteSelected = onSelect, - modifier = - Modifier - .weight(1f) - .padding(vertical = 8.dp), - ) - } - } -} - -@Composable -private fun RouteTab( - label: String, - route: String, - selectedRoute: String?, - onRouteSelected: (String) -> Unit, - modifier: Modifier, -) { - val selected = route == selectedRoute - - Surface( - onClick = { onRouteSelected(route) }, - shape = RoundedCornerShape(12.dp), - tonalElevation = if (selected) 2.dp else 0.dp, - color = - if (selected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - modifier = modifier, - ) { - Text( - text = label, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelLarge, - color = - if (selected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } -} - -@Composable -private fun StopEtaRow( - stopName: String, - etaLabels: List, - onClick: () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .clickable { onClick() }, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stopName, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (etaLabels.isEmpty()) { - Text( - text = "—", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - etaLabels.forEach { eta -> - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(999.dp), - ) { - Text( - text = - when { - eta.scheduledTime != null -> "Scheduled • ${eta.scheduledTime}" - eta.minutes != null -> "${eta.vehicleLabel} • ${eta.minutes}m" - else -> eta.vehicleLabel - }, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - } - } - - HorizontalDivider( - modifier = Modifier.padding(start = 16.dp, end = 16.dp), - thickness = 0.5.dp, - ) - } -} - -@Composable -fun EmptyState(textRes: Int) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text(text = stringResource(textRes)) - } -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt deleted file mode 100644 index b5f6080..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/EtaUtils.kt +++ /dev/null @@ -1,286 +0,0 @@ -package edu.rpi.shuttletracker.ui.maps.utils - -import androidx.compose.runtime.Immutable -import edu.rpi.shuttletracker.data.models.DayOfWeek -import edu.rpi.shuttletracker.data.models.Route -import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.data.models.Vehicle -import java.time.Duration -import java.time.Instant -import java.time.LocalTime -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter -import java.util.Calendar -import java.util.Locale - -private const val PREVIOUS_LOOP_ETA_MAX_AGE_SECONDS = 300L - -@Immutable -data class VehicleEtaUi( - val vehicleId: String, - val vehicleLabel: String, - val etaText: String, - val etaInstant: Instant, -) - -@Immutable -data class StopRowUi( - val stopKey: String, - val stop: Stop, - val etaLabels: List, -) - -@Immutable -data class VehicleEtaLabel( - val vehicleLabel: String, - val minutes: Long? = null, - val scheduledTime: String? = null, -) - -object EtaUtils { - fun updateLastSeenStopIndices( - vehicles: List, - routesByName: Map, - previousStopIndexByVehicleId: Map, - ): Map { - val updatedStopIndexByVehicleId = previousStopIndexByVehicleId.toMutableMap() - - vehicles.forEach { vehicle -> - val route = vehicle.routeName?.let(routesByName::get) ?: return@forEach - val currentStopIndex = findCurrentStopIndex(vehicle, route) ?: return@forEach - - val previousStopIndex = updatedStopIndexByVehicleId[vehicle.id] - - when { - previousStopIndex == null -> { - updatedStopIndexByVehicleId[vehicle.id] = currentStopIndex - } - - currentStopIndex > previousStopIndex -> { - updatedStopIndexByVehicleId[vehicle.id] = currentStopIndex - } - - currentStopIndex == 0 && previousStopIndex > 0 -> { - // Bus looped back to the beginning of the route - updatedStopIndexByVehicleId[vehicle.id] = 0 - } - } - } - - return updatedStopIndexByVehicleId - } - - fun buildSelectedStopEtas( - selectedStopKey: String, - routesByName: Map, - vehicles: List, - lastSeenStopIndexByVehicleId: Map, - now: Instant = Instant.now(), - ): List { - return vehicles - .asSequence() - .mapNotNull { vehicle -> - val route = vehicle.routeName?.let(routesByName::get) ?: return@mapNotNull null - val stopIndex = route.stops.indexOf(selectedStopKey) - if (stopIndex == -1) return@mapNotNull null - - val lastSeenStopIndex = lastSeenStopIndexByVehicleId[vehicle.id] - if (!shouldShowStopForVehicle(vehicle, route, stopIndex, lastSeenStopIndex)) { - return@mapNotNull null - } - - val etaInstant = vehicle.stopTimes[selectedStopKey]?.toInstantOrNull() ?: return@mapNotNull null - if (shouldHideOldEtaAtRouteStart(vehicle, route, etaInstant, now)) { - return@mapNotNull null - } - - VehicleEtaUi( - vehicleId = vehicle.id, - vehicleLabel = vehicle.name, - etaText = formatEtaText(now, etaInstant), - etaInstant = etaInstant, - ) - }.sortedBy { it.etaInstant } - .toList() - } - - fun buildStopRowsForRoute( - route: Route, - vehicles: List, - routeName: String, - lastSeenStopIndexByVehicleId: Map, - schedule: Schedule?, - day: DayOfWeek, - now: Instant = Instant.now(), - maxEtasPerStop: Int = 2, - ): List { - return route.stops.mapNotNull { stopKey -> - val stop = route.stopDetails[stopKey] ?: return@mapNotNull null - val stopIndex = route.stops.indexOf(stopKey) - - val etaLabels = - vehicles - .asSequence() - .filter { it.routeName == routeName } - .mapNotNull { vehicle -> - val lastSeenStopIndex = lastSeenStopIndexByVehicleId[vehicle.id] - if (!shouldShowStopForVehicle(vehicle, route, stopIndex, lastSeenStopIndex)) { - return@mapNotNull null - } - - val etaInstant = - vehicle.stopTimes[stopKey]?.toInstantOrNull() ?: return@mapNotNull null - - if (shouldHideOldEtaAtRouteStart(vehicle, route, etaInstant, now)) { - return@mapNotNull null - } - - val minutesUntilArrival = Duration.between(now, etaInstant).toMinutes() - - VehicleEtaLabel( - vehicleLabel = vehicle.name, - minutes = minutesUntilArrival, - ) - }.sortedBy { it.minutes ?: Long.MAX_VALUE } - .take(maxEtasPerStop) - .toMutableList() - - val firstStopKey = route.stops.firstOrNull() - val isFirstStop = stopKey == firstStopKey - - if (isFirstStop && etaLabels.isEmpty() && schedule != null) { - etaLabels += - nextScheduledDepartures( - routeName = routeName, - day = day, - schedule = schedule, - maxCount = 2, - ) - } - - StopRowUi( - stopKey = stopKey, - stop = stop, - etaLabels = etaLabels, - ) - } - } - - fun shouldShowStopForVehicle( - vehicle: Vehicle, - route: Route, - candidateStopIndex: Int, - lastSeenStopIndex: Int?, - ): Boolean { - val currentStopIndex = findCurrentStopIndex(vehicle, route) - val isCurrentStop = currentStopIndex != null && candidateStopIndex == currentStopIndex - - if (lastSeenStopIndex != null) { - if (candidateStopIndex < lastSeenStopIndex) return false - - // If this is the same stop as the last seen stop, only show it - // when the vehicle still reports that stop as current. - if (candidateStopIndex == lastSeenStopIndex && !isCurrentStop) { - return false - } - } - - return true - } - - fun findCurrentStopIndex( - vehicle: Vehicle, - route: Route, - ): Int? { - val currentStopKey = vehicle.currentStop ?: return null - - val stopIndex = - route.stops.indexOfFirst { routeStopKey -> - routeStopKey.equals(currentStopKey, ignoreCase = true) - } - - return stopIndex.takeIf { it != -1 } - } - - fun formatEtaText( - now: Instant, - etaInstant: Instant, - ): String { - val minutesUntilArrival = Duration.between(now, etaInstant).toMinutes() - return formatEtaMinutes(minutesUntilArrival) - } - - private fun formatEtaMinutes(minutesUntilArrival: Long): String = - when { - minutesUntilArrival <= 0 -> "${minutesUntilArrival}m" - else -> "${minutesUntilArrival}m" - } - - private fun shouldHideOldEtaAtRouteStart( - vehicle: Vehicle, - route: Route, - etaInstant: Instant, - now: Instant, - ): Boolean { - val firstStopKey = route.stops.firstOrNull() ?: return false - val isCurrentlyAtFirstStop = - vehicle.currentStop?.equals(firstStopKey, ignoreCase = true) == true - - return isCurrentlyAtFirstStop && - etaInstant.isBefore(now.minusSeconds(PREVIOUS_LOOP_ETA_MAX_AGE_SECONDS)) - } - - private fun String.toInstantOrNull(): Instant? = - runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull() - - private fun nextScheduledDepartures( - routeName: String, - day: DayOfWeek, - schedule: Schedule, - maxCount: Int, - ): List { - val scheduleMap = schedule.scheduleMapFor(day) - - val now = Calendar.getInstance() - val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) - - val formatter = DateTimeFormatter.ofPattern("h:mm a", Locale.US) - - val departures = - buildList { - for ((vehicleName, times) in scheduleMap) { - for (pair in times) { - if (pair.size <= 1) continue - - val timeStr = pair[0] - val scheduledRouteName = pair[1] - if (scheduledRouteName != routeName) continue - - val localTime = - runCatching { LocalTime.parse(timeStr.trim(), formatter) }.getOrNull() - ?: continue - - val minutesOfDay = localTime.hour * 60 + localTime.minute - if (minutesOfDay < nowMinutes) continue - - add( - Triple( - vehicleName, - timeStr, - minutesOfDay, - ), - ) - } - } - }.sortedBy { it.third } - .take(maxCount) - - return departures.map { (vehicleName, timeStr, _) -> - VehicleEtaLabel( - vehicleLabel = vehicleName, - scheduledTime = timeStr, - ) - } - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0effb22..853ff0d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,24 +112,11 @@ No shuttles running %1$s Route - Student Union Schedule Times are based on departures from the Student Union. Arrival times at other stops are estimated using fixed offsets and may not be exact. - - Tap a stop to see etas - Note: ETAs may be off by a few minutes. - No ETAs found - Shuttle %1$s • %2$s - - - Stop ETAs - Chasan stop available M–F, 7:00 AM – 5:30 PM. - ETAs are still in development and may be inaccurate. - - I understand Network error From e711d36ae8936bb6062ada8b4a9290704daf9227 Mon Sep 17 00:00:00 2001 From: bryantran24 <158430748+bryantran24@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:02:44 -0400 Subject: [PATCH 13/13] Update schedule ui and add vehicle color memory --- .../rpi/shuttletracker/data/models/Route.kt | 7 +- .../rpi/shuttletracker/ui/maps/MapsScreen.kt | 49 ++++-- .../ui/maps/components/ScheduleSheet.kt | 140 ++++++++++++------ app/src/main/res/values/strings.xml | 3 +- 4 files changed, 140 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt index 67ae420..7d363ce 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt @@ -5,6 +5,7 @@ import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken import java.lang.reflect.Type data class Route( @@ -38,19 +39,19 @@ class RouteDeserializer : JsonDeserializer { val stops: List = context.deserialize( obj["STOPS"], - object : com.google.gson.reflect.TypeToken>() {}.type, + object : TypeToken>() {}.type, ) val polylineStops: List = context.deserialize( obj["POLYLINE_STOPS"], - object : com.google.gson.reflect.TypeToken>() {}.type, + object : TypeToken>() {}.type, ) val coordinates: List>> = context.deserialize( obj["ROUTES"], - object : com.google.gson.reflect.TypeToken>>>() {}.type, + object : TypeToken>>>() {}.type, ) // Decode dynamic stop keys, but only those listed in STOPS diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 27bf4a3..6040d19 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -79,6 +80,7 @@ import edu.rpi.shuttletracker.ui.maps.components.ScheduleSheet import edu.rpi.shuttletracker.ui.maps.components.getVehicleMarkerDescriptor import edu.rpi.shuttletracker.ui.theme.VehicleColors import edu.rpi.shuttletracker.ui.util.CheckResponseError +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -271,9 +273,6 @@ private fun ShuttleMap( } } -/** - * Creates a marker for a stop - * */ @Composable private fun StopMarker( stop: Stop, @@ -310,9 +309,6 @@ private fun StopMarker( ) } -/** - * Creates a marker for a vehicle - * */ @Composable private fun VehicleMarker(vehicle: Vehicle) { val context = LocalContext.current @@ -323,16 +319,31 @@ private fun VehicleMarker(vehicle: Vehicle) { markerState.position = vehicle.latLng() } - val vehicleColor = + val resolvedColor = when (vehicle.routeName) { "NORTH" -> VehicleColors.North "WEST" -> VehicleColors.West - else -> VehicleColors.Default + else -> null } + var vehicleColor by remember { mutableStateOf(resolvedColor) } + + LaunchedEffect(resolvedColor) { + if (resolvedColor != null) { + vehicleColor = resolvedColor + } else { + delay(30_000) + if (vehicleColor == null) { + vehicleColor = VehicleColors.Default + } + } + } + + val finalColor = resolvedColor ?: vehicleColor ?: VehicleColors.Default + val icon = - remember(vehicleColor) { - getVehicleMarkerDescriptor(context, 25f, vehicleColor.toArgb()) + remember(finalColor) { + getVehicleMarkerDescriptor(context, 25f, finalColor.toArgb()) } // gets vehicle speed and last time it updated @@ -386,9 +397,6 @@ private fun MapButtonsOverlay( ActionButton(icon = Icons.Outlined.Settings) { onSettingsClick() } - ActionButton(icon = Icons.Outlined.Schedule) { - onScheduleClick() - } } // Right side Column( @@ -411,6 +419,21 @@ private fun MapButtonsOverlay( onToggleMapTypeClick() } } + + FloatingActionButton( + onClick = onScheduleClick, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + imageVector = Icons.Outlined.Schedule, + contentDescription = "Open schedule", + ) + } } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt index 5d975f5..d5650fe 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt @@ -1,5 +1,12 @@ package edu.rpi.shuttletracker.ui.maps.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -9,7 +16,9 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -46,6 +55,7 @@ import edu.rpi.shuttletracker.ui.maps.utils.StopTimeInfo import edu.rpi.shuttletracker.ui.maps.utils.consolidatedTimes import edu.rpi.shuttletracker.ui.maps.utils.routesForDay import java.util.Calendar +import kotlin.text.lowercase @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -276,11 +286,6 @@ private fun RouteSelector( ) { routes.forEach { dir -> RouteTab( - label = - stringResource( - R.string.route_label_format, - dir.lowercase().replaceFirstChar { it.titlecase() }, - ), route = dir, selectedRoute = selectedRoute, onRouteSelected = onSelect, @@ -295,7 +300,6 @@ private fun RouteSelector( @Composable private fun RouteTab( - label: String, route: String, selectedRoute: String?, onRouteSelected: (String) -> Unit, @@ -303,20 +307,31 @@ private fun RouteTab( ) { val selected = route == selectedRoute + val selectedColor = + when { + "north" in route.lowercase() -> Color(0xFFD32F2F) + "west" in route.lowercase() -> Color(0xFF1976D2) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( onClick = { onRouteSelected(route) }, shape = RoundedCornerShape(12.dp), tonalElevation = if (selected) 2.dp else 0.dp, color = if (selected) { - MaterialTheme.colorScheme.primaryContainer + selectedColor.copy(alpha = 0.30f) } else { MaterialTheme.colorScheme.surfaceVariant }, modifier = modifier, ) { Text( - text = label, + text = + stringResource( + R.string.route_label_format, + route.lowercase().replaceFirstChar { it.titlecase() }, + ), modifier = Modifier .fillMaxWidth() @@ -341,6 +356,13 @@ private fun ScheduleTimeRow( stopTimes: List, onToggleExpanded: () -> Unit, ) { + val tagColor = + when { + "north" in vehicleName.lowercase() -> Color(0xFFD32F2F) + "west" in vehicleName.lowercase() -> Color(0xFF1976D2) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Column(modifier = Modifier.fillMaxWidth()) { Row( modifier = @@ -356,8 +378,6 @@ private fun ScheduleTimeRow( modifier = Modifier.weight(1f), ) - val tagColor = vehicleTagColor(vehicleName, MaterialTheme.colorScheme.onSurfaceVariant) - Surface( color = tagColor.copy(alpha = 0.15f), shape = RoundedCornerShape(6.dp), @@ -379,47 +399,95 @@ private fun ScheduleTimeRow( } if (expanded) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 0.5.dp, + ) + } + + AnimatedVisibility( + visible = expanded, + enter = + expandVertically( + animationSpec = tween(durationMillis = 220), + ) + + fadeIn( + animationSpec = tween(durationMillis = 180), + ), + exit = + shrinkVertically( + animationSpec = tween(durationMillis = 200), + ) + + fadeOut( + animationSpec = tween(durationMillis = 150), + ), + ) { if (stopTimes.isEmpty()) { Text( - text = "No stop times available", + text = stringResource(R.string.no_stop_times), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } else { Column( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { stopTimes.forEach { stopTime -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stopTime.stopName, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - - Text( - text = stopTime.time, - style = MaterialTheme.typography.bodyMedium, - ) - } + StopTimeItem( + stopName = stopTime.stopName, + time = stopTime.time, + accentColor = tagColor, + ) } } } } HorizontalDivider( - modifier = Modifier.padding(start = 16.dp, end = 16.dp), + modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp, ) } } +@Composable +private fun StopTimeItem( + stopName: String, + time: String, + accentColor: Color, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .width(2.dp) + .height(28.dp) + .background(accentColor, RoundedCornerShape(999.dp)), + ) + + Text( + text = stopName, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .weight(1f) + .padding(start = 10.dp), + ) + + Text( + text = time, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + @Composable private fun EmptyState(textRes: Int) { Box( @@ -432,15 +500,3 @@ private fun EmptyState(textRes: Int) { Text(text = stringResource(textRes)) } } - -private fun vehicleTagColor( - vehicleName: String, - defaultColor: Color, -): Color { - val n = vehicleName.lowercase() - return when { - "north" in n -> Color(0xFFD32F2F) - "west" in n -> Color(0xFF1976D2) - else -> defaultColor - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 853ff0d..e788175 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -113,9 +113,10 @@ %1$s Route - Student Union Schedule + Schedule Times are based on departures from the Student Union. Arrival times at other stops are estimated using fixed offsets and may not be exact. + No stop times available I understand