diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt index f1c0e364..7759356d 100644 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt @@ -19,24 +19,45 @@ import uniffi.ferrostar.UserLocation import uniffi.ferrostar.VisualInstruction data class NavigationUiState( + /** The user's location as reported by the location provider. */ + val location: UserLocation?, + /** The user's location snapped to the route shape. */ val snappedLocation: UserLocation?, + /** + * The last known heading of the user. + * + * NOTE: This is distinct from the course over ground (direction of travel), which is included + * in the `location` and `snappedLocation` properties. + */ val heading: Float?, + /** The geometry of the full route. */ val routeGeometry: List, + /** Visual instructions which should be displayed based on the user's current progress. */ val visualInstruction: VisualInstruction?, + /** + * Instructions which should be spoken via speech synthesis based on the user's current + * progress. + */ val spokenInstruction: SpokenInstruction?, + /** The user's progress through the current trip. */ val progress: TripProgress?, + /** If true, the core is currently calculating a new route. */ val isCalculatingNewRoute: Boolean?, + /** Describes whether the user is believed to be off the correct route. */ val routeDeviation: RouteDeviation?, + /** If true, spoken instructions will not be synthesized. */ val isMuted: Boolean? ) { companion object { fun fromFerrostar( coreState: NavigationState, isMuted: Boolean?, - userLocation: UserLocation? + location: UserLocation?, + snappedLocation: UserLocation? ): NavigationUiState = NavigationUiState( - snappedLocation = userLocation, + snappedLocation = snappedLocation, + location = location, // TODO: Heading/course over ground heading = null, routeGeometry = coreState.routeGeometry, @@ -60,22 +81,22 @@ interface NavigationViewModel { class DefaultNavigationViewModel( private val ferrostarCore: FerrostarCore, private val spokenInstructionObserver: SpokenInstructionObserver? = null, - locationProvider: LocationProvider + private val locationProvider: LocationProvider ) : ViewModel(), NavigationViewModel { - private var lastLocation: UserLocation? = locationProvider.lastLocation + private var userLocation: UserLocation? = locationProvider.lastLocation override val uiState = ferrostarCore.state .map { coreState -> - lastLocation = + val location = locationProvider.lastLocation + userLocation = when (coreState.tripState) { is TripState.Navigating -> coreState.tripState.snappedUserLocation is TripState.Complete, TripState.Idle -> locationProvider.lastLocation } - - uiState(coreState, spokenInstructionObserver?.isMuted, lastLocation) + uiState(coreState, spokenInstructionObserver?.isMuted, location, userLocation) // This awkward dance is required because Kotlin doesn't have a way to map over // StateFlows // without converting to a generic Flow in the process. @@ -85,7 +106,10 @@ class DefaultNavigationViewModel( started = SharingStarted.WhileSubscribed(), initialValue = uiState( - ferrostarCore.state.value, spokenInstructionObserver?.isMuted, lastLocation)) + ferrostarCore.state.value, + spokenInstructionObserver?.isMuted, + locationProvider.lastLocation, + userLocation)) override fun stopNavigation() { ferrostarCore.stopNavigation() @@ -99,6 +123,10 @@ class DefaultNavigationViewModel( spokenInstructionObserver.isMuted = !spokenInstructionObserver.isMuted } - private fun uiState(coreState: NavigationState, isMuted: Boolean?, location: UserLocation?) = - NavigationUiState.fromFerrostar(coreState, isMuted, location) + private fun uiState( + coreState: NavigationState, + isMuted: Boolean?, + location: UserLocation?, + snappedLocation: UserLocation? + ) = NavigationUiState.fromFerrostar(coreState, isMuted, location, snappedLocation) } diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt index 116612d8..4b501b2b 100644 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt @@ -60,7 +60,11 @@ fun NavigationState.Companion.pedestrianExample(): NavigationState { } fun NavigationUiState.Companion.pedestrianExample(): NavigationUiState = - fromFerrostar(NavigationState.pedestrianExample(), false, UserLocation.pedestrianExample()) + fromFerrostar( + NavigationState.pedestrianExample(), + false, + UserLocation.pedestrianExample(), + UserLocation.pedestrianExample()) class MockNavigationViewModel(override val uiState: StateFlow) : ViewModel(), NavigationViewModel { diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt index adb40869..b6d6c8b2 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt @@ -115,6 +115,9 @@ fun DemoNavigationScene( // Most vendors offer free API keys for development use. styleUrl = "https://demotiles.maplibre.org/style.json", viewModel = viewModel!!, + // This is the default value, which works well for motor vehicle navigation. + // Other travel modes though, such as walking, may not want snapping. + snapUserLocationToRoute = true, onTapExit = { viewModel!!.stopNavigation() }) { uiState -> // Trivial, if silly example of how to add your own overlay layers. // (Also incidentally highlights the lag inherent in MapLibre location tracking diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index c3440a9c..66da095f 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.mapbox.mapboxsdk.geometry.LatLng @@ -30,6 +29,8 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera * @param viewModel The navigation view model provided by Ferrostar Core. * @param locationRequestProperties The location request properties to use for the map's location * engine. + * @param snapUserLocationToRoute If true, the user's displayed location will be snapped to the + * route line. * @param onMapReadyCallback A callback that is invoked when the map is ready to be interacted with. * You must set your desired MapViewCamera tracking mode here! * @param content Any additional composable map symbol content to render. @@ -43,13 +44,19 @@ fun NavigationMapView( viewModel: NavigationViewModel, locationRequestProperties: LocationRequestProperties = LocationRequestProperties.NavigationDefault(), + snapUserLocationToRoute: Boolean = true, onMapReadyCallback: (Style) -> Unit = { camera.value = navigationCamera }, content: @Composable @MapLibreComposable() ((State) -> Unit)? = null ) { val uiState = viewModel.uiState.collectAsState() val locationEngine = remember { StaticLocationEngine() } - locationEngine.lastLocation = uiState.value.snappedLocation?.toAndroidLocation() + locationEngine.lastLocation = + if (snapUserLocationToRoute) { + uiState.value.snappedLocation?.toAndroidLocation() + } else { + uiState.value.location?.toAndroidLocation() + } MapView( modifier = Modifier.fillMaxSize(), diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt index e57d6bb8..441b8fe9 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt +++ b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt @@ -36,6 +36,8 @@ import com.stadiamaps.ferrostar.maplibreui.views.overlays.PortraitNavigationOver * @param viewModel The navigation view model provided by Ferrostar Core. * @param locationRequestProperties The location request properties to use for the map's location * engine. + * @param snapUserLocationToRoute If true, the user's displayed location will be snapped to the + * route line. * @param config The configuration for the navigation view. * @param landscapeOverlayModifier The modifier to apply to the overlay view. * @param portraitOverlayModifier The modifier to apply to the overlay view. @@ -51,6 +53,7 @@ fun DynamicallyOrientingNavigationView( viewModel: NavigationViewModel, locationRequestProperties: LocationRequestProperties = LocationRequestProperties.NavigationDefault(), + snapUserLocationToRoute: Boolean = true, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), landscapeOverlayModifier: Modifier = Modifier.fillMaxSize().padding(16.dp), portraitOverlayModifier: Modifier = Modifier.fillMaxSize().padding(16.dp), @@ -68,6 +71,7 @@ fun DynamicallyOrientingNavigationView( navigationCamera, viewModel, locationRequestProperties, + snapUserLocationToRoute, onMapReadyCallback = { camera.value = navigationCamera }, content) diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt index 9c802315..c0a5377c 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt +++ b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt @@ -38,6 +38,8 @@ import kotlinx.coroutines.flow.asStateFlow * @param viewModel The navigation view model provided by Ferrostar Core. * @param locationRequestProperties The location request properties to use for the map's location * engine. + * @param snapUserLocationToRoute If true, the user's displayed location will be snapped to the + * route line. * @param config The configuration for the navigation view. * @param overlayModifier The modifier to apply to the overlay view. * @param onTapExit The callback to invoke when the exit button is tapped. @@ -52,6 +54,7 @@ fun LandscapeNavigationView( viewModel: NavigationViewModel, locationRequestProperties: LocationRequestProperties = LocationRequestProperties.NavigationDefault(), + snapUserLocationToRoute: Boolean = true, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), overlayModifier: Modifier = Modifier.fillMaxSize().padding(16.dp), onTapExit: (() -> Unit)? = null, @@ -65,6 +68,7 @@ fun LandscapeNavigationView( navigationCamera, viewModel, locationRequestProperties, + snapUserLocationToRoute, onMapReadyCallback = { camera.value = navigationCamera }, content) diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt index b50d6f76..21b4b08b 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt +++ b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt @@ -39,6 +39,8 @@ import kotlinx.coroutines.flow.asStateFlow * @param viewModel The navigation view model provided by Ferrostar Core. * @param locationRequestProperties The location request properties to use for the map's location * engine. + * @param snapUserLocationToRoute If true, the user's displayed location will be snapped to the + * route line. * @param config The configuration for the navigation view. * @param overlayModifier The modifier to apply to the overlay view. * @param onTapExit The callback to invoke when the exit button is tapped. @@ -53,6 +55,7 @@ fun PortraitNavigationView( viewModel: NavigationViewModel, locationRequestProperties: LocationRequestProperties = LocationRequestProperties.NavigationDefault(), + snapUserLocationToRoute: Boolean = true, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), overlayModifier: Modifier = Modifier.fillMaxSize().padding(16.dp), onTapExit: (() -> Unit)? = null, @@ -66,6 +69,7 @@ fun PortraitNavigationView( navigationCamera, viewModel, locationRequestProperties, + snapUserLocationToRoute, onMapReadyCallback = { camera.value = navigationCamera }, content)