diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e26ff992a..c2f7feb8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,11 +17,14 @@ spotless = "8.10.4" versions = "0.66.0" # Android +accompanistPermissions = "0.37.3" activitycompose = "1.13.0" androidx-core = "1.19.1" androidx-startup = "1.2.0" compose-bom = "2026.09.00" constraintlayout = "2.2.2" +desugarJdkLibs = "2.1.5" +lifecycleRuntimeKtx = "2.11.0" # Material material = "1.14.0" @@ -31,6 +34,9 @@ materialIconsExtendedAndroid = "1.7.8" # Google Maps Platform mapsecrets = "2.0.1" mapsutils = "6.0.0" +navigation = "8.0.0" +places = "6.0.2" +playServicesLocation = "21.4.0" # Testing androidCore = "1.7.0" @@ -48,21 +54,29 @@ truth = "1.4.5" [libraries] # Base android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugarJdkLibs" } dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } kotlin = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" } # Android +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activitycompose" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-preview-tooling = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } # Material @@ -75,6 +89,9 @@ material = { group = "com.google.android.material", name = "material", version.r maps-utils-core = { module = "com.google.maps.android:android-maps-utils-core", version.ref = "mapsutils" } maps-utils = { module = "com.google.maps.android:android-maps-utils", version.ref = "mapsutils" } maps-secrets-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "mapsecrets" } +navigation = { module = "com.google.android.libraries.navigation:navigation", version.ref = "navigation" } +places = { group = "com.google.android.libraries.places", name = "places", version.ref = "places" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } # Testing androidx-test-compose-ui = { module = "androidx.compose.ui:ui-test-junit4" } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt b/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt index 9a89365a0..3c06846ad 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt @@ -19,7 +19,6 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.compose.runtime.Composable import com.google.android.gms.maps.GoogleMap -import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.Marker /** @@ -34,11 +33,11 @@ import com.google.android.gms.maps.model.Marker * * [ComposeView][androidx.compose.ui.platform.ComposeView] content is therefore * rendered into a plain [android.graphics.Bitmap] up front (while briefly attached - * to [mapView] to drive composition), and that bitmap is wrapped in a fresh, + * to [mapViewDelegate] to drive composition), and that bitmap is wrapped in a fresh, * parent-less [ImageView] for the Maps SDK to use. */ internal class ComposeInfoWindowAdapter( - private val mapView: MapView, + private val mapViewDelegate: AbstractMapViewDelegate<*>, private val markerNodeFinder: (Marker) -> MarkerNode?, ) : GoogleMap.InfoWindowAdapter { @@ -55,9 +54,10 @@ internal class ComposeInfoWindowAdapter( } private fun renderToImageView(markerNode: MarkerNode, content: @Composable () -> Unit): View? { - val bitmap = mapView.renderComposableToBitmap(markerNode.compositionContext, content) - ?: return null - return ImageView(mapView.context).apply { + val bitmap = + mapViewDelegate.renderComposableToBitmap(markerNode.compositionContext, content) + ?: return null + return ImageView(mapViewDelegate.mapView.context).apply { layoutParams = ViewGroup.LayoutParams(bitmap.width, bitmap.height) scaleType = ImageView.ScaleType.FIT_XY setImageBitmap(bitmap) diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index 552852c1d..9278946e2 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -17,6 +17,7 @@ package com.google.maps.android.compose import android.content.ComponentCallbacks2 import android.content.Context import android.content.res.Configuration +import android.graphics.Bitmap import android.location.Location import android.os.Bundle import android.util.Log @@ -38,6 +39,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle @@ -48,6 +50,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.LocationSource import com.google.android.gms.maps.MapView @@ -91,6 +94,9 @@ import kotlinx.coroutines.launch * may be obscured. The map will move the Google logo, etc. to avoid overlapping the padding. * @param mapColorScheme Defines the color scheme for the Map. Defaults to [ComposeMapColorScheme.FOLLOW_SYSTEM]. * Not applied to Lite mode maps, which do not support color schemes. + * @param mapViewCreator optional factory for creating a custom [AbstractMapViewDelegate] (such as + * wrapping a Navigation SDK `NavigationView`). When null, [mapViewFactory] wrapped in [MapViewDelegate] is used. + * @param mapViewFactory factory for creating the underlying [MapView] when [mapViewCreator] is null * @param content the content of the map */ @Composable @@ -113,6 +119,7 @@ public fun GoogleMap( onPOIClick: ((PointOfInterest) -> Unit)? = null, contentPadding: PaddingValues = DefaultMapContentPadding, mapColorScheme: ComposeMapColorScheme? = ComposeMapColorScheme.FOLLOW_SYSTEM, + mapViewCreator: ((Context, GoogleMapOptions) -> AbstractMapViewDelegate<*>)? = null, mapViewFactory: (Context, GoogleMapOptions) -> MapView = ::MapView, content: @Composable @GoogleMapComposable @@ -192,20 +199,23 @@ public fun GoogleMap( baseOptions } cameraPositionState.isLiteMode = isLiteMode - mapViewFactory(context, options).also { mapView -> + val mapViewDelegate = mapViewCreator?.invoke(context, options) + ?: MapViewDelegate(mapViewFactory(context, options)) + mapViewDelegate.mapView.also { mapView -> mapView.applyFocusability(focusable) mapView.setViewTreeLifecycleOwner(lifecycleOwner) mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - val componentCallbacks = mapView.componentCallbacks2() + val componentCallbacks = mapViewDelegate.componentCallbacks2() context.registerComponentCallbacks(componentCallbacks) - val lifecycleObserver = MapLifecycleEventObserver(mapView) + val lifecycleObserver = MapLifecycleEventObserver(mapViewDelegate) // Only register for [lifecycleOwner]'s lifecycle events while MapView is attached val onAttachStateListener = MapAttachStateChangeListener(lifecycleObserver) mapView.tag = MapTagData( + mapViewDelegate = mapViewDelegate, componentCallbacks = componentCallbacks, lifecycleObserver = lifecycleObserver, detachLifecycleObserver = onAttachStateListener::cleanup, @@ -231,7 +241,7 @@ public fun GoogleMap( tagData.subcompositionJob = parentCompositionScope.launchSubcomposition( mapUpdaterState, parentComposition, - mapView, + tagData.mapViewDelegate, tagData, mapClickListeners, currentContent, @@ -242,19 +252,21 @@ public fun GoogleMap( } /** - * When [focusable] is true, treat the MapView as a single focus stop. + * When [focusable] is true, treat the map view as a single focus stop. * FOCUS_BEFORE_DESCENDANTS ensures the map container gets focused first, and prevents the * keyboard focus from tabbing through all internal map elements (like zoom buttons or the * Google logo) by default. * When [focusable] is false, remove the map and all of its internal elements from keyboard * focus traversal altogether. */ -private fun MapView.applyFocusability(focusable: Boolean) { +private fun View.applyFocusability(focusable: Boolean) { isFocusable = focusable - descendantFocusability = if (focusable) { - ViewGroup.FOCUS_BEFORE_DESCENDANTS - } else { - ViewGroup.FOCUS_BLOCK_DESCENDANTS + if (this is ViewGroup) { + descendantFocusability = if (focusable) { + ViewGroup.FOCUS_BEFORE_DESCENDANTS + } else { + ViewGroup.FOCUS_BLOCK_DESCENDANTS + } } } @@ -271,20 +283,38 @@ internal fun CoroutineScope.launchSubcomposition( content: @Composable @GoogleMapComposable () -> Unit, +): Job = launchSubcomposition( + mapUpdaterState = mapUpdaterState, + parentComposition = parentComposition, + mapViewDelegate = MapViewDelegate(mapView), + tagData = tagData, + mapClickListeners = mapClickListeners, + content = content, +) + +internal fun CoroutineScope.launchSubcomposition( + mapUpdaterState: MapUpdaterState, + parentComposition: CompositionContext, + mapViewDelegate: AbstractMapViewDelegate<*>, + tagData: MapTagData, + mapClickListeners: MapClickListeners, + content: + @Composable @GoogleMapComposable + () -> Unit, ): Job { // Use [CoroutineStart.UNDISPATCHED] to kick off GoogleMap loading immediately return launch( context = Dispatchers.Main.immediate, start = CoroutineStart.UNDISPATCHED, ) { - val map = mapView.awaitMap() - if (!isActive || mapView.tagDataOrNull !== tagData) { + val map = mapViewDelegate.awaitMap() + if (!isActive || mapViewDelegate.mapView.tagDataOrNull !== tagData) { return@launch } val composition = Composition( applier = MapApplier( map, - mapView, + mapViewDelegate, mapClickListeners, isLiteMode = tagData.isLiteMode, ), @@ -338,12 +368,27 @@ private const val TAG = "GoogleMap" /** Used to store things in the tag which must be retrievable across recompositions */ internal class MapTagData( + val mapViewDelegate: AbstractMapViewDelegate<*>, val componentCallbacks: ComponentCallbacks2, val lifecycleObserver: MapLifecycleEventObserver, val detachLifecycleObserver: () -> Unit, val onAttachStateChangeListener: View.OnAttachStateChangeListener, val isLiteMode: Boolean, ) { + internal constructor( + componentCallbacks: ComponentCallbacks2, + lifecycleObserver: MapLifecycleEventObserver, + detachLifecycleObserver: () -> Unit, + onAttachStateChangeListener: View.OnAttachStateChangeListener, + isLiteMode: Boolean, + ) : this( + mapViewDelegate = lifecycleObserver.mapView, + componentCallbacks = componentCallbacks, + lifecycleObserver = lifecycleObserver, + detachLifecycleObserver = detachLifecycleObserver, + onAttachStateChangeListener = onAttachStateChangeListener, + isLiteMode = isLiteMode, + ) var subcompositionJob: Job? = null var subcomposition: Composition? = null @@ -383,7 +428,9 @@ internal class MapAttachStateChangeListener(private val lifecycleObserver: MapLi } } -internal fun MapView.componentCallbacks2(): ComponentCallbacks2 = object : ComponentCallbacks2 { +internal fun MapView.componentCallbacks2(): ComponentCallbacks2 = MapViewDelegate(this).componentCallbacks2() + +internal fun AbstractMapViewDelegate<*>.componentCallbacks2(): ComponentCallbacks2 = object : ComponentCallbacks2 { override fun onConfigurationChanged(newConfig: Configuration) {} @Deprecated( @@ -401,14 +448,14 @@ internal fun MapView.componentCallbacks2(): ComponentCallbacks2 = object : Compo } } -internal val MapView.tagDataOrNull: MapTagData? +internal val View.tagDataOrNull: MapTagData? get() = tag as? MapTagData -internal fun MapView.onReset() { +internal fun View.onReset() { tagDataOrNull?.disposeSubcomposition() } -internal fun MapView.onRelease() { +internal fun View.onRelease() { val tagData = tagDataOrNull tagData?.disposeSubcomposition() if (tagData != null) { @@ -419,7 +466,7 @@ internal fun MapView.onRelease() { } setViewTreeLifecycleOwner(null) setViewTreeSavedStateRegistryOwner(null) - removeAllViews() + (this as? ViewGroup)?.removeAllViews() tag = null } @@ -460,7 +507,130 @@ public fun googleMapFactory( } } -internal class MapLifecycleEventObserver(private val mapView: MapView) : LifecycleEventObserver { +/** + * Delegate interface abstracting map view lifecycle, [GoogleMap] acquisition, and Compose view + * rendering so that [GoogleMap] can host either a standard [MapView] (via [MapViewDelegate]) or a + * custom map view such as the Navigation SDK's `NavigationView`. + * + * @param T the underlying [View] type hosted by this delegate + */ +public interface AbstractMapViewDelegate { + /** Forwards `onCreate` to the underlying map view. */ + public fun onCreate(savedInstanceState: Bundle?) + + /** Forwards `onStart` to the underlying map view. */ + public fun onStart() + + /** Forwards `onResume` to the underlying map view. */ + public fun onResume() + + /** Forwards `onPause` to the underlying map view. */ + public fun onPause() + + /** Forwards `onStop` to the underlying map view. */ + public fun onStop() + + /** Forwards `onLowMemory` to the underlying map view. */ + public fun onLowMemory() + + /** Forwards `onDestroy` to the underlying map view. */ + public fun onDestroy() + + /** Suspends until the [GoogleMap] instance is available and returns it. */ + public suspend fun awaitMap(): GoogleMap + + /** + * Prepares [view] for a single render by temporarily attaching it as a descendant of [mapView]. + */ + public fun renderComposeViewOnce( + view: AbstractComposeView, + parentContext: CompositionContext, + onAddedToWindow: ((View) -> Unit)? = null, + ) { + val viewGroup = mapView as? ViewGroup ?: return + viewGroup.renderComposeViewOnce( + view = view, + parentContext = parentContext, + onAddedToWindow = onAddedToWindow, + ) + } + + /** + * Attaches [view] as a descendant of [mapView] so it can compose until the returned + * [ComposeUiViewRenderer.RenderHandle] is disposed. + */ + public fun startRenderingComposeView( + view: AbstractComposeView, + parentContext: CompositionContext, + ): ComposeUiViewRenderer.RenderHandle { + val viewGroup = mapView as? ViewGroup + ?: return object : ComposeUiViewRenderer.RenderHandle { + override fun dispose() {} + } + return viewGroup.startRenderingComposeView( + view = view, + parentContext = parentContext, + ) + } + + /** + * Renders [content] into a standalone [Bitmap] by temporarily attaching a `ComposeView` as a + * descendant of [mapView], measuring, laying out, and drawing it, then detaching it again. + */ + public fun renderComposableToBitmap(parentContext: CompositionContext, content: @Composable () -> Unit): Bitmap? { + val viewGroup = mapView as? ViewGroup ?: return null + return viewGroup.renderComposableToBitmap(parentContext, content) + } + + /** The underlying [View] hosted by this delegate. */ + public val mapView: T +} + +/** + * Default [AbstractMapViewDelegate] implementation wrapping a standard [MapView]. + * + * @property mapView the underlying [MapView] instance + */ +public class MapViewDelegate(override val mapView: MapView) : AbstractMapViewDelegate { + override fun onCreate(savedInstanceState: Bundle?): Unit = mapView.onCreate(savedInstanceState) + override fun onStart(): Unit = mapView.onStart() + override fun onResume(): Unit = mapView.onResume() + override fun onPause(): Unit = mapView.onPause() + override fun onStop(): Unit = mapView.onStop() + override fun onLowMemory(): Unit = mapView.onLowMemory() + override fun onDestroy(): Unit = mapView.onDestroy() + override suspend fun awaitMap(): GoogleMap = mapView.awaitMap() + override fun renderComposeViewOnce( + view: AbstractComposeView, + parentContext: CompositionContext, + onAddedToWindow: ((View) -> Unit)?, + ) { + mapView.renderComposeViewOnce( + view = view, + parentContext = parentContext, + onAddedToWindow = onAddedToWindow, + ) + } + + override fun startRenderingComposeView( + view: AbstractComposeView, + parentContext: CompositionContext, + ): ComposeUiViewRenderer.RenderHandle = mapView.startRenderingComposeView( + view = view, + parentContext = parentContext, + ) + + override fun renderComposableToBitmap( + parentContext: CompositionContext, + content: @Composable () -> Unit, + ): Bitmap? = mapView.renderComposableToBitmap( + parentContext = parentContext, + content = content, + ) +} + +internal class MapLifecycleEventObserver(internal val mapView: AbstractMapViewDelegate<*>) : LifecycleEventObserver { + internal constructor(mapView: MapView) : this(MapViewDelegate(mapView)) private var currentLifecycleState: Lifecycle.State = Lifecycle.State.INITIALIZED override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt index 74fe2a540..044e5665f 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt @@ -39,12 +39,24 @@ private object MapNodeRoot : MapNode // for that particular listener; yet MapClickListeners never actually changes. internal class MapApplier( val map: GoogleMap, - internal val mapView: MapView, + internal val mapViewDelegate: AbstractMapViewDelegate<*>, val mapClickListeners: MapClickListeners, - /** Whether [mapView] was created in Lite mode, which does not support color schemes. */ + /** Whether [mapViewDelegate] was created in Lite mode, which does not support color schemes. */ internal val isLiteMode: Boolean, ) : AbstractApplier(MapNodeRoot) { + internal constructor( + map: GoogleMap, + mapView: MapView, + mapClickListeners: MapClickListeners, + isLiteMode: Boolean, + ) : this( + map = map, + mapViewDelegate = MapViewDelegate(mapView), + mapClickListeners = mapClickListeners, + isLiteMode = isLiteMode, + ) + private val decorations = mutableListOf() init { @@ -216,7 +228,7 @@ internal class MapApplier( }) map.setInfoWindowAdapter( ComposeInfoWindowAdapter( - mapView, + mapViewDelegate, markerNodeFinder = { marker -> decorations.firstOrNull { it is MarkerNode && it.marker == marker } as MarkerNode? diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt index e63a722c1..014bded19 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt @@ -31,17 +31,16 @@ import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.ComposeView import androidx.core.graphics.applyCanvas import androidx.core.graphics.createBitmap -import com.google.android.gms.maps.MapView import java.io.Closeable /** * Prepares [view] for a single render by temporarily attaching it as a descendant of this - * [MapView]. + * [ViewGroup]. * This is a trick to enable [ComposeView] to start its composition, as it requires being attached * to a window. [onAddedToWindow] is called in place, and then [view] is removed from the window * before returning. */ -internal fun MapView.renderComposeViewOnce( +internal fun ViewGroup.renderComposeViewOnce( view: AbstractComposeView, onAddedToWindow: ((View) -> Unit)? = null, parentContext: CompositionContext, @@ -56,22 +55,22 @@ private val unspecifiedMeasureSpec = /** * Renders [content] into a standalone [Bitmap] by temporarily attaching a [ComposeView] as a - * descendant of this [MapView], measuring, laying out and drawing it, then detaching it again. + * descendant of this [ViewGroup], measuring, laying out and drawing it, then detaching it again. * - * Unlike [startRenderingComposeView], the returned bitmap has no ties back to this [MapView] or + * Unlike [startRenderingComposeView], the returned bitmap has no ties back to this [ViewGroup] or * its composition once this function returns, so it is safe to hand off to APIs (such as * [com.google.android.gms.maps.GoogleMap.InfoWindowAdapter]) that take ownership of the view they * receive and re-parent it into their own hierarchy — which would otherwise crash with * "The specified child already has a parent" if handed a view still attached elsewhere. * - * Returns `null` if this [MapView] is no longer attached to a window by the time [content] is + * Returns `null` if this [ViewGroup] is no longer attached to a window by the time [content] is * measured. That happens when the Maps SDK's own async render request (which drives this call) - * lands after Compose has already unparented the [MapView] (e.g. `LazyColumn` recycling/detach): + * lands after Compose has already unparented the map view (e.g. `LazyColumn` recycling/detach): * the composition never gets a real layout pass, so it measures to zero size. There's no info * window worth rendering for a map that's already being torn down, so this is treated as "nothing * to show" rather than an error. */ -internal fun MapView.renderComposableToBitmap( +internal fun ViewGroup.renderComposableToBitmap( parentContext: CompositionContext, content: @Composable () -> Unit, ): Bitmap? { @@ -110,12 +109,12 @@ internal fun MapView.renderComposableToBitmap( } /** - * Prepares [view] for a rendering by attaching it as a descendant of this [MapView]. + * Prepares [view] for a rendering by attaching it as a descendant of this [ViewGroup]. * This is a trick to enable [ComposeView] to start its composition, as it requires being attached * to a window. A [ComposeUiViewRenderer.RenderHandle] is returned, which must be disposed after - * this view no longer needs to render. Disposing removes [view] from the [MapView]. + * this view no longer needs to render. Disposing removes [view] from the [ViewGroup]. */ -internal fun MapView.startRenderingComposeView( +internal fun ViewGroup.startRenderingComposeView( view: AbstractComposeView, parentContext: CompositionContext, ): ComposeUiViewRenderer.RenderHandle { @@ -132,10 +131,10 @@ internal fun MapView.startRenderingComposeView( } /** - * Retrieves the [NoDrawContainerView] from this [MapView], or adds one if there isn't already one. + * Retrieves the [NoDrawContainerView] from this [ViewGroup], or adds one if there isn't already one. * @see NoDrawContainerView */ -private fun MapView.ensureContainerView(): NoDrawContainerView = findViewById(R.id.maps_compose_nodraw_container_view) +private fun ViewGroup.ensureContainerView(): NoDrawContainerView = findViewById(R.id.maps_compose_nodraw_container_view) ?: NoDrawContainerView(context) .apply { id = R.id.maps_compose_nodraw_container_view } .also(::addView) @@ -143,7 +142,7 @@ private fun MapView.ensureContainerView(): NoDrawContainerView = findViewById(R. @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @Composable public fun rememberComposeUiViewRenderer(): ComposeUiViewRenderer { - val mapView = (currentComposer.applier as MapApplier).mapView + val mapView = (currentComposer.applier as MapApplier).mapViewDelegate val compositionContext = rememberCompositionContext() return remember(compositionContext) { @@ -166,12 +165,12 @@ public fun rememberComposeUiViewRenderer(): ComposeUiViewRenderer { } } -/** @see MapView.renderComposeViewOnce */ +/** @see ViewGroup.renderComposeViewOnce */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public interface ComposeUiViewRenderer { /** - * Prepares [view] for a single render by temporarily attaching it as a child of the [MapView]. + * Prepares [view] for a single render by temporarily attaching it as a child of the map view. * Its composition will start. [onAddedToWindow] is called in place, and then [view] is removed * from the window before returning. */ diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt index febf7d511..744ea0ba1 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt @@ -109,7 +109,7 @@ public val DefaultMapContentPadding: PaddingValues = PaddingValues() @Composable internal inline fun MapUpdater(mapUpdaterState: MapUpdaterState) = with(mapUpdaterState) { val map = (currentComposer.applier as MapApplier).map - val mapView = (currentComposer.applier as MapApplier).mapView + val mapView = (currentComposer.applier as MapApplier).mapViewDelegate.mapView val isLiteMode = (currentComposer.applier as MapApplier).isLiteMode if (mergeDescendants) { mapView.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS diff --git a/navigation-app/.gitignore b/navigation-app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/navigation-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/navigation-app/build.gradle.kts b/navigation-app/build.gradle.kts new file mode 100644 index 000000000..b81ba6133 --- /dev/null +++ b/navigation-app/build.gradle.kts @@ -0,0 +1,135 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") + alias(libs.plugins.compose.compiler) +} + +android { + lint { + sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile + } + + namespace = "com.google.maps.android.compose.navigation" + compileSdk = libs.versions.androidCompileSdk.get().toInt() + + defaultConfig { + applicationId = "com.google.maps.android.compose.navigation" + minSdk = 24 + targetSdk = libs.versions.androidTargetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders["MAPS_API_KEY"] = "DEFAULT_API_KEY" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + buildConfig = true + compose = true + } + + packaging { + resources { + pickFirsts += listOf( + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md", + ) + } + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + ) + } +} + +configurations.all { + // IMPORTANT: Exclude play-services-maps because the Navigation SDK already bundles the Maps SDK. + exclude(group = "com.google.android.gms", module = "play-services-maps") +} + +dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) + + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.preview.tooling) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended.android) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + + testImplementation(libs.test.junit) + androidTestImplementation(libs.androidx.test.junit.ktx) + androidTestImplementation(libs.androidx.test.espresso) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.test.compose.ui) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + // Instead of the lines below, regular apps would load these libraries from Maven according to + // the README installation instructions + implementation(project(":maps-compose")) + implementation(project(":maps-compose-widgets")) + implementation(project(":maps-compose-utils")) + + implementation(libs.maps.utils) + implementation(libs.maps.utils.core) + + // Use the Navigation SDK which includes the Maps SDK + implementation(libs.navigation) + + implementation(libs.play.services.location) + + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + + implementation(libs.places) + + // Accompanist permission helper + implementation(libs.accompanist.permissions) +} + +secrets { + // To add your Maps API key to this project: + // 1. If the secrets.properties file does not exist, create it in the same folder as the local.properties file. + // 2. Add this line, where YOUR_API_KEY is your API key: + // MAPS_API_KEY=YOUR_API_KEY + propertiesFileName = "secrets.properties" + + // A properties file containing default secret values. This file can be + // checked in version control. + defaultPropertiesFileName = "local.defaults.properties" +} diff --git a/navigation-app/proguard-rules.pro b/navigation-app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/navigation-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/navigation-app/src/androidTest/java/com/google/maps/android/compose/navigation/ExampleInstrumentedTest.kt b/navigation-app/src/androidTest/java/com/google/maps/android/compose/navigation/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..0d4207e7c --- /dev/null +++ b/navigation-app/src/androidTest/java/com/google/maps/android/compose/navigation/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.google.maps.android.compose.navigation + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.google.maps.android.compose.navigation", appContext.packageName) + } +} diff --git a/navigation-app/src/main/AndroidManifest.xml b/navigation-app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..93f321ef8 --- /dev/null +++ b/navigation-app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainActivity.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainActivity.kt new file mode 100644 index 000000000..bef5aa232 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/MainActivity.kt @@ -0,0 +1,134 @@ +package com.google.maps.android.compose.navigation + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.navigation.NavigationApi +import com.google.maps.android.compose.navigation.ui.theme.AndroidmapscomposeTheme +import kotlinx.coroutines.launch + +val defaultLocation = LatLng(39.9828503662161, -105.71835147137016) + +@OptIn(ExperimentalPermissionsApi::class) +class MainActivity : ComponentActivity() { + private val myViewModel: NavigationViewModel by viewModels { NavigationViewModel.Factory } + + override fun onResume() { + super.onResume() + myViewModel.checkLocationPermission() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + NavigationApi.getNavigator(this, myViewModel) + + setContent { + val context = LocalContext.current + val snackbarHostState by remember { mutableStateOf(SnackbarHostState()) } + val scope = rememberCoroutineScope() + val locationPermissionsState = rememberMultiplePermissionsState( + listOf(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION), + ) + + val location by myViewModel.location.collectAsStateWithLifecycle() + val placesAlongRoute by myViewModel.placesAlongRoute.collectAsStateWithLifecycle() + val routeReady by myViewModel.routeReady.collectAsStateWithLifecycle() + val simulationState by myViewModel.simulationState.collectAsStateWithLifecycle() + val simulationSpeed by myViewModel.simulationSpeed.collectAsStateWithLifecycle() + val destinationLabel by myViewModel.destinationLabel.collectAsStateWithLifecycle() + + fun onShowSnackbar(message: String) { + scope.launch { + val duration = if (message.length > 40) { + SnackbarDuration.Long + } else { + SnackbarDuration.Short + } + snackbarHostState.showSnackbar( + message = message, + duration = duration, + ) + } + } + + LaunchedEffect(myViewModel, context) { + myViewModel.uiEvent.collect { event -> + when (event) { + is UiEvent.ShowSnackbar -> onShowSnackbar(event.message) + + is UiEvent.RequestLocationPermission -> { + locationPermissionsState.launchMultiplePermissionRequest() + } + } + } + } + + AndroidmapscomposeTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + NavigationScreen( + modifier = Modifier.padding(innerPadding), + deviceLocation = location, + placesAlongRoute = placesAlongRoute, + routeReady = routeReady, + simulationState = simulationState, + simulationSpeed = simulationSpeed, + destinationLabel = destinationLabel, + onClearSearchResults = { + myViewModel.clearSearchResults() + }, + onSearchClicked = { + myViewModel.searchAlongRoute(it) + }, + onDestinationSearchClicked = { + myViewModel.searchDestinationPlaces(it) + }, + onSimulationSpeedChanged = { speed -> + myViewModel.setSimulationSpeed(speed) + }, + onPauseSimulation = { + myViewModel.pauseSimulation() + }, + onResumeSimulation = { + myViewModel.resumeSimulation() + }, + onStopNavigation = { + myViewModel.stopNavigation() + }, + onPlaceSelected = { place -> + myViewModel.navigateToSelectedPlace(place) + }, + onMapDestinationSelected = { latLng -> + myViewModel.navigateToLatLng(latLng) + }, + ) + } + } + } + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationApplication.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationApplication.kt new file mode 100644 index 000000000..daab98090 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationApplication.kt @@ -0,0 +1,23 @@ +package com.google.maps.android.compose.navigation + +import android.app.Application +import com.google.android.libraries.places.api.Places +import com.google.android.libraries.places.api.net.PlacesClient +import com.google.maps.android.compose.navigation.repositories.ApiKeyProvider + +class NavigationApplication : Application() { + private val defaultPlacesClient: PlacesClient by lazy { + Places.createClient(this) + } + + var customPlacesClient: PlacesClient? = null + + val placesClient: PlacesClient + get() = customPlacesClient ?: defaultPlacesClient + + override fun onCreate() { + super.onCreate() + val keyProvider = ApiKeyProvider(this) + Places.initializeWithNewPlacesApiEnabled(applicationContext, keyProvider.placesApiKey) + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationScreen.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationScreen.kt new file mode 100644 index 000000000..c1d1f9bd7 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationScreen.kt @@ -0,0 +1,508 @@ +package com.google.maps.android.compose.navigation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.heightIn +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +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.Marker +import com.google.maps.android.compose.MarkerComposable +import com.google.maps.android.compose.navigation.components.MovableMarker +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberUpdatedMarkerState +import kotlinx.coroutines.delay + +internal const val CONTROLS_AUTO_COLLAPSE_DELAY_MS = 4_000L + +@Composable +fun NavigationScreen( + deviceLocation: LatLng?, + placesAlongRoute: List, + onClearSearchResults: () -> Unit, + onSearchClicked: (String) -> Unit, + routeReady: Boolean, + modifier: Modifier = Modifier, + simulationState: SimulationState = SimulationState.IDLE, + simulationSpeed: Float = DEFAULT_SIMULATION_SPEED, + destinationLabel: String = DEFAULT_DESTINATION_NAME, + onSimulationSpeedChanged: (Float) -> Unit = {}, + onPauseSimulation: () -> Unit = {}, + onResumeSimulation: () -> Unit = {}, + onStopNavigation: () -> Unit = {}, + onDestinationSearchClicked: (String) -> Unit = onSearchClicked, + onPlaceSelected: (Place) -> Unit = {}, + onMapDestinationSelected: (LatLng) -> Unit = {}, +) { + var isSearchExpanded by rememberSaveable { mutableStateOf(false) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchAlongRouteMode by rememberSaveable { mutableStateOf(false) } + + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom( + deviceLocation ?: defaultLocation, + 15f, + ) + } + + LaunchedEffect(deviceLocation, simulationState) { + if (simulationState != SimulationState.RUNNING) { + deviceLocation?.let { location -> + cameraPositionState.animate( + update = CameraUpdateFactory.newCameraPosition( + CameraPosition.fromLatLngZoom( + location, + 15f, + ), + ), + ) + } + } + } + + Box(modifier = modifier.fillMaxSize()) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + mapColorScheme = ComposeMapColorScheme.FOLLOW_SYSTEM, + onMapLongClick = onMapDestinationSelected, + mapViewCreator = { context, options -> + NavigationViewDelegate(NavigationView(context, options)) + }, + ) { + if (deviceLocation != null) { + MovableMarker( + position = deviceLocation, + title = "User location", + ) + } + + placesAlongRoute.forEach { place -> + val placeLocation = place.location + if (placeLocation != null) { + Marker( + state = rememberUpdatedMarkerState(position = placeLocation), + title = place.displayName ?: "Place", + snippet = place.formattedAddress, + ) + } + } + + MarkerComposable( + title = "Bigfoot", + state = rememberUpdatedMarkerState(position = LatLng(39.99932703674056, -105.28152457787887)), + ) { + Box( + modifier = Modifier + .width(48.dp) + .height(48.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.bigfoot), + contentDescription = "", + ) + } + } + } + + if (isSearchExpanded || placesAlongRoute.isNotEmpty()) { + FloatingPlaceSearchCard( + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it }, + routeReady = routeReady, + searchAlongRouteMode = searchAlongRouteMode && routeReady, + onSearchModeChange = { searchAlongRouteMode = it }, + places = placesAlongRoute, + onSubmitSearch = { + val trimmed = searchQuery.trim() + if (trimmed.isNotEmpty()) { + if (searchAlongRouteMode && routeReady) { + onSearchClicked(trimmed) + } else { + onDestinationSearchClicked(trimmed) + } + } + }, + onPlaceSelected = { place -> + isSearchExpanded = false + onPlaceSelected(place) + }, + onClose = { + isSearchExpanded = false + onClearSearchResults() + }, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) + } + + CollapsibleFloatingControls( + routeReady = routeReady, + simulationState = simulationState, + simulationSpeed = simulationSpeed, + onOpenSearch = { + searchAlongRouteMode = routeReady + isSearchExpanded = true + }, + onSimulationSpeedChanged = onSimulationSpeedChanged, + onPauseSimulation = onPauseSimulation, + onResumeSimulation = onResumeSimulation, + onStopNavigation = onStopNavigation, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 12.dp), + ) + } +} + +@Composable +private fun FloatingPlaceSearchCard( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + routeReady: Boolean, + searchAlongRouteMode: Boolean, + onSearchModeChange: (Boolean) -> Unit, + places: List, + onSubmitSearch: () -> Unit, + onPlaceSelected: (Place) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { + Text( + if (searchAlongRouteMode) { + "Search along active route..." + } else { + "Search any destination..." + }, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSubmitSearch() }), + ) + Button( + onClick = onSubmitSearch, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Text("Find") + } + IconButton(onClick = onClose) { + Icon(Icons.Filled.Close, contentDescription = "Close") + } + } + + if (routeReady) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = !searchAlongRouteMode, + onClick = { onSearchModeChange(false) }, + label = { Text("New Destination") }, + ) + FilterChip( + selected = searchAlongRouteMode, + onClick = { onSearchModeChange(true) }, + label = { Text("Along Route") }, + ) + } + } + + if (places.isNotEmpty()) { + HorizontalDivider() + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(places) { place -> + PlaceItem( + place = place, + onNavigateClicked = { onPlaceSelected(place) }, + ) + } + } + } + } + } +} + +/** + * Floating control menu positioned along the mid-right edge of the screen so it never obscures + * the top turn-by-turn guidance banner or the bottom trip progress bar. + * + * Automatically collapses into a single compact FAB after [CONTROLS_AUTO_COLLAPSE_DELAY_MS] of + * inactivity. + */ +@Composable +private fun CollapsibleFloatingControls( + routeReady: Boolean, + simulationState: SimulationState, + simulationSpeed: Float, + onOpenSearch: () -> Unit, + onSimulationSpeedChanged: (Float) -> Unit, + onPauseSimulation: () -> Unit, + onResumeSimulation: () -> Unit, + onStopNavigation: () -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable { mutableStateOf(true) } + var speedMenuExpanded by remember { mutableStateOf(false) } + var interactionTick by remember { mutableIntStateOf(0) } + + LaunchedEffect(expanded, speedMenuExpanded, interactionTick) { + if (expanded && !speedMenuExpanded) { + delay(CONTROLS_AUTO_COLLAPSE_DELAY_MS) + expanded = false + } + } + + Box(modifier = modifier) { + AnimatedVisibility( + visible = !expanded, + enter = fadeIn(), + exit = fadeOut(), + ) { + SmallFloatingActionButton( + onClick = { + interactionTick++ + expanded = true + }, + shape = CircleShape, + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Expand controls", + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(), + exit = fadeOut(), + ) { + Surface( + shape = RoundedCornerShape(24.dp), + tonalElevation = 6.dp, + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedButton( + onClick = { + interactionTick++ + expanded = false + onOpenSearch() + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search places", + ) + Text( + text = "Search", + modifier = Modifier.padding(start = 4.dp), + ) + } + + Box { + OutlinedButton( + onClick = { + interactionTick++ + speedMenuExpanded = true + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + ) { + Text("${simulationSpeed.toInt()}x") + } + DropdownMenu( + expanded = speedMenuExpanded, + onDismissRequest = { + interactionTick++ + speedMenuExpanded = false + }, + ) { + SIMULATION_SPEED_OPTIONS.forEach { speed -> + val label = "${speed.toInt()}x" + DropdownMenuItem( + text = { Text(label) }, + onClick = { + interactionTick++ + speedMenuExpanded = false + onSimulationSpeedChanged(speed) + }, + ) + } + } + } + + if (simulationState == SimulationState.RUNNING) { + OutlinedButton( + onClick = { + interactionTick++ + onPauseSimulation() + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + ) { + Text("Pause") + } + } else if (simulationState == SimulationState.PAUSED || routeReady) { + OutlinedButton( + onClick = { + interactionTick++ + onResumeSimulation() + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + ) { + Text("Resume") + } + } + + if (routeReady || simulationState == SimulationState.RUNNING || + simulationState == SimulationState.PAUSED + ) { + Button( + onClick = { + interactionTick++ + onStopNavigation() + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + ) { + Text("Stop") + } + } + + IconButton( + onClick = { expanded = false }, + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Collapse controls", + ) + } + } + } + } + } +} + +@Composable +fun PlaceItem(place: Place, onNavigateClicked: () -> Unit = {}) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onNavigateClicked) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = place.displayName ?: "Unknown place", + style = MaterialTheme.typography.bodyMedium, + ) + place.formattedAddress?.let { address -> + Text( + text = address, + style = MaterialTheme.typography.bodySmall, + ) + } + } + TextButton(onClick = onNavigateClicked) { + Text("Navigate") + } + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewDelegate.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewDelegate.kt new file mode 100644 index 000000000..c5c011bd2 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewDelegate.kt @@ -0,0 +1,31 @@ +package com.google.maps.android.compose.navigation + +import android.content.ComponentCallbacks2 +import android.os.Bundle +import com.google.android.gms.maps.GoogleMap +import com.google.android.libraries.navigation.NavigationView +import com.google.maps.android.compose.AbstractMapViewDelegate +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class NavigationViewDelegate(override val mapView: NavigationView) : AbstractMapViewDelegate { + override fun onCreate(savedInstanceState: Bundle?): Unit = mapView.onCreate(savedInstanceState) + override fun onStart(): Unit = mapView.onStart() + override fun onResume(): Unit = mapView.onResume() + override fun onPause(): Unit = mapView.onPause() + override fun onStop(): Unit = mapView.onStop() + + override fun onLowMemory() { + mapView.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) + } + + override fun onDestroy(): Unit = mapView.onDestroy() + + override suspend fun awaitMap(): GoogleMap = mapView.awaitMap() +} + +suspend inline fun NavigationView.awaitMap(): GoogleMap = suspendCoroutine { continuation -> + getMapAsync { + continuation.resume(it) + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewModel.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewModel.kt new file mode 100644 index 000000000..7405cdbce --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/NavigationViewModel.kt @@ -0,0 +1,518 @@ +package com.google.maps.android.compose.navigation + +import android.Manifest +import android.annotation.SuppressLint +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.navigation.ListenableResultFuture +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.navigation.RoutingOptions +import com.google.android.libraries.navigation.SimulationOptions +import com.google.android.libraries.navigation.Waypoint +import com.google.android.libraries.places.api.model.CircularBounds +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.android.libraries.places.api.net.kotlin.awaitSearchByText +import com.google.maps.android.compose.navigation.repositories.LocationProvider +import com.google.maps.android.compose.navigation.repositories.PermissionChecker +import com.google.maps.android.latLngListEncode +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +const val DEFAULT_SIMULATION_SPEED = 5f +const val DEFAULT_DESTINATION_PLACE_ID = "ChIJ9zb1-0bsa4cRcpW_h34lLBU" +const val DEFAULT_DESTINATION_NAME = "" +private const val DEFAULT_SEARCH_BIAS_RADIUS_METERS = 50_000.0 + +val SIMULATION_SPEED_OPTIONS = listOf(1f, 2f, 5f, 10f, 20f) + +/** + * Represents the lifecycle state of the turn-by-turn vehicle simulator. + */ +enum class SimulationState { + IDLE, + RUNNING, + PAUSED, + STOPPED, +} + +class NavigationViewModel( + private val placesClient: PlacesClient, + private val locationProvider: LocationProvider, + private val permissionChecker: PermissionChecker, +) : ViewModel(), + NavigatorListener { + + private fun String.isPermissionGranted() = permissionChecker.isGranted(this) + + private val _location = MutableStateFlow(null) + val location = _location.asStateFlow().onStart { + requestLocation() + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null, + ) + + private val _uiEvent = MutableSharedFlow() + val uiEvent: SharedFlow = _uiEvent.asSharedFlow() + + private val hasLocationPermission = MutableStateFlow(false) + + private var navigator: Navigator? = null + private var routeChangedListener: Navigator.RouteChangedListener? = null + + private val _placesAlongRoute = MutableStateFlow>(emptyList()) + val placesAlongRoute = _placesAlongRoute.asStateFlow() + + private val _routeReady = MutableStateFlow(false) + val routeReady = _routeReady.asStateFlow() + + private val _simulationState = MutableStateFlow(SimulationState.IDLE) + val simulationState: StateFlow = _simulationState.asStateFlow() + + private val _simulationSpeed = MutableStateFlow(DEFAULT_SIMULATION_SPEED) + val simulationSpeed: StateFlow = _simulationSpeed.asStateFlow() + + private val _destinationLabel = MutableStateFlow(DEFAULT_DESTINATION_NAME) + val destinationLabel: StateFlow = _destinationLabel.asStateFlow() + + init { + viewModelScope.launch { + hasLocationPermission.collect { + if (it) { + requestLocation() + } + } + } + } + + @SuppressLint("MissingPermission") + fun requestLocation() { + viewModelScope.launch { + if (Manifest.permission.ACCESS_FINE_LOCATION.isPermissionGranted() || + Manifest.permission.ACCESS_COARSE_LOCATION.isPermissionGranted() + ) { + val location = locationProvider.getLastLocation()?.toLatLng() + if (location != null) { + _location.value = location + } + } else { + _uiEvent.emit(UiEvent.RequestLocationPermission) + } + } + } + + fun checkLocationPermission() { + viewModelScope.launch { + hasLocationPermission.value = + Manifest.permission.ACCESS_FINE_LOCATION.isPermissionGranted() || + Manifest.permission.ACCESS_COARSE_LOCATION.isPermissionGranted() + } + } + + /** + * Updates the simulation speed multiplier (`1x`..`20x`). + * If a route simulation is currently running, immediately applies the new speed to the simulator. + */ + fun setSimulationSpeed(speedMultiplier: Float) { + if (speedMultiplier <= 0f) return + _simulationSpeed.value = speedMultiplier + if (_simulationState.value == SimulationState.RUNNING) { + navigator?.simulator?.simulateLocationsAlongExistingRoute( + SimulationOptions().speedMultiplier(speedMultiplier), + ) + } + } + + /** + * Pauses the active vehicle simulation along the route without clearing the route. + */ + fun pauseSimulation() { + val localNavigator = navigator ?: return + localNavigator.simulator.pause() + _simulationState.value = SimulationState.PAUSED + } + + /** + * Resumes a paused simulation, or restarts simulation along the active route if stopped. + */ + fun resumeSimulation() { + val localNavigator = navigator ?: return + when (_simulationState.value) { + SimulationState.PAUSED -> { + localNavigator.simulator.resume() + _simulationState.value = SimulationState.RUNNING + } + + SimulationState.STOPPED, SimulationState.IDLE -> { + if (_routeReady.value) { + localNavigator.simulator.simulateLocationsAlongExistingRoute( + SimulationOptions().speedMultiplier(_simulationSpeed.value), + ) + localNavigator.startGuidance() + _simulationState.value = SimulationState.RUNNING + } + } + + SimulationState.RUNNING -> Unit + } + } + + /** + * Stops turn-by-turn guidance and vehicle simulation, clears active destinations, + * unsets the simulated user location override so the SDK reverts to real GPS, + * and refreshes [_location] from the device's location provider. + */ + fun stopNavigation() { + val localNavigator = navigator + if (localNavigator != null) { + localNavigator.stopGuidance() + localNavigator.clearDestinations() + localNavigator.simulator.unsetUserLocation() + } + _simulationState.value = SimulationState.STOPPED + _routeReady.value = false + _destinationLabel.value = "" + _placesAlongRoute.value = emptyList() + _location.value = null + requestLocation() + displayMessage("Navigation stopped") + } + + /** + * Navigates to a [Place] selected from search results. + */ + fun navigateToSelectedPlace(place: Place) { + val label = place.displayName ?: "Selected Place" + val placeId = place.id + val location = place.location + _placesAlongRoute.value = emptyList() + if (placeId != null) { + navigateToPlace(placeId = placeId, label = label) + } else if (location != null) { + navigateToLatLng(latLng = location, label = label) + } + } + + /** + * Navigates to an arbitrary [LatLng] coordinate (e.g., chosen by long-pressing on the map). + */ + fun navigateToLatLng( + latLng: LatLng, + label: String = String.format(Locale.US, "Pin (%.4f, %.4f)", latLng.latitude, latLng.longitude), + routingOptions: RoutingOptions = defaultRoutingOptions(), + ) { + val localNavigator = navigator + if (localNavigator == null) { + displayMessage("Navigator is not ready yet.") + return + } + _destinationLabel.value = label + _routeReady.value = false + + viewModelScope.launch { + val destination = withContext(Dispatchers.IO) { + Waypoint.builder() + .setLatLng(latLng.latitude, latLng.longitude) + .setTitle(label) + .build() + } + startNavigationToWaypoint(localNavigator, destination, routingOptions) + } + } + + /** + * Navigates to a Google Maps [placeId]. + */ + fun navigateToPlace( + placeId: String, + label: String = _destinationLabel.value, + routingOptions: RoutingOptions = defaultRoutingOptions(), + ) { + val localNavigator = navigator + if (localNavigator == null) { + displayMessage("Navigator is not ready yet.") + return + } + _destinationLabel.value = label + _routeReady.value = false + + viewModelScope.launch { + try { + val destination = withContext(Dispatchers.IO) { + Waypoint.builder().setPlaceIdString(placeId).build() + } + startNavigationToWaypoint(localNavigator, destination, routingOptions) + } catch (e: CancellationException) { + throw e + } catch (e: Waypoint.UnsupportedPlaceIdException) { + displayMessage("Error starting navigation: Place ID is not supported.") + } catch (e: Exception) { + displayMessage(e.message ?: "Error starting navigation.") + } + } + } + + private suspend fun startNavigationToWaypoint( + localNavigator: Navigator, + destination: Waypoint, + routingOptions: RoutingOptions, + ) { + try { + val routeStatusFuture = localNavigator.setDestination(destination, routingOptions) + + suspendCoroutine { continuation -> + val callback = + ListenableResultFuture.OnResultListener { status -> + fun onFailure(message: String) { + continuation.resumeWithException(Exception(message)) + } + + when (status) { + Navigator.RouteStatus.OK -> { + // Simulate vehicle progress along the route for demo/debug builds. + if (BuildConfig.DEBUG) { + localNavigator.simulator.simulateLocationsAlongExistingRoute( + SimulationOptions().speedMultiplier(_simulationSpeed.value), + ) + } + + // Start turn-by-turn guidance along the current route. + localNavigator.startGuidance() + _simulationState.value = SimulationState.RUNNING + + continuation.resume(Unit) + } + + Navigator.RouteStatus.NO_ROUTE_FOUND -> onFailure( + "Error starting navigation: No route found", + ) + + Navigator.RouteStatus.NETWORK_ERROR -> onFailure( + "Error starting navigation: Network error", + ) + + Navigator.RouteStatus.QUOTA_CHECK_FAILED -> onFailure( + "Error starting navigation: Quota check failed", + ) + + Navigator.RouteStatus.ROUTE_CANCELED -> onFailure( + "Error starting navigation: Route canceled", + ) + + Navigator.RouteStatus.LOCATION_DISABLED -> onFailure( + "Error starting navigation: Location disabled", + ) + + Navigator.RouteStatus.LOCATION_UNKNOWN -> onFailure( + "Error starting navigation: Location unknown", + ) + + Navigator.RouteStatus.WAYPOINT_ERROR -> onFailure( + "Error starting navigation: Waypoint error", + ) + + else -> onFailure("Error starting navigation: $status") + } + } + + routeStatusFuture.setOnResultListener(callback) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _simulationState.value = SimulationState.STOPPED + displayMessage(e.message ?: "Error starting navigation.") + } + } + + override fun onNavigatorReady(navigator: Navigator?) { + val readyNavigator = navigator ?: error("Navigator is null") + this.navigator = readyNavigator + readyNavigator.setTaskRemovedBehavior(Navigator.TaskRemovedBehavior.QUIT_SERVICE) + // Clear any leftover simulated location from a prior run so we start at real GPS. + readyNavigator.stopGuidance() + readyNavigator.clearDestinations() + readyNavigator.simulator.unsetUserLocation() + displayMessage("navigator ready") + + // Listen for route changes + routeChangedListener?.let { readyNavigator.removeRouteChangedListener(it) } + val listener = Navigator.RouteChangedListener { + Log.d("NavigationContainer", "Route changed") + _routeReady.value = true + } + routeChangedListener = listener + readyNavigator.addRouteChangedListener(listener) + } + + override fun onCleared() { + super.onCleared() + routeChangedListener?.let { listener -> + navigator?.removeRouteChangedListener(listener) + } + routeChangedListener = null + navigator?.stopGuidance() + navigator?.clearDestinations() + navigator?.simulator?.unsetUserLocation() + } + + /** + * Searches for places matching [searchText]. + * When [alongRouteOnly] is true and an active route polyline exists, biases the search along + * the current route via [SearchAlongRouteParameters]. Otherwise biases around the device's + * location (or [defaultLocation]) so the user can search for any destination place. + */ + private suspend fun searchPlaces(searchText: String, placeFields: List, alongRouteOnly: Boolean) { + if (searchText.isBlank()) return + + val route = if (alongRouteOnly) navigator?.currentRouteSegment?.latLngs else null + val searchAlongRouteParameters = if (!route.isNullOrEmpty()) { + val encodedPolyline = EncodedPolyline.newInstance(route.latLngListEncode()) + SearchAlongRouteParameters.newInstance(encodedPolyline) + } else { + null + } + + val biasCenter = _location.value ?: defaultLocation + + try { + val response = placesClient.awaitSearchByText(searchText, placeFields) { + if (searchAlongRouteParameters != null) { + setSearchAlongRouteParameters(searchAlongRouteParameters) + } else { + locationBias = CircularBounds.newInstance( + biasCenter, + DEFAULT_SEARCH_BIAS_RADIUS_METERS, + ) + } + maxResultCount = 10 + } + + response.places.forEach { + Log.d("NavigationViewModel", "Place ID: ${it.id}, Display Name: ${it.displayName}") + } + + _placesAlongRoute.value = response.places + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + displayMessage("Error searching places: ${e.message}") + } + } + + 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) { + Log.w("NavigationViewModel", message) + viewModelScope.launch { + _uiEvent.emit(UiEvent.ShowSnackbar(message)) + } + } + + fun clearSearchResults() { + _placesAlongRoute.value = emptyList() + } + + /** + * Searches along the active route polyline when a route is available, or performs a + * location-biased place search when no route is active. + */ + fun searchAlongRoute(searchText: String) { + viewModelScope.launch { + searchPlaces( + searchText = searchText, + placeFields = DEFAULT_PLACE_FIELDS, + alongRouteOnly = true, + ) + } + } + + /** + * Searches for places (such as a new destination) biased around the user's current location, + * regardless of whether a route is currently active. + */ + fun searchDestinationPlaces(searchText: String) { + viewModelScope.launch { + searchPlaces( + searchText = searchText, + placeFields = DEFAULT_PLACE_FIELDS, + alongRouteOnly = false, + ) + } + } + + private fun defaultRoutingOptions(): RoutingOptions = RoutingOptions().apply { + travelMode(RoutingOptions.TravelMode.DRIVING) + } + + companion object { + private val DEFAULT_PLACE_FIELDS = listOf( + Place.Field.ID, + Place.Field.DISPLAY_NAME, + Place.Field.LOCATION, + Place.Field.FORMATTED_ADDRESS, + ) + + val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + val application = + checkNotNull( + extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY], + ) as NavigationApplication + + return NavigationViewModel( + placesClient = application.placesClient, + locationProvider = LocationProvider(application.applicationContext), + permissionChecker = PermissionChecker(application.applicationContext), + ) as T + } + } + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/SearchAlongRouteExample.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/SearchAlongRouteExample.kt new file mode 100644 index 000000000..c34d0688c --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/SearchAlongRouteExample.kt @@ -0,0 +1,80 @@ +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.android.libraries.places.api.net.kotlin.awaitSearchByText +import com.google.maps.android.latLngListEncode +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) { + 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}") + } + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/UiEvent.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/UiEvent.kt new file mode 100644 index 000000000..6f0d70574 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/UiEvent.kt @@ -0,0 +1,9 @@ +package com.google.maps.android.compose.navigation + +/** + * Represents UI events that can be triggered within the UI from a view model. + */ +sealed interface UiEvent { + data class ShowSnackbar(val message: String) : UiEvent + data object RequestLocationPermission : UiEvent +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/Utils.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/Utils.kt new file mode 100644 index 000000000..c3b25790f --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/Utils.kt @@ -0,0 +1,6 @@ +package com.google.maps.android.compose.navigation + +import android.location.Location +import com.google.android.gms.maps.model.LatLng + +fun Location.toLatLng() = LatLng(this.latitude, this.longitude) diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/components/MovableMarker.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/components/MovableMarker.kt new file mode 100644 index 000000000..203e43640 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/components/MovableMarker.kt @@ -0,0 +1,21 @@ +package com.google.maps.android.compose.navigation.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState + +@Composable +fun MovableMarker(position: LatLng, title: String? = null, snippet: String? = null) { + val state = rememberUpdatedMarkerState(position) + Marker( + state = state, + title = title, + snippet = snippet, + ) +} + +@Composable +fun rememberUpdatedMarkerState(newPosition: LatLng) = + remember { MarkerState(position = newPosition) }.apply { position = newPosition } diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/ApiKeyProvider.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/ApiKeyProvider.kt new file mode 100644 index 000000000..433c9c56a --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/ApiKeyProvider.kt @@ -0,0 +1,58 @@ +package com.google.maps.android.compose.navigation.repositories + +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import android.widget.Toast +import com.google.maps.android.compose.navigation.BuildConfig + +/** + * Provides the Google Maps API key from the AndroidManifest.xml file. + * + * @param context The application context. + */ +class ApiKeyProvider(private val context: Context) { + val mapsApiKey: String by lazy { + getMapsApiKeyFromManifest() + } + + val placesApiKey: String by lazy { + BuildConfig.PLACES_API_KEY.takeIf { + it.isNotBlank() && it != "DEFAULT_API_KEY" && it != "YOUR_API_KEY" + } ?: mapsApiKey + } + + private fun getMapsApiKeyFromManifest(): String { + val mapsApiKey = + try { + val applicationInfo = + context.packageManager.getApplicationInfo( + context.packageName, + PackageManager.GET_META_DATA, + ) + applicationInfo.metaData?.getString("com.google.android.geo.API_KEY") ?: "" + } catch (e: PackageManager.NameNotFoundException) { + error("Unable to find package: ${e.message}") + } + if (mapsApiKey.isBlank()) { + error("MapsApiKey missing from AndroidManifest.") + } + return mapsApiKey + } + + init { + if (placesApiKey == "DEFAULT_API_KEY" || + placesApiKey == "YOUR_API_KEY" || + mapsApiKey == "DEFAULT_API_KEY" || + mapsApiKey == "YOUR_API_KEY" + ) { + val message = "One or more API keys have not been set. Please see the README.md file." + Log.w("ApiKeyProvider", message) + Toast.makeText( + context, + message, + Toast.LENGTH_LONG, + ).show() + } + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/LocationProvider.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/LocationProvider.kt new file mode 100644 index 000000000..e91f4be77 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/LocationProvider.kt @@ -0,0 +1,18 @@ +package com.google.maps.android.compose.navigation.repositories + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.content.Context +import android.location.Location +import androidx.annotation.RequiresPermission +import com.google.android.gms.location.LocationServices +import kotlinx.coroutines.tasks.await + +class LocationProvider(private val applicationContext: Context) { + + @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) + suspend fun getLastLocation(): Location? { + val fusedLocationClient = LocationServices.getFusedLocationProviderClient(applicationContext) + return fusedLocationClient.lastLocation.await() + } +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/PermissionChecker.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/PermissionChecker.kt new file mode 100644 index 000000000..7e74101b2 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/repositories/PermissionChecker.kt @@ -0,0 +1,10 @@ +package com.google.maps.android.compose.navigation.repositories + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +class PermissionChecker(private val applicationContext: Context) { + fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(applicationContext, permission) == PackageManager.PERMISSION_GRANTED +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Color.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Color.kt new file mode 100644 index 000000000..f37af00f4 --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.google.maps.android.compose.navigation.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Theme.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Theme.kt new file mode 100644 index 000000000..894344daf --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Theme.kt @@ -0,0 +1,59 @@ +package com.google.maps.android.compose.navigation.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun AndroidmapscomposeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Type.kt b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Type.kt new file mode 100644 index 000000000..434ec82ff --- /dev/null +++ b/navigation-app/src/main/java/com/google/maps/android/compose/navigation/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.google.maps.android.compose.navigation.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/navigation-app/src/main/res/drawable/bigfoot.png b/navigation-app/src/main/res/drawable/bigfoot.png new file mode 100644 index 000000000..1f7cc629d Binary files /dev/null and b/navigation-app/src/main/res/drawable/bigfoot.png differ diff --git a/navigation-app/src/main/res/drawable/ic_launcher_background.xml b/navigation-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/navigation-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/navigation-app/src/main/res/drawable/ic_launcher_foreground.xml b/navigation-app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/navigation-app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/navigation-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/navigation-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/navigation-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/navigation-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/navigation-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/navigation-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/navigation-app/src/main/res/mipmap-hdpi/ic_launcher.webp b/navigation-app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/navigation-app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/navigation-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/navigation-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/navigation-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/navigation-app/src/main/res/mipmap-mdpi/ic_launcher.webp b/navigation-app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/navigation-app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/navigation-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/navigation-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/navigation-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/navigation-app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/navigation-app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/navigation-app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/navigation-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/navigation-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/navigation-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/navigation-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/navigation-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/navigation-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/navigation-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/navigation-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/navigation-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/navigation-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/navigation-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/navigation-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/navigation-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/navigation-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/navigation-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/navigation-app/src/main/res/values/colors.xml b/navigation-app/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/navigation-app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/navigation-app/src/main/res/values/strings.xml b/navigation-app/src/main/res/values/strings.xml new file mode 100644 index 000000000..5dc6ab09b --- /dev/null +++ b/navigation-app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + navigation-app + \ No newline at end of file diff --git a/navigation-app/src/main/res/values/themes.xml b/navigation-app/src/main/res/values/themes.xml new file mode 100644 index 000000000..ef7248be7 --- /dev/null +++ b/navigation-app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +