diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f42fa94..580a257 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,8 +36,7 @@ android:theme="@style/AppTheme"> + android:exported="true" > diff --git a/app/src/main/java/com/google/places/android/ktx/demo/DemoActivity.kt b/app/src/main/java/com/google/places/android/ktx/demo/DemoActivity.kt index 9849385..3cd0d4f 100644 --- a/app/src/main/java/com/google/places/android/ktx/demo/DemoActivity.kt +++ b/app/src/main/java/com/google/places/android/ktx/demo/DemoActivity.kt @@ -72,4 +72,4 @@ class DemoActivity : AppCompatActivity() { .inflate(R.layout.item_demo, this) } } -} \ No newline at end of file +} diff --git a/build.gradle b/build.gradle index 00e9157..b674c3b 100644 --- a/build.gradle +++ b/build.gradle @@ -34,7 +34,6 @@ plugins { id "org.jetbrains.dokka" version "1.9.10" } - ext.projectArtifactId = { project -> if (project.name == 'places-ktx') { return project.name diff --git a/local.defaults.properties b/local.defaults.properties index 7825929..0fe128e 100644 --- a/local.defaults.properties +++ b/local.defaults.properties @@ -1,4 +1,7 @@ # This file contains a default value for your GMP API Key. # To provide your actual GMP API Key, update the local.properties file using the PLACES_API_KEY # as demonstrated below. -PLACES_API_KEY="YOUR_API_KEY" +PLACES_API_KEY=DEFAULT_API_KEY + +# Used by the new client demo +MAPS_API_KEY=DEFAULT_API_KEY diff --git a/new-places-client/.gitignore b/new-places-client/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/new-places-client/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/new-places-client/build.gradle.kts b/new-places-client/build.gradle.kts new file mode 100644 index 0000000..f777f40 --- /dev/null +++ b/new-places-client/build.gradle.kts @@ -0,0 +1,92 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") +} + +android { + namespace = "com.example.new_places_client" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.new_places_client" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + buildConfig = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.9" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation(platform("androidx.compose:compose-bom:2024.01.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3-android:1.2.1") + + testImplementation("junit:junit:4.13.2") + testImplementation("com.google.truth:truth:1.4.2") + + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2024.01.00")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + implementation("com.google.android.libraries.places:places:3.3.0") + implementation("com.google.android.gms:play-services-maps:18.2.0") + implementation(project(":places-ktx")) + + // Jetpack navigation + val navVersion = "2.7.7" + // Jetpack Compose Integration + implementation("androidx.navigation:navigation-compose:$navVersion") + + // Only needed for Google Maps compose + implementation("com.google.maps.android:maps-compose:4.3.3") +} + +secrets { + // To add your Maps API key to this project: + // 1. Create a file ./secrets.properties in the root folder of the project + // 2. Add this line, where YOUR_API_KEY is your API key: + // PLACES_API_KEY=YOUR_API_KEY + propertiesFileName = "secrets.properties" + defaultPropertiesFileName = "local.defaults.properties" +} diff --git a/new-places-client/proguard-rules.pro b/new-places-client/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/new-places-client/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/new-places-client/src/androidTest/java/com/example/new_places_client/ExampleInstrumentedTest.kt b/new-places-client/src/androidTest/java/com/example/new_places_client/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..7be6539 --- /dev/null +++ b/new-places-client/src/androidTest/java/com/example/new_places_client/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.new_places_client + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * 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.example.new_places_client", appContext.packageName) + } +} \ No newline at end of file diff --git a/new-places-client/src/main/AndroidManifest.xml b/new-places-client/src/main/AndroidManifest.xml new file mode 100644 index 0000000..322e9e9 --- /dev/null +++ b/new-places-client/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/new-places-client/src/main/java/com/example/new_places_client/AutocompleteScreen.kt b/new-places-client/src/main/java/com/example/new_places_client/AutocompleteScreen.kt new file mode 100644 index 0000000..b7ef06b --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/AutocompleteScreen.kt @@ -0,0 +1,148 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.google.android.libraries.places.ktx.widget.ExperimentalPlacesApi +import com.google.android.libraries.places.ktx.widget.PlacesAutocomplete +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.places.api.model.AutocompletePrediction +import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.api.model.PlaceTypes +import com.google.android.libraries.places.api.model.RectangularBounds +import com.google.android.libraries.places.api.net.PlacesClient +import com.google.android.libraries.places.ktx.api.net.awaitFetchPlace +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.rememberCameraPositionState +import kotlinx.coroutines.ExperimentalCoroutinesApi + +private val predictionsHighlightStyle = SpanStyle(fontWeight = FontWeight.Bold, color = Color.Blue) + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalPlacesApi::class) +@Composable +fun AutocompleteScreen(placesClient: PlacesClient, onShowMessage: (String) -> Unit) { + // The list of place details fields to retrieve from the server for the selected place. + // See the full list at https://developers.google.com/maps/documentation/places/android-sdk/place-data-fields + val fields = listOf(Place.Field.ID, Place.Field.NAME, Place.Field.ADDRESS, Place.Field.LAT_LNG) + + var selectedPlace by remember { + mutableStateOf(null) + } + + var placeDetails by remember { + mutableStateOf(null) + } + + LaunchedEffect(selectedPlace) { + placeDetails = selectedPlace?.placeId?.let { placeId -> + placesClient.awaitFetchPlace(placeId, fields).place.toPlaceDetails() + } + } + + val resources = LocalContext.current.resources + + Column(Modifier.fillMaxSize()) { + PlacesAutocomplete( + placesClient, + searchLabelContent = { Text(stringResource(id = R.string.auto_complete_hint)) }, + actions = { + locationBias = RectangularBounds.newInstance( + LatLng(39.95106, -105.31828), // SW lat, lng + LatLng(40.07399, -105.18096) // NE lat, lng + ) + typesFilter = listOf(PlaceTypes.ESTABLISHMENT) + countries = listOf("US") + }, + onPlaceSelected = { place -> + place?.getPrimaryText(null)?.toString()?.let { placeText -> + onShowMessage(resources.getString(R.string.selected_place, placeText)) + } + selectedPlace = place + }, + modifier = Modifier.fillMaxWidth(), + predictionsHighlightStyle = predictionsHighlightStyle + ) + + selectedPlace?.let { prediction -> + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Text( + modifier = Modifier.padding(8.dp), + text = prediction.getFullText(null).toString() + ) + } + } + + placeDetails?.let { place -> + Spacer(modifier = Modifier.height(8.dp)) + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(place.location, 15f) + } + + LaunchedEffect(place.location) { + cameraPositionState.animate( + CameraUpdateFactory.newLatLngZoom(place.location, 15f) + ) + } + + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState + ) { + Marker( + state = MarkerState(place.location), + title = place.name, + snippet = place.address + ) + } + } + } + } +} + diff --git a/new-places-client/src/main/java/com/example/new_places_client/MainActivity.kt b/new-places-client/src/main/java/com/example/new_places_client/MainActivity.kt new file mode 100644 index 0000000..bb769a9 --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/MainActivity.kt @@ -0,0 +1,212 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import com.example.new_places_client.ui.theme.AndroidplacesktxTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.google.android.libraries.places.api.Places +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +data class NavigationItem( + val route: String, + @StringRes val title: Int, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, +) + +val navigationItems = listOf( + NavigationItem( + route = "text_search", + title = R.string.text_search_label, + selectedIcon = Icons.Filled.Search, + unselectedIcon = Icons.Outlined.Search, + ), + NavigationItem( + route = "auto_complete", + title = R.string.auto_complete_label, + selectedIcon = Icons.Filled.Add, + unselectedIcon = Icons.Outlined.Add, + ), +) + +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val placesClient = Places.createClient(this) + + setContent { + val navController = rememberNavController() + + val snackbarHostState = remember { SnackbarHostState() } + + val scope = rememberCoroutineScope() + + AndroidplacesktxTheme { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + var selectedScreen by remember { + mutableStateOf(navigationItems.first()) + } + + LaunchedEffect(currentDestination) { + selectedScreen = navigationItems.firstOrNull { item -> + currentDestination?.hierarchy?.any { it.route == item.route } == true + } ?: selectedScreen + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Scaffold( + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text(stringResource(id = selectedScreen.title)) } + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + bottomBar = { + NavigationBar { + navigationItems.forEach { item -> + val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true + + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(item.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + label = { + Text(text = stringResource(id = item.title)) + }, + alwaysShowLabel = true, + icon = { + Icon( + imageVector = if (selected) item.selectedIcon else item.unselectedIcon, + contentDescription = null + ) + } + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = navigationItems.first().route, + modifier = Modifier.padding(innerPadding) + ) { + composable(navigationItems[0].route) { + TextSearchScreen(placesClient) { message: String -> + showErrorSnackBar(message, scope, snackbarHostState) + } + } + composable(navigationItems[1].route) { + AutocompleteScreen(placesClient) { message: String -> + showErrorSnackBar(message, scope, snackbarHostState) + } + } + } + } + } + } + } + } + + private fun showErrorSnackBar( + message: String, + scope: CoroutineScope, + snackbarHostState: SnackbarHostState + ) { + if (message.isNotEmpty()) { + scope.launch { + snackbarHostState.showSnackbar(message = message) + } + } + } +} + +@Composable +fun BigSpinner() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.width(64.dp), + color = MaterialTheme.colorScheme.secondary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } +} + diff --git a/new-places-client/src/main/java/com/example/new_places_client/NewPlacesApplication.kt b/new-places-client/src/main/java/com/example/new_places_client/NewPlacesApplication.kt new file mode 100644 index 0000000..d26da54 --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/NewPlacesApplication.kt @@ -0,0 +1,26 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client + +import android.app.Application +import com.google.android.libraries.places.api.Places + +class NewPlacesApplication : Application() { + override fun onCreate() { + super.onCreate() + + Places.initializeWithNewPlacesApiEnabled(this, BuildConfig.PLACES_API_KEY) + } +} \ No newline at end of file diff --git a/new-places-client/src/main/java/com/example/new_places_client/PlaceDetails.kt b/new-places-client/src/main/java/com/example/new_places_client/PlaceDetails.kt new file mode 100644 index 0000000..16aff05 --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/PlaceDetails.kt @@ -0,0 +1,43 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client + +import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.places.api.model.Place + +// Simple data class to hold the place data needed from the search results +data class PlaceDetails( + val placeId: String, + val name: String, + val location: LatLng, + val address: String? = null, +) + +fun Place.toPlaceDetails(): PlaceDetails? { + val name = this.name + val placeId = this.id + val address = this.address + val latLng = this.latLng + return if (placeId != null && name != null && latLng != null) { + PlaceDetails( + placeId = placeId, + name = name, + location = latLng, + address = address + ) + } else { + null + } +} \ No newline at end of file diff --git a/new-places-client/src/main/java/com/example/new_places_client/TextSearchScreen.kt b/new-places-client/src/main/java/com/example/new_places_client/TextSearchScreen.kt new file mode 100644 index 0000000..6eb0c90 --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/TextSearchScreen.kt @@ -0,0 +1,355 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client + +import android.util.Log +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.android.gms.common.api.ApiException +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.gms.maps.model.LatLngBounds +import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.api.net.PlacesClient +import com.google.android.libraries.places.ktx.api.net.awaitSearchByText +import com.google.maps.android.compose.CameraPositionState +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.rememberCameraPositionState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch + +private const val TAG = "TextSearch" + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun TextSearchScreen(placesClient: PlacesClient, onShowMessage: (String) -> Unit) { + // The list of fields to retrieve from the server + // See the full list at https://developers.google.com/maps/documentation/places/android-sdk/place-data-fields + val fields = listOf(Place.Field.ID, Place.Field.NAME, Place.Field.ADDRESS, Place.Field.LAT_LNG) + + val scope = rememberCoroutineScope() + + // To keep the demo a bit simpler, use remembers. + var searchText by rememberSaveable { + mutableStateOf("") + } + + var searchResults by rememberSaveable { + mutableStateOf>(emptyList()) + } + + var showSpinner by rememberSaveable { + mutableStateOf(false) + } + + var selectedPlace by rememberSaveable { + mutableStateOf(null) + } + + var markerStates by remember { + mutableStateOf>(emptyList()) + } + + var bounds by remember { + mutableStateOf(null) + } + + var center by remember { + mutableStateOf(LatLng(0.0, 0.0)) + } + + val keyboardController = LocalSoftwareKeyboardController.current + + // Perform a new search whenever the searchText is changed + LaunchedEffect(key1 = searchText) { + if (searchText.isEmpty()) { + searchResults = emptyList() + bounds = null + } else { + showSpinner = true + try { + val response = placesClient.awaitSearchByText(searchText, fields) { + maxResultCount = 10 + } + + searchResults = response.places.mapNotNull { place -> + place.toPlaceDetails() + } + + val locations = searchResults.map { place -> place.location } + + markerStates = locations.map { + MarkerState(position = it) + } + + bounds = locations.toLatLngBounds().also { + center = it.center + } + } catch (e: ApiException) { + Log.e(TAG, "Exception during call to placesClient.awaitSearchByText") + onShowMessage(e.message ?: "Unknown error") + searchText = "" + } + + showSpinner = false + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + + SearchRow { newSearchText -> + searchText = newSearchText + keyboardController?.hide() + } + + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(center, 10f) + } + + if (showSpinner) { + BigSpinner() + } else { + val focusedPlace = selectedPlace + + if (searchResults.isNotEmpty()) { + Card( + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp) + .weight(1f) + ) { + SearchResultsList(searchResults, focusedPlace) { place, doubleClick -> + if (doubleClick) { + selectedPlace = place + scope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newLatLngZoom( + place.location, + 15f + ) + ) + } + } else { + selectedPlace = if (selectedPlace == place) { + null + } else { + place + } + } + } + } + + Card( + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp) + .weight(2f) + ) { + if (focusedPlace != null) { + PlaceDetails(focusedPlace) + } + + if (searchResults.isNotEmpty()) { + PlacesMap(cameraPositionState, center, searchResults, focusedPlace, bounds) + } + } + } + } + } +} + +@Composable +private fun SearchRow(onSearchClick: (String) -> Unit) { + var searchText by rememberSaveable { + mutableStateOf("grocery stores near the grand canyon") + } + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + modifier = Modifier.weight(1f), + value = searchText, + onValueChange = { newText -> searchText = newText }, + singleLine = false, + label = { Text(stringResource(R.string.places_text_search_hint)) }, + trailingIcon = { + IconButton(onClick = { searchText = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear_search) + ) + } + } + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + modifier = Modifier.background(MaterialTheme.colorScheme.primary, CircleShape), + onClick = { onSearchClick(searchText) } + ) { + Icon( + tint = MaterialTheme.colorScheme.onPrimary, + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action) + ) + } + } +} + +@Composable +private fun PlacesMap( + cameraPositionState: CameraPositionState, + center: LatLng, + searchResults: List, + focusedPlace: PlaceDetails?, + bounds: LatLngBounds?, +) { + val padding = with(LocalDensity.current) { 16.dp.toPx() }.toInt() + + val markerStates by remember(key1 = searchResults) { + mutableStateOf( + searchResults.associateWith { place -> + MarkerState(position = place.location) + } + ) + } + + LaunchedEffect(key1 = focusedPlace, key2 = bounds, key3 = center) { + val update = when { + // If we have a focused place, center on that + focusedPlace != null -> { + CameraUpdateFactory.newLatLng(focusedPlace.location) + } + + // Otherwise, show all of the places on the map + bounds != null -> { + CameraUpdateFactory.newLatLngBounds(bounds, padding) + } + + // Fall-back to showing the "center" + else -> { + CameraUpdateFactory.newLatLng(center) + } + } + + cameraPositionState.animate(update) + } + + LaunchedEffect(key1 = focusedPlace) { + // If the focused place changes, show its info window + if (focusedPlace != null) { + markerStates[focusedPlace]?.showInfoWindow() + } + } + + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState + ) { + searchResults.forEach { place -> + Marker( + state = markerStates.getValue(place), + title = place.name, + snippet = place.address, + ) + } + } +} + +@Composable +private fun PlaceDetails(focusedPlace: PlaceDetails) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text(text = focusedPlace.name) + Text(text = focusedPlace.address ?: stringResource(R.string.no_address_found)) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SearchResultsList( + searchResults: List, + focusedPlace: PlaceDetails?, + onPlaceSelected: (PlaceDetails, Boolean) -> Unit, +) { + LazyColumn { + items(searchResults) { place -> + Row( + modifier = Modifier + .then( + if (place == focusedPlace) + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + else + Modifier + ) + .fillMaxWidth() + .padding(8.dp) + .combinedClickable( + onClick = { onPlaceSelected(place, false) }, + onDoubleClick = { onPlaceSelected(place, true) } + ), + ) { + Text(place.name) + } + } + } +} + +// Creates a LatLngBounds object from a collection of LatLng objects +private fun Collection.toLatLngBounds(): LatLngBounds { + if (isEmpty()) error("Cannot create a LatLngBounds from an empty list") + + return LatLngBounds.builder().apply { + forEach { latLng -> this.include(latLng) } + }.build() +} \ No newline at end of file diff --git a/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Color.kt b/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Color.kt new file mode 100644 index 0000000..0ef8353 --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Color.kt @@ -0,0 +1,25 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client.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) \ No newline at end of file diff --git a/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Theme.kt b/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Theme.kt new file mode 100644 index 0000000..445ef45 --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Theme.kt @@ -0,0 +1,84 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client.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.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +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 AndroidplacesktxTheme( + 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 + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Type.kt b/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Type.kt new file mode 100644 index 0000000..cc7ef41 --- /dev/null +++ b/new-places-client/src/main/java/com/example/new_places_client/ui/theme/Type.kt @@ -0,0 +1,48 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.new_places_client.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 + ) + */ +) \ No newline at end of file diff --git a/new-places-client/src/main/res/drawable/ic_launcher_background.xml b/new-places-client/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..90b4f5b --- /dev/null +++ b/new-places-client/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/new-places-client/src/main/res/drawable/ic_launcher_foreground.xml b/new-places-client/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..acc082a --- /dev/null +++ b/new-places-client/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/new-places-client/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/new-places-client/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f7bd9c4 --- /dev/null +++ b/new-places-client/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/new-places-client/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/new-places-client/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..f7bd9c4 --- /dev/null +++ b/new-places-client/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/new-places-client/src/main/res/mipmap-hdpi/ic_launcher.webp b/new-places-client/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/new-places-client/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/new-places-client/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/new-places-client/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/new-places-client/src/main/res/mipmap-mdpi/ic_launcher.webp b/new-places-client/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/new-places-client/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/new-places-client/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/new-places-client/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/new-places-client/src/main/res/mipmap-xhdpi/ic_launcher.webp b/new-places-client/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/new-places-client/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/new-places-client/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/new-places-client/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/new-places-client/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/new-places-client/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/new-places-client/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/new-places-client/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/new-places-client/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/new-places-client/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/new-places-client/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/new-places-client/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/new-places-client/src/main/res/values/colors.xml b/new-places-client/src/main/res/values/colors.xml new file mode 100644 index 0000000..09837df --- /dev/null +++ b/new-places-client/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/new-places-client/src/main/res/values/strings.xml b/new-places-client/src/main/res/values/strings.xml new file mode 100644 index 0000000..8536baa --- /dev/null +++ b/new-places-client/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + New Places Client + Text Search + Auto Complete + Clear search + Places text search + Places autocomplete search + Selected place: %1$s + Search + No address found + \ No newline at end of file diff --git a/new-places-client/src/main/res/values/themes.xml b/new-places-client/src/main/res/values/themes.xml new file mode 100644 index 0000000..c556584 --- /dev/null +++ b/new-places-client/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +