diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 83edad7..00f14b3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,6 +22,9 @@ on: types: [gh-pages] workflow_dispatch: +permissions: + contents: read + jobs: gh-page-sync: runs-on: ubuntu-latest @@ -29,16 +32,16 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 # Run dokka and create tar - name: Generate documentation @@ -54,7 +57,7 @@ jobs: # Commit changes and create a PR - name: PR Changes - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} commit-message: 'docs: Update docs' diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 7a168cf..13c0504 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -19,34 +19,38 @@ on: branches: - main +permissions: + contents: read + security-events: write + jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Run Android Lint run: ./gradlew lint - name: Upload SARIF for places-compose - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: sarif_file: places-compose/build/reports/lint-results.sarif category: places-compose - name: Upload SARIF for places-compose-demo - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: sarif_file: places-compose-demo/build/reports/lint-results.sarif category: places-compose-demo diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 404c684..51d292e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,23 +18,26 @@ on: release: types: [published] +permissions: + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Create .gpg key run: | diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 3b935e2..5f7359d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -27,7 +27,7 @@ jobs: release-please: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@v4 + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release with: token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 516d0e2..4d30d49 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,10 @@ on: workflow_dispatch: workflow_call: +permissions: + contents: read + pull-requests: write + jobs: test: runs-on: ubuntu-latest @@ -30,23 +34,23 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Build modules run: ./gradlew build jacocoTestReport --stacktrace - name: Jacoco Report to PR id: jacoco - uses: madrapps/jacoco-report@v1.7.2 + uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0 with: paths: | ${{ github.workspace }}/places-compose/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml @@ -58,13 +62,16 @@ jobs: update-comment: true - name: Get the Coverage info + env: + COVERAGE_OVERALL: ${{ steps.jacoco.outputs.coverage-overall }} + COVERAGE_CHANGED_FILES: ${{ steps.jacoco.outputs.coverage-changed-files }} run: | - echo "Total coverage ${{ steps.jacoco.outputs.coverage-overall }}" - echo "Changed Files coverage ${{ steps.jacoco.outputs.coverage-changed-files }}" + echo "Total coverage $COVERAGE_OVERALL" + echo "Changed Files coverage $COVERAGE_CHANGED_FILES" - name: Upload test reports if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-reports path: | diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 01493cf..11fa420 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -28,7 +28,14 @@ class PublishingConventionPlugin : Plugin { private fun Project.configureJacoco() { configure { - toolVersion = "0.8.11" // Compatible with newer JDKs + toolVersion = "0.8.15" // Compatible with newer JDKs + } + + tasks.withType().configureEach { + configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } } // AGP 9.0+ built-in Jacoco support or manual configuration. diff --git a/build.gradle.kts b/build.gradle.kts index 2895b69..61fbeb7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,8 +10,8 @@ plugins { alias(libs.plugins.hilt.android) apply false alias(libs.plugins.secrets.gradle.plugin) apply false alias(libs.plugins.compose.compiler) apply false - alias(libs.plugins.ksp) - id("org.jetbrains.dokka") version "2.1.0" + alias(libs.plugins.ksp) apply false + alias(libs.plugins.dokka) apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86017af..476ca21 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,35 +1,35 @@ [versions] accompanistPermissions = "0.37.3" activityCompose = "1.13.0" -agp = "9.2.0" -appcompat = "1.7.1" -composeBom = "2026.04.01" -coreKtx = "1.18.0" +agp = "9.3.2" +appcompat = "1.8.0" +composeBom = "2026.08.00" +coreKtx = "1.19.0" dokka = "2.2.0" espressoCore = "3.7.0" gson = "2.14.0" -hiltVersion = "2.59.2" +hiltVersion = "2.60.1" junit = "4.13.2" junitVersion = "1.3.0" kotlin = "2.3.21" kotlinReflect = "2.3.21" -kotlinxCoroutinesPlayServices = "1.10.2" -ksp = "2.3.2" -lifecycleRuntimeKtx = "2.10.0" -lifecycleViewmodelCompose = "2.10.0" -mapsCompose = "8.3.0" -mapsUtilsKtx = "6.0.1" +kotlinxCoroutinesPlayServices = "1.11.0" +ksp = "2.3.11" +lifecycleRuntimeKtx = "2.11.0" +lifecycleViewmodelCompose = "2.11.0" +mapsCompose = "8.5.0" +mapsUtilsKtx = "6.3.0" material-icons = "1.7.8" -materialVersion = "1.13.0" -navigationCompose = "2.9.8" -org-jacoco-core = "0.8.14" -places = "5.2.0" +materialVersion = "1.14.0" +navigationCompose = "2.10.0" +org-jacoco-core = "0.8.15" +places = "5.3.0" robolectric = "4.16.1" secretsGradlePlugin = "2.0.1" truth = "1.4.5" -uiTestAndroid = "1.11.0" -uiToolingVersion = "1.11.0" -gradleMavenPublishPlugin = "0.36.0" +uiTestAndroid = "1.12.0" +uiToolingVersion = "1.12.0" +gradleMavenPublishPlugin = "0.37.0" mockk = "1.14.11" startup-runtime = "1.2.0" androidxTestCoreKtx = "1.7.0" @@ -93,6 +93,7 @@ play-services-maps = { group = "com.google.android.gms", name = "play-services-m android-application = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b52fb7e..df6a6ad 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/places-compose-demo/src/main/AndroidManifest.xml b/places-compose-demo/src/main/AndroidManifest.xml index 31bb061..4c5ab86 100644 --- a/places-compose-demo/src/main/AndroidManifest.xml +++ b/places-compose-demo/src/main/AndroidManifest.xml @@ -36,7 +36,6 @@ diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/MainActivity.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/MainActivity.kt index 0440b71..569afc4 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/MainActivity.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -27,7 +28,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.google.android.libraries.places.compose.demo.ui.theme.AndroidPlacesComposeDemoTheme import android.content.Intent -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -60,7 +60,7 @@ class MainActivity : ComponentActivity() { setContent { AndroidPlacesComposeDemoTheme { Surface( - modifier = Modifier.fillMaxSize().systemBarsPadding(), + modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { ActivityButtons() @@ -70,11 +70,17 @@ class MainActivity : ComponentActivity() { } } +data class ActivityItem( + val activityClass: Class<*>, + val titleRes: Int, + val descRes: Int, +) + val activities = listOf( - AutocompleteActivity::class.java to R.string.autocomplete_button, - LandmarkSelectionActivity::class.java to R.string.landmark_selection_button, - AddressCompletionActivity::class.java to R.string.address_validation_button, - PlacesAutocompleteMinimalActivity::class.java to R.string.minimal_autocomplete_button + ActivityItem(AutocompleteActivity::class.java, R.string.autocomplete_button, R.string.autocomplete_button_desc), + ActivityItem(LandmarkSelectionActivity::class.java, R.string.landmark_selection_button, R.string.landmark_selection_button_desc), + ActivityItem(AddressCompletionActivity::class.java, R.string.address_validation_button, R.string.address_validation_button_desc), + ActivityItem(PlacesAutocompleteMinimalActivity::class.java, R.string.minimal_autocomplete_button, R.string.minimal_autocomplete_button_desc), ) @OptIn(ExperimentalMaterial3Api::class) @@ -98,15 +104,33 @@ fun ActivityButtons() { Column( Modifier .padding(paddingValues) - .padding(top = 16.dp) .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 20.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(14.dp) ) { - for ((activityClass, buttonTextResId) in activities) { - Button(onClick = { context.startActivity(Intent(context, activityClass)) }) { - Text(stringResource(buttonTextResId)) + for (item in activities) { + Button( + onClick = { context.startActivity(Intent(context, item.activityClass)) }, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(item.titleRes), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(item.descRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.85f) + ) + } } } } diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/data/repositories/GeocoderRepository.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/data/repositories/GeocoderRepository.kt index f650b1e..a4fe459 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/data/repositories/GeocoderRepository.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/data/repositories/GeocoderRepository.kt @@ -15,6 +15,7 @@ package com.google.android.libraries.places.compose.demo.data.repositories import android.content.Context import android.net.Uri +import android.util.Log import com.android.volley.Request import com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.Volley @@ -67,26 +68,42 @@ class GeocoderRepository( suspend fun reverseGeocode( latLng: LatLng, includeAddressDescriptors: Boolean = true - ): ReverseGeocodingResponse { + ): ReverseGeocodingResponse? { val url = buildRequestUrl(latLng, includeAddressDescriptors) - return suspendCancellableCoroutine { cont -> - val queue = Volley.newRequestQueue(context) - val stringRequest = - StringRequest( - Request.Method.GET, - url, - { response -> cont.resume(parseFullGeocodeResponse(response)) }, - { error -> cont.resumeWithException(RuntimeException(error.localizedMessage)) }, - ) - queue.add(stringRequest) + return try { + suspendCancellableCoroutine { cont -> + val queue = Volley.newRequestQueue(context) + val stringRequest = + StringRequest( + Request.Method.GET, + url, + { response -> + try { + cont.resume(parseFullGeocodeResponse(response)) + } catch (e: Exception) { + Log.e("GeocoderRepository", "Error parsing geocoder response", e) + cont.resume(null) + } + }, + { error -> + Log.e("GeocoderRepository", "Volley reverse geocode error: ${error.networkResponse?.statusCode} - ${error.localizedMessage}", error) + cont.resume(null) + }, + ) + queue.add(stringRequest) + } + } catch (e: Exception) { + Log.e("GeocoderRepository", "Reverse geocode exception", e) + null } } private fun parseFullGeocodeResponse(response: String): ReverseGeocodingResponse { val fullResult = gson.fromJson(response, ReverseGeocodingResponse::class.java) - // TODO: handle the status -- consider a monad pattern (Result?) - + if (fullResult.status != "OK" && fullResult.status != "ZERO_RESULTS") { + Log.w("GeocoderRepository", "Geocode API warning: status=${fullResult.status}, error_message=${fullResult.errorMessage}") + } return fullResult } diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/addresscompletion/AddressCompletionScreen.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/addresscompletion/AddressCompletionScreen.kt index 6729aba..986c36a 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/addresscompletion/AddressCompletionScreen.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/addresscompletion/AddressCompletionScreen.kt @@ -24,11 +24,19 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon import com.google.android.gms.maps.model.LatLng import com.google.android.libraries.places.compose.autocomplete.components.PlacesAutocompleteTextField import com.google.android.libraries.places.compose.autocomplete.models.AutocompletePlace @@ -150,6 +158,42 @@ private fun AddressEntryForm( .padding(top = 16.dp) .verticalScroll(rememberScrollState()), ) { + if (addressEntry.nearbyObjects.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.address_descriptors_info_title), + style = MaterialTheme.typography.titleMedium + ) + } + Text( + text = stringResource(R.string.address_descriptors_unavailable_message), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + AddressDisplay( address = addressEntry.displayAddress, modifier = Modifier.fillMaxWidth(), diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/common/CommonScreen.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/common/CommonScreen.kt index 70c4fb8..c427dc6 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/common/CommonScreen.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/common/CommonScreen.kt @@ -3,7 +3,7 @@ package com.google.android.libraries.places.compose.demo.presentation.common import androidx.annotation.StringRes import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,6 +33,10 @@ import com.google.android.libraries.places.compose.demo.presentation.landmark.Ge import com.google.android.libraries.places.compose.demo.ui.theme.AndroidPlacesComposeDemoTheme import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommonScreen( @@ -51,57 +55,75 @@ fun CommonScreen( getUnitsConverter(country) } + val coroutineScope = rememberCoroutineScope() + fun onEvent(event: CommonEvent) { commonViewModel.onEvent(event) } AndroidPlacesComposeDemoTheme { - GetLocationPermission { - CompositionLocalProvider(LocalUnitsConverter provides unitsConverter) { - Scaffold( - modifier = Modifier.fillMaxSize().systemBarsPadding(), - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - actionIconContentColor = MaterialTheme.colorScheme.primary, - navigationIconContentColor = MaterialTheme.colorScheme.primary, - ), - title = { Text(stringResource(titleId)) }, - navigationIcon = { - IconButton(onClick = { onNavigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(R.string.back) - ) - } - }, - actions = { - SelectableButton( - buttonState = commonViewState.buttonStates.currentLocation, - onClick = { onEvent(CommonEvent.OnUseSystemLocation) }, - iconId = R.drawable.baseline_my_location_24, - contentDescription = R.string.fill_address_from_current_location + CompositionLocalProvider(LocalUnitsConverter provides unitsConverter) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + actionIconContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text(stringResource(titleId)) }, + navigationIcon = { + IconButton(onClick = { onNavigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back) ) + } + }, + actions = { + SelectableButton( + buttonState = commonViewState.buttonStates.currentLocation, + onClick = { onEvent(CommonEvent.OnUseSystemLocation) }, + iconId = R.drawable.baseline_my_location_24, + contentDescription = R.string.fill_address_from_current_location + ) - NextLocationButton( - isSelected = commonViewState.buttonStates.mockLocation == ButtonState.SELECTED - ) { - onEvent(CommonEvent.OnNextMockLocation) + NextLocationButton( + isSelected = commonViewState.buttonStates.mockLocation == ButtonState.SELECTED + ) { + onEvent(CommonEvent.OnNextMockLocation) + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = "Location: ${commonViewState.locationLabel ?: "Mock Location"}", + duration = SnackbarDuration.Short + ) } - - SelectableButton( - buttonState = commonViewState.buttonStates.map, - iconId = R.drawable.baseline_map_24, - contentDescription = R.string.toggle_map, - onClick = { onEvent(CommonEvent.OnToggleMap) } - ) } - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { paddingValues -> + + SelectableButton( + buttonState = commonViewState.buttonStates.map, + iconId = R.drawable.baseline_map_24, + contentDescription = R.string.toggle_map, + onClick = { onEvent(CommonEvent.OnToggleMap) } + ) + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + GetLocationPermission( + modifier = Modifier.padding(paddingValues), + onFallbackToMock = { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = "Falling back to mock locations. You can cycle preset locations via the top bar.", + duration = SnackbarDuration.Long + ) + } + } + ) { content(paddingValues) } } diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/GetLocationPermission.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/GetLocationPermission.kt index ea8fc78..cea9d9c 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/GetLocationPermission.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/GetLocationPermission.kt @@ -14,36 +14,54 @@ package com.google.android.libraries.places.compose.demo.presentation.landmark import android.Manifest +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.android.libraries.places.compose.demo.R /** - * A composable that handles the logic for requesting location permissions. + * A composable that handles the logic for requesting location permissions, with an optional + * fallback to preset mock locations if the user chooses not to grant device permissions. * * @param modifier The modifier to apply to the composable. - * @param content The content to display if the location permissions are granted. - * - * This composable uses the `rememberMultiplePermissionsState` API to manage the state of the location permissions. - * It displays a different message and button based on the current state of the permissions. - * - * If the user has granted all the permissions, the `content` composable is displayed. - * If the user has denied all the permissions, a message explaining why the permissions are needed is displayed, - * along with a button to request the permissions. - * If the user has only granted the coarse location permission, a message asking the user to grant the fine location permission is displayed, - * along with a button to request the fine location permission. + * @param onFallbackToMock Callback invoked when the user elects to use mock location fallback. + * @param content The content to display if permissions are granted or mock fallback is selected. */ @OptIn(ExperimentalPermissionsApi::class) @Composable -fun GetLocationPermission(modifier: Modifier = Modifier, content: @Composable () -> Unit) { +fun GetLocationPermission( + modifier: Modifier = Modifier, + onFallbackToMock: (() -> Unit)? = null, + content: @Composable () -> Unit +) { val locationPermissionsState = rememberMultiplePermissionsState( listOf( Manifest.permission.ACCESS_COARSE_LOCATION, @@ -51,41 +69,97 @@ fun GetLocationPermission(modifier: Modifier = Modifier, content: @Composable () ) ) - if (locationPermissionsState.allPermissionsGranted) { + var useMockFallback by rememberSaveable { mutableStateOf(false) } + + if (locationPermissionsState.allPermissionsGranted || useMockFallback) { content() } else { - Column(modifier = modifier.padding(16.dp)) { - val allPermissionsRevoked = - locationPermissionsState.permissions.size == - locationPermissionsState.revokedPermissions.size + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = MaterialTheme.shapes.large + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val allPermissionsRevoked = + locationPermissionsState.permissions.size == + locationPermissionsState.revokedPermissions.size - val textToShow = if (!allPermissionsRevoked) { - // If not all the permissions are revoked, it's because the user accepted the COARSE - // location permission, but not the FINE one. - "Thank you for granting access to your approximate location. To provide you with " + - "the most accurate and relevant information, please grant permission " + - "to access your precise location. This will enable the app to tailor " + - "responses to your specific needs and circumstances, ensuring a more " + - "relevant and effective experience." - } else if (locationPermissionsState.shouldShowRationale) { - // Both location permissions have been denied - "Getting your exact location is important for this app. " + - "Please grant us fine location. Thank you :D" - } else { - // First time the user sees this feature or the user doesn't want to be asked again - "This feature requires location permission" - } + val titleText = if (!allPermissionsRevoked) { + "Precise Location Access" + } else { + "Location Access Required" + } - val buttonText = if (!allPermissionsRevoked) { - "Allow precise location" - } else { - "Request permissions" - } + val textToShow = if (!allPermissionsRevoked) { + "Thank you for granting access to your approximate location. To provide you with " + + "the most accurate and relevant information, please grant permission " + + "to access your precise location." + } else if (locationPermissionsState.shouldShowRationale) { + "Getting your location is important for this demo to provide relevant places and autocomplete suggestions." + } else { + "This feature requires location permission to bias place suggestions and display relevant map markers." + } + + val buttonText = if (!allPermissionsRevoked) { + "Allow Precise Location" + } else { + "Grant Location Permission" + } + + Icon( + painter = painterResource(R.drawable.baseline_my_location_24), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = titleText, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = textToShow, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { locationPermissionsState.launchMultiplePermissionRequest() }, + modifier = Modifier.fillMaxWidth() + ) { + Text(buttonText) + } - Text(text = textToShow) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { locationPermissionsState.launchMultiplePermissionRequest() }) { - Text(buttonText) + OutlinedButton( + onClick = { + useMockFallback = true + onFallbackToMock?.invoke() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Use Mock Location Instead") + } + } } } } diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionActivity.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionActivity.kt index d99ae6d..6a63af6 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionActivity.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionActivity.kt @@ -25,8 +25,10 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import com.google.android.libraries.places.compose.demo.R import com.google.android.libraries.places.compose.demo.presentation.ViewModelEvent import com.google.android.libraries.places.compose.demo.presentation.common.CommonEvent @@ -82,6 +84,8 @@ class LandmarkSelectionActivity : ComponentActivity() { val userMarker = rememberUpdatedMarkerState(position = location) val commonViewState by commonViewModel.commonViewState.collectAsStateWithLifecycle() val landmarkMarkers by landmarkSelectionViewModel.landmarkMarkers.collectAsStateWithLifecycle() + val isLoading by landmarkSelectionViewModel.isLoading.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() LandmarkSelectionContent( modifier = Modifier @@ -89,9 +93,40 @@ class LandmarkSelectionActivity : ComponentActivity() { .padding(paddingValues), userLocation = location, userMarker = userMarker, + isLoading = isLoading, onMapClicked = { latLng -> landmarkSelectionViewModel.onEvent(LandmarkSelectionEvent.OnUserLocationChanged(latLng)) }, + onAddressChanged = { address -> + landmarkSelectionViewModel.onEvent(LandmarkSelectionEvent.OnAddressChanged(address)) + }, + onConfirmAddress = { + val formatted = when (val addr = displayAddress) { + is com.google.android.libraries.places.compose.demo.presentation.landmark.addresshandlers.`in`.IndiaDisplayAddress -> listOfNotNull( + addr.aptSuiteUnit.ifBlank { null }, + addr.streetAddress.ifBlank { null }, + addr.city.ifBlank { null }, + addr.state.ifBlank { null }, + addr.pinCode.ifBlank { null }, + addr.country.ifBlank { null } + ).joinToString(", ") + is com.google.android.libraries.places.compose.demo.presentation.landmark.addresshandlers.us.UsDisplayAddress -> listOfNotNull( + addr.streetAddress.ifBlank { null }, + addr.additionalAddressInfo.ifBlank { null }, + addr.city.ifBlank { null }, + addr.state.ifBlank { null }, + addr.zipCode.ifBlank { null }, + addr.country.ifBlank { null } + ).joinToString(", ") + else -> "No address selected" + } + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = "Address saved: $formatted", + duration = SnackbarDuration.Short + ) + } + }, nearbyObjectsWithLocations = nearbyObjectsWithLocations, address = displayAddress, showMap = commonViewState.showMap, diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionEvent.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionEvent.kt index 9d2a1b9..ee8143e 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionEvent.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionEvent.kt @@ -15,6 +15,7 @@ package com.google.android.libraries.places.compose.demo.presentation.landmark import com.google.android.gms.maps.model.LatLng import com.google.android.libraries.places.compose.autocomplete.models.NearbyObject +import com.google.android.libraries.places.compose.demo.presentation.landmark.addresshandlers.DisplayAddress /** * A sealed class representing events that can occur during landmark selection. @@ -22,6 +23,7 @@ import com.google.android.libraries.places.compose.autocomplete.models.NearbyObj sealed class LandmarkSelectionEvent { data class OnUserLocationChanged(val location: LatLng) : LandmarkSelectionEvent() data class OnNearbyObjectSelected(val nearbyObject: NearbyObject) : LandmarkSelectionEvent() + data class OnAddressChanged(val address: DisplayAddress) : LandmarkSelectionEvent() data object OnCloseAddressDisplayClicked: LandmarkSelectionEvent() } diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionScreen.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionScreen.kt index 4967f1e..b2ca909 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionScreen.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionScreen.kt @@ -13,14 +13,25 @@ // limitations under the License. package com.google.android.libraries.places.compose.demo.presentation.landmark +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Place +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,6 +47,8 @@ import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.libraries.places.api.model.Place import com.google.android.libraries.places.compose.autocomplete.models.NearbyObject +import androidx.compose.material3.LinearProgressIndicator +import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.libraries.places.compose.demo.R import com.google.android.libraries.places.compose.demo.presentation.landmark.addresshandlers.DisplayAddress import com.google.android.libraries.places.compose.demo.presentation.landmark.components.AddressDisplay @@ -52,6 +65,9 @@ fun LandmarkSelectionContent( showMap: Boolean, onMapClicked: (LatLng) -> Unit, modifier: Modifier = Modifier, + isLoading: Boolean = false, + onAddressChanged: ((DisplayAddress) -> Unit)? = null, + onConfirmAddress: (() -> Unit)? = null, ) { val cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(userLocation, 15f) @@ -59,7 +75,11 @@ fun LandmarkSelectionContent( LaunchedEffect(userLocation) { userMarker.position = userLocation - cameraPositionState.position = CameraPosition.fromLatLngZoom(userLocation, 15f) + try { + cameraPositionState.animate(CameraUpdateFactory.newLatLng(userLocation)) + } catch (_: Exception) { + cameraPositionState.position = CameraPosition.fromLatLngZoom(userLocation, 15f) + } } var selectedPlaceId by remember(landmarkMarkers) { @@ -75,21 +95,123 @@ fun LandmarkSelectionContent( userMarker = userMarker, modifier = Modifier .fillMaxWidth() - .height(350.dp), - onMapClick = onMapClicked, + .height(300.dp), + onMapClick = { latLng -> + userMarker.position = latLng + onMapClicked(latLng) + }, selectedPlaceId = selectedPlaceId, onLandmarkSelected = { selectedPlaceId = it }, landmarkMarkers = landmarkMarkers ) + Text( + text = stringResource(R.string.map_tap_instruction), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp) + ) + } + + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + ) + Text( + text = "Looking up address & landmarks for (${"%.4f".format(userLocation.latitude)}, ${"%.4f".format(userLocation.longitude)})...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 24.dp) + ) } + Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(horizontal = 24.dp, vertical = 12.dp) .verticalScroll(rememberScrollState()), ) { + if (nearbyObjectsWithLocations.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.address_descriptors_info_title), + style = MaterialTheme.typography.titleMedium + ) + } + Text( + text = stringResource(R.string.address_descriptors_unavailable_message), + style = MaterialTheme.typography.bodyMedium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { onMapClicked(LatLng(12.9794404, 77.7179181)) }, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.switch_to_india_bangalore), + style = MaterialTheme.typography.labelMedium + ) + } + } + } + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Outlined.Place, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource( + R.string.address_descriptors_available_hint, + landmarkMarkers.size + ), + style = MaterialTheme.typography.bodySmall + ) + } + } + } if (address != null) { AddressDisplay( @@ -97,10 +219,23 @@ fun LandmarkSelectionContent( modifier = Modifier.fillMaxWidth(), nearbyObjects = nearbyObjectsWithLocations.map { it.first }, selectedPlaceId = selectedPlaceId, + onAddressChanged = onAddressChanged, onNearbyLandmarkSelected = { selectedPlaceId = it } ) + + Button( + onClick = { onConfirmAddress?.invoke() }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + Text( + text = stringResource(R.string.confirm_address_button), + style = MaterialTheme.typography.labelLarge + ) + } } else { Box( modifier = Modifier diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionViewModel.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionViewModel.kt index 1e3bfe4..c297893 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionViewModel.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/LandmarkSelectionViewModel.kt @@ -20,13 +20,16 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.places.api.model.Place import com.google.android.libraries.places.compose.autocomplete.domain.mappers.toAddress import com.google.android.libraries.places.compose.autocomplete.models.NearbyObject +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.ReverseGeocodingResponse import com.google.android.libraries.places.compose.demo.data.repositories.GeocoderRepository import com.google.android.libraries.places.compose.demo.data.repositories.MergedLocationRepository import com.google.android.libraries.places.compose.demo.data.repositories.PlaceRepository import com.google.android.libraries.places.compose.demo.mappers.toNearbyObjects import com.google.android.libraries.places.compose.demo.presentation.ViewModelEvent +import com.google.android.libraries.places.compose.demo.presentation.landmark.addresshandlers.DisplayAddress import com.google.android.libraries.places.compose.demo.presentation.landmark.addresshandlers.toDisplayAddress import com.google.android.libraries.places.compose.demo.presentation.landmark.addresshandlers.us.UsDisplayAddress import com.google.maps.android.compose.MarkerState @@ -47,6 +50,11 @@ import kotlinx.coroutines.flow.stateIn import javax.inject.Inject import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + data class LandmarkMarker( val landmark: NearbyObject.NearbyLandmark, val marker: MarkerState, @@ -75,12 +83,13 @@ class LandmarkSelectionViewModel initialValue = LatLng(0.0, 0.0) ) - private val geocoderResult = location.filterNotNull().mapNotNull { location -> - geocoderRepository.reverseGeocode(location, includeAddressDescriptors = true) - } + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() - private val nearbyObjects = geocoderResult.mapNotNull { result -> - result.addressDescriptor?.toNearbyObjects() + private val _geocoderResult = MutableStateFlow(null) + + private val nearbyObjects: StateFlow> = _geocoderResult.map { result -> + result?.addressDescriptor?.toNearbyObjects() ?: emptyList() }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5.seconds), @@ -88,11 +97,11 @@ class LandmarkSelectionViewModel ) @OptIn(ExperimentalCoroutinesApi::class) - val nearbyObjectsWithLatLngs = nearbyObjects.mapLatest { nearbyObjects -> - nearbyObjects.map { nearbyObject -> + val nearbyObjectsWithLatLngs: StateFlow>> = nearbyObjects.mapLatest { objects -> + objects.map { nearbyObject -> viewModelScope.async { placesRepository.getPlaceLatLng(nearbyObject.placeId) } }.awaitAll().map { place -> - nearbyObjects.first { address -> address.placeId == place.first } to place.second + objects.first { address -> address.placeId == place.first } to place.second } }.stateIn( scope = viewModelScope, @@ -100,22 +109,58 @@ class LandmarkSelectionViewModel initialValue = emptyList() ) - @OptIn(ExperimentalCoroutinesApi::class) - val displayAddress = geocoderResult.mapLatest { geocoderDto -> - geocoderDto.addresses.firstOrNull()?.let { address -> - address.toAddress(address.getCountryCode() ?: "US").toDisplayAddress() - } ?: UsDisplayAddress() - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = null - ) + private val _displayAddress = MutableStateFlow(null) + val displayAddress = _displayAddress.asStateFlow() private val _viewModelEventChannel = MutableSharedFlow() val viewModelEventChannel: SharedFlow = _viewModelEventChannel.asSharedFlow() - val landmarkMarkers = nearbyObjectsWithLatLngs.map { nearbyObjectsWithLatLngs -> - nearbyObjectsWithLatLngs.filter{ + init { + viewModelScope.launch { + location.collect { loc -> + if (loc.latitude != 0.0 || loc.longitude != 0.0) { + _isLoading.value = true + try { + val response = geocoderRepository.reverseGeocode(loc, includeAddressDescriptors = true) + _geocoderResult.value = response + if (response != null) { + if (response.status == "OK") { + val addr = response.addresses.firstOrNull()?.let { address -> + address.toAddress(address.getCountryCode() ?: "US").toDisplayAddress() + } ?: UsDisplayAddress() + _displayAddress.value = addr + + val landmarkCount = response.addressDescriptor?.landmarks?.size ?: 0 + if (landmarkCount > 0) { + _viewModelEventChannel.emit( + ViewModelEvent.UserMessage("Found $landmarkCount nearby landmarks.") + ) + } + } else { + val err = response.errorMessage ?: "Geocoding status: ${response.status}" + _viewModelEventChannel.emit( + ViewModelEvent.UserMessage("Geocoding notice: $err") + ) + } + } else { + _viewModelEventChannel.emit( + ViewModelEvent.UserMessage("Reverse geocoding request failed. Check network or API key restrictions.") + ) + } + } catch (e: Exception) { + _viewModelEventChannel.emit( + ViewModelEvent.UserMessage("Geocoding error: ${e.localizedMessage ?: "Unknown"}") + ) + } finally { + _isLoading.value = false + } + } + } + } + } + + val landmarkMarkers: StateFlow> = nearbyObjectsWithLatLngs.map { list -> + list.filter { it.first is NearbyObject.NearbyLandmark }.mapNotNull { (nearbyObject, place) -> place.location?.let { latLng -> @@ -125,7 +170,7 @@ class LandmarkSelectionViewModel marker = MarkerState(position = latLng) ) } - } + } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5.seconds), @@ -138,6 +183,13 @@ class LandmarkSelectionViewModel fun onEvent(event: LandmarkSelectionEvent) { when (event) { is LandmarkSelectionEvent.OnUserLocationChanged -> { + viewModelScope.launch { + val lat = "%.4f".format(event.location.latitude) + val lng = "%.4f".format(event.location.longitude) + _viewModelEventChannel.emit( + ViewModelEvent.UserMessage("Pin set to ($lat, $lng). Looking up address...") + ) + } mergedLocationRepository.setMockLocation(event.location) } @@ -145,6 +197,10 @@ class LandmarkSelectionViewModel selectedNearbyObject = event.nearbyObject } + is LandmarkSelectionEvent.OnAddressChanged -> { + _displayAddress.value = event.address + } + LandmarkSelectionEvent.OnCloseAddressDisplayClicked -> { selectedNearbyObject = null } diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/addresshandlers/in/IndiaAddressForm.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/addresshandlers/in/IndiaAddressForm.kt index 9586832..ec6e98a 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/addresshandlers/in/IndiaAddressForm.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/landmark/addresshandlers/in/IndiaAddressForm.kt @@ -44,14 +44,18 @@ fun IndiaAddressForm( value = address.aptSuiteUnit, label = R.string.india_address_unit_number, modifier = Modifier.fillMaxWidth(), - onValueChange = { onAddressChanged?.invoke(address.copy(aptSuiteUnit = it)) }, + onValueChange = onAddressChanged?.let { + { onAddressChanged.invoke(address.copy(aptSuiteUnit = it)) } + }, ) AddressTextField( value = address.streetAddress, label = R.string.india_address_street_address, modifier = Modifier.fillMaxWidth(), - onValueChange = { onAddressChanged?.invoke(address.copy(streetAddress = it)) }, + onValueChange = onAddressChanged?.let { + { onAddressChanged.invoke(address.copy(streetAddress = it)) } + }, ) if (nearbyObjects.isNotEmpty()) { @@ -66,7 +70,9 @@ fun IndiaAddressForm( value = address.city, label = R.string.india_address_city, modifier = Modifier.fillMaxWidth(), - onValueChange = { onAddressChanged?.invoke(address.copy(city = it)) }, + onValueChange = onAddressChanged?.let { + { onAddressChanged.invoke(address.copy(city = it)) } + }, ) Row( @@ -78,14 +84,18 @@ fun IndiaAddressForm( value = address.state, label = R.string.india_address_state, modifier = Modifier.weight(1f), - onValueChange = { onAddressChanged?.invoke(address.copy(state = it)) }, + onValueChange = onAddressChanged?.let { + { onAddressChanged.invoke(address.copy(state = it)) } + }, ) AddressTextField( value = address.pinCode, label = R.string.india_address_pincode, modifier = Modifier.weight(1f), - onValueChange = { onAddressChanged?.invoke(address.copy(pinCode = it)) }, + onValueChange = onAddressChanged?.let { + { onAddressChanged.invoke(address.copy(pinCode = it)) } + }, ) } diff --git a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/minimal/PlacesAutocompleteMinimalActivity.kt b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/minimal/PlacesAutocompleteMinimalActivity.kt index 70d803b..c1fdabc 100644 --- a/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/minimal/PlacesAutocompleteMinimalActivity.kt +++ b/places-compose-demo/src/main/java/com/google/android/libraries/places/compose/demo/presentation/minimal/PlacesAutocompleteMinimalActivity.kt @@ -63,6 +63,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlin.time.Duration.Companion.milliseconds +import androidx.activity.enableEdgeToEdge +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarDefaults + class PlacesAutocompleteMinimalActivity : ComponentActivity() { @SuppressLint("MissingPermission") @OptIn( @@ -71,6 +79,7 @@ class PlacesAutocompleteMinimalActivity : ComponentActivity() { ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() Places.initializeWithNewPlacesApiEnabled(this, BuildConfig.PLACES_API_KEY) val placesClient = Places.createClient(this) @@ -86,56 +95,85 @@ class PlacesAutocompleteMinimalActivity : ComponentActivity() { setContent { AndroidPlacesComposeDemoTheme { - GetLocationPermission { - val location by locationFlow.collectAsStateWithLifecycle() + val location by locationFlow.collectAsStateWithLifecycle() - // We just want to get the last location once. - LaunchedEffect(Unit) { - locationFlow.value = locationRepository.getLastLocation() + // We just want to get the last location once. + LaunchedEffect(Unit) { + val lastLoc = locationRepository.getLastLocation() + if (lastLoc != null) { + locationFlow.value = lastLoc + } else if (locationFlow.value == null) { + locationFlow.value = LatLng(40.01924246438453, -105.259858527573) } + } - val searchText by searchTextFlow.collectAsStateWithLifecycle() + val searchText by searchTextFlow.collectAsStateWithLifecycle() - val country by remember { - locationFlow.mapNotNull { location -> - location?.let { - geocoder.reverseGeocode(it).addresses.firstOrNull() - ?.getCountryCode() ?: deviceCountry - } + val country by remember { + locationFlow.mapNotNull { location -> + location?.let { + geocoder.reverseGeocode(it)?.addresses?.firstOrNull() + ?.getCountryCode() ?: deviceCountry } - }.collectAsState(initial = deviceCountry) - - // Determine which units converter to use based on the country. - val unitsConverter = remember(country) { - getUnitsConverter(country) } + }.collectAsState(initial = deviceCountry) - // TODO: this feels like it would make a great UseCase. - val predictions by remember(country, location) { - searchTextFlow.debounce(500.milliseconds).map { query -> - if (query.isBlank()) { - emptyList() - } else { - placesClient.awaitFindAutocompletePredictions { - origin = location - locationBias = location?.toRectangularBounds() - typesFilter = listOf(PlaceTypes.ESTABLISHMENT) - this.query = query - countries = listOf(country) - }.autocompletePredictions.map { it.toPlaceDetails() } - } + // Determine which units converter to use based on the country. + val unitsConverter = remember(country) { + getUnitsConverter(country) + } + + // TODO: this feels like it would make a great UseCase. + val predictions by remember(country, location) { + searchTextFlow.debounce(500.milliseconds).map { query -> + if (query.isBlank()) { + emptyList() + } else { + placesClient.awaitFindAutocompletePredictions { + origin = location + locationBias = location?.toRectangularBounds() + typesFilter = listOf(PlaceTypes.ESTABLISHMENT) + this.query = query + countries = listOf(country) + }.autocompletePredictions.map { it.toPlaceDetails() } } - }.collectAsState(initial = emptyList()) + } + }.collectAsState(initial = emptyList()) - Scaffold( - topBar = { - TopAppBar( - title = { Text("Places Autocomplete") } - ) + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + actionIconContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text("Places Autocomplete") }, + navigationIcon = { + IconButton(onClick = { finish() }) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) + } + ) { paddingValues -> + GetLocationPermission( + modifier = Modifier.padding(paddingValues), + onFallbackToMock = { + locationFlow.value = LatLng(40.01924246438453, -105.259858527573) + Toast.makeText( + this@PlacesAutocompleteMinimalActivity, + "Falling back to mock location (Boulder, CO)", + Toast.LENGTH_LONG + ).show() } - ) { paddingValues -> + ) { CompositionLocalProvider(LocalUnitsConverter provides unitsConverter) { - if (location == null) { Box( modifier = Modifier diff --git a/places-compose-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml b/places-compose-demo/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from places-compose-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to places-compose-demo/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/places-compose-demo/src/main/res/values/strings.xml b/places-compose-demo/src/main/res/values/strings.xml index 6411809..1da22c0 100644 --- a/places-compose-demo/src/main/res/values/strings.xml +++ b/places-compose-demo/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - + Android Places Compose Demo Maps compose widgets @@ -78,4 +78,19 @@ Landmark Area/sector/locality Fill in the address from the current location + + + Address Descriptors + Google Maps Address Descriptors (landmarks and areas) are available in select regions such as India. No landmarks are found for the current location. + Try Bangalore, India (KTPO) + Showing %1$d nearby landmarks. Tap a yellow marker on the map or select from the dropdown below to attach a landmark. + + Tap anywhere on the map to set location & reverse-geocode + Save & Confirm Address + + + Search places with interactive predictions + Address Descriptors & nearby landmarks on map + Autocomplete to structured address with descriptors + Standalone autocomplete text field \ No newline at end of file diff --git a/places-compose/build.gradle.kts b/places-compose/build.gradle.kts index 0113cdb..86b3ae4 100644 --- a/places-compose/build.gradle.kts +++ b/places-compose/build.gradle.kts @@ -53,6 +53,13 @@ android { buildConfig = true compose = true } + + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } } dependencies { @@ -83,6 +90,7 @@ dependencies { testImplementation(libs.google.truth) testImplementation(kotlin("test")) testImplementation(libs.robolectric) + testImplementation(libs.mockk) testImplementation(libs.ui.test.junit4) testImplementation(libs.ui.test.manifest) diff --git a/places-compose/src/debug/AndroidManifest.xml b/places-compose/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..e41fd72 --- /dev/null +++ b/places-compose/src/debug/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/places-compose/src/main/java/com/google/android/libraries/places/compose/autocomplete/models/geocoder/AddressDto.kt b/places-compose/src/main/java/com/google/android/libraries/places/compose/autocomplete/models/geocoder/AddressDto.kt index 79ab2bb..5fb727a 100644 --- a/places-compose/src/main/java/com/google/android/libraries/places/compose/autocomplete/models/geocoder/AddressDto.kt +++ b/places-compose/src/main/java/com/google/android/libraries/places/compose/autocomplete/models/geocoder/AddressDto.kt @@ -17,9 +17,10 @@ import com.google.gson.annotations.SerializedName data class ReverseGeocodingResponse( @SerializedName("status") val status: String, + @SerializedName("error_message") val errorMessage: String? = null, @SerializedName("address_descriptor") val addressDescriptor: AddressDescriptor?, @SerializedName("plus_code") val plusCode: PlusCode?, - @SerializedName("results") val addresses: List + @SerializedName("results") val addresses: List = emptyList() ) data class AddressDescriptor( diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/components/PlacesAutocompleteTextFieldTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/components/PlacesAutocompleteTextFieldTest.kt new file mode 100644 index 0000000..bb7fdf0 --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/components/PlacesAutocompleteTextFieldTest.kt @@ -0,0 +1,228 @@ +// 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.google.android.libraries.places.compose.autocomplete.components + +import android.text.SpannableString +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.google.android.libraries.places.compose.autocomplete.data.meters +import com.google.android.libraries.places.compose.autocomplete.models.AutocompletePlace +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * UI unit tests verifying the [PlacesAutocompleteTextField] and [AutocompletePlaceRow] composables. + * + * This test suite covers: + * 1. Search text field rendering with query text and placeholder. + * 2. Entering text and triggering [onQueryChanged]. + * 3. Rendering place predictions with primary and secondary texts. + * 4. Selecting a place prediction item and triggering [onSelected]. + * 5. Clearing search text via the clear trailing icon button. + * 6. Clicking back navigation button when [onBackClicked] is provided. + */ +@RunWith(RobolectricTestRunner::class) +class PlacesAutocompleteTextFieldTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val samplePlaces = listOf( + AutocompletePlace( + placeId = "place_1", + primaryText = SpannableString("Googleplex"), + secondaryText = SpannableString("1600 Amphitheatre Pkwy, Mountain View, CA"), + distance = 350.meters + ), + AutocompletePlace( + placeId = "place_2", + primaryText = SpannableString("Golden Gate Bridge"), + secondaryText = SpannableString("San Francisco, CA"), + distance = 15000.meters + ) + ) + + @Test + fun placesAutocompleteTextField_displaysPlaceholderAndInitialPredictions() { + composeTestRule.setContent { + MaterialTheme { + PlacesAutocompleteTextField( + searchText = "", + predictions = samplePlaces, + onQueryChanged = {}, + placeHolderText = "Search Google Maps" + ) + } + } + + // Verify search field and placeholder + composeTestRule.onNodeWithTag("placesAutocompleteSearchField").assertIsDisplayed() + composeTestRule.onNodeWithText("Search Google Maps").assertIsDisplayed() + + // Verify prediction rows + composeTestRule.onNodeWithText("Googleplex").assertIsDisplayed() + composeTestRule.onNodeWithText("1600 Amphitheatre Pkwy, Mountain View, CA").assertIsDisplayed() + composeTestRule.onNodeWithText("Golden Gate Bridge").assertIsDisplayed() + composeTestRule.onNodeWithText("San Francisco, CA").assertIsDisplayed() + } + + @Test + fun placesAutocompleteTextField_typingUpdatesQuery() { + var query by mutableStateOf("") + + composeTestRule.setContent { + MaterialTheme { + PlacesAutocompleteTextField( + searchText = query, + predictions = emptyList(), + onQueryChanged = { query = it }, + placeHolderText = "Search here" + ) + } + } + + composeTestRule.onNodeWithTag("placesAutocompleteSearchField") + .performTextInput("Mountain View") + + assertThat(query).isEqualTo("Mountain View") + } + + @Test + fun placesAutocompleteTextField_selectingPlaceInvokesCallback() { + var selectedPlace: AutocompletePlace? = null + + composeTestRule.setContent { + MaterialTheme { + PlacesAutocompleteTextField( + searchText = "Google", + predictions = samplePlaces, + onQueryChanged = {}, + onSelected = { selectedPlace = it } + ) + } + } + + composeTestRule.onNodeWithText("Googleplex").performClick() + + assertThat(selectedPlace).isNotNull() + assertThat(selectedPlace?.placeId).isEqualTo("place_1") + assertThat(selectedPlace?.primaryText.toString()).isEqualTo("Googleplex") + } + + @Test + fun placesAutocompleteTextField_clearButtonClearsQuery() { + var query by mutableStateOf("Initial query") + + composeTestRule.setContent { + MaterialTheme { + PlacesAutocompleteTextField( + searchText = query, + predictions = emptyList(), + onQueryChanged = { query = it } + ) + } + } + + composeTestRule.onNodeWithContentDescription("Clear").performClick() + + assertThat(query).isEmpty() + } + + @Test + fun placesAutocompleteTextField_backButtonClickedInvokesCallback() { + var backClicked = false + + composeTestRule.setContent { + MaterialTheme { + PlacesAutocompleteTextField( + searchText = "", + predictions = emptyList(), + onQueryChanged = {}, + onBackClicked = { backClicked = true } + ) + } + } + + composeTestRule.onNodeWithContentDescription("Back").performClick() + + assertThat(backClicked).isTrue() + } + + @Test + fun placesAutocompleteTextField_previewsRenderCleanly() { + composeTestRule.setContent { + AutocompleteFieldPreview() + AutocompletePlaceRowPreview() + AutocompletePlaceRowPreviewShortText() + AutocompletePlaceRowPreviewLongRows() + } + + composeTestRule.onNodeWithText("463 km").assertIsDisplayed() + composeTestRule.onNodeWithText("this is a primary test").assertIsDisplayed() + composeTestRule.onNodeWithText("REI").assertIsDisplayed() + } + + @Test + fun placesAutocompleteTextField_darkThemeAndNotScrollable() { + composeTestRule.setContent { + MaterialTheme(colorScheme = androidx.compose.material3.darkColorScheme()) { + PlacesAutocompleteTextField( + searchText = "Dark test", + predictions = samplePlaces, + onQueryChanged = {}, + scrollable = false, + selectedPlace = samplePlaces.first() + ) + } + } + + composeTestRule.onNodeWithText("Googleplex").assertIsDisplayed() + } + + @Test + fun autocompletePlaceRow_expandedStateRendersAndInvokesCallbacks() { + var placeSelected = false + var expandedClicked = false + + composeTestRule.setContent { + MaterialTheme { + AutocompletePlaceRow( + autocompletePlace = samplePlaces.first(), + isSelected = true, + onPlaceSelected = { placeSelected = true }, + onExpandClick = { expandedClicked = true }, + isExpanded = true, + primaryTextMaxLines = 1, + secondaryTextMaxLines = 1 + ) + } + } + + composeTestRule.onNodeWithText("Googleplex").performClick() + assertThat(placeSelected).isTrue() + } +} diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/data/UnitsTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/data/UnitsTest.kt new file mode 100644 index 0000000..f26c858 --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/data/UnitsTest.kt @@ -0,0 +1,163 @@ +// 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.google.android.libraries.places.compose.autocomplete.data + +import android.content.res.Resources +import com.google.android.libraries.places.compose.R +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +/** + * Unit tests verifying distance measurement units, unit conversions, and localized formatting. + * + * This test suite covers: + * 1. [Meters] value class arithmetic and comparisons. + * 2. Extension operators converting numbers to [Meters] and [Meters] to scalar units. + * 3. Country-specific [UnitsConverter] selection (Imperial for "US", Metric otherwise). + * 4. Distance unit templates and formatted string generation using Android resources. + */ +class UnitsTest { + + // ---------------------------------------------------------------------------------- + // Meters Value Class: Basic Arithmetic & Comparisons + // ---------------------------------------------------------------------------------- + + @Test + fun meters_compareTo_ordersCorrectly() { + val shortDistance = 100.meters + val longDistance = 500.meters + + assertThat(shortDistance).isLessThan(longDistance) + assertThat(longDistance).isGreaterThan(shortDistance) + assertThat(100.meters).isEquivalentAccordingToCompareTo(100.meters) + } + + @Test + fun meters_minus_computesDifferenceAccurately() { + val distanceA = 500.meters + val distanceB = 150.meters + + val difference = distanceA - distanceB + + assertThat(difference.value).isEqualTo(350.0) + } + + // ---------------------------------------------------------------------------------- + // Number Extensions: Inlined Unit Constructors + // ---------------------------------------------------------------------------------- + + @Test + fun numberExtensions_constructMetersEquivalently() { + assertThat(50.meters.value).isEqualTo(50.0) + assertThat(50.m.value).isEqualTo(50.0) + + // 1 km = 1,000 meters + assertThat(2.km.value).isEqualTo(2000.0) + + // Feet to meters + val feetMeters = 328.084.feet + assertThat(feetMeters.toFeet).isWithin(0.1).of(328.084) + + // Miles to meters + val oneMile = 1.0.miles + assertThat(oneMile.toMiles).isWithin(0.001).of(1.0) + } + + @Test + fun metersExtensions_convertToScalarsCorrectly() { + val distance = 1500.meters + + assertThat(distance.toMeters).isEqualTo(1500.0) + assertThat(distance.toKilometers).isEqualTo(1.5) + assertThat(distance.toFeet).isWithin(0.01).of(1500.0 * METERS_PER_FOOT) + assertThat(distance.toMiles).isWithin(0.001).of(1500.0 * MILES_PER_METER) + } + + // ---------------------------------------------------------------------------------- + // Units Converter Strategy: Country-Based Factory + // ---------------------------------------------------------------------------------- + + @Test + fun getUnitsConverter_returnsImperialForUnitedStates() { + val converter = getUnitsConverter("US") + assertThat(converter).isSameInstanceAs(ImperialUnitsConverter) + } + + @Test + fun getUnitsConverter_returnsMetricForNonUsAndNull() { + assertThat(getUnitsConverter("IN")).isSameInstanceAs(MetricUnitsConverter) + assertThat(getUnitsConverter("GB")).isSameInstanceAs(MetricUnitsConverter) + assertThat(getUnitsConverter(null)).isSameInstanceAs(MetricUnitsConverter) + } + + // ---------------------------------------------------------------------------------- + // Imperial Units Converter Thresholds (< 0.25 miles vs >= 0.25 miles) + // ---------------------------------------------------------------------------------- + + @Test + fun imperialUnitsConverter_belowQuarterMile_usesFeetTemplate() { + // 0.1 miles is less than 0.25 miles threshold + val distance = 0.1.miles + val result = ImperialUnitsConverter.toDistanceUnits(distance) + + assertThat(result.unitsTemplate).isEqualTo(R.string.in_feet) + assertThat(result.value).isWithin(0.1).of(distance.toFeet) + } + + @Test + fun imperialUnitsConverter_atOrAboveQuarterMile_usesMilesTemplate() { + val distance = 2.5.miles + val result = ImperialUnitsConverter.toDistanceUnits(distance) + + assertThat(result.unitsTemplate).isEqualTo(R.string.in_miles) + assertThat(result.value).isEqualTo(2.5) + } + + // ---------------------------------------------------------------------------------- + // Metric Units Converter Thresholds (< 1000m vs >= 1000m) + // ---------------------------------------------------------------------------------- + + @Test + fun metricUnitsConverter_belowKilometer_usesMetersTemplate() { + val distance = 450.meters + val result = MetricUnitsConverter.toDistanceUnits(distance) + + assertThat(result.unitsTemplate).isEqualTo(R.string.in_meters) + assertThat(result.value).isEqualTo(450.0) + } + + @Test + fun metricUnitsConverter_atOrAboveKilometer_usesKilometersTemplate() { + val distance = 2500.meters + val result = MetricUnitsConverter.toDistanceUnits(distance) + + assertThat(result.unitsTemplate).isEqualTo(R.string.in_kilometers) + assertThat(result.value).isEqualTo(2.5) + } + + @Test + fun toDistanceString_withMockedResources_formatsCorrectly() { + val mockResources = mockk() + every { mockResources.getString(R.string.in_feet, *anyVararg()) } returns "528 ft" + every { mockResources.getString(R.string.in_kilometers, *anyVararg()) } returns "2.5 km" + + val imperialString = ImperialUnitsConverter.toDistanceString(mockResources, 0.1.miles) + assertThat(imperialString).isEqualTo("528 ft") + + val metricString = MetricUnitsConverter.toDistanceString(mockResources, 2500.meters) + assertThat(metricString).isEqualTo("2.5 km") + } +} diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/domain/mappers/AddressMapperTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/domain/mappers/AddressMapperTest.kt new file mode 100644 index 0000000..ec9065b --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/domain/mappers/AddressMapperTest.kt @@ -0,0 +1,170 @@ +// 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.google.android.libraries.places.compose.autocomplete.domain.mappers + +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.AddressComponent +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.AddressDto +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.Geometry +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.Location +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.PlusCode +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.Viewport +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests verifying address component mapping and multimap lookups. + * + * This test suite covers: + * 1. [AddressComponentMultiMap] indexing components across multiple type tags. + * 2. Lookup behaviors by [AddressComponentType], including [AddressComponentMultiMap.get] and [AddressComponentMultiMap.getValue]. + * 3. Formatted string aggregations using [AddressComponentMultiMap.longName] and [AddressComponentMultiMap.shortName]. + * 4. Conversions from collections, lists, and [AddressDto] instances to domain [Address] models. + * 5. Country code extraction and fallback logic. + */ +class AddressMapperTest { + + private val sampleComponents = listOf( + AddressComponent( + longName = "1600", + shortName = "1600", + types = listOf("street_number") + ), + AddressComponent( + longName = "Amphitheatre Parkway", + shortName = "Amphitheatre Pkwy", + types = listOf("route") + ), + AddressComponent( + longName = "Mountain View", + shortName = "Mountain View", + types = listOf("locality", "political") + ), + AddressComponent( + longName = "Santa Clara County", + shortName = "Santa Clara", + types = listOf("administrative_area_level_2", "political") + ), + AddressComponent( + longName = "California", + shortName = "CA", + types = listOf("administrative_area_level_1", "political") + ), + AddressComponent( + longName = "United States", + shortName = "US", + types = listOf("country", "political") + ), + AddressComponent( + longName = "94043", + shortName = "94043", + types = listOf("postal_code") + ) + ) + + // ---------------------------------------------------------------------------------- + // MultiMap Grouping & Lookups + // ---------------------------------------------------------------------------------- + + @Test + fun multiMap_groupsByTypesCorrectly() { + val multiMap = sampleComponents.toAddressComponentMultiMap() + + val country = multiMap[AddressComponentType.COUNTRY] + assertThat(country).isNotNull() + assertThat(country).hasSize(1) + assertThat(country?.first()?.longName).isEqualTo("United States") + + val politicalComponents = multiMap.getValue(AddressComponentType.POLITICAL) + assertThat(politicalComponents).hasSize(4) + } + + @Test + fun multiMap_longNameAndShortName_aggregateWithSeparator() { + val multiMap = sampleComponents.toAddressComponentMultiMap() + + val routeLong = multiMap.longName(AddressComponentType.ROUTE) + assertThat(routeLong).isEqualTo("Amphitheatre Parkway") + + val routeShort = multiMap.shortName(AddressComponentType.ROUTE) + assertThat(routeShort).isEqualTo("Amphitheatre Pkwy") + + val politicalShort = multiMap.shortName(AddressComponentType.POLITICAL, separator = ", ") + assertThat(politicalShort).isEqualTo("Mountain View, Santa Clara, CA, US") + } + + @Test + fun multiMap_missingType_returnsNull() { + val multiMap = sampleComponents.toAddressComponentMultiMap() + + val airport = multiMap[AddressComponentType.AIRPORT] + assertThat(airport).isNull() + assertThat(multiMap.longName(AddressComponentType.AIRPORT)).isNull() + assertThat(multiMap.shortName(AddressComponentType.AIRPORT)).isNull() + } + + // ---------------------------------------------------------------------------------- + // Conversions to Domain Address Model + // ---------------------------------------------------------------------------------- + + @Test + fun toAddress_fromMultiMap_extractsCountryCode() { + val multiMap = sampleComponents.toAddressComponentMultiMap() + val address = multiMap.toAddress() + + assertThat(address.countryCode).isEqualTo("US") + assertThat(address.componentMap).isEqualTo(multiMap) + } + + @Test + fun toAddress_fromMultiMap_fallsBackToUsWhenCountryMissing() { + val componentsWithoutCountry = sampleComponents.filterNot { it.types.contains("country") } + val multiMap = componentsWithoutCountry.toAddressComponentMultiMap() + + val address = multiMap.toAddress() + assertThat(address.countryCode).isEqualTo("US") + } + + @Test + fun toAddress_fromCollectionAndList_convertsCorrectly() { + val addressFromCollection = (sampleComponents as Collection).toAddress() + assertThat(addressFromCollection.countryCode).isEqualTo("US") + + val addressFromList = sampleComponents.toAddress() + assertThat(addressFromList.countryCode).isEqualTo("US") + } + + @Test + fun toAddress_fromAddressDto_usesSuppliedCountryCode() { + val addressDto = AddressDto( + addressComponents = sampleComponents, + formattedAddress = "1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA", + geometry = Geometry( + location = Location(37.422, -122.084), + locationType = "ROOFTOP", + viewport = Viewport( + northeast = Location(37.423, -122.083), + southwest = Location(37.421, -122.085) + ), + bounds = null + ), + placeId = "ChIJ2eUgeAK6j4ARbn5u_wAGqWA", + plusCode = PlusCode("849VCWC8+R9", "849VCWC8+R9 Mountain View"), + types = listOf("street_address") + ) + + val address = addressDto.toAddress("US") + assertThat(address.countryCode).isEqualTo("US") + assertThat(address.componentMap[AddressComponentType.STREET_NUMBER]?.first()?.longName).isEqualTo("1600") + } +} diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/domain/mappers/PlaceMapperTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/domain/mappers/PlaceMapperTest.kt new file mode 100644 index 0000000..d91520e --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/domain/mappers/PlaceMapperTest.kt @@ -0,0 +1,61 @@ +// 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.google.android.libraries.places.compose.autocomplete.domain.mappers + +import com.google.android.libraries.places.api.model.AddressComponent +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests verifying conversion of Places SDK [AddressComponent] lists into domain [Address] models. + */ +class PlaceMapperTest { + + @Test + fun toAddress_mapsPlacesSdkAddressComponentsToDomainAddress() { + val placesComponents = listOf( + AddressComponent.builder("India", listOf("country", "political")) + .setShortName("IN") + .build(), + AddressComponent.builder("Bengaluru", listOf("locality", "political")) + .setShortName("BLR") + .build(), + AddressComponent.builder("Whitefield", listOf("sublocality", "political")) + .setShortName("Whitefield") + .build() + ) + + val domainAddress = placesComponents.toAddress() + + assertThat(domainAddress.countryCode).isEqualTo("IN") + val countryComponent = domainAddress.componentMap[AddressComponentType.COUNTRY] + assertThat(countryComponent).isNotNull() + assertThat(countryComponent?.first()?.longName).isEqualTo("India") + assertThat(countryComponent?.first()?.shortName).isEqualTo("IN") + } + + @Test + fun toAddress_handlesNullShortNameGracefully() { + val placesComponents = listOf( + AddressComponent.builder("United States", listOf("country", "political")) + .setShortName(null) + .build() + ) + + val domainAddress = placesComponents.toAddress() + + val countryComponent = domainAddress.componentMap[AddressComponentType.COUNTRY] + assertThat(countryComponent?.first()?.shortName).isEmpty() + } +} diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/AutocompleteMapperTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/AutocompleteMapperTest.kt new file mode 100644 index 0000000..0469ce3 --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/AutocompleteMapperTest.kt @@ -0,0 +1,64 @@ +// 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.google.android.libraries.places.compose.autocomplete.models + +import android.text.SpannableString +import com.google.android.libraries.places.api.model.AutocompletePrediction +import com.google.android.libraries.places.compose.autocomplete.data.meters +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +/** + * Unit tests verifying conversion from [AutocompletePrediction] to [AutocompletePlace]. + */ +class AutocompleteMapperTest { + + @Test + fun toPlaceDetails_mapsPredictionFieldsCorrectly() { + val primarySpannable = mockk() + val secondarySpannable = mockk() + val prediction = mockk { + every { placeId } returns "place_xyz" + every { getPrimaryText(any()) } returns primarySpannable + every { getSecondaryText(any()) } returns secondarySpannable + every { distanceMeters } returns 1200 + } + + val placeDetails = prediction.toPlaceDetails() + + assertThat(placeDetails.placeId).isEqualTo("place_xyz") + assertThat(placeDetails.primaryText).isSameInstanceAs(primarySpannable) + assertThat(placeDetails.secondaryText).isSameInstanceAs(secondarySpannable) + assertThat(placeDetails.distance).isEqualTo(1200.meters) + } + + @Test + fun toPlaceDetails_withNullDistanceMeters_setsNullDistance() { + val primarySpannable = mockk() + val secondarySpannable = mockk() + val prediction = mockk { + every { placeId } returns "place_no_dist" + every { getPrimaryText(any()) } returns primarySpannable + every { getSecondaryText(any()) } returns secondarySpannable + every { distanceMeters } returns null + } + + val placeDetails = prediction.toPlaceDetails() + + assertThat(placeDetails.placeId).isEqualTo("place_no_dist") + assertThat(placeDetails.distance).isNull() + } +} diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/ModelsTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/ModelsTest.kt new file mode 100644 index 0000000..ced3168 --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/ModelsTest.kt @@ -0,0 +1,162 @@ +// 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.google.android.libraries.places.compose.autocomplete.models + +import android.text.SpannableString +import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.places.compose.R +import com.google.android.libraries.places.compose.autocomplete.data.meters +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.Area +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.DisplayName +import com.google.android.libraries.places.compose.autocomplete.models.geocoder.Landmark +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests verifying models and spatial relationship resolution for Address Descriptors. + * + * This test suite covers: + * 1. [AutocompletePlace] construction and attributes. + * 2. [Area.getSpatialRelationshipStringRes] mapping across all supported containment values. + * 3. [Landmark.getSpatialRelationshipStringRes] mapping across all spatial relations. + * 4. [Landmark.distanceMeters] selection logic between travel and straight-line distances. + * 5. Polymorphic [NearbyObject] properties for landmarks and areas. + */ +class ModelsTest { + + // ---------------------------------------------------------------------------------- + // AutocompletePlace Construction + // ---------------------------------------------------------------------------------- + + @Test + fun autocompletePlace_properties_matchInputs() { + val primary = SpannableString("Googleplex") + val secondary = SpannableString("Mountain View, CA") + val latLng = LatLng(37.422, -122.084) + val distance = 250.meters + + val place = AutocompletePlace( + placeId = "place_123", + primaryText = primary, + secondaryText = secondary, + distance = distance, + latLng = latLng + ) + + assertThat(place.placeId).isEqualTo("place_123") + assertThat(place.primaryText).isEqualTo(primary) + assertThat(place.secondaryText).isEqualTo(secondary) + assertThat(place.distance).isEqualTo(distance) + assertThat(place.latLng).isEqualTo(latLng) + } + + // ---------------------------------------------------------------------------------- + // Area Spatial Relationships + // ---------------------------------------------------------------------------------- + + @Test + fun area_spatialRelationship_resolvesExpectedResource() { + val outskirtsArea = createArea(containment = "OUTSKIRTS", name = "City Limits") + assertThat(outskirtsArea.getSpatialRelationshipStringRes()) + .isEqualTo(R.string.spatial_relationship_outskirts_of) + + val withinArea = createArea(containment = "WITHIN", name = "Indiranagar") + assertThat(withinArea.getSpatialRelationshipStringRes()) + .isEqualTo(R.string.spatial_relationship_within) + + val defaultArea = createArea(containment = "NEAR", name = "Downtown") + assertThat(defaultArea.getSpatialRelationshipStringRes()) + .isEqualTo(R.string.spatial_relationship_near) + + val nearbyArea = NearbyObject.NearbyArea(withinArea) + assertThat(nearbyArea.name).isEqualTo("Indiranagar") + assertThat(nearbyArea.placeId).isEqualTo("area_id") + assertThat(nearbyArea.spatialRelationshipStringRes).isEqualTo(R.string.spatial_relationship_within) + } + + // ---------------------------------------------------------------------------------- + // Landmark Spatial Relationships + // ---------------------------------------------------------------------------------- + + @Test + fun landmark_spatialRelationship_resolvesAllCases() { + val expectedMappings = mapOf( + "WITHIN" to R.string.spatial_relationship_within, + "BESIDE" to R.string.spatial_relationship_beside, + "ACROSS_THE_ROAD" to R.string.spatial_relationship_across_the_road, + "DOWN_THE_ROAD" to R.string.spatial_relationship_down_the_road, + "AROUND_THE_CORNER" to R.string.spatial_relationship_around_the_corner, + "BEHIND" to R.string.spatial_relationship_behind, + "UNKNOWN" to R.string.spatial_relationship_near + ) + + for ((relation, expectedResId) in expectedMappings) { + val landmark = createLandmark(spatialRelationship = relation, name = "Target Landmark") + assertThat(landmark.getSpatialRelationshipStringRes()).isEqualTo(expectedResId) + } + } + + // ---------------------------------------------------------------------------------- + // Landmark Distance Computation + // ---------------------------------------------------------------------------------- + + @Test + fun landmark_distanceMeters_selectsCorrectDistance() { + val landmarkPositiveTravel = createLandmark( + spatialRelationship = "NEAR", + name = "Park", + straightLine = 120.0, + travel = 150.0 + ) + // When travelDistanceMeters >= 0, returns straightLineDistanceMeters according to implementation + assertThat(landmarkPositiveTravel.distanceMeters()).isEqualTo(120.meters) + + val landmarkNegativeTravel = createLandmark( + spatialRelationship = "NEAR", + name = "Tower", + straightLine = 200.0, + travel = -1.0 + ) + assertThat(landmarkNegativeTravel.distanceMeters()).isEqualTo((-1.0).meters) + + val nearbyLandmark = NearbyObject.NearbyLandmark(landmarkPositiveTravel) + assertThat(nearbyLandmark.name).isEqualTo("Park") + assertThat(nearbyLandmark.placeId).isEqualTo("landmark_id") + assertThat(nearbyLandmark.spatialRelationshipStringRes).isEqualTo(R.string.spatial_relationship_near) + } + + // ---------------------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------------------- + + private fun createArea(containment: String, name: String) = Area( + containment = containment, + displayName = DisplayName(languageCode = "en", text = name), + placeId = "area_id" + ) + + private fun createLandmark( + spatialRelationship: String, + name: String, + straightLine: Double = 100.0, + travel: Double = 150.0 + ) = Landmark( + displayName = DisplayName(languageCode = "en", text = name), + placeId = "landmark_id", + spatialRelationship = spatialRelationship, + straightLineDistanceMeters = straightLine, + travelDistanceMeters = travel, + types = listOf("point_of_interest") + ) +} diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/geocoder/AddressDtoTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/geocoder/AddressDtoTest.kt new file mode 100644 index 0000000..8df7d43 --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/models/geocoder/AddressDtoTest.kt @@ -0,0 +1,220 @@ +// 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.google.android.libraries.places.compose.autocomplete.models.geocoder + +import com.google.common.truth.Truth.assertThat +import com.google.gson.Gson +import org.junit.Test + +class AddressDtoTest { + + private val gson = Gson() + + @Test + fun getCountryCode_returnsShortNameWhenCountryPresent() { + val addressComponentCountry = AddressComponent( + longName = "India", + shortName = "IN", + types = listOf("country", "political") + ) + val addressComponentRoute = AddressComponent( + longName = "Main Street", + shortName = "Main St", + types = listOf("route") + ) + + val addressDto = AddressDto( + addressComponents = listOf(addressComponentRoute, addressComponentCountry), + formattedAddress = "Main St, India", + geometry = Geometry( + location = Location(lat = 12.9716, lng = 77.5946), + locationType = "ROOFTOP", + viewport = Viewport( + northeast = Location(12.98, 77.60), + southwest = Location(12.96, 77.58) + ), + bounds = Bounds( + northeast = Location(12.98, 77.60), + southwest = Location(12.96, 77.58) + ) + ), + placeId = "ChIJbU60yXAWrjsR4E9-UejD3_g", + plusCode = PlusCode( + compoundCode = "XHQQ+34 Bengaluru, Karnataka", + globalCode = "7J4VXHQQ+34" + ), + types = listOf("street_address") + ) + + assertThat(addressDto.getCountryCode()).isEqualTo("IN") + } + + @Test + fun getCountryCode_returnsNullWhenCountryAbsent() { + val addressDto = AddressDto( + addressComponents = listOf( + AddressComponent( + longName = "Bengaluru", + shortName = "BLR", + types = listOf("locality") + ) + ), + formattedAddress = "Bengaluru", + geometry = Geometry( + location = Location(lat = 12.9716, lng = 77.5946), + locationType = "APPROXIMATE", + viewport = Viewport( + northeast = Location(12.98, 77.60), + southwest = Location(12.96, 77.58) + ), + bounds = null + ), + placeId = "ChIJbU60yXAWrjsR4E9-UejD3_g", + plusCode = PlusCode( + compoundCode = "XHQQ+34 Bengaluru", + globalCode = "7J4VXHQQ+34" + ), + types = listOf("locality") + ) + + assertThat(addressDto.getCountryCode()).isNull() + } + + @Test + fun reverseGeocodingResponse_defaultValues() { + val response = ReverseGeocodingResponse( + status = "ZERO_RESULTS", + errorMessage = "No results found", + addressDescriptor = null, + plusCode = null + ) + + assertThat(response.status).isEqualTo("ZERO_RESULTS") + assertThat(response.errorMessage).isEqualTo("No results found") + assertThat(response.addressDescriptor).isNull() + assertThat(response.plusCode).isNull() + assertThat(response.addresses).isEmpty() + } + + @Test + fun addressDescriptor_modelsCreationAndSerialization() { + val area = Area( + containment = "WITHIN", + displayName = DisplayName(languageCode = "en", text = "Whitefield"), + placeId = "area_place_1" + ) + val landmark = Landmark( + displayName = DisplayName(languageCode = "en", text = "KTPO Convention Centre"), + placeId = "landmark_place_1", + spatialRelationship = "NEAR", + straightLineDistanceMeters = 150.0, + travelDistanceMeters = 200.0, + types = listOf("establishment", "point_of_interest") + ) + val descriptor = AddressDescriptor( + areas = listOf(area), + landmarks = listOf(landmark) + ) + + val json = gson.toJson(descriptor) + val deserialized = gson.fromJson(json, AddressDescriptor::class.java) + + assertThat(deserialized.areas).hasSize(1) + assertThat(deserialized.areas[0].displayName.text).isEqualTo("Whitefield") + assertThat(deserialized.areas[0].containment).isEqualTo("WITHIN") + assertThat(deserialized.landmarks).hasSize(1) + assertThat(deserialized.landmarks[0].displayName.text).isEqualTo("KTPO Convention Centre") + assertThat(deserialized.landmarks[0].spatialRelationship).isEqualTo("NEAR") + assertThat(deserialized.landmarks[0].straightLineDistanceMeters).isEqualTo(150.0) + assertThat(deserialized.landmarks[0].travelDistanceMeters).isEqualTo(200.0) + assertThat(deserialized.landmarks[0].types).contains("point_of_interest") + } + + @Test + fun reverseGeocodingResponse_fullJsonSerialization() { + val jsonInput = """ + { + "status": "OK", + "results": [ + { + "address_components": [ + { + "long_name": "Bengaluru", + "short_name": "BLR", + "types": ["locality", "political"] + }, + { + "long_name": "India", + "short_name": "IN", + "types": ["country", "political"] + } + ], + "formatted_address": "Bengaluru, Karnataka, India", + "geometry": { + "location": { "lat": 12.9716, "lng": 77.5946 }, + "location_type": "APPROXIMATE", + "viewport": { + "northeast": { "lat": 13.0, "lng": 77.7 }, + "southwest": { "lat": 12.8, "lng": 77.4 } + }, + "bounds": { + "northeast": { "lat": 13.0, "lng": 77.7 }, + "southwest": { "lat": 12.8, "lng": 77.4 } + } + }, + "place_id": "ChIJbU60yXAWrjsR4E9-UejD3_g", + "plus_code": { + "compound_code": "XHQQ+34 Bengaluru, Karnataka", + "global_code": "7J4VXHQQ+34" + }, + "types": ["locality", "political"] + } + ], + "address_descriptor": { + "areas": [ + { + "containment": "WITHIN", + "display_name": { "language_code": "en", "text": "Indiranagar" }, + "place_id": "ChIJarea123" + } + ], + "landmarks": [ + { + "display_name": { "language_code": "en", "text": "100 Feet Road" }, + "place_id": "ChIJlandmark123", + "spatial_relationship": "NEAR", + "straight_line_distance_meters": 50.5, + "travel_distance_meters": 75.2, + "types": ["route"] + } + ] + }, + "plus_code": { + "compound_code": "XHQQ+34 Bengaluru", + "global_code": "7J4VXHQQ+34" + } + } + """.trimIndent() + + val response = gson.fromJson(jsonInput, ReverseGeocodingResponse::class.java) + + assertThat(response.status).isEqualTo("OK") + assertThat(response.errorMessage).isNull() + assertThat(response.addresses).hasSize(1) + assertThat(response.addresses[0].getCountryCode()).isEqualTo("IN") + assertThat(response.addressDescriptor?.areas).hasSize(1) + assertThat(response.addressDescriptor?.landmarks).hasSize(1) + assertThat(response.plusCode?.globalCode).isEqualTo("7J4VXHQQ+34") + } +} diff --git a/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/repositories/AutocompleteRepositoryTest.kt b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/repositories/AutocompleteRepositoryTest.kt new file mode 100644 index 0000000..4985a4f --- /dev/null +++ b/places-compose/src/test/java/com/google/android/libraries/places/compose/autocomplete/repositories/AutocompleteRepositoryTest.kt @@ -0,0 +1,50 @@ +// 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.google.android.libraries.places.compose.autocomplete.repositories + +import com.google.android.gms.tasks.Tasks +import com.google.android.libraries.places.api.model.AutocompletePrediction +import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest +import com.google.android.libraries.places.api.net.FindAutocompletePredictionsResponse +import com.google.android.libraries.places.api.net.PlacesClient +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Unit tests verifying [AutocompleteRepository] delegation to [PlacesClient]. + */ +class AutocompleteRepositoryTest { + + @Test + fun getAutocompletePlaces_delegatesToPlacesClient() = runTest { + val placesClient = mockk() + val request = mockk() + val prediction1 = mockk() + val prediction2 = mockk() + val response = mockk { + every { autocompletePredictions } returns listOf(prediction1, prediction2) + } + + every { placesClient.findAutocompletePredictions(request) } returns Tasks.forResult(response) + + val repository = AutocompleteRepository(placesClient) + val result = repository.getAutocompletePlaces(request) + + assertThat(result).hasSize(2) + assertThat(result).containsExactly(prediction1, prediction2).inOrder() + } +} diff --git a/places-compose/src/test/resources/robolectric.properties b/places-compose/src/test/resources/robolectric.properties new file mode 100644 index 0000000..f6d588c --- /dev/null +++ b/places-compose/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# Robolectric configuration for places-compose tests +sdk=34