Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/android/view builder and speed json #360

Merged
merged 12 commits into from
Nov 19, 2024
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 @@ -19,14 +19,16 @@ import com.stadiamaps.ferrostar.composeui.views.components.TripProgressView
import com.stadiamaps.ferrostar.core.NavigationUiState

data class NavigationViewComponentBuilder(
val instructionsView: @Composable (modifier: Modifier, uiState: NavigationUiState) -> Unit,
val progressView:
internal val instructionsView:
Archdoog marked this conversation as resolved.
Show resolved Hide resolved
@Composable
(modifier: Modifier, uiState: NavigationUiState) -> Unit,
internal val progressView:
@Composable
(modifier: Modifier, uiState: NavigationUiState, onTapExit: (() -> Unit)?) -> Unit,
val streetNameView:
internal val streetNameView:
@Composable
(modifier: Modifier, roadName: String?, cameraControlState: CameraControlState) -> Unit,
val customOverlayView: @Composable (BoxScope.(Modifier) -> Unit)? = null,
internal val customOverlayView: @Composable (BoxScope.(Modifier) -> Unit)? = null,
// TODO: We may reasonably be able to add the NavigationMapView here. But not sure how much
// value that would add
// since most of the hard config already exists within the overlay views which are not
Expand Down Expand Up @@ -72,6 +74,8 @@ data class NavigationViewComponentBuilder(
}
})
}

fun getCustomOverlayView(): @Composable (BoxScope.(Modifier) -> Unit)? = customOverlayView
}

fun NavigationViewComponentBuilder.withInstructionsView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ fun LandscapeNavigationOverlayView(
val windowInsets = WindowInsets.statusBars.asPaddingValues()
val halfOfScreen: Dp = with(density) { LocalConfiguration.current.screenWidthDp.dp / 2 }

val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.navigationUiState.collectAsState()

var instructionsViewSize by remember { mutableStateOf(DpSize.Zero) }
var progressViewSize by remember { mutableStateOf(DpSize.Zero) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ fun PortraitNavigationOverlayView(
val density = LocalDensity.current
val windowInsets = WindowInsets.statusBars.asPaddingValues()

val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.navigationUiState.collectAsState()

var instructionsViewSize by remember { mutableStateOf(DpSize.Zero) }
var progressViewSize by remember { mutableStateOf(DpSize.Zero) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ data class NavigationUiState(
}

interface NavigationViewModel {
val uiState: StateFlow<NavigationUiState>
val navigationUiState: StateFlow<NavigationUiState>

fun toggleMute()

Expand All @@ -112,7 +112,7 @@ open class DefaultNavigationViewModel(
private val muteState: StateFlow<Boolean?> =
ferrostarCore.spokenInstructionObserver?.muteState ?: MutableStateFlow(null)

override val uiState =
override val navigationUiState =
Copy link
Collaborator Author

@Archdoog Archdoog Nov 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ahmedre based on your comment, I've renamed this to navigationUiState. My thought was most of the time a developer will just pass navigationUiState into whatever NavigationView they're using from ferrostar. If they want additional custom ui state, they now have access to common name and can even merge in navigationUiState into their custom state. A custom view model may look something like this:

class NavViewModel(
  val ferrostarCore: FerrostarCore,
  val annotationPublisher: AnnotationPublisher<ValhallaOSRMExtendedAnnotation>
) : DefaultNavigationViewModel(ferrostarCore, annotationPublisher) {

  // your own custom uiState can also be added to use alongside or merged with the navigationUiState.

  val speed = navigationUiState
    .map { navigationUiState ->
      val metersPerSecond = navigationUiState.location?.speed?.value
      metersPerSecond?.let {
        return@map MeasurementSpeed(it, SpeedUnit.MetersPerSecond)
      }
      return@map null
    }

  fun addStop(latitude: Double, longitude: Double, name: String) {
    viewModelScope.launch {
      val userLocation = uiState.value.location ?: return@launch

      val nextWaypoint = Waypoint(
        coordinate = GeographicCoordinate(latitude, longitude),
        kind = WaypointKind.BREAK
      )
      val routes = ferrostarCore.getRoutes(userLocation, listOf(nextWaypoint))
      ferrostarCore.startNavigation(routes.first())
    }
  }
}

combine(ferrostarCore.state, muteState) { a, b -> a to b }
.map { (coreState, muteState) -> annotationPublisher.map(coreState) to muteState }
.map { (stateWrapper, muteState) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import uniffi.ferrostar.TripState
class DefaultAnnotationPublisher<T>(
private val adapter: JsonAdapter<T>,
private val speedLimitMapper: (T?) -> Speed?,
private val onError: ((Throwable) -> Unit)? = null
) : AnnotationPublisher<T> {

override fun map(state: NavigationState): AnnotationWrapper<T> {
Expand All @@ -16,10 +17,10 @@ class DefaultAnnotationPublisher<T>(

private fun decodeAnnotations(state: NavigationState): T? {
return if (state.tripState is TripState.Navigating) {
val json = state.tripState.annotationJson
if (json != null) {
adapter.fromJson(json)
} else {
try {
Copy link
Collaborator Author

@Archdoog Archdoog Nov 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now try catch and pass null when the serialization fails.

state.tripState.annotationJson?.let { adapter.fromJson(it) }
} catch (e: Exception) {
onError?.invoke(e)
null
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class SpeedSerializationAdapter : JsonAdapter<Speed>() {
is Speed.NoLimit -> writer.name("none").value(true)
is Speed.Unknown -> writer.name("unknown").value(true)
is Speed.Value ->
writer.name("value").value(speed.value).name("unit").value(speed.unit.text)
writer.name("speed").value(speed.value).name("unit").value(speed.unit.text)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ahmedre I believe the actual key should be "speed" when there's a value. E.g.

{
  "speed": 56,
  "unit": "km/h"
},

Let me know if there are additional cases. Definitely a bit of an obnoxious json object 😄.

Also, do we want to try catch the annotations parsing? On iOS we just convert a json parsing error to nil/null for annotations and an onError callback so that developers can log the failure. We figured better to let navigation succeed even in the case of a degraded/malformed annotations. See

do {
return try decoder.decode(Annotation.self, from: data)
} catch {
onError(error)
return nil
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, you are right - my mistake! agree re: try/catching the parsing with an error callback 👍

}
writer.endObject()
}
Expand All @@ -33,7 +33,7 @@ class SpeedSerializationAdapter : JsonAdapter<Speed>() {
var unit: String? = null

while (reader.hasNext()) {
when (reader.selectName(JsonReader.Options.of("none", "unknown", "value", "unit"))) {
when (reader.selectName(JsonReader.Options.of("none", "unknown", "speed", "unit"))) {
0 -> none = reader.nextBoolean()
1 -> unknown = reader.nextBoolean()
2 -> value = reader.nextDouble()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ fun valhallaExtendedOSRMAnnotationPublisher(): AnnotationPublisher<ValhallaOSRME
val moshi =
Moshi.Builder().add(SpeedSerializationAdapter()).add(KotlinJsonAdapterFactory()).build()
val adapter = moshi.adapter(ValhallaOSRMExtendedAnnotation::class.java)
return DefaultAnnotationPublisher<ValhallaOSRMExtendedAnnotation>(adapter) { it?.speedLimit }
return DefaultAnnotationPublisher<ValhallaOSRMExtendedAnnotation>(
adapter, speedLimitMapper = { it?.speedLimit })
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ fun NavigationUiState.Companion.pedestrianExample(): NavigationUiState =
UserLocation.pedestrianExample(),
UserLocation.pedestrianExample())

class MockNavigationViewModel(override val uiState: StateFlow<NavigationUiState>) :
class MockNavigationViewModel(override val navigationUiState: StateFlow<NavigationUiState>) :
ViewModel(), NavigationViewModel {
override fun toggleMute() {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ fun DemoNavigationScene(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
}

val vmState by viewModel.uiState.collectAsState(scope.coroutineContext)
val navigationUiState by viewModel.navigationUiState.collectAsState(scope.coroutineContext)

val permissionsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
Expand Down Expand Up @@ -88,7 +88,7 @@ fun DemoNavigationScene(
}

// For smart casting
val loc = vmState.location
val loc = navigationUiState.location
if (loc == null) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Text("Waiting to acquire your GPS location...", modifier = Modifier.padding(innerPadding))
Expand All @@ -113,7 +113,7 @@ fun DemoNavigationScene(
AutocompleteOverlay(
modifier = modifier,
scope = scope,
isNavigating = vmState.isNavigating(),
isNavigating = navigationUiState.isNavigating(),
locationProvider = locationProvider,
loc = loc)
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package com.stadiamaps.ferrostar

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stadiamaps.ferrostar.core.DefaultNavigationViewModel
import com.stadiamaps.ferrostar.core.FerrostarCore
import com.stadiamaps.ferrostar.core.LocationProvider
import com.stadiamaps.ferrostar.core.LocationUpdateListener
import com.stadiamaps.ferrostar.core.NavigationUiState
import com.stadiamaps.ferrostar.core.NavigationViewModel
import com.stadiamaps.ferrostar.core.annotation.AnnotationPublisher
import com.stadiamaps.ferrostar.core.annotation.valhalla.valhallaExtendedOSRMAnnotationPublisher
import com.stadiamaps.ferrostar.core.isNavigating
import java.util.concurrent.Executors
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -23,8 +24,9 @@ import uniffi.ferrostar.UserLocation

class DemoNavigationViewModel(
// This is a simple example, but these would typically be dependency injected
private val ferrostarCore: FerrostarCore = AppModule.ferrostarCore,
) : ViewModel(), LocationUpdateListener, NavigationViewModel {
val ferrostarCore: FerrostarCore = AppModule.ferrostarCore,
annotationPublisher: AnnotationPublisher<*> = valhallaExtendedOSRMAnnotationPublisher()
) : DefaultNavigationViewModel(ferrostarCore, annotationPublisher), LocationUpdateListener {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ianthetechie now that this view model uses the DefaultNavigationViewModel. We should be able to refactor it to use the existing uiState from ferrostar and implement its own additional state for things like route loading, etc. Just to highlight how easy it is to customize without interrupting the default navigation behavior.

private val locationStateFlow = MutableStateFlow<UserLocation?>(null)
private val executor = Executors.newSingleThreadScheduledExecutor()

Expand All @@ -40,7 +42,7 @@ class DemoNavigationViewModel(
locationProvider.removeListener(this)
}

override val uiState: StateFlow<NavigationUiState> =
override val navigationUiState: StateFlow<NavigationUiState> =
combine(ferrostarCore.state, muteState, locationStateFlow) { a, b, c -> Triple(a, b, c) }
.map { (ferrostarCoreState, isMuted, userLocation) ->
if (ferrostarCoreState.isNavigating()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ fun DynamicallyOrientingNavigationView(
// Maintain the actual size of the progress view for dynamic layout purposes.
val rememberProgressViewSize = remember { mutableStateOf(DpSize.Zero) }
val progressViewSize by rememberProgressViewSize
val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.navigationUiState.collectAsState()

// Get the correct padding based on edge-to-edge status.
val gridPadding = paddingForGridView()
Expand Down Expand Up @@ -140,7 +140,7 @@ fun DynamicallyOrientingNavigationView(
}
}

views.customOverlayView?.let { customOverlayView ->
views.getCustomOverlayView()?.let { customOverlayView ->
customOverlayView(Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ fun LandscapeNavigationView(
onTapExit: (() -> Unit)? = null,
mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null,
) {
val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.navigationUiState.collectAsState()

// Get the correct padding based on edge-to-edge status.
val gridPadding = paddingForGridView()
Expand Down Expand Up @@ -112,7 +112,7 @@ fun LandscapeNavigationView(
mapViewInsets = mapViewInsets,
onTapExit = onTapExit)

views.customOverlayView?.let { customOverlayView ->
views.getCustomOverlayView()?.let { customOverlayView ->
customOverlayView(Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ fun PortraitNavigationView(
onTapExit: (() -> Unit)? = null,
mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null,
) {
val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.navigationUiState.collectAsState()

LaunchedEffect(mapViewInsets.value) {
Log.d("PortraitNavigationView", "mapViewInsets.value: ${mapViewInsets.value}")
Expand Down Expand Up @@ -123,7 +123,7 @@ fun PortraitNavigationView(
mapViewInsets = mapViewInsets,
onTapExit = onTapExit)

views.customOverlayView?.let { customOverlayView ->
views.getCustomOverlayView()?.let { customOverlayView ->
customOverlayView(
Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding))
}
Expand Down
Loading