diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt index 62ca0ebf..502231b5 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt @@ -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())) @@ -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 = {}, @@ -108,6 +116,7 @@ class EtaComponentsTest { StopEtaList( routes = mapOf("NORTH" to testRoute()), vehicles = emptyList(), + routesLoaded = true, selectedRouteFilter = null, onRouteFilterChange = {}, onStopClick = { clickedStopKey = it }, @@ -171,12 +180,14 @@ class EtaComponentsTest { private fun setListContent( routes: Map, vehicles: List = emptyList(), + routesLoaded: Boolean = true, ) { composeRule.setContent { ShuttleTrackerTheme(dynamicColor = false) { StopEtaList( routes = routes, vehicles = vehicles, + routesLoaded = routesLoaded, selectedRouteFilter = null, onRouteFilterChange = {}, onStopClick = {}, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt index 0dd7f932..3f2a5d13 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt @@ -62,6 +62,7 @@ fun EtasScreen( StopEtaList( routes = uiState.routes, vehicles = allVehicles, + routesLoaded = uiState.routesLoaded, selectedRouteFilter = uiState.selectedRouteFilter, onRouteFilterChange = viewModel::selectRouteFilter, onStopClick = viewModel::selectStop, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasViewModel.kt index 7ca435da..3163de2c 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasViewModel.kt @@ -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(), @@ -157,7 +157,7 @@ class EtasViewModel fun retry() { clearErrors() - if (etasUiState.value.routes.isEmpty()) loadRoutes() + if (!etasUiState.value.routesLoaded) loadRoutes() } private fun loadRoutes() { @@ -166,7 +166,7 @@ class EtasViewModel viewModelScope.launch { readApiResponse(shuttleRepository.getRoutes()) { routes -> _etasUiState.update { - it.copy(routes = routes) + it.copy(routes = routes, routesLoaded = true) } } } @@ -196,6 +196,7 @@ data class EtasUiState( val vehicles: List = emptyList(), val fakeVehicles: List = emptyList(), val routes: Map = emptyMap(), + val routesLoaded: Boolean = false, val selectedRouteFilter: String? = null, val selectedStopKey: String? = null, val networkError: NetworkError.Connectivity? = null, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt index f509a233..a8584d88 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt @@ -49,6 +49,7 @@ import edu.rpi.shuttletracker.feature.etas.utils.etaMinutesFromNow fun StopEtaList( routes: Map, vehicles: List, + routesLoaded: Boolean, selectedRouteFilter: String?, onRouteFilterChange: (String?) -> Unit, onStopClick: (String) -> Unit, @@ -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, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt index 36e38cf6..1f8ec6ac 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt @@ -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 @@ -47,6 +48,7 @@ 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 = @@ -54,6 +56,26 @@ private val CampusBounds = 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 @@ -87,6 +109,8 @@ internal fun ShuttleMap( var selectedStop by remember { mutableStateOf(null) } var isDevPanelOpen by remember { mutableStateOf(false) } var selectedDevVehicleId by remember { mutableStateOf(null) } + var followMode by remember { mutableStateOf(LocationFollowMode.NotFollowing) } + var gestureStartTarget by remember { mutableStateOf(null) } val useDarkMap = uiState.themeMode.isDarkTheme(isSystemInDarkTheme()) val fallbackRouteColor = MaterialTheme.colorScheme.primary @@ -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 } @@ -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 @@ -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 }, ), ) diff --git a/app/src/main/res/drawable/ic_navigation_filled.xml b/app/src/main/res/drawable/ic_navigation_filled.xml new file mode 100644 index 00000000..f07b7858 --- /dev/null +++ b/app/src/main/res/drawable/ic_navigation_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_near_me.xml b/app/src/main/res/drawable/ic_near_me.xml new file mode 100644 index 00000000..925f7838 --- /dev/null +++ b/app/src/main/res/drawable/ic_near_me.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_near_me_filled.xml b/app/src/main/res/drawable/ic_near_me_filled.xml new file mode 100644 index 00000000..7591df76 --- /dev/null +++ b/app/src/main/res/drawable/ic_near_me_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d8378c2..a050aa43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,6 +71,8 @@ ETAs Open settings Center map on my location + Following your location. Tap to tilt the view + Tap to return to a north-up view Location unavailable Change map type Shuttle %1$s @@ -95,6 +97,7 @@ Tap a stop to see every shuttle\'s live arrival time. All Routes No stops found + No routes are running right now No live ETAs now %1$dm diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/EtasViewModelTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/etas/EtasViewModelTest.kt index 8cd76012..2d8c9b58 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/EtasViewModelTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/feature/etas/EtasViewModelTest.kt @@ -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 {