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 @@ -48,11 +48,18 @@ class EtaComponentsTest {

@Test
fun loadingShowsASpinnerBeforeRoutesArrive() {
setListContent(routes = emptyMap())
setListContent(routes = emptyMap(), routesLoaded = false)

composeRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
}

@Test
fun emptyRoutesOnceLoadedShowsTheNoRoutesMessageInsteadOfSpinningForever() {
setListContent(routes = emptyMap(), routesLoaded = true)

composeRule.onNodeWithText("No routes are running right now").assertIsDisplayed()
}

@Test
fun routesOutsideTheVisibleAllowlistProduceNoStops() {
setListContent(routes = mapOf("ACADEMY_SHUTTLE" to testRoute()))
Expand Down Expand Up @@ -88,6 +95,7 @@ class EtaComponentsTest {
StopEtaList(
routes = mapOf("NORTH" to testRoute(), "WEST" to testRoute()),
vehicles = emptyList(),
routesLoaded = true,
selectedRouteFilter = selectedFilter,
onRouteFilterChange = { selectedFilter = it },
onStopClick = {},
Expand All @@ -108,6 +116,7 @@ class EtaComponentsTest {
StopEtaList(
routes = mapOf("NORTH" to testRoute()),
vehicles = emptyList(),
routesLoaded = true,
selectedRouteFilter = null,
onRouteFilterChange = {},
onStopClick = { clickedStopKey = it },
Expand Down Expand Up @@ -171,12 +180,14 @@ class EtaComponentsTest {
private fun setListContent(
routes: Map<String, Route>,
vehicles: List<Vehicle> = emptyList(),
routesLoaded: Boolean = true,
) {
composeRule.setContent {
ShuttleTrackerTheme(dynamicColor = false) {
StopEtaList(
routes = routes,
vehicles = vehicles,
routesLoaded = routesLoaded,
selectedRouteFilter = null,
onRouteFilterChange = {},
onStopClick = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ fun EtasScreen(
StopEtaList(
routes = uiState.routes,
vehicles = allVehicles,
routesLoaded = uiState.routesLoaded,
selectedRouteFilter = uiState.selectedRouteFilter,
onRouteFilterChange = viewModel::selectRouteFilter,
onStopClick = viewModel::selectStop,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class EtasViewModel
private var fakeVehiclesJob: Job? = null

init {
if (etasUiState.value.routes.isEmpty()) loadRoutes()
if (!etasUiState.value.routesLoaded) loadRoutes()

combine(
userPreferences.getDevOptions(),
Expand Down Expand Up @@ -157,7 +157,7 @@ class EtasViewModel

fun retry() {
clearErrors()
if (etasUiState.value.routes.isEmpty()) loadRoutes()
if (!etasUiState.value.routesLoaded) loadRoutes()
}

private fun loadRoutes() {
Expand All @@ -166,7 +166,7 @@ class EtasViewModel
viewModelScope.launch {
readApiResponse(shuttleRepository.getRoutes()) { routes ->
_etasUiState.update {
it.copy(routes = routes)
it.copy(routes = routes, routesLoaded = true)
}
}
}
Expand Down Expand Up @@ -196,6 +196,7 @@ data class EtasUiState(
val vehicles: List<Vehicle> = emptyList(),
val fakeVehicles: List<Vehicle> = emptyList(),
val routes: Map<String, Route> = emptyMap(),
val routesLoaded: Boolean = false,
val selectedRouteFilter: String? = null,
val selectedStopKey: String? = null,
val networkError: NetworkError.Connectivity? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import edu.rpi.shuttletracker.feature.etas.utils.etaMinutesFromNow
fun StopEtaList(
routes: Map<String, Route>,
vehicles: List<Vehicle>,
routesLoaded: Boolean,
selectedRouteFilter: String?,
onRouteFilterChange: (String?) -> Unit,
onStopClick: (String) -> Unit,
Expand All @@ -65,11 +66,16 @@ fun StopEtaList(
thickness = DividerDefaults.Thickness,
)

if (routes.isEmpty()) {
if (!routesLoaded) {
CircularProgressIndicator(modifier = Modifier.padding(24.dp))
return
}

if (routes.isEmpty()) {
EmptyState(R.string.etas_no_routes)
return
}

RouteFilterRow(
routeNames = routes.keys.filter { it in ETA_VISIBLE_ROUTES }.sorted(),
selectedRoute = selectedRouteFilter,
Expand Down
138 changes: 119 additions & 19 deletions app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ 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.GoogleMap
import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
Expand All @@ -47,13 +48,34 @@ import edu.rpi.shuttletracker.data.models.Stop
import edu.rpi.shuttletracker.feature.map.components.AnnouncementStrip
import edu.rpi.shuttletracker.feature.map.components.DeveloperVehicleView
import kotlinx.coroutines.launch
import com.google.android.gms.maps.GoogleMap as AndroidGoogleMap

private val CampusCenter = LatLng(42.73068146020498, -73.67619731950525)
private val CampusBounds =
LatLngBounds(
LatLng(42.72095724005504, -73.70196321825452),
LatLng(42.741173465236876, -73.6543446409232),
)
private const val TILTED_DEGREES = 60f
private const val TILT_ZOOM = 18f

// A pinch-zoom that isn't perfectly centered can nudge the target a little even though the user
// 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

/**
* Mirrors the stock Google Maps app's location FAB: [NotFollowing] until tapped, then
* [Following] the user north-up, then [FollowingTilted] into a 3D perspective on a second tap.
* A user gesture that actually re-targets the camera (a pan) drops back to [NotFollowing]; a
* gesture that only changes zoom/rotation in place does not (see the `MapEffect` in
* [ShuttleMap]) so the button never claims to be following a camera the user just took over.
* */
private enum class LocationFollowMode {
NotFollowing,
Following,
FollowingTilted,
}

/**
* The actual Google Map: draws stops ([StopMarker]), route polylines, and vehicles
Expand Down Expand Up @@ -87,6 +109,8 @@ internal fun ShuttleMap(
var selectedStop by remember { mutableStateOf<Stop?>(null) }
var isDevPanelOpen by remember { mutableStateOf(false) }
var selectedDevVehicleId by remember { mutableStateOf<String?>(null) }
var followMode by remember { mutableStateOf(LocationFollowMode.NotFollowing) }
var gestureStartTarget by remember { mutableStateOf<LatLng?>(null) }
val useDarkMap = uiState.themeMode.isDarkTheme(isSystemInDarkTheme())
val fallbackRouteColor = MaterialTheme.colorScheme.primary

Expand Down Expand Up @@ -115,6 +139,33 @@ internal fun ShuttleMap(
myLocationButtonEnabled = false,
),
) {
// Only drop follow mode for a real pan, not an in-place pinch-zoom/rotate - compare
// the target when a gesture starts vs. where it lands.
MapEffect(Unit) { map ->
map.setOnCameraMoveStartedListener { reason ->
if (reason == AndroidGoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) {
gestureStartTarget = map.cameraPosition.target
}
}
map.setOnCameraIdleListener {
val start = gestureStartTarget ?: return@setOnCameraIdleListener
gestureStartTarget = null

val end = map.cameraPosition.target
val distanceMeters = FloatArray(1)
Location.distanceBetween(
start.latitude,
start.longitude,
end.latitude,
end.longitude,
distanceMeters,
)
if (distanceMeters[0] > PAN_DETECTION_THRESHOLD_METERS) {
followMode = LocationFollowMode.NotFollowing
}
}
}

val uniqueStops =
uiState.routes.values
.flatMap { it.stopDetails.values }
Expand Down Expand Up @@ -246,24 +297,71 @@ internal fun ShuttleMap(
// Schedule is reached via the bottom nav bar now, so Recenter is the map's only FAB.
val (fabContainerColor, fabContentColor) = mapButtonColors()
FloatingActionButton(
onClick = recenter@{
if (!hasLocationPermission) return@recenter
onClick = handleClick@{
if (!hasLocationPermission) return@handleClick

LocationServices
.getFusedLocationProviderClient(context)
.lastLocation
.addOnSuccessListener { location: Location? ->
location ?: return@addOnSuccessListener
when (followMode) {
LocationFollowMode.NotFollowing -> {
LocationServices
.getFusedLocationProviderClient(context)
.lastLocation
.addOnSuccessListener { location: Location? ->
location ?: return@addOnSuccessListener
coroutineScope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newCameraPosition(
CameraPosition(
LatLng(location.latitude, location.longitude),
cameraPositionState.position.zoom,
0f,
0f,
),
),
durationMs = 1000,
)
}
followMode = LocationFollowMode.Following
}
}

// Already centered north-up: tilt into a 3D perspective, like the stock app's
// compass button does on a second tap.
LocationFollowMode.Following -> {
coroutineScope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newCameraPosition(
CameraPosition(
cameraPositionState.position.target,
maxOf(cameraPositionState.position.zoom, TILT_ZOOM),
TILTED_DEGREES,
cameraPositionState.position.bearing,
),
),
durationMs = 500,
)
}
followMode = LocationFollowMode.FollowingTilted
}

// Tilted: flatten back to north-up rather than dropping out of follow mode -
// only an actual map drag (the MapEffect above) should do that.
LocationFollowMode.FollowingTilted -> {
coroutineScope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
LatLng(location.latitude, location.longitude),
cameraPositionState.position.zoom,
CameraUpdateFactory.newCameraPosition(
CameraPosition(
cameraPositionState.position.target,
cameraPositionState.position.zoom,
0f,
cameraPositionState.position.bearing,
),
),
durationMs = 1000,
durationMs = 500,
)
}
followMode = LocationFollowMode.Following
}
}
},
modifier =
Modifier
Expand All @@ -276,18 +374,20 @@ internal fun ShuttleMap(
Icon(
painter =
painterResource(
if (hasLocationPermission) {
R.drawable.ic_my_location
} else {
R.drawable.ic_location_disabled
when {
!hasLocationPermission -> R.drawable.ic_location_disabled
followMode == LocationFollowMode.NotFollowing -> R.drawable.ic_near_me
followMode == LocationFollowMode.Following -> R.drawable.ic_near_me_filled
else -> R.drawable.ic_navigation_filled
},
),
contentDescription =
stringResource(
if (hasLocationPermission) {
R.string.map_recenter
} else {
R.string.map_location_unavailable
when {
!hasLocationPermission -> R.string.map_location_unavailable
followMode == LocationFollowMode.NotFollowing -> R.string.map_recenter
followMode == LocationFollowMode.Following -> R.string.map_following
else -> R.string.map_following_tilted
},
),
)
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/drawable/ic_navigation_filled.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
android:height="24dp" android:viewportWidth="960" android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path android:fillColor="@android:color/white"
android:pathData="M200,840L160,800L480,80L800,800L760,840L480,720L200,840Z" />
</vector>
6 changes: 6 additions & 0 deletions app/src/main/res/drawable/ic_near_me.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
android:height="24dp" android:viewportWidth="960" android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path android:fillColor="@android:color/white"
android:pathData="M516,840L402,558L120,444L120,388L840,120L572,840L516,840ZM542,692L704,256L268,418L464,496L542,692ZM464,496L464,496L464,496L464,496Z" />
</vector>
6 changes: 6 additions & 0 deletions app/src/main/res/drawable/ic_near_me_filled.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
android:height="24dp" android:viewportWidth="960" android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path android:fillColor="@android:color/white"
android:pathData="M516,840L402,558L120,444L120,388L840,120L572,840L516,840Z" />
</vector>
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
<string name="nav_etas">ETAs</string>
<string name="map_open_settings">Open settings</string>
<string name="map_recenter">Center map on my location</string>
<string name="map_following">Following your location. Tap to tilt the view</string>
<string name="map_following_tilted">Tap to return to a north-up view</string>
<string name="map_location_unavailable">Location unavailable</string>
<string name="map_toggle_type">Change map type</string>
<string name="vehicle_number">Shuttle %1$s</string>
Expand All @@ -95,6 +97,7 @@
<string name="etas_subtitle">Tap a stop to see every shuttle\'s live arrival time.</string>
<string name="etas_route_all">All Routes</string>
<string name="etas_no_stops">No stops found</string>
<string name="etas_no_routes">No routes are running right now</string>
<string name="etas_no_live_etas">No live ETAs</string>
<string name="eta_now">now</string>
<string name="eta_minutes_format">%1$dm</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ class EtasViewModelTest {
assertThat(viewModel.etasUiState.value.networkError).isInstanceOf(NetworkError.NoConnection::class.java)
}

@Test
fun `empty routes response marks routes as loaded instead of leaving ui stuck loading`() =
runTest {
repository.routesResult = NetworkResult.Success(emptyMap())
val viewModel = createViewModel()
advanceUntilIdle()

assertThat(viewModel.etasUiState.value.routesLoaded).isTrue()
assertThat(viewModel.etasUiState.value.routes).isEmpty()
}

@Test
fun `retry clears the error and reloads missing routes`() =
runTest {
Expand Down