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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ class EtaComponentsTest {
composeRule.onAllNodesWithText("No live ETAs").assertCountEquals(2)
}

@Test
fun etaChipShowsTheShuttleNameAndMinutes() {
composeRule.setContent {
ShuttleTrackerTheme(dynamicColor = false) {
EtaChip(vehicleName = "500", routeName = "NORTH", minutes = 2)
}
}

composeRule.onNodeWithText("500 · 2m").assertIsDisplayed()
}

@Test
fun tappingARouteFilterInvokesTheCallbackWithThatRoute() {
var selectedFilter: String? = null
Expand Down Expand Up @@ -131,6 +142,7 @@ class EtaComponentsTest {

@Test
fun sheetShowsEveryVehicleEtaForTheSelectedStop() {
var clickedVehicleId: String? = null
val futureEta = Instant.now().plusSeconds(120).toString()
val stop =
buildStopsWithEtas(
Expand All @@ -144,6 +156,7 @@ class EtaComponentsTest {
stop = stop,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
onDismiss = {},
onVehicleClick = { clickedVehicleId = it },
)
}
}
Expand All @@ -152,7 +165,8 @@ class EtaComponentsTest {
composeRule.waitForIdle()

composeRule.onNodeWithText("Student Union").assertIsDisplayed()
composeRule.onNodeWithText("NORTH Bus").assertIsDisplayed()
composeRule.onNodeWithText("NORTH Bus").performClick()
assertEquals("bus-1", clickedVehicleId)
}

@Test
Expand All @@ -169,6 +183,7 @@ class EtaComponentsTest {
stop = stop,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
onDismiss = {},
onVehicleClick = {},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import edu.rpi.shuttletracker.core.network.NetworkResult
import edu.rpi.shuttletracker.core.ui.theme.ShuttleTrackerTheme
import edu.rpi.shuttletracker.data.models.VehicleLocation
import edu.rpi.shuttletracker.data.models.VehicleStopEta
import edu.rpi.shuttletracker.data.models.VehicleVelocities
import edu.rpi.shuttletracker.feature.schedule.ScheduleViewModel
import edu.rpi.shuttletracker.testing.fakes.FakeShuttleRepository
import edu.rpi.shuttletracker.testing.fakes.FakeUserPreferences
Expand All @@ -16,6 +19,7 @@ import edu.rpi.shuttletracker.testing.fixtures.testSchedule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant

/**
* Constructs MapsViewModel directly with fakes (bypassing hiltViewModel()) so the bottom
Expand Down Expand Up @@ -72,11 +76,35 @@ class MapsScreenNavigationTest {
composeRule.onNodeWithContentDescription("Open settings").assertIsDisplayed()
}

private fun setContent() {
@Test
fun tappingAVehicleInTheEtaSheetSwitchesToMap() {
setContent {
vehicleLocations.tryEmit(
NetworkResult.Success(
mapOf("bus-1" to VehicleLocation("500", 42.730, -73.680, 12.0, Instant.now().toString(), 90)),
),
)
vehicleVelocities.tryEmit(NetworkResult.Success(mapOf("bus-1" to VehicleVelocities("NORTH", false, null))))
vehicleEtas.tryEmit(
NetworkResult.Success(
mapOf("bus-1" to VehicleStopEta(mapOf("union" to Instant.now().plusSeconds(300).toString()))),
),
)
}

composeRule.onNodeWithText("ETAs").performClick()
composeRule.onNodeWithText("Student Union").performClick()
composeRule.onNodeWithText("500").performClick()

composeRule.onNodeWithContentDescription("Open settings").assertIsDisplayed()
}

private fun setContent(configureRepository: FakeShuttleRepository.() -> Unit = {}) {
val repository =
FakeShuttleRepository().apply {
routesResult = NetworkResult.Success(mapOf("NORTH" to testRoute()))
scheduleResult = NetworkResult.Success(testSchedule())
configureRepository()
}
val preferences = FakeUserPreferences()
val viewModel = MapsViewModel(repository, preferences)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ fun VehicleLocationDto.toModel(): VehicleLocation {
require(latitude.isFinite() && latitude in -90.0..90.0) { "Invalid vehicle latitude" }
require(longitude.isFinite() && longitude in -180.0..180.0) { "Invalid vehicle longitude" }
OffsetDateTime.parse(timestamp)
return VehicleLocation(name, latitude, longitude, speedMph, timestamp, headingDegrees)
return VehicleLocation(name, latitude, longitude, speedMph, timestamp, headingDegrees?.toInt())
}

fun VehicleStopEtaDto.toModel() = VehicleStopEta(stopTimes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ data class VehicleStopEta(

/** The `/velocities` endpoint's data, one per vehicle: which route it's on and its stop status. */
data class VehicleVelocities(
val routeName: String,
val routeName: String?,
val isAtStop: Boolean,
val currentStop: String?,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ data class VehicleLocationDto(
val longitude: Double,
@SerialName("speed_mph") val speedMph: Double,
val timestamp: String,
@SerialName("heading_degrees") val headingDegrees: Int? = null,
@SerialName("heading_degrees") val headingDegrees: Double? = null,
)

@Serializable
Expand All @@ -24,7 +24,7 @@ data class VehicleStopEtaDto(

@Serializable
data class VehicleVelocitiesDto(
@SerialName("route_name") val routeName: String,
@SerialName("route_name") val routeName: String?,
@SerialName("is_at_stop") val isAtStop: Boolean,
@SerialName("current_stop") val currentStop: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fun EtasScreen(
routes: Map<String, Route>,
vehicles: List<Vehicle>,
routesLoaded: Boolean,
onVehicleClick: (String) -> Unit,
showTitle: Boolean = true,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
Expand All @@ -52,6 +53,10 @@ fun EtasScreen(
stop = selectedStop,
sheetState = sheetState,
onDismiss = { selectedStopKey = null },
onVehicleClick = { vehicleId ->
selectedStopKey = null
onVehicleClick(vehicleId)
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,11 @@ private fun StopEtaRow(
modifier = Modifier.padding(top = 6.dp),
) {
stop.etas.take(3).forEach { eta ->
EtaChip(routeName = eta.routeName, minutes = etaMinutesFromNow(eta.etaInstant))
EtaChip(
vehicleName = eta.vehicleName,
routeName = eta.routeName,
minutes = etaMinutesFromNow(eta.etaInstant),
)
}
}
}
Expand All @@ -217,6 +221,7 @@ private fun StopEtaRow(

@Composable
fun EtaChip(
vehicleName: String,
routeName: String?,
minutes: Long,
) {
Expand All @@ -227,7 +232,7 @@ fun EtaChip(
shape = RoundedCornerShape(6.dp),
) {
Text(
text = etaLabelText(minutes),
text = stringResource(R.string.eta_vehicle_format, vehicleName, etaLabelText(minutes)),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = tagColor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ fun StopEtaSheet(
stop: StopWithEtas?,
sheetState: SheetState,
onDismiss: () -> Unit,
onVehicleClick: (String) -> Unit,
) {
if (stop == null) return

Expand Down Expand Up @@ -64,7 +65,7 @@ fun StopEtaSheet(
)
} else {
stop.etas.forEach { eta ->
StopEtaDetailRow(eta)
StopEtaDetailRow(eta, onVehicleClick)
}

Text(
Expand All @@ -78,10 +79,14 @@ fun StopEtaSheet(
}

@Composable
private fun StopEtaDetailRow(eta: VehicleEta) {
private fun StopEtaDetailRow(
eta: VehicleEta,
onVehicleClick: (String) -> Unit,
) {
val tagColor = routeAccentColor(eta.routeName)

Surface(
onClick = { onVehicleClick(eta.vehicleId) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = tagColor.copy(alpha = 0.10f),
Expand All @@ -103,7 +108,11 @@ private fun StopEtaDetailRow(eta: VehicleEta) {
)
}

EtaChip(routeName = eta.routeName, minutes = etaMinutesFromNow(eta.etaInstant))
EtaChip(
vehicleName = eta.vehicleName,
routeName = eta.routeName,
minutes = etaMinutesFromNow(eta.etaInstant),
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ data class StopWithEtas(
* */
val ETA_VISIBLE_ROUTES = listOf("NORTH", "WEST")

/** How long a just-passed arrival remains visible in the ETA tab. */
internal const val ETA_PAST_GRACE_PERIOD_MINUTES = 1L

/**
* Inverts each vehicle's [Vehicle.stopTimes] (stop key -> eta) into a per-stop view, one entry per
* stop across [ETA_VISIBLE_ROUTES] (or just [routeFilter] if given), each carrying its own sorted
Expand All @@ -39,8 +42,10 @@ fun buildStopsWithEtas(
routes: Map<String, Route>,
vehicles: List<Vehicle>,
routeFilter: String? = null,
now: Instant = Instant.now(),
): List<StopWithEtas> {
val stopsByKey = linkedMapOf<String, Pair<Stop, MutableSet<String>>>()
val oldestVisibleEta = now.minusSeconds(ETA_PAST_GRACE_PERIOD_MINUTES * 60)

for ((routeName, route) in routes) {
if (routeName !in ETA_VISIBLE_ROUTES) continue
Expand All @@ -61,8 +66,10 @@ fun buildStopsWithEtas(
vehicles
.mapNotNull { vehicle ->
if (vehicle.routeName !in ETA_VISIBLE_ROUTES) return@mapNotNull null
if (routeFilter != null && vehicle.routeName != routeFilter) return@mapNotNull null
val rawEta = vehicle.stopTimes[stopKey] ?: return@mapNotNull null
val etaInstant = rawEta.toEtaInstantOrNull() ?: return@mapNotNull null
if (etaInstant.isBefore(oldestVisibleEta)) return@mapNotNull null

VehicleEta(
vehicleId = vehicle.id,
Expand All @@ -78,7 +85,7 @@ fun buildStopsWithEtas(
routeNames = routeNames.sorted(),
etas = etas,
)
}.sortedBy { it.stop.name }
}
}

fun String.toEtaInstantOrNull(): Instant? = runCatching { OffsetDateTime.parse(trim()).toInstant() }.getOrNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
Expand Down Expand Up @@ -65,6 +66,7 @@ private const val TILT_ZOOM = 18f
// didn't mean to pan - only treat a gesture as a real pan (and drop out of follow mode) once it
// moves the target further than incidental zoom/rotate drift would.
private const val PAN_DETECTION_THRESHOLD_METERS = 20f
private const val VEHICLE_FOCUS_ZOOM = 17f

/**
* Mirrors the stock Google Maps app's location FAB: [NotFollowing] until tapped, then
Expand All @@ -91,6 +93,8 @@ internal fun ShuttleMap(
onSettingsClick: () -> Unit,
onToggleMapTypeClick: () -> Unit,
onAnnouncementsClick: () -> Unit,
focusedVehicleId: String?,
onFocusedVehicleHandled: () -> Unit,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Expand All @@ -111,6 +115,27 @@ internal fun ShuttleMap(
val useDarkMap = uiState.themeMode.isDarkTheme(isSystemInDarkTheme())
val fallbackRouteColor = MaterialTheme.colorScheme.primary

LaunchedEffect(focusedVehicleId) {
val vehicleId = focusedVehicleId ?: return@LaunchedEffect
val vehicle =
(uiState.vehicles + uiState.fakeVehicles).firstOrNull { it.id == vehicleId }
?: run {
onFocusedVehicleHandled()
return@LaunchedEffect
}

// Drop selection for one frame so repeatedly choosing the same shuttle reopens its window.
selectedDevVehicleId = null
withFrameNanos {}
selectedDevVehicleId = vehicle.id
followMode = LocationFollowMode.NotFollowing
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(vehicle.latLng(), VEHICLE_FOCUS_ZOOM),
durationMs = 1000,
)
onFocusedVehicleHandled()
}

LaunchedEffect(cameraPositionState.isMoving) {
if (cameraPositionState.isMoving) {
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
Expand Down Expand Up @@ -275,7 +300,7 @@ internal fun ShuttleMap(
selectedDevVehicleId = vehicle.id
coroutineScope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(vehicle.latLng(), 17f),
CameraUpdateFactory.newLatLngZoom(vehicle.latLng(), VEHICLE_FOCUS_ZOOM),
durationMs = 1000,
)
}
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -75,6 +76,7 @@ fun MapsScreen(
val pagerState = rememberPagerState { MainTab.entries.size }
val selectedTab = MainTab.entries[pagerState.currentPage]
var isAnnouncementsSheetVisible by rememberSaveable { mutableStateOf(false) }
var focusedVehicleId by remember { mutableStateOf<String?>(null) }
val announcementsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)

// Compact is a phone in portrait; anything wider (a rotated phone, a foldable, a tablet) gets
Expand Down Expand Up @@ -149,6 +151,8 @@ fun MapsScreen(
isAnnouncementsSheetVisible = isAnnouncementsSheetVisible,
onAnnouncementsSheetVisibleChange = { isAnnouncementsSheetVisible = it },
announcementsSheetState = announcementsSheetState,
focusedVehicleId = focusedVehicleId,
onFocusedVehicleHandled = { focusedVehicleId = null },
)

MainTab.Etas ->
Expand All @@ -163,6 +167,10 @@ fun MapsScreen(
routes = uiState.routes,
vehicles = uiState.vehicles + uiState.fakeVehicles,
routesLoaded = uiState.routesLoaded,
onVehicleClick = { vehicleId ->
focusedVehicleId = vehicleId
pagerState.requestScrollToPage(MainTab.Map.ordinal)
},
showTitle = !useNavigationRail,
)
}
Expand Down Expand Up @@ -199,6 +207,8 @@ private fun MapTab(
isAnnouncementsSheetVisible: Boolean,
onAnnouncementsSheetVisibleChange: (Boolean) -> Unit,
announcementsSheetState: SheetState,
focusedVehicleId: String?,
onFocusedVehicleHandled: () -> Unit,
) {
Box(Modifier.fillMaxSize()) {
ShuttleMap(
Expand All @@ -207,6 +217,8 @@ private fun MapTab(
onSettingsClick = onSettingsClick,
onToggleMapTypeClick = viewModel::toggleMapType,
onAnnouncementsClick = { onAnnouncementsSheetVisibleChange(true) },
focusedVehicleId = focusedVehicleId,
onFocusedVehicleHandled = onFocusedVehicleHandled,
)

AnnouncementSheet(
Expand Down
Loading