Google Map
Manage Car Location
We need to keep track of our car across application runs. To do this, we'll use a Preferences Datastore to store the latitude and longitude of the car.
The Datastore API exposes the data from the preference as a Kotlin Flow; whenever the preference
is updated, the new value is emitted. We'll expose the car location to the UI from the view model,
allowing new values to be collected. The preferences data store is a simple file that stores values.
This is useful when a database would be overkill for storing small amounts of data. The datastore
is typically used to hold simple application state across application runs.
We start by Adding the Datastore Dependency.
show in full file gradle/libs.versions.toml
[versions]
// ...
location-services = "21.4.0"
dokka = "2.2.0"
datastore-preferences = "1.2.1"
[libraries]
// ...
maps-compose-utils = { group = "com.google.maps.android", name = "maps-compose-utils", version.ref = "maps-compose" }
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location-services" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore-preferences" }
[plugins]
// ...
show in full file app/build.gradle.kts
// ...
dependencies {
// implementation(libs.location.services)
implementation(libs.datastore.preferences)
implementation(libs.location.services)
implementation(libs.maps.compose)
// ...
}
// ...
Then we can store and load the car's location.
show in full file app/src/main/java/com/androidbyexample/google/maps/CarViewModel.kt
// ...
import kotlinx.coroutines.launch
private val Context.preferencesDataStore:
DataStore<Preferences> by preferencesDataStore(name = "carfinder")
private val LAT_PREF = stringPreferencesKey("lat")
private val LON_PREF = stringPreferencesKey("lon")
class CarViewModel(application: Application) : AndroidViewModel(application) {
// ...
}
val carLatLng = application.preferencesDataStore.data.map { preferences ->
preferences[LAT_PREF]?.let { latString ->
preferences[LON_PREF]?.let { lonString ->
LatLng(latString.toDouble(), lonString.toDouble())
}
}
}
fun clearCarLocation() {
viewModelScope.launch {
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences.remove(LAT_PREF)
preferences.remove(LON_PREF)
}
}
}
fun setCarLocation() {
viewModelScope.launch {
currentLocation.value?.let { location ->
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences[LAT_PREF] = location.latitude.toString()
preferences[LON_PREF] = location.longitude.toString()
}
} ?: run {
clearCarLocation()
}
}
}
}
carLatLngis aFlowcreated by mapping the data store'sFlowinto aLatLnginstance. If either latitude or longitude are missing, anullis emitted.clearCarLocation()removes the latitude and longitude from the data store.setCarLocation()stores the current location's latitude and longitude in datastore (or removes them if there is no current location)
When clearCarLocation() or setCarLocation() change the latitude/longitude in the data store, new
data is emitted to the data store Flow, which will trigger the carLatLng to emit a new LatLng
or null.
We'll need a new icon for the Car. I found a nice free car Marker, made by Vectors Market https://www.flaticon.com/authors/vectors-market from https://www.flaticon.com.
show in full file app/src/main/res/drawable/ic_car.xml
<!--
Car icon made by Vectors Market (https://www.flaticon.com/authors/vectors-market) from https://www.flaticon.com
-->
<vector android:height="48dp" android:viewportHeight="496.474"
android:viewportWidth="496.474" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#E95353" android:pathData="M489.544,269.628c-0.729,-14.739 -6.206,-28.858 -15.205,-40.572c-20.294,-26.422 -24.25,-47.399 -24.25,-47.399c-15.934,-41.751 -30.099,-69.57 -40.51,-87.226c-10.954,-18.541 -29.898,-31.03 -51.262,-33.776c-42.961,-5.554 -177.23,-5.554 -220.191,0c-21.349,2.762 -40.293,15.251 -51.247,33.776c-10.411,17.656 -24.56,45.475 -40.51,87.226c0,0 -3.956,20.977 -24.25,47.399c-8.983,11.714 -14.476,25.833 -15.189,40.572c-1.536,31.977 7.727,39.005 16.012,111.368c0.652,5.694 5.461,9.976 11.202,9.976h428.203c5.741,0 10.55,-4.298 11.202,-9.976c2.327,-20.294 9.232,-61.456 9.232,-61.456C488.83,299.076 490.149,282.397 489.544,269.628z"/>
<path android:fillColor="#168DE2" android:pathData="M441.074,179.982c-12.567,-31.449 -25.383,-58.911 -38.136,-80.803c-9.666,-16.555 -26.717,-28.113 -45.583,-30.642c-43.055,-5.71 -175.476,-5.663 -218.221,0c-18.866,2.529 -35.918,14.088 -45.584,30.642c-12.924,22.171 -25.91,50.005 -38.291,80.803C183.662,190.082 312.655,190.082 441.074,179.982z"/>
<path android:fillColor="#FFFFFF" android:pathData="M422.844,216.349c-4.903,1.536 -9.464,3.693 -13.281,5.834c-6.951,3.879 -14.088,7.494 -21.535,10.318c-12.35,4.686 -14.445,11.683 -14.445,11.683c-0.372,0.481 -0.729,0.962 -1.071,1.458c-9.371,13.917 2.932,32.442 19.549,30.084c24.033,-3.398 43.83,-10.364 53.884,-14.398c5.756,-2.312 10.255,-7.121 11.729,-13.157c0.14,-0.574 0.264,-1.148 0.372,-1.707C461.74,226.543 442.191,210.314 422.844,216.349z"/>
<path android:fillColor="#FFE21F" android:pathData="M448.522,327.857h-26.733c-4.018,0 -6.035,4.856 -3.196,7.711l26.733,26.733c2.839,2.839 7.711,0.822 7.711,-3.196v-26.733C453.036,329.874 451.019,327.857 448.522,327.857z"/>
<path android:fillColor="#454545" android:pathData="M473.547,380.965c-0.652,5.71 -5.477,10.007 -11.217,10.007h-51.433v29.851c0,10.566 8.564,19.146 19.146,19.146h32.303c10.566,0 19.146,-8.564 19.146,-19.146v-93.448C479.366,340.315 475.239,366.179 473.547,380.965z"/>
<path android:fillColor="#ED6262" android:pathData="M496.107,159.083c-1.552,-6.951 -8.083,-11.667 -15.205,-11.667h-12.909c-8.27,0 -14.972,6.703 -14.972,14.972v9.2h-6.842l3.925,9.325h15.05c7.789,0 15.5,-1.676 22.575,-4.918C494.105,173.078 497.674,166.096 496.107,159.083z"/>
<path android:fillColor="#FFBD49" android:pathData="M431.315,240.46m-18.82,0a18.82,18.82 0,1 1,37.64 0a18.82,18.82 0,1 1,-37.64 0"/>
<path android:fillColor="#FFDB6F" android:pathData="M393.458,255.308m-13.824,0a13.824,13.824 0,1 1,27.648 0a13.824,13.824 0,1 1,-27.648 0"/>
<path android:fillColor="#6F6F6F" android:pathData="M357.634,255.324l-21.613,37.639l-175.569,0l-21.613,-37.639z"/>
<path android:fillColor="#FFFFFF" android:pathData="M123.945,245.642c-0.326,-0.496 -0.683,-0.977 -1.071,-1.458c0,0 -2.095,-6.997 -14.445,-11.683c-7.447,-2.824 -14.569,-6.439 -21.535,-10.318c-3.832,-2.141 -8.378,-4.298 -13.281,-5.834c-19.332,-6.051 -38.896,10.193 -35.219,30.115c0.109,0.574 0.233,1.133 0.357,1.707c1.474,6.035 5.973,10.845 11.729,13.157c10.054,4.034 29.851,11 53.9,14.398C121.012,278.084 133.316,259.559 123.945,245.642z"/>
<path android:fillColor="#FFE21F" android:pathData="M74.684,327.857H47.951c-2.498,0 -4.515,2.017 -4.515,4.515v26.733c0,4.018 4.872,6.035 7.711,3.196l26.733,-26.733C80.719,332.729 78.702,327.857 74.684,327.857z"/>
<path android:fillColor="#777777" android:pathData="M426.226,374.511c-27.834,-48.159 -59.842,-46.654 -59.842,-46.654H130.073c0,0 -31.992,-1.505 -59.842,46.654H22.119l0.59,6.206c0.543,5.834 5.415,10.256 11.248,10.256h428.56c5.834,0 10.705,-4.422 11.249,-10.255l0.59,-6.206L426.226,374.511L426.226,374.511z"/>
<path android:fillColor="#454545" android:pathData="M34.143,390.988c-5.741,0 -10.566,-4.313 -11.217,-10.007c-1.691,-14.786 -5.834,-40.665 -7.944,-53.589v93.448c0,10.566 8.564,19.146 19.146,19.146H66.43c10.566,0 19.146,-8.564 19.146,-19.146v-29.851H34.143z"/>
<path android:fillColor="#ED6262" android:pathData="M43.436,171.588v-9.2c0,-8.27 -6.703,-14.972 -14.957,-14.972H15.571c-7.121,0 -13.653,4.717 -15.205,11.667c-1.567,6.997 2.017,13.995 8.378,16.896c7.09,3.243 14.786,4.918 22.575,4.918h15.05l3.925,-9.325h-6.858V171.588z"/>
<path android:fillColor="#FFBD49" android:pathData="M65.142,240.46m-18.82,0a18.82,18.82 0,1 1,37.64 0a18.82,18.82 0,1 1,-37.64 0"/>
<path android:fillColor="#FFDB6F" android:pathData="M103.03,255.308m-13.824,0a13.824,13.824 0,1 1,27.648 0a13.824,13.824 0,1 1,-27.648 0"/>
<path android:fillColor="#6F6F6F" android:pathData="M348.294,341.029H148.179c-3.134,0 -5.663,2.544 -5.663,5.663c0,3.134 2.529,5.663 5.663,5.663h200.099c3.134,0 5.663,-2.544 5.663,-5.663C353.957,343.558 351.412,341.029 348.294,341.029z"/>
<path android:fillColor="#6F6F6F" android:pathData="M348.294,363.635H148.179c-3.134,0 -5.663,2.544 -5.663,5.663c0,3.134 2.529,5.663 5.663,5.663h200.099c3.134,0 5.663,-2.544 5.663,-5.663C353.957,366.164 351.412,363.635 348.294,363.635z"/>
<path android:fillColor="#3AA2EB" android:pathData="M420.098,132.009v1.939c0,7.804 -6.408,14.057 -14.367,13.731c-7.339,-0.465 -12.955,-7.028 -12.955,-14.522v-27.834c0,-6.563 -5.461,-11.559 -12.024,-11.388c-0.155,0 -0.155,0 -0.31,0c0,0 0,0 -0.155,0c-6.563,-0.155 -12.024,4.841 -12.024,11.388v25.181c0,7.804 -6.718,14.057 -14.522,13.576c-7.339,-0.31 -12.955,-7.028 -12.955,-14.367v-21.892c0,-6.563 -5.461,-12.024 -12.179,-12.024h-0.931c-6.253,0 -11.404,5.151 -11.404,11.404v28.47c0,7.804 -6.563,14.041 -14.522,13.731c-7.339,-0.465 -12.8,-7.028 -12.8,-14.522V96.712c0,-6.082 -5.306,-11.233 -11.559,-10.768h-0.931c-6.408,0 -11.543,5.151 -11.543,11.404v39.021c0,7.494 -6.082,13.731 -13.731,13.731c-7.339,0 -13.421,-5.927 -13.576,-13.265v-32.752c0,-7.494 -5.616,-14.041 -12.955,-14.522c-7.959,-0.31 -14.367,5.927 -14.367,13.731l-0.155,30.255c0,7.804 -6.392,14.041 -14.367,13.731c-7.199,-0.45 -12.66,-6.78 -12.878,-13.948V97.829c0,-7.37 -5.539,-13.964 -12.909,-14.398c-7.897,-0.341 -14.414,5.88 -14.414,13.7v37.19c-0.217,7.618 -6.609,13.684 -14.445,13.374c-7.339,-0.465 -12.8,-7.028 -12.8,-14.522v-27.85c0,-6.563 -5.616,-11.559 -12.179,-11.388c-0.155,0 -0.155,0 -0.155,0c-0.155,0 -0.155,0 -0.155,0c-6.718,-0.155 -12.179,4.841 -12.179,11.388v25.181c0,7.804 -6.563,14.057 -14.367,13.576c-6.796,-0.264 -11.854,-5.942 -12.722,-12.521c-7.215,14.941 -14.367,31.123 -21.318,48.407c128.403,10.116 257.412,10.116 385.815,0C434.139,162.636 427.126,146.655 420.098,132.009z"/>
</vector>
To use these in the map, we add some new parameters, set up some state, load the icon as we did the location icon, and display the car marker on the map.
show in full file app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
currentLocation: Location?,
carLatLng: LatLng?,
cameraPositionState: CameraPositionState,
modifier: Modifier,
// ...
) {
// ...
Box(
// ...
) {
Column(
// ...
) {
// ...
val context = LocalContext.current
var currentLocationIcon by remember { mutableStateOf<BitmapDescriptor?>(null) }
val carState = rememberUpdatedMarkerState()
var carIcon by remember { mutableStateOf<BitmapDescriptor?>(null) }
val scope = rememberCoroutineScope()
// ...
GoogleMap(
// ...
onMapLoaded = {
// ...
scope.launch(Dispatchers.IO) {
// ...
R.drawable.ic_current_location
)
carIcon =
context.loadBitmapDescriptor(
R.drawable.ic_car
)
}
},
// ...
) {
// ...
)
}
carLatLng?.let {
carState.position = it
MarkerInfoWindowContent(
state = carState,
icon = carIcon,
anchor = Offset(0.5f, 0.5f),
title = stringResource(R.string.car_location),
)
}
}
}
// ...
}
}
Finally, we collect the car location, wire the view model, and pass the car location to
ShowMap
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
initialValue = null
)
val carLatLng by viewModel.carLatLng.collectAsStateWithLifecycle(
initialValue = null
)
var animateToCurrentLocation by remember { mutableStateOf(false) }
GooglemapsTheme {
Scaffold(
topBar = {
CarTopBar(
currentLocation = currentLocation,
// carLatLng = null,
// onSetCarLocation = {},
carLatLng = carLatLng,
onSetCarLocation = viewModel::setCarLocation,
onGoToCurrentLocation = {
animateToCurrentLocation = true
},
// onClearCarLocation = {},
onClearCarLocation = viewModel::clearCarLocation,
onWalkToCar = {},
)
// },
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
// ...
when (appState) {
// ...
ShowMap ->
ShowMap(
currentLocation = currentLocation,
carLatLng = carLatLng,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = {
// ...
)
// ...
}
}
}
}
}
// ...
@Composable
fun ShowMap(
carLatLng: LatLng?,
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
// ...
) {
// ...
GoogleMapDisplay(
carLatLng = carLatLng,
currentLocation = currentLocation,
cameraPositionState = cameraPositionState,
// ...
)
}
// ...
}
When we run the application, we can press the star icon to save the current location as the car's location. This will cause the car icon to be added to the map.
The location is saved in the preferences datastore, so if you close and reopen the app, the location will appear again.
Pressing the trash can will remove the location from the datastore, causing the icon to be removed.
All code changes
$CHANGED: app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.secrets)
alias(libs.plugins.dokka)
}
android {
namespace = "com.androidbyexample.google.maps"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "com.androidbyexample.google.maps"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
optimization {
enable = false
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
// implementation(libs.location.services)
implementation(libs.datastore.preferences)
implementation(libs.location.services)
implementation(libs.maps.compose)
implementation(libs.maps.compose.utils)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
secrets {
propertiesFileName = "secrets.properties"
}
$CHANGED: app/src/main/java/com/androidbyexample/google/maps/CarViewModel.kt
package com.androidbyexample.google.maps
import android.app.Application
import android.content.Context
import android.location.Location
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.maps.model.LatLng
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private val Context.preferencesDataStore:
DataStore<Preferences> by preferencesDataStore(name = "carfinder")
private val LAT_PREF = stringPreferencesKey("lat")
private val LON_PREF = stringPreferencesKey("lon")
class CarViewModel(application: Application) : AndroidViewModel(application) {
val appState: Flow<AppState>
field = MutableStateFlow<AppState>(Startup)
fun updateAppState(state: AppState) {
appState.value = state
}
val currentLocation: Flow<Location?>
field = MutableStateFlow<Location?>(null)
fun updateLocation(location: Location?) {
currentLocation.value = location
}
val carLatLng = application.preferencesDataStore.data.map { preferences ->
preferences[LAT_PREF]?.let { latString ->
preferences[LON_PREF]?.let { lonString ->
LatLng(latString.toDouble(), lonString.toDouble())
}
}
}
fun clearCarLocation() {
viewModelScope.launch {
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences.remove(LAT_PREF)
preferences.remove(LON_PREF)
}
}
}
fun setCarLocation() {
viewModelScope.launch {
currentLocation.value?.let { location ->
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences[LAT_PREF] = location.latitude.toString()
preferences[LON_PREF] = location.longitude.toString()
}
} ?: run {
clearCarLocation()
}
}
}
}
$CHANGED: app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
package com.androidbyexample.google.maps
import android.location.Location
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MarkerInfoWindowContent
import com.google.maps.android.compose.MarkerState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun GoogleMapDisplay(
currentLocation: Location?,
carLatLng: LatLng?,
cameraPositionState: CameraPositionState,
modifier: Modifier,
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
) {
var mapLoaded by remember { mutableStateOf(false) }
var currentMapType by remember { mutableStateOf(MapType.NORMAL) }
var mapProperties by remember {
mutableStateOf(MapProperties(mapType = MapType.NORMAL))
}
val currentLocationState = remember(currentLocation) {
currentLocation?.let {
MarkerState(
LatLng(
it.latitude,
it.longitude
)
)
}
}
Box(
modifier = modifier,
) {
Column(
modifier = Modifier.fillMaxSize()
) {
MapTypeSelector(
currentValue = currentMapType,
modifier = Modifier.fillMaxWidth(),
) {
mapProperties = mapProperties.copy(mapType = it)
currentMapType = it
}
val context = LocalContext.current
var currentLocationIcon by remember { mutableStateOf<BitmapDescriptor?>(null) }
val carState = rememberUpdatedMarkerState()
var carIcon by remember { mutableStateOf<BitmapDescriptor?>(null) }
val scope = rememberCoroutineScope()
var initialBoundsSet by remember { mutableStateOf(false) }
fun Location.toLatLng() = LatLng(latitude, longitude)
suspend fun goTo(latLng: LatLng) {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
latLng,
16f
), 1000
)
}
LaunchedEffect(key1 = mapLoaded, key2 = currentLocation, animateToCurrentLocation) {
if (mapLoaded) {
if (currentLocation != null) {
if (!initialBoundsSet) {
initialBoundsSet = true
goTo(currentLocation.toLatLng())
}
if (animateToCurrentLocation) {
goTo(currentLocation.toLatLng())
onAnimatedToCurrentLocation() // tell the caller we did it
}
}
}
}
GoogleMap(
cameraPositionState = cameraPositionState,
onMapLoaded = {
mapLoaded = true
scope.launch(Dispatchers.IO) {
currentLocationIcon =
context.loadBitmapDescriptor(
R.drawable.ic_current_location
)
carIcon =
context.loadBitmapDescriptor(
R.drawable.ic_car
)
}
},
properties = mapProperties,
modifier = Modifier
.fillMaxSize()
.weight(1f),
) {
currentLocationState?.let {
MarkerInfoWindowContent(
state = it,
icon = currentLocationIcon,
anchor = Offset(0.5f, 0.5f),
// the actual location is at the center of the icon
title = stringResource(R.string.current_location),
)
}
carLatLng?.let {
carState.position = it
MarkerInfoWindowContent(
state = carState,
icon = carIcon,
anchor = Offset(0.5f, 0.5f),
title = stringResource(R.string.car_location),
)
}
}
}
if (!mapLoaded) {
AnimatedVisibility(
visible = true,
modifier = Modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
}
}
$CHANGED: app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
package com.androidbyexample.google.maps
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.os.Bundle
import android.os.Looper
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.google.maps.ui.theme.GooglemapsTheme
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.rememberCameraPositionState
class MainActivity : ComponentActivity() {
private val viewModel: CarViewModel by viewModels()
private lateinit var fusedLocationProviderClient: FusedLocationProviderClient
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
viewModel.updateLocation(locationResult.lastLocation)
}
}
private val locationPermissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
private val quitOption = DialogOption(R.string.quit) { finish() }
private val getLocationPermission =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
viewModel.updateAppState(
when {
isGranted.values.any { it } -> PermissionsGranted
else -> RequestPermission // ask again
}
)
}
// helper to make the checks more readable in onCreate
private fun locationPermissionsGranted() =
locationPermissions.any { permission ->
ActivityCompat.checkSelfPermission(this, permission) ==
PackageManager.PERMISSION_GRANTED
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkPlayServices()
enableEdgeToEdge()
setContent {
val appState by viewModel.appState.collectAsStateWithLifecycle(Startup)
val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle(
initialValue = null
)
val carLatLng by viewModel.carLatLng.collectAsStateWithLifecycle(
initialValue = null
)
var animateToCurrentLocation by remember { mutableStateOf(false) }
GooglemapsTheme {
Scaffold(
topBar = {
CarTopBar(
currentLocation = currentLocation,
// carLatLng = null,
// onSetCarLocation = {},
carLatLng = carLatLng,
onSetCarLocation = viewModel::setCarLocation,
onGoToCurrentLocation = {
animateToCurrentLocation = true
},
// onClearCarLocation = {},
onClearCarLocation = viewModel::clearCarLocation,
onWalkToCar = {},
)
// },
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
val modifier = Modifier.padding(innerPadding)
when (appState) {
Startup -> Loading(modifier)
PlayServicesOk -> {
viewModel.updateAppState(
when {
locationPermissionsGranted() -> PermissionsGranted
else -> RequestPermission
}
)
}
PermissionsGranted -> {
startLocation()
viewModel.updateAppState(ShowMap)
}
ShowMap ->
ShowMap(
currentLocation = currentLocation,
carLatLng = carLatLng,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = {
animateToCurrentLocation = false
},
modifier = modifier
)
RequestPermission -> ExplainPermission()
PlayServicesRequired -> PlayServicesRequired()
}
}
}
}
}
private fun checkPlayServices() {
GoogleApiAvailability.getInstance()
.makeGooglePlayServicesAvailable(this)
.addOnSuccessListener {
viewModel.updateAppState(PlayServicesOk)
}.addOnFailureListener(this) {
viewModel.updateAppState(PlayServicesRequired)
}
}
@Composable
fun ExplainPermission() {
ThreeButtonDialog(
titleId = R.string.permissions_needed,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"This application requires location permissions to save and locate " +
"the position of your car. Please grant " +
"'Precise Location' permission.\n\n" +
"Press Ok for permission request.\n\n" +
"If Ok does nothing, this means you " +
"have previously permanently denied the request. In this case, press " +
"'App Info' to change the permissions and restart the app.",
DialogOption(R.string.app_info) {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
)
finish()
},
DialogOption(R.string.quit) { finish() },
DialogOption(R.string.ok) {
getLocationPermission.launch(locationPermissions)
}
)
}
@Composable
fun PlayServicesRequired() {
ThreeButtonDialog(
titleId = R.string.play_services_required,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"Google Play services required (or upgrade required)",
quitOption,
)
}
@SuppressLint("MissingPermission")
fun startLocation() {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
.setWaitForAccurateLocation(false)
.setMinUpdateIntervalMillis(0)
.setMaxUpdateDelayMillis(5000)
.build()
fusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(this)
fusedLocationProviderClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper()
)
}
@Composable
fun ShowMap(
carLatLng: LatLng?,
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
modifier: Modifier,
currentLocation: Location?,
) {
val googleHQ = LatLng(37.42423291057923, -122.08811454627153)
val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)
val cameraPositionState = rememberCameraPositionState {
position = defaultCameraPosition
}
GoogleMapDisplay(
carLatLng = carLatLng,
currentLocation = currentLocation,
cameraPositionState = cameraPositionState,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = onAnimatedToCurrentLocation,
modifier = modifier.fillMaxSize(),
)
}
@Composable
fun Loading(
modifier: Modifier,
) {
AnimatedVisibility(
visible = true,
modifier = modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
}
$ADDED: app/src/main/res/drawable/ic_car.xml
<!--
Car icon made by Vectors Market (https://www.flaticon.com/authors/vectors-market) from https://www.flaticon.com
-->
<vector android:height="48dp" android:viewportHeight="496.474"
android:viewportWidth="496.474" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#E95353" android:pathData="M489.544,269.628c-0.729,-14.739 -6.206,-28.858 -15.205,-40.572c-20.294,-26.422 -24.25,-47.399 -24.25,-47.399c-15.934,-41.751 -30.099,-69.57 -40.51,-87.226c-10.954,-18.541 -29.898,-31.03 -51.262,-33.776c-42.961,-5.554 -177.23,-5.554 -220.191,0c-21.349,2.762 -40.293,15.251 -51.247,33.776c-10.411,17.656 -24.56,45.475 -40.51,87.226c0,0 -3.956,20.977 -24.25,47.399c-8.983,11.714 -14.476,25.833 -15.189,40.572c-1.536,31.977 7.727,39.005 16.012,111.368c0.652,5.694 5.461,9.976 11.202,9.976h428.203c5.741,0 10.55,-4.298 11.202,-9.976c2.327,-20.294 9.232,-61.456 9.232,-61.456C488.83,299.076 490.149,282.397 489.544,269.628z"/>
<path android:fillColor="#168DE2" android:pathData="M441.074,179.982c-12.567,-31.449 -25.383,-58.911 -38.136,-80.803c-9.666,-16.555 -26.717,-28.113 -45.583,-30.642c-43.055,-5.71 -175.476,-5.663 -218.221,0c-18.866,2.529 -35.918,14.088 -45.584,30.642c-12.924,22.171 -25.91,50.005 -38.291,80.803C183.662,190.082 312.655,190.082 441.074,179.982z"/>
<path android:fillColor="#FFFFFF" android:pathData="M422.844,216.349c-4.903,1.536 -9.464,3.693 -13.281,5.834c-6.951,3.879 -14.088,7.494 -21.535,10.318c-12.35,4.686 -14.445,11.683 -14.445,11.683c-0.372,0.481 -0.729,0.962 -1.071,1.458c-9.371,13.917 2.932,32.442 19.549,30.084c24.033,-3.398 43.83,-10.364 53.884,-14.398c5.756,-2.312 10.255,-7.121 11.729,-13.157c0.14,-0.574 0.264,-1.148 0.372,-1.707C461.74,226.543 442.191,210.314 422.844,216.349z"/>
<path android:fillColor="#FFE21F" android:pathData="M448.522,327.857h-26.733c-4.018,0 -6.035,4.856 -3.196,7.711l26.733,26.733c2.839,2.839 7.711,0.822 7.711,-3.196v-26.733C453.036,329.874 451.019,327.857 448.522,327.857z"/>
<path android:fillColor="#454545" android:pathData="M473.547,380.965c-0.652,5.71 -5.477,10.007 -11.217,10.007h-51.433v29.851c0,10.566 8.564,19.146 19.146,19.146h32.303c10.566,0 19.146,-8.564 19.146,-19.146v-93.448C479.366,340.315 475.239,366.179 473.547,380.965z"/>
<path android:fillColor="#ED6262" android:pathData="M496.107,159.083c-1.552,-6.951 -8.083,-11.667 -15.205,-11.667h-12.909c-8.27,0 -14.972,6.703 -14.972,14.972v9.2h-6.842l3.925,9.325h15.05c7.789,0 15.5,-1.676 22.575,-4.918C494.105,173.078 497.674,166.096 496.107,159.083z"/>
<path android:fillColor="#FFBD49" android:pathData="M431.315,240.46m-18.82,0a18.82,18.82 0,1 1,37.64 0a18.82,18.82 0,1 1,-37.64 0"/>
<path android:fillColor="#FFDB6F" android:pathData="M393.458,255.308m-13.824,0a13.824,13.824 0,1 1,27.648 0a13.824,13.824 0,1 1,-27.648 0"/>
<path android:fillColor="#6F6F6F" android:pathData="M357.634,255.324l-21.613,37.639l-175.569,0l-21.613,-37.639z"/>
<path android:fillColor="#FFFFFF" android:pathData="M123.945,245.642c-0.326,-0.496 -0.683,-0.977 -1.071,-1.458c0,0 -2.095,-6.997 -14.445,-11.683c-7.447,-2.824 -14.569,-6.439 -21.535,-10.318c-3.832,-2.141 -8.378,-4.298 -13.281,-5.834c-19.332,-6.051 -38.896,10.193 -35.219,30.115c0.109,0.574 0.233,1.133 0.357,1.707c1.474,6.035 5.973,10.845 11.729,13.157c10.054,4.034 29.851,11 53.9,14.398C121.012,278.084 133.316,259.559 123.945,245.642z"/>
<path android:fillColor="#FFE21F" android:pathData="M74.684,327.857H47.951c-2.498,0 -4.515,2.017 -4.515,4.515v26.733c0,4.018 4.872,6.035 7.711,3.196l26.733,-26.733C80.719,332.729 78.702,327.857 74.684,327.857z"/>
<path android:fillColor="#777777" android:pathData="M426.226,374.511c-27.834,-48.159 -59.842,-46.654 -59.842,-46.654H130.073c0,0 -31.992,-1.505 -59.842,46.654H22.119l0.59,6.206c0.543,5.834 5.415,10.256 11.248,10.256h428.56c5.834,0 10.705,-4.422 11.249,-10.255l0.59,-6.206L426.226,374.511L426.226,374.511z"/>
<path android:fillColor="#454545" android:pathData="M34.143,390.988c-5.741,0 -10.566,-4.313 -11.217,-10.007c-1.691,-14.786 -5.834,-40.665 -7.944,-53.589v93.448c0,10.566 8.564,19.146 19.146,19.146H66.43c10.566,0 19.146,-8.564 19.146,-19.146v-29.851H34.143z"/>
<path android:fillColor="#ED6262" android:pathData="M43.436,171.588v-9.2c0,-8.27 -6.703,-14.972 -14.957,-14.972H15.571c-7.121,0 -13.653,4.717 -15.205,11.667c-1.567,6.997 2.017,13.995 8.378,16.896c7.09,3.243 14.786,4.918 22.575,4.918h15.05l3.925,-9.325h-6.858V171.588z"/>
<path android:fillColor="#FFBD49" android:pathData="M65.142,240.46m-18.82,0a18.82,18.82 0,1 1,37.64 0a18.82,18.82 0,1 1,-37.64 0"/>
<path android:fillColor="#FFDB6F" android:pathData="M103.03,255.308m-13.824,0a13.824,13.824 0,1 1,27.648 0a13.824,13.824 0,1 1,-27.648 0"/>
<path android:fillColor="#6F6F6F" android:pathData="M348.294,341.029H148.179c-3.134,0 -5.663,2.544 -5.663,5.663c0,3.134 2.529,5.663 5.663,5.663h200.099c3.134,0 5.663,-2.544 5.663,-5.663C353.957,343.558 351.412,341.029 348.294,341.029z"/>
<path android:fillColor="#6F6F6F" android:pathData="M348.294,363.635H148.179c-3.134,0 -5.663,2.544 -5.663,5.663c0,3.134 2.529,5.663 5.663,5.663h200.099c3.134,0 5.663,-2.544 5.663,-5.663C353.957,366.164 351.412,363.635 348.294,363.635z"/>
<path android:fillColor="#3AA2EB" android:pathData="M420.098,132.009v1.939c0,7.804 -6.408,14.057 -14.367,13.731c-7.339,-0.465 -12.955,-7.028 -12.955,-14.522v-27.834c0,-6.563 -5.461,-11.559 -12.024,-11.388c-0.155,0 -0.155,0 -0.31,0c0,0 0,0 -0.155,0c-6.563,-0.155 -12.024,4.841 -12.024,11.388v25.181c0,7.804 -6.718,14.057 -14.522,13.576c-7.339,-0.31 -12.955,-7.028 -12.955,-14.367v-21.892c0,-6.563 -5.461,-12.024 -12.179,-12.024h-0.931c-6.253,0 -11.404,5.151 -11.404,11.404v28.47c0,7.804 -6.563,14.041 -14.522,13.731c-7.339,-0.465 -12.8,-7.028 -12.8,-14.522V96.712c0,-6.082 -5.306,-11.233 -11.559,-10.768h-0.931c-6.408,0 -11.543,5.151 -11.543,11.404v39.021c0,7.494 -6.082,13.731 -13.731,13.731c-7.339,0 -13.421,-5.927 -13.576,-13.265v-32.752c0,-7.494 -5.616,-14.041 -12.955,-14.522c-7.959,-0.31 -14.367,5.927 -14.367,13.731l-0.155,30.255c0,7.804 -6.392,14.041 -14.367,13.731c-7.199,-0.45 -12.66,-6.78 -12.878,-13.948V97.829c0,-7.37 -5.539,-13.964 -12.909,-14.398c-7.897,-0.341 -14.414,5.88 -14.414,13.7v37.19c-0.217,7.618 -6.609,13.684 -14.445,13.374c-7.339,-0.465 -12.8,-7.028 -12.8,-14.522v-27.85c0,-6.563 -5.616,-11.559 -12.179,-11.388c-0.155,0 -0.155,0 -0.155,0c-0.155,0 -0.155,0 -0.155,0c-6.718,-0.155 -12.179,4.841 -12.179,11.388v25.181c0,7.804 -6.563,14.057 -14.367,13.576c-6.796,-0.264 -11.854,-5.942 -12.722,-12.521c-7.215,14.941 -14.367,31.123 -21.318,48.407c128.403,10.116 257.412,10.116 385.815,0C434.139,162.636 427.126,146.655 420.098,132.009z"/>
</vector>
$CHANGED: app/src/main/res/values/strings.xml
<resources>
<string name="app_name">Google Map</string>
<string name="map_type">Map Type</string>
<string name="current_location">Current Location</string>
<string name="permissions_needed">Permissions Needed</string>
<string name="app_info">App Info</string>
<string name="quit">Quit</string>
<string name="ok">Ok</string>
<string name="play_services_required">Play Services Required</string>
<string name="go_to_current_location">Go to current location</string>
<string name="remember_location">Remember location</string>
<string name="navigate">Navigate</string>
<string name="forget_location">Forget location</string>
<string name="car_location">Car location</string>
</resources>
$CHANGED: gradle/libs.versions.toml
[versions]
agp = "9.3.1"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.4.10"
composeBom = "2026.08.00"
secrets = "2.0.1"
maps-compose = "8.4.0"
location-services = "21.4.0"
dokka = "2.2.0"
datastore-preferences = "1.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { group = "com.google.maps.android", name = "maps-compose-utils", version.ref = "maps-compose" }
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location-services" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore-preferences" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }