Skip to content

feat(navigation): Implement place search along route and example #684

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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 @@ -53,6 +53,8 @@ class MainActivity : ComponentActivity() {
)

val location by myViewModel.location.collectAsStateWithLifecycle()
val placesAlongRoute by myViewModel.placesAlongRoute.collectAsStateWithLifecycle()
val routeReady by myViewModel.routeReady.collectAsStateWithLifecycle()

fun onShowSnackbar(message: String) {
scope.launch {
Expand Down Expand Up @@ -80,11 +82,18 @@ class MainActivity : ComponentActivity() {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
NavigationScreen(
modifier = Modifier.padding(innerPadding),
deviceLocation = location
deviceLocation = location,
placesAlongRoute = placesAlongRoute,
routeReady = routeReady,
onClearSearchResults = {
myViewModel.clearSearchResults()
},
onSearchClicked = {
myViewModel.searchAlongRoute(it)
}
)
}
}
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@ package com.google.maps.android.compose.navigation

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
Expand All @@ -20,6 +33,7 @@ import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.android.libraries.navigation.NavigationView
import com.google.android.libraries.places.api.model.Place
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MarkerComposable
Expand All @@ -30,7 +44,11 @@ import com.google.maps.android.compose.rememberMarkerState
@Composable
fun NavigationScreen(
deviceLocation: LatLng?,
modifier: Modifier = Modifier
placesAlongRoute: List<Place>,
onClearSearchResults: () -> Unit,
onSearchClicked: (String) -> Unit,
routeReady: Boolean,
modifier: Modifier = Modifier,
) {
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(
Expand All @@ -53,6 +71,59 @@ fun NavigationScreen(
Column(
modifier = modifier
) {
if (placesAlongRoute.isNotEmpty()) {
OutlinedCard(
modifier = Modifier.height(250.dp).fillMaxWidth()
) {
Box(
modifier = Modifier.padding(16.dp),
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(placesAlongRoute) { place ->
PlaceItem(place)
}
}

IconButton(
onClick = onClearSearchResults,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
} else if (routeReady) {
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.fillMaxWidth()) {
Button(
modifier = Modifier.weight(1f),
onClick = {
onSearchClicked("Spicy Vegetarian Food")
}
) {
Text("Spicy Veg")
}
Button(
modifier = Modifier.weight(1f),
onClick = {
onSearchClicked("Pizza")
}
) {
Text("Pizza")
}
}
}
}

GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
Expand Down Expand Up @@ -89,3 +160,9 @@ fun NavigationScreen(
}
}
}

@Composable
fun PlaceItem(place: Place) {
// Should probably filter these. I would not use the place object directly.
Text(place.displayName ?: "Unknown place")
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.google.android.gms.tasks.CancellationTokenSource
import com.google.android.libraries.navigation.ListenableResultFuture
import com.google.android.libraries.places.api.model.EncodedPolyline
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.api.model.SearchAlongRouteParameters
import com.google.android.libraries.places.api.net.kotlin.awaitSearchByText
import com.google.maps.android.ktx.utils.latLngListEncode
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import com.google.maps.android.compose.navigation.BuildConfig

class NavigationViewModel(
private val placesClient: PlacesClient,
Expand All @@ -60,6 +64,12 @@ class NavigationViewModel(

private var navigator: Navigator? = null

private val _placesAlongRoute = MutableStateFlow<List<Place>>(emptyList())
val placesAlongRoute = _placesAlongRoute.asStateFlow()

private val _routeReady = MutableStateFlow(false)
val routeReady = _routeReady.asStateFlow()

init {
viewModelScope.launch {
_hasLocationPermission.collect() {
Expand Down Expand Up @@ -204,9 +214,43 @@ class NavigationViewModel(
travelMode(RoutingOptions.TravelMode.DRIVING)
}

// Listen for route changes
navigator.addRouteChangedListener {
Log.d("NavigationContainer", "Route changed")
_routeReady.value = true
// viewModelScope.launch {
// val placeFields = listOf(Place.Field.ID, Place.Field.DISPLAY_NAME)
// searchAlongRoute("Spicy Vegetarian Food", placeFields)
// }
}

navigateToPlace(chautauquaDinningHall, routingOptions)
}

private suspend fun searchAlongRoute(searchText: String, placeFields: List<Place.Field>) {
val route = navigator?.currentRouteSegment?.latLngs

if (route.isNullOrEmpty()) {
Log.d("NavigationContainer", "No route found")
return
}

val encodedPolyline = EncodedPolyline.newInstance(route.latLngListEncode())

val searchAlongRouteParameters = SearchAlongRouteParameters.newInstance(encodedPolyline)

val response = placesClient.awaitSearchByText(searchText, placeFields) {
setSearchAlongRouteParameters(searchAlongRouteParameters)
maxResultCount = 10
}

response.places.forEach {
Log.d("Gollum", "Place ID: ${it.id}, Display Name: ${it.displayName}")
}

_placesAlongRoute.value = response.places
}

override fun onError(@NavigationApi.ErrorCode errorCode: Int) {
when (errorCode) {
NavigationApi.ErrorCode.NOT_AUTHORIZED -> displayMessage(
Expand Down Expand Up @@ -235,5 +279,14 @@ class NavigationViewModel(
_uiEvent.emit(UiEvent.ShowSnackbar(message))
}
}
}

fun clearSearchResults() {
_placesAlongRoute.value = emptyList()
}

fun searchAlongRoute(searchText: String) {
viewModelScope.launch {
searchAlongRoute(searchText, listOf(Place.Field.ID, Place.Field.DISPLAY_NAME))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.google.maps.android.compose.navigation

import android.util.Log
import com.google.android.gms.maps.model.LatLng
import com.google.android.libraries.navigation.NavigationApi
import com.google.android.libraries.navigation.NavigationApi.NavigatorListener
import com.google.android.libraries.navigation.Navigator
import com.google.android.libraries.places.api.model.EncodedPolyline
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.api.model.SearchAlongRouteParameters
import com.google.android.libraries.places.api.net.PlacesClient
import com.google.maps.android.ktx.utils.latLngListEncode
import com.google.android.libraries.places.api.net.kotlin.awaitSearchByText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/**
* Holds an instance of the navigator class. Most likely an activity or a view model. Take a look at NavigationViewModel
*/
class NavigationContainer(
private val placesClient: PlacesClient,
private val scope: CoroutineScope,
) : NavigatorListener {
private var navigator: Navigator? = null

override fun onError(@NavigationApi.ErrorCode errorCode: Int) {
when (errorCode) {
NavigationApi.ErrorCode.NOT_AUTHORIZED -> displayMessage(
"Error loading Navigation SDK: Your API key is "
+ "invalid or not authorized to use the Navigation SDK."
)

NavigationApi.ErrorCode.TERMS_NOT_ACCEPTED -> displayMessage(
"Error loading Navigation SDK: User did not accept "
+ "the Navigation Terms of Use."
)

NavigationApi.ErrorCode.NETWORK_ERROR -> displayMessage("Error loading Navigation SDK: Network error.")
NavigationApi.ErrorCode.LOCATION_PERMISSION_MISSING -> displayMessage(
"Error loading Navigation SDK: Location permission "
+ "is missing."
)

else -> displayMessage("Error loading Navigation SDK: $errorCode")
}
}

private fun displayMessage(message: String) {
// Could show a snackbar or toast here
Log.w("NavigationContainer", message)
}

override fun onNavigatorReady(navigator: Navigator?) {
this.navigator = navigator ?: error("Navigator is null")
navigator.addRouteChangedListener {
Log.d("NavigationContainer", "Route changed")
scope.launch {
navigator.currentRouteSegment?.latLngs?.let { route ->
//pass the encoded string to a function to perform the search
searchPlacesAlongRoute(route)
}
}
}
}

private suspend fun searchPlacesAlongRoute(route: List<LatLng>) {
val encodedPolyline = EncodedPolyline.newInstance(route.latLngListEncode())
val placeFields = listOf(Place.Field.ID, Place.Field.DISPLAY_NAME)

val searchAlongRouteParameters = SearchAlongRouteParameters.newInstance(encodedPolyline)

val response = placesClient.awaitSearchByText("Spicy Vegetarian Food", placeFields) {
setSearchAlongRouteParameters(searchAlongRouteParameters)
maxResultCount = 10
}

response.places.forEach {
Log.d("Places API", "Place ID: ${it.id}, Display Name: ${it.displayName}")
}
}
}
Loading