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
1 change: 1 addition & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
.externalNativeBuild
.cxx
local.properties
.claude/settings.local.json
1 change: 1 addition & 0 deletions android/demo-app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ dependencies {
implementation libs.androidx.compose.ui.graphics
implementation libs.androidx.compose.ui.tooling
implementation libs.androidx.compose.material3
implementation libs.androidx.compose.material.icon.extended

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can probably should replace this with SVGs (it's not a merge blocker for the demo app but does increase build times).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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


implementation libs.androidx.car.app

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
Expand All @@ -23,13 +26,20 @@ import com.stadiamaps.ferrostar.composeui.config.withSpeedLimitStyle
import com.stadiamaps.ferrostar.composeui.runtime.KeepScreenOnDisposableEffect
import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageStyle
import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickResult
import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState
import com.stadiamaps.ferrostar.maplibreui.views.DynamicallyOrientingNavigationView
import com.stadiamaps.ferrostar.ui.DestinationSelectionBottomSheet
import com.stadiamaps.ferrostar.ui.DestinationSelectionCameraEffect
import kotlinx.serialization.json.buildJsonObject
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.MaplibreComposable
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
import uniffi.ferrostar.GeographicCoordinate

@Composable
Expand Down Expand Up @@ -84,54 +94,83 @@ fun DemoNavigationScene(viewModel: DemoNavigationViewModel = AppModule.viewModel
permissionsLauncher.launch(allPermissions)
}
}
val droppedPin by viewModel.droppedPin.collectAsState()
val sceneState by viewModel.sceneState.collectAsState()
val navigationMapState = rememberNavigationMapState()
var destinationPreviewTopPaddingPx by remember { mutableStateOf(0) }
DestinationSelectionCameraEffect(
selectedDestination = sceneState.selectedDestination,
destinationSheetHeightPx = sceneState.destinationSheetHeightPx,
topOverlayBottomPx = destinationPreviewTopPaddingPx,
navigationMapState = navigationMapState,
)

DynamicallyOrientingNavigationView(
modifier = Modifier.fillMaxSize(),
baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl),
navigationMapState = navigationMapState,
viewModel = viewModel,
config = VisualNavigationViewConfig.Default().withSpeedLimitStyle(SignageStyle.MUTCD),
views =
NavigationViewComponentBuilder.Default()
.withCustomOverlayView(
customOverlayView = { modifier -> NotNavigatingOverlay(modifier, viewModel) },
customOverlayView = { modifier ->
NotNavigatingOverlay(
modifier = modifier,
viewModel = viewModel,
navigationMapState = navigationMapState,
onTopOverlayBottomChanged = { destinationPreviewTopPaddingPx = it },
)
},
),
onTapExit = { viewModel.stopNavigation() },
onMapLongClick = { position, screenPosition ->
Log.d(
"DemoNavigationScene",
"Long press at lat=${position.lat}, lng=${position.lng}, screen=$screenPosition",
)
viewModel.setDroppedPin(position)
NavigationMapClickResult.Pass
viewModel.selectDestination(position)
NavigationMapClickResult.Consume
},
) {
DemoDroppedPinOverlay(droppedPin)
DemoDroppedPinOverlay(sceneState.droppedPin)
}

if (sceneState.isDestinationSheetVisible) {
sceneState.selectedDestination?.let { destination ->
DestinationSelectionBottomSheet(
destination = destination,
onClose = { viewModel.clearSelectedDestination() },
onStartNavigation = { viewModel.startSelectedDestinationNavigation() },
onSheetHeightChanged = viewModel::setDestinationSheetHeight,
)
}
}
}

@Composable
@MaplibreComposable
private fun DemoDroppedPinOverlay(droppedPin: GeographicCoordinate?) {
val pinJson = droppedPinFeatureCollectionJsonOrNull(droppedPin) ?: return
val pointSource = rememberGeoJsonSource(GeoJsonData.JsonString(pinJson))
val pinFeatureCollection = droppedPinFeatureCollectionOrNull(droppedPin) ?: return
val pointSource = rememberGeoJsonSource(GeoJsonData.Features(pinFeatureCollection))

CircleLayer(
id = "demo-dropped-pin",
source = pointSource,
color = const(Color.Green),
radius = const(12.dp),
color = const(Color.Red),
radius = const(10.dp),
strokeColor = const(Color.White),
strokeWidth = const(3.dp),
strokeWidth = const(2.dp),
)
}

internal fun droppedPinFeatureCollectionJsonOrNull(pin: GeographicCoordinate?): String? = pin?.let {
droppedPinFeatureCollectionJson(it)
internal fun droppedPinFeatureCollectionOrNull(pin: GeographicCoordinate?) = pin?.let {
droppedPinFeatureCollection(it)
}

internal fun droppedPinFeatureCollectionJson(pin: GeographicCoordinate): String =
"""
{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[${pin.lng},${pin.lat}]},"properties":{}}]}
"""
.trimIndent()
internal fun droppedPinFeatureCollection(pin: GeographicCoordinate) =
FeatureCollection(
Feature(
geometry = Point(longitude = pin.lng, latitude = pin.lat),
properties = buildJsonObject {},
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ import uniffi.ferrostar.UserLocation
import uniffi.ferrostar.Waypoint
import uniffi.ferrostar.WaypointKind

data class DestinationSelection(
val coordinate: GeographicCoordinate,
val label: String? = null,
val origin: DestinationSelectionOrigin = DestinationSelectionOrigin.MapLongPress,
)

enum class DestinationSelectionOrigin {
MapLongPress,
SearchResult,
}

data class DemoNavigationSceneState(
val droppedPin: GeographicCoordinate? = null,
val selectedDestination: DestinationSelection? = null,
val isDestinationSheetVisible: Boolean = false,
val destinationSheetHeightPx: Int = 0,
)

@OptIn(ExperimentalCoroutinesApi::class)
class DemoNavigationViewModel(
// This is a simple example, but these would typically be dependency injected
Expand All @@ -44,8 +62,8 @@ class DemoNavigationViewModel(
private val locationStateFlow = MutableStateFlow<UserLocation?>(null)
val location = locationStateFlow.asStateFlow()

private val _droppedPin = MutableStateFlow<GeographicCoordinate?>(null)
val droppedPin = _droppedPin.asStateFlow()
private val _sceneState = MutableStateFlow(DemoNavigationSceneState())
val sceneState = _sceneState.asStateFlow()

// Here's an example of injecting a custom location into the navigation UI state when isNavigating
// is false.
Expand Down Expand Up @@ -93,8 +111,61 @@ class DemoNavigationViewModel(
_simulated.value = true
}

fun setDroppedPin(coordinate: GeographicCoordinate) {
_droppedPin.value = coordinate
fun selectDestination(
coordinate: GeographicCoordinate,
label: String? = null,
origin: DestinationSelectionOrigin = DestinationSelectionOrigin.MapLongPress,
) {
_sceneState.value =
_sceneState.value.copy(
droppedPin = coordinate,
selectedDestination =
DestinationSelection(coordinate = coordinate, label = label, origin = origin),
isDestinationSheetVisible = true,
)
}

fun selectDestination(
location: Location,
label: String? = null,
origin: DestinationSelectionOrigin = DestinationSelectionOrigin.MapLongPress,
) {
selectDestination(
coordinate = GeographicCoordinate(location.latitude, location.longitude),
label = label,
origin = origin,
)
}

fun clearSelectedDestination() {
_sceneState.value =
_sceneState.value.copy(
droppedPin = null,
selectedDestination = null,
isDestinationSheetVisible = false,
destinationSheetHeightPx = 0,
)
}

fun hideDestinationSheet() {
_sceneState.value =
_sceneState.value.copy(
isDestinationSheetVisible = false,
destinationSheetHeightPx = 0,
)
}

fun setDestinationSheetHeight(heightPx: Int) {
if (_sceneState.value.destinationSheetHeightPx == heightPx) {
return
}
_sceneState.value = _sceneState.value.copy(destinationSheetHeightPx = heightPx)
}

fun startSelectedDestinationNavigation() {
val destination = sceneState.value.selectedDestination ?: return
clearSelectedDestination()
startNavigation(destination.coordinate, destination.label)
}

override fun toggleMute() {
Expand All @@ -107,6 +178,13 @@ class DemoNavigationViewModel(
}

fun startNavigation(destination: Location, name: String?) {
startNavigation(
destination = GeographicCoordinate(destination.latitude, destination.longitude),
name = name,
)
}

fun startNavigation(destination: GeographicCoordinate, name: String? = null) {
viewModelScope.launch(Dispatchers.IO) {
// TODO: Fail gracefully
val lastLocation = location.value ?: return@launch
Expand All @@ -118,11 +196,7 @@ class DemoNavigationViewModel(
ferrostarCore.getRoutes(
lastLocation,
listOf(
Waypoint(
coordinate =
GeographicCoordinate(destination.latitude, destination.longitude),
kind = WaypointKind.BREAK,
),
Waypoint(coordinate = destination, kind = WaypointKind.BREAK),
),
)

Expand All @@ -132,7 +206,11 @@ class DemoNavigationViewModel(
locationProvider.enableSimulationOn(route)
}

ferrostarCore.startNavigation(route = route)
if (navigationUiState.value.isNavigating()) {
ferrostarCore.replaceRoute(route = route)
} else {
ferrostarCore.startNavigation(route = route)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,90 @@
package com.stadiamaps.ferrostar

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.stadiamaps.autocomplete.AutocompleteSearch
import com.stadiamaps.autocomplete.center
import com.stadiamaps.ferrostar.composeui.views.components.controls.NavigationUIButton
import com.stadiamaps.ferrostar.composeui.views.components.gridviews.InnerGridView
import com.stadiamaps.ferrostar.core.location.toAndroidLocation
import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState
import kotlin.math.roundToInt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotNavigatingOverlay(
modifier: Modifier = Modifier,
viewModel: DemoNavigationViewModel,
navigationMapState: NavigationMapState,
onTopOverlayBottomChanged: (Int) -> Unit = {},
) {
val location by viewModel.location.collectAsState()
val isSimulating by viewModel.simulated.collectAsState()
val uiState by viewModel.navigationUiState.collectAsState()
val stadiaApiKey = AppModule.stadiaApiKey

LaunchedEffect(stadiaApiKey) {
if (stadiaApiKey == null) {
onTopOverlayBottomChanged(0)
}
}

if (!uiState.isNavigating()) {
InnerGridView(
modifier = modifier.fillMaxSize().padding(bottom = 16.dp, top = 16.dp),
topCenter = {
AppModule.stadiaApiKey?.let { apiKey ->
AutocompleteSearch(apiKey = apiKey, userLocation = location?.toAndroidLocation()) {
feature ->
feature.center()?.let { center ->
viewModel.startNavigation(center, feature.properties.name)
stadiaApiKey?.let { apiKey ->
Box(
modifier =
Modifier.onGloballyPositioned { coordinates ->
onTopOverlayBottomChanged(coordinates.boundsInRoot().bottom.roundToInt())
}
) {
AutocompleteSearch(apiKey = apiKey, userLocation = location?.toAndroidLocation()) {
feature ->
feature.center()?.let { center ->
viewModel.selectDestination(
location = center,
label = feature.properties.name,
origin = DestinationSelectionOrigin.SearchResult,
)
}
}
}
}
},
centerEnd = {
NavigationUIButton(
onClick = { navigationMapState.recenter(isNavigating = false) },
buttonSize = DpSize(48.dp, 48.dp),
) {
Icon(
imageVector = Icons.Filled.MyLocation,
contentDescription = stringResource(R.string.center_on_my_location),
)
}
},
bottomEnd = {
Column(modifier = Modifier.padding(bottom = 24.dp), horizontalAlignment = Alignment.End) {
Button({ viewModel.toggleSimulation() }) {
Expand Down
Loading
Loading