Skip to content

Commit ca5dff4

Browse files
authored
Android demo app improvements (bottom sheet, recenter button, etc.) (#884)
* Introduce a destination selection workflow in the Android Compose demo app based on maplibre-compose, featuring a bottom sheet for location details and improved camera handling. * Refine camera framing for destination selection to account for top overlays. * Reformat OrnamentOptions padding in MapControls.kt * Reformat demo app source code for consistency
1 parent f30276b commit ca5dff4

10 files changed

Lines changed: 488 additions & 50 deletions

File tree

android/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
.externalNativeBuild
1515
.cxx
1616
local.properties
17+
.claude/settings.local.json

android/demo-app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dependencies {
6363
implementation libs.androidx.compose.ui.graphics
6464
implementation libs.androidx.compose.ui.tooling
6565
implementation libs.androidx.compose.material3
66+
implementation libs.androidx.compose.material.icon.extended
6667

6768
implementation libs.androidx.car.app
6869

android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import androidx.compose.runtime.Composable
1111
import androidx.compose.runtime.LaunchedEffect
1212
import androidx.compose.runtime.collectAsState
1313
import androidx.compose.runtime.getValue
14+
import androidx.compose.runtime.mutableStateOf
15+
import androidx.compose.runtime.remember
16+
import androidx.compose.runtime.setValue
1417
import androidx.compose.ui.Modifier
1518
import androidx.compose.ui.graphics.Color
1619
import androidx.compose.ui.platform.LocalContext
@@ -23,13 +26,20 @@ import com.stadiamaps.ferrostar.composeui.config.withSpeedLimitStyle
2326
import com.stadiamaps.ferrostar.composeui.runtime.KeepScreenOnDisposableEffect
2427
import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageStyle
2528
import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickResult
29+
import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState
2630
import com.stadiamaps.ferrostar.maplibreui.views.DynamicallyOrientingNavigationView
31+
import com.stadiamaps.ferrostar.ui.DestinationSelectionBottomSheet
32+
import com.stadiamaps.ferrostar.ui.DestinationSelectionCameraEffect
33+
import kotlinx.serialization.json.buildJsonObject
2734
import org.maplibre.compose.expressions.dsl.const
2835
import org.maplibre.compose.layers.CircleLayer
2936
import org.maplibre.compose.sources.GeoJsonData
3037
import org.maplibre.compose.sources.rememberGeoJsonSource
3138
import org.maplibre.compose.style.BaseStyle
3239
import org.maplibre.compose.util.MaplibreComposable
40+
import org.maplibre.spatialk.geojson.Feature
41+
import org.maplibre.spatialk.geojson.FeatureCollection
42+
import org.maplibre.spatialk.geojson.Point
3343
import uniffi.ferrostar.GeographicCoordinate
3444

3545
@Composable
@@ -84,54 +94,83 @@ fun DemoNavigationScene(viewModel: DemoNavigationViewModel = AppModule.viewModel
8494
permissionsLauncher.launch(allPermissions)
8595
}
8696
}
87-
val droppedPin by viewModel.droppedPin.collectAsState()
97+
val sceneState by viewModel.sceneState.collectAsState()
98+
val navigationMapState = rememberNavigationMapState()
99+
var destinationPreviewTopPaddingPx by remember { mutableStateOf(0) }
100+
DestinationSelectionCameraEffect(
101+
selectedDestination = sceneState.selectedDestination,
102+
destinationSheetHeightPx = sceneState.destinationSheetHeightPx,
103+
topOverlayBottomPx = destinationPreviewTopPaddingPx,
104+
navigationMapState = navigationMapState,
105+
)
88106

89107
DynamicallyOrientingNavigationView(
90108
modifier = Modifier.fillMaxSize(),
91109
baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl),
110+
navigationMapState = navigationMapState,
92111
viewModel = viewModel,
93112
config = VisualNavigationViewConfig.Default().withSpeedLimitStyle(SignageStyle.MUTCD),
94113
views =
95114
NavigationViewComponentBuilder.Default()
96115
.withCustomOverlayView(
97-
customOverlayView = { modifier -> NotNavigatingOverlay(modifier, viewModel) },
116+
customOverlayView = { modifier ->
117+
NotNavigatingOverlay(
118+
modifier = modifier,
119+
viewModel = viewModel,
120+
navigationMapState = navigationMapState,
121+
onTopOverlayBottomChanged = { destinationPreviewTopPaddingPx = it },
122+
)
123+
},
98124
),
99125
onTapExit = { viewModel.stopNavigation() },
100126
onMapLongClick = { position, screenPosition ->
101127
Log.d(
102128
"DemoNavigationScene",
103129
"Long press at lat=${position.lat}, lng=${position.lng}, screen=$screenPosition",
104130
)
105-
viewModel.setDroppedPin(position)
106-
NavigationMapClickResult.Pass
131+
viewModel.selectDestination(position)
132+
NavigationMapClickResult.Consume
107133
},
108134
) {
109-
DemoDroppedPinOverlay(droppedPin)
135+
DemoDroppedPinOverlay(sceneState.droppedPin)
136+
}
137+
138+
if (sceneState.isDestinationSheetVisible) {
139+
sceneState.selectedDestination?.let { destination ->
140+
DestinationSelectionBottomSheet(
141+
destination = destination,
142+
onClose = { viewModel.clearSelectedDestination() },
143+
onStartNavigation = { viewModel.startSelectedDestinationNavigation() },
144+
onSheetHeightChanged = viewModel::setDestinationSheetHeight,
145+
)
146+
}
110147
}
111148
}
112149

113150
@Composable
114151
@MaplibreComposable
115152
private fun DemoDroppedPinOverlay(droppedPin: GeographicCoordinate?) {
116-
val pinJson = droppedPinFeatureCollectionJsonOrNull(droppedPin) ?: return
117-
val pointSource = rememberGeoJsonSource(GeoJsonData.JsonString(pinJson))
153+
val pinFeatureCollection = droppedPinFeatureCollectionOrNull(droppedPin) ?: return
154+
val pointSource = rememberGeoJsonSource(GeoJsonData.Features(pinFeatureCollection))
118155

119156
CircleLayer(
120157
id = "demo-dropped-pin",
121158
source = pointSource,
122-
color = const(Color.Green),
123-
radius = const(12.dp),
159+
color = const(Color.Red),
160+
radius = const(10.dp),
124161
strokeColor = const(Color.White),
125-
strokeWidth = const(3.dp),
162+
strokeWidth = const(2.dp),
126163
)
127164
}
128165

129-
internal fun droppedPinFeatureCollectionJsonOrNull(pin: GeographicCoordinate?): String? = pin?.let {
130-
droppedPinFeatureCollectionJson(it)
166+
internal fun droppedPinFeatureCollectionOrNull(pin: GeographicCoordinate?) = pin?.let {
167+
droppedPinFeatureCollection(it)
131168
}
132169

133-
internal fun droppedPinFeatureCollectionJson(pin: GeographicCoordinate): String =
134-
"""
135-
{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[${pin.lng},${pin.lat}]},"properties":{}}]}
136-
"""
137-
.trimIndent()
170+
internal fun droppedPinFeatureCollection(pin: GeographicCoordinate) =
171+
FeatureCollection(
172+
Feature(
173+
geometry = Point(longitude = pin.lng, latitude = pin.lat),
174+
properties = buildJsonObject {},
175+
)
176+
)

android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ import uniffi.ferrostar.UserLocation
2828
import uniffi.ferrostar.Waypoint
2929
import uniffi.ferrostar.WaypointKind
3030

31+
data class DestinationSelection(
32+
val coordinate: GeographicCoordinate,
33+
val label: String? = null,
34+
val origin: DestinationSelectionOrigin = DestinationSelectionOrigin.MapLongPress,
35+
)
36+
37+
enum class DestinationSelectionOrigin {
38+
MapLongPress,
39+
SearchResult,
40+
}
41+
42+
data class DemoNavigationSceneState(
43+
val droppedPin: GeographicCoordinate? = null,
44+
val selectedDestination: DestinationSelection? = null,
45+
val isDestinationSheetVisible: Boolean = false,
46+
val destinationSheetHeightPx: Int = 0,
47+
)
48+
3149
@OptIn(ExperimentalCoroutinesApi::class)
3250
class DemoNavigationViewModel(
3351
// This is a simple example, but these would typically be dependency injected
@@ -44,8 +62,8 @@ class DemoNavigationViewModel(
4462
private val locationStateFlow = MutableStateFlow<UserLocation?>(null)
4563
val location = locationStateFlow.asStateFlow()
4664

47-
private val _droppedPin = MutableStateFlow<GeographicCoordinate?>(null)
48-
val droppedPin = _droppedPin.asStateFlow()
65+
private val _sceneState = MutableStateFlow(DemoNavigationSceneState())
66+
val sceneState = _sceneState.asStateFlow()
4967

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

96-
fun setDroppedPin(coordinate: GeographicCoordinate) {
97-
_droppedPin.value = coordinate
114+
fun selectDestination(
115+
coordinate: GeographicCoordinate,
116+
label: String? = null,
117+
origin: DestinationSelectionOrigin = DestinationSelectionOrigin.MapLongPress,
118+
) {
119+
_sceneState.value =
120+
_sceneState.value.copy(
121+
droppedPin = coordinate,
122+
selectedDestination =
123+
DestinationSelection(coordinate = coordinate, label = label, origin = origin),
124+
isDestinationSheetVisible = true,
125+
)
126+
}
127+
128+
fun selectDestination(
129+
location: Location,
130+
label: String? = null,
131+
origin: DestinationSelectionOrigin = DestinationSelectionOrigin.MapLongPress,
132+
) {
133+
selectDestination(
134+
coordinate = GeographicCoordinate(location.latitude, location.longitude),
135+
label = label,
136+
origin = origin,
137+
)
138+
}
139+
140+
fun clearSelectedDestination() {
141+
_sceneState.value =
142+
_sceneState.value.copy(
143+
droppedPin = null,
144+
selectedDestination = null,
145+
isDestinationSheetVisible = false,
146+
destinationSheetHeightPx = 0,
147+
)
148+
}
149+
150+
fun hideDestinationSheet() {
151+
_sceneState.value =
152+
_sceneState.value.copy(
153+
isDestinationSheetVisible = false,
154+
destinationSheetHeightPx = 0,
155+
)
156+
}
157+
158+
fun setDestinationSheetHeight(heightPx: Int) {
159+
if (_sceneState.value.destinationSheetHeightPx == heightPx) {
160+
return
161+
}
162+
_sceneState.value = _sceneState.value.copy(destinationSheetHeightPx = heightPx)
163+
}
164+
165+
fun startSelectedDestinationNavigation() {
166+
val destination = sceneState.value.selectedDestination ?: return
167+
clearSelectedDestination()
168+
startNavigation(destination.coordinate, destination.label)
98169
}
99170

100171
override fun toggleMute() {
@@ -107,6 +178,13 @@ class DemoNavigationViewModel(
107178
}
108179

109180
fun startNavigation(destination: Location, name: String?) {
181+
startNavigation(
182+
destination = GeographicCoordinate(destination.latitude, destination.longitude),
183+
name = name,
184+
)
185+
}
186+
187+
fun startNavigation(destination: GeographicCoordinate, name: String? = null) {
110188
viewModelScope.launch(Dispatchers.IO) {
111189
// TODO: Fail gracefully
112190
val lastLocation = location.value ?: return@launch
@@ -118,11 +196,7 @@ class DemoNavigationViewModel(
118196
ferrostarCore.getRoutes(
119197
lastLocation,
120198
listOf(
121-
Waypoint(
122-
coordinate =
123-
GeographicCoordinate(destination.latitude, destination.longitude),
124-
kind = WaypointKind.BREAK,
125-
),
199+
Waypoint(coordinate = destination, kind = WaypointKind.BREAK),
126200
),
127201
)
128202

@@ -132,7 +206,11 @@ class DemoNavigationViewModel(
132206
locationProvider.enableSimulationOn(route)
133207
}
134208

135-
ferrostarCore.startNavigation(route = route)
209+
if (navigationUiState.value.isNavigating()) {
210+
ferrostarCore.replaceRoute(route = route)
211+
} else {
212+
ferrostarCore.startNavigation(route = route)
213+
}
136214
}
137215
}
138216

android/demo-app/src/main/java/com/stadiamaps/ferrostar/NotNavigatingOverlay.kt

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,90 @@
11
package com.stadiamaps.ferrostar
22

3+
import androidx.compose.foundation.layout.Box
34
import androidx.compose.foundation.layout.Column
45
import androidx.compose.foundation.layout.fillMaxSize
56
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.material.icons.Icons
8+
import androidx.compose.material.icons.filled.MyLocation
69
import androidx.compose.material3.Button
710
import androidx.compose.material3.ExperimentalMaterial3Api
11+
import androidx.compose.material3.Icon
812
import androidx.compose.material3.MaterialTheme
913
import androidx.compose.material3.Text
1014
import androidx.compose.runtime.Composable
15+
import androidx.compose.runtime.LaunchedEffect
1116
import androidx.compose.runtime.collectAsState
1217
import androidx.compose.runtime.getValue
1318
import androidx.compose.ui.Alignment
1419
import androidx.compose.ui.Modifier
1520
import androidx.compose.ui.graphics.Shadow
21+
import androidx.compose.ui.layout.boundsInRoot
22+
import androidx.compose.ui.layout.onGloballyPositioned
23+
import androidx.compose.ui.res.stringResource
24+
import androidx.compose.ui.unit.DpSize
1625
import androidx.compose.ui.unit.dp
1726
import com.stadiamaps.autocomplete.AutocompleteSearch
1827
import com.stadiamaps.autocomplete.center
28+
import com.stadiamaps.ferrostar.composeui.views.components.controls.NavigationUIButton
1929
import com.stadiamaps.ferrostar.composeui.views.components.gridviews.InnerGridView
2030
import com.stadiamaps.ferrostar.core.location.toAndroidLocation
31+
import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState
32+
import kotlin.math.roundToInt
2133

2234
@OptIn(ExperimentalMaterial3Api::class)
2335
@Composable
2436
fun NotNavigatingOverlay(
2537
modifier: Modifier = Modifier,
2638
viewModel: DemoNavigationViewModel,
39+
navigationMapState: NavigationMapState,
40+
onTopOverlayBottomChanged: (Int) -> Unit = {},
2741
) {
2842
val location by viewModel.location.collectAsState()
2943
val isSimulating by viewModel.simulated.collectAsState()
3044
val uiState by viewModel.navigationUiState.collectAsState()
45+
val stadiaApiKey = AppModule.stadiaApiKey
46+
47+
LaunchedEffect(stadiaApiKey) {
48+
if (stadiaApiKey == null) {
49+
onTopOverlayBottomChanged(0)
50+
}
51+
}
3152

3253
if (!uiState.isNavigating()) {
3354
InnerGridView(
3455
modifier = modifier.fillMaxSize().padding(bottom = 16.dp, top = 16.dp),
3556
topCenter = {
36-
AppModule.stadiaApiKey?.let { apiKey ->
37-
AutocompleteSearch(apiKey = apiKey, userLocation = location?.toAndroidLocation()) {
38-
feature ->
39-
feature.center()?.let { center ->
40-
viewModel.startNavigation(center, feature.properties.name)
57+
stadiaApiKey?.let { apiKey ->
58+
Box(
59+
modifier =
60+
Modifier.onGloballyPositioned { coordinates ->
61+
onTopOverlayBottomChanged(coordinates.boundsInRoot().bottom.roundToInt())
62+
}
63+
) {
64+
AutocompleteSearch(apiKey = apiKey, userLocation = location?.toAndroidLocation()) {
65+
feature ->
66+
feature.center()?.let { center ->
67+
viewModel.selectDestination(
68+
location = center,
69+
label = feature.properties.name,
70+
origin = DestinationSelectionOrigin.SearchResult,
71+
)
72+
}
4173
}
4274
}
4375
}
4476
},
77+
centerEnd = {
78+
NavigationUIButton(
79+
onClick = { navigationMapState.recenter(isNavigating = false) },
80+
buttonSize = DpSize(48.dp, 48.dp),
81+
) {
82+
Icon(
83+
imageVector = Icons.Filled.MyLocation,
84+
contentDescription = stringResource(R.string.center_on_my_location),
85+
)
86+
}
87+
},
4588
bottomEnd = {
4689
Column(modifier = Modifier.padding(bottom = 24.dp), horizontalAlignment = Alignment.End) {
4790
Button({ viewModel.toggleSimulation() }) {

0 commit comments

Comments
 (0)