Repository: googlemaps/android-places-demos Branch: main Commit: 871c70b1b9f2 Files: 282 Total size: 1005.3 KB Directory structure: gitextract_jubtpusk/ ├── .gemini/ │ ├── config.yaml │ ├── skills/ │ │ └── places-android/ │ │ └── SKILL.md │ └── styleguide.md ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── support_request.md │ ├── PULL_REQUEST_TEMPLATE/ │ │ └── pull_request_template.md │ ├── dependabot.yml │ ├── header-checker-lint.yml │ ├── pull_request_template.md │ ├── snippet-bot.yml │ ├── stale.yml │ ├── sync-repo-settings.yaml │ └── workflows/ │ └── build.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PlaceDetailsCompose/ │ ├── .gitignore │ ├── ARCHITECTURE.md │ ├── README.md │ ├── build.gradle.kts │ ├── local.defaults.properties │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── placedetailscompose/ │ │ ├── MainActivity.kt │ │ ├── PlaceDetailsComposeApplication.kt │ │ ├── repository/ │ │ │ └── LocationRepository.kt │ │ ├── ui/ │ │ │ ├── map/ │ │ │ │ ├── MapScreen.kt │ │ │ │ ├── PlaceContentSelectionDialog.kt │ │ │ │ └── PlaceDetailsView.kt │ │ │ └── theme/ │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ ├── Typography.kt │ │ │ └── Utils.kt │ │ └── viewmodels/ │ │ └── MapViewModel.kt │ └── res/ │ ├── drawable/ │ │ ├── close_button_background.xml │ │ ├── ic_close.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── outline_my_location_24.xml │ │ └── outline_settings_24.xml │ ├── layout/ │ │ └── place_details_fragment.xml │ ├── mipmap-anydpi/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── ids.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── xml/ │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── PlaceDetailsUIKit/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ ├── lint.xml │ ├── local.defaults.properties │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── example/ │ │ └── placedetailsuikit/ │ │ ├── MainActivityInstrumentedTest.kt │ │ ├── compact/ │ │ │ └── ConfigurablePlaceDetailsActivityInstrumentedTest.kt │ │ └── full/ │ │ └── FullConfigurablePlaceDetailsActivityInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── example/ │ │ │ └── placedetailsuikit/ │ │ │ ├── LauncherActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── compact/ │ │ │ │ ├── ConfigurablePlaceDetailsActivity.kt │ │ │ │ └── ContentSelectionViewModel.kt │ │ │ ├── full/ │ │ │ │ ├── FullConfigurablePlaceDetailsActivity.kt │ │ │ │ └── FullContentSelectionViewModel.kt │ │ │ └── ui/ │ │ │ └── theme/ │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── close_button_background.xml │ │ │ ├── ic_close.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── outline_my_location_24.xml │ │ │ └── outline_settings_24.xml │ │ ├── font/ │ │ │ └── custom_font.xml │ │ ├── layout/ │ │ │ ├── activity_configurable_map.xml │ │ │ ├── activity_full_configurable_map.xml │ │ │ ├── activity_main.xml │ │ │ └── content_selector_dialog.xml │ │ ├── mipmap-anydpi/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ ├── values-night/ │ │ │ └── themes.xml │ │ └── xml/ │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test/ │ └── java/ │ └── com/ │ └── example/ │ └── placedetailsuikit/ │ └── ExampleUnitTest.kt ├── PlacesUIKit3D/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ ├── local.defaults.properties │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── placesuikit3d/ │ │ ├── Landmark.kt │ │ ├── LandmarkList.kt │ │ ├── MainActivity.kt │ │ ├── MainViewModel.kt │ │ ├── Maps3DPlacesApplication.kt │ │ ├── common/ │ │ │ ├── ActiveMapObject.kt │ │ │ ├── Map3dViewModel.kt │ │ │ └── MapObject.kt │ │ ├── ui/ │ │ │ └── theme/ │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── utils/ │ │ ├── CameraUpdate.kt │ │ ├── Units.kt │ │ └── Utilities.kt │ └── res/ │ ├── drawable/ │ │ ├── close_button_background.xml │ │ ├── ic_close.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_my_location.xml │ │ ├── loader_background.xml │ │ └── outline_my_location_24.xml │ ├── font/ │ │ └── custom_font.xml │ ├── layout/ │ │ └── activity_main.xml │ ├── mipmap-anydpi/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── xml/ │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── README.md ├── SECURITY.md ├── build-logic/ │ ├── convention/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ ├── places-demo.android.application.gradle.kts │ │ └── places-demo.secrets.gradle.kts │ └── settings.gradle.kts ├── build.gradle.kts ├── demo-java/ │ ├── build.gradle.kts │ ├── local.defaults.properties │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── placesdemo/ │ │ ├── AutocompleteAddressActivity.java │ │ ├── CurrentPlaceActivity.java │ │ ├── FieldSelector.java │ │ ├── MainActivity.java │ │ ├── PlaceAutocompleteActivity.java │ │ ├── PlaceDetailsAndPhotosActivity.java │ │ ├── PlaceIsOpenActivity.java │ │ ├── PlacesDemoApplication.java │ │ ├── StringUtil.java │ │ ├── model/ │ │ │ ├── AddressType.java │ │ │ ├── AutocompleteEditText.java │ │ │ ├── Bounds.java │ │ │ ├── GeocodingResult.java │ │ │ ├── Geometry.java │ │ │ ├── LocationType.java │ │ │ └── PlusCode.java │ │ └── programmatic_autocomplete/ │ │ ├── LatLngAdapter.java │ │ ├── PlacePredictionAdapter.java │ │ └── ProgrammaticAutocompleteToolbarActivity.java │ └── res/ │ ├── drawable/ │ │ └── ic_search_black_24dp.xml │ ├── layout/ │ │ ├── activity_main.xml │ │ ├── activity_programmatic_autocomplete.xml │ │ ├── autocomplete_address_activity.xml │ │ ├── autocomplete_address_map.xml │ │ ├── current_place_activity.xml │ │ ├── place_autocomplete_activity.xml │ │ ├── place_details_and_photos_activity.xml │ │ ├── place_is_open_activity.xml │ │ └── place_prediction_item.xml │ ├── menu/ │ │ └── menu.xml │ ├── raw/ │ │ └── style_json.json │ └── values/ │ ├── dimens.xml │ └── strings.xml ├── demo-kotlin/ │ ├── build.gradle.kts │ ├── local.defaults.properties │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── placesdemo/ │ │ ├── AutocompleteAddressActivity.kt │ │ ├── BaseActivity.kt │ │ ├── CurrentPlaceActivity.kt │ │ ├── FieldSelector.kt │ │ ├── MainActivity.kt │ │ ├── PlaceAutocompleteActivity.kt │ │ ├── PlaceDetailsAndPhotosActivity.kt │ │ ├── PlaceIsOpenActivity.kt │ │ ├── PlacesDemoApplication.kt │ │ ├── PlacesDemoGlideModule.kt │ │ ├── StringUtil.kt │ │ ├── model/ │ │ │ ├── AddressType.kt │ │ │ ├── AutocompleteEditText.kt │ │ │ ├── Bounds.kt │ │ │ ├── GeocodingResult.kt │ │ │ ├── Geometry.kt │ │ │ ├── LocationType.kt │ │ │ └── PlusCode.kt │ │ └── programmatic_autocomplete/ │ │ ├── LatLngAdapter.kt │ │ ├── PlacePredictionAdapter.kt │ │ └── ProgrammaticAutocompleteGeocodingActivity.kt │ └── res/ │ ├── drawable/ │ │ ├── ic_exit.xml │ │ ├── ic_exit_to_app_black_24dp.xml │ │ └── ic_search_black_24dp.xml │ ├── layout/ │ │ ├── activity_main.xml │ │ ├── activity_programmatic_autocomplete.xml │ │ ├── autocomplete_address_activity.xml │ │ ├── autocomplete_address_map.xml │ │ ├── current_place_activity.xml │ │ ├── place_autocomplete_activity.xml │ │ ├── place_details_and_photos_activity.xml │ │ ├── place_is_open_activity.xml │ │ └── place_prediction_item.xml │ ├── menu/ │ │ ├── main_activity_menu.xml │ │ └── menu.xml │ ├── raw/ │ │ └── style_json.json │ └── values/ │ ├── dimens.xml │ └── strings.xml ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── kotlin-demos/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── local.defaults.properties │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── google/ │ │ └── places/ │ │ └── android/ │ │ └── ktx/ │ │ └── demo/ │ │ ├── AutocompleteDemoActivity.kt │ │ ├── Demo.kt │ │ ├── DemoActivity.kt │ │ ├── DemoApplication.kt │ │ ├── PlacesPhotoDemoActivity.kt │ │ ├── PlacesPhotoViewModel.kt │ │ ├── PlacesSearchDemoActivity.kt │ │ ├── PlacesSearchEvent.kt │ │ ├── PlacesSearchViewModel.kt │ │ ├── inject/ │ │ │ └── DemoModule.kt │ │ └── ui/ │ │ ├── Color.kt │ │ ├── Theme.kt │ │ └── Type.kt │ └── res/ │ ├── drawable/ │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ └── values/ │ ├── colors.xml │ ├── strings.xml │ └── styles.xml ├── local.defaults.properties ├── settings.gradle.kts └── snippets/ ├── .gitignore ├── build.gradle.kts ├── local.defaults.properties ├── proguard-rules.pro └── src/ └── main/ ├── AndroidManifest.xml ├── java/ │ └── com/ │ └── google/ │ └── places/ │ ├── CurrentPlaceActivity.java │ ├── GetStartedActivity.java │ ├── JavaMainActivity.java │ ├── PlaceAutocompleteActivity.java │ ├── PlaceDetailsActivity.java │ ├── PlaceIsOpenActivity.java │ ├── PlacePhotosActivity.java │ ├── PlacesIconActivity.java │ ├── data/ │ │ └── PlaceIdProvider.java │ └── kotlin/ │ ├── CurrentPlaceActivity.kt │ ├── GetStartedActivity.kt │ ├── KotlinMainActivity.kt │ ├── MainApplication.kt │ ├── PlaceAutocompleteActivity.kt │ ├── PlaceDetailsActivity.kt │ ├── PlaceIsOpenActivity.kt │ ├── PlacePhotosActivity.kt │ └── PlacesIconActivity.kt └── res/ ├── drawable/ │ └── ic_launcher_background.xml ├── drawable-v24/ │ └── ic_launcher_foreground.xml ├── layout/ │ ├── activity_current_place.xml │ ├── activity_main.xml │ ├── activity_place_autocomplete.xml │ ├── activity_place_details.xml │ ├── activity_place_is_open.xml │ ├── activity_place_photos.xml │ ├── activity_places_icon.xml │ ├── list_item_activity.xml │ └── list_item_place.xml ├── mipmap-anydpi-v26/ │ ├── ic_launcher.xml │ └── ic_launcher_round.xml ├── values/ │ ├── colors.xml │ ├── strings.xml │ └── styles.xml └── values-v27/ └── styles.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gemini/config.yaml ================================================ # Copyright 2026 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. # Gemini Code Assist Configuration # See: https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github # Feature settings have_fun: false code_review: disable: false comment_severity_threshold: MEDIUM max_review_comments: -1 pull_request_opened: summary: true code_review: true include_drafts: true # Files to ignore in Gemini analysis ignore_patterns: - "**/*.bin" - "**/*.exe" - "**/build/**" - "**/.gradle/**" - "**/secrets.properties" ================================================ FILE: .gemini/skills/places-android/SKILL.md ================================================ --- name: places-android description: Guide for integrating the Places SDK for Android into an application. Use when users ask to add Places, autocomplete, place details, or search for places. --- # Places SDK for Android Integration You are an expert Android developer specializing in modern Android architecture. Before generating any code, ask the user the following questions to tailor the solution: ### 📋 Design & Architectural Questions to Ask the User * **UI Framework**: Are you using Jetpack Compose or standard UI Views? * **Widget vs Custom UI**: Do you want to use the pre-built Google Autocomplete Widget (Dialog/Overlay) or build a completely custom programmatic search bar? * **Compact vs Full Details**: Do you want a compact half-sheet overlay (`PlaceDetailsCompactFragment`) or a full-page details viewer (`PlaceDetailsFragment`)? * **Cost & Field Scoping**: What exact fields do you need (e.g., `DISPLAY_NAME`, `FORMATTED_ADDRESS`, `PHOTO_METADATAS`)? Limiting fields saves costs! * **Theming Options**: Are you using Material 3 themes so we can bridge default styling automatically? --- ## 1. Setup Dependencies Add the necessary dependencies to your module-level `build.gradle.kts` file. It is recommended to use the Versions Catalog if available: ```toml [versions] places = "5.1.1" # x-release-please-version [libraries] places = { group = "com.google.android.libraries.places", name = "places", version.ref = "places" } ``` Then in `build.gradle.kts`: ```kotlin dependencies { implementation(libs.places) } ``` ## 2. Setup the Secrets Gradle Plugin Use the Secrets Gradle Plugin for Android to inject the API key securely into your project (e.g., via `BuildConfig`), so you can access it programmatically during initialization. Ensure you have the plugin applied in your app-level `build.gradle.kts`: ```kotlin plugins { alias(libs.plugins.secrets.gradle.plugin) } secrets { propertiesFileName = "secrets.properties" defaultPropertiesFileName = "local.defaults.properties" } ``` Add your API Key to `secrets.properties`: ```properties PLACES_API_KEY=YOUR_API_KEY ``` ## 3. Initialize the Places SDK In your `Application` or `Activity` (before accessing any Places APIs, usually inside `onCreate`), initialize the Places SDK. ### Kotlin ```kotlin import com.google.android.libraries.places.api.Places class DemoApplication : Application() { override fun onCreate() { super.onCreate() val apiKey = BuildConfig.PLACES_API_KEY if (apiKey.isNotEmpty()) { Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey) } } } ``` ### Java ```java import com.google.android.libraries.places.api.Places; public class DemoApplication extends Application { @Override public void onCreate() { super.onCreate(); String apiKey = BuildConfig.PLACES_API_KEY; if (!apiKey.isEmpty()) { Places.initializeWithNewPlacesApiEnabled(getApplicationContext(), apiKey); } } } ``` ## 4. Best Practices & Guidelines * **Prefer Places UI Kit**: For displaying place details (photos, reviews, addresses), prefer using the **Places UI Kit** over manual programmatic retrieval. It provides pre-built, beautifully designed, and automatically maintained UI components! * **Null Safety & Validation**: Handle nulls defensively for optional parameters (e.g. Place fields). * **Scoped Fields**: Always specify *only* parameters that are needed (e.g. `Place.Field.ID`, `Place.Field.DISPLAY_NAME`) to avoid over-billing. * **Coroutine Extensions**: Use Kotlin Coroutines extensions (`places-ktx` if available) to make code cleaner. * **Location Permission**: Location permissions are optional but helpful. `ACCESS_COARSE_LOCATION` is sufficient for biasing prediction calls (like searching search results) to general cities. `ACCESS_FINE_LOCATION` is necessary only for exact current position tracking. Declare them in your `AndroidManifest.xml`: ```xml ``` ## 5. Compose Interop with Places UI Kit The Places UI Kit (`PlaceDetailsCompactFragment` and `PlaceDetailsFragment`) are View-based. To use them in Jetpack Compose, use `AndroidView` to host a `FragmentContainerView`. ### Key Pattern: Fragment Container in Compose * **Access FragmentManager**: Use standard `LocalActivity.current as FragmentActivity` to access the support FragmentManager. Avoid casting `LocalContext.current` directly to Activity. * **Deferred Updates**: Inside the `AndroidView` `update` block, always wrap calls (like `.loadWithPlaceId()`) in `view.post { ... }` to ensure updates run *after* the layout is inflated and bindings are stable. ```kotlin @Composable fun PlaceDetailsCompactView( placeId: String, onDismiss: () -> Unit ) { val fragmentContainerId = remember { View.generateViewId() } val fragmentManager = (LocalActivity.current as FragmentActivity).supportFragmentManager val fragment = remember { PlaceDetailsCompactFragment.newInstance( PlaceDetailsCompactFragment.ALL_CONTENT, Orientation.VERTICAL ) } Box(modifier = Modifier.fillMaxWidth()) { AndroidView( modifier = Modifier.fillMaxWidth(), factory = { context -> FragmentContainerView(context).apply { id = fragmentContainerId if (fragmentManager.findFragmentById(fragmentContainerId) == null) { fragmentManager.beginTransaction() .add(fragmentContainerId, fragment) .commit() } } }, update = { view -> // Ensures updates run after view hierarchy is ready view.post { fragment.loadWithPlaceId(placeId) } } ) } } ``` ## 📏 6. Advanced Compose Viewports & BottomSheetScaffold When hosting UI Kit fragments inside navigation drawers or overlays, follow these architectural bounds to avoid viewport clipping snags: * **System Viewport Edge Overlap**: If using `enableEdgeToEdge()` and your container loses standard `Scaffold` body padding context, manually append `Modifier.statusBarsPadding()` to avoid overlapping with system status bar text: ```kotlin Column(modifier = modifier.statusBarsPadding()) { // Beautiful search results sit safely under the status bar } ``` * **Unified Sheet Content State**: To achieve clean mutual exclusivity between "Compact" and "Full" details click toggles, hoist the viewport state to a high enum variable level: ```kotlin enum class DetailsUiType { COMPACT, FULL } ``` Track `currentUiType` at the Activity level and pass it to a single shared `BottomSheetScaffold`. Users can swipe to dismiss natively without custom button overrides! ## 7. Autocomplete with Compose (Widget) To implement autocomplete in Compose, use `ActivityResultContracts.StartActivityForResult` with an Intent from `Autocomplete.IntentBuilder`. This is the recommended way to use the pre-built widget, as it handles session tokens and debouncing automatically. ```kotlin @Composable fun AutocompleteSearchButton() { val context = LocalContext.current val fields = listOf(Place.Field.ID, Place.Field.DISPLAY_NAME, Place.Field.FORMATTED_ADDRESS) val intent = Autocomplete.IntentBuilder(AutocompleteActivityMode.OVERLAY, fields) .build(context) val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { val place = Autocomplete.getPlaceFromIntent(result.data!!) Log.d("Autocomplete", "Place selected: ${place.name}") } } Button(onClick = { launcher.launch(intent) }) { Text("Search Places") } } ``` ## 8. Execution Steps 1. Add the Places SDK dependencies to `build.gradle.kts`. 2. Set up the Secrets Gradle Plugin in `build.gradle.kts`. 3. Implement initialization (e.g., in a subclass of `Application`). 4. Provide a summary of how to use it (retrieve place details, display autocomplete). ================================================ FILE: .gemini/styleguide.md ================================================ # Gemini Code Assist Style Guide: android-places-demos This guide defines the custom code review and generation rules for the `android-places-demos` project. ## Jetpack Compose Guidelines - **API Guidelines**: Strictly follow the [Jetpack Compose API guidelines](https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md). - **Naming**: Composable functions must be PascalCase. - **Modifiers**: The first optional parameter of any Composable should be `modifier: Modifier = Modifier`. ## Kotlin & Java Style - **Naming**: Use camelCase for variables and functions. - **Documentation**: Provide KDoc for all public classes, properties, and functions. Explain the "why" in comments, not just the "what". - **Safety**: Use null-safe operators and avoid `!!` in Kotlin. In Java, use standard null checks or `@NonNull`/`@Nullable` annotations if available. - **Imports vs FQCNs**: Avoid using Fully Qualified Class Names (FQCNs) in code if a standard `import` statement would suffice. Keep code readable. ## Places SDK Specifics - **Secrets**: Never commit API keys. Ensure they are read from `secrets.properties` (or `local.properties`) via `BuildConfig` or injected into `AndroidManifest.xml` by the Secrets Gradle Plugin. - **Initialization**: - Strongly recommend `Places.initializeWithNewPlacesApiEnabled` over the legacy `Places.initialize` for modern demos. - **Literate Programming**: Write clear, well-documented code that functions as an example for developers. Explain architectural decisions. ================================================ FILE: .github/CODEOWNERS ================================================ # Copyright 2022 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. # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners .github/ @googlemaps/admin ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: 'type: bug, triage me' assignees: '' --- Thanks for stopping by to let us know something could be better! --- **PLEASE READ** If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/). This will ensure a timely response. Discover additional support services for the Google Maps Platform, including developer communities, technical guidance, and expert support at the Google Maps Platform [support resources page](https://developers.google.com/maps/support/). If your bug or feature request is not related to this particular library, please visit the Google Maps Platform [issue trackers](https://developers.google.com/maps/support/#issue_tracker). Check for answers on StackOverflow with the [google-maps](http://stackoverflow.com/questions/tagged/google-maps) tag. --- Please be sure to include as much information as possible: #### Environment details 1. Specify the API at the beginning of the title (for example, "Places: ...") 2. OS type and version 3. Library version and other environment information #### Steps to reproduce 1. ? #### Code example ``` # example ``` #### Stack trace ``` # example ``` Following these steps will guarantee the quickest resolution possible. Thanks! ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this library title: '' labels: 'type: feature request, triage me' assignees: '' --- Thanks for stopping by to let us know something could be better! --- **PLEASE READ** If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/). This will ensure a timely response. Discover additional support services for the Google Maps Platform, including developer communities, technical guidance, and expert support at the Google Maps Platform [support resources page](https://developers.google.com/maps/support/). If your bug or feature request is not related to this particular library, please visit the Google Maps Platform [issue trackers](https://developers.google.com/maps/support/#issue_tracker). Check for answers on StackOverflow with the [google-maps](http://stackoverflow.com/questions/tagged/google-maps) tag. --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/support_request.md ================================================ --- name: Support request about: If you have a support contract with Google, please create an issue in the Google Cloud Support console. title: '' labels: 'triage me, type: question' assignees: '' --- **PLEASE READ** If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/). This will ensure a timely response. Discover additional support services for the Google Maps Platform, including developer communities, technical guidance, and expert support at the Google Maps Platform [support resources page](https://developers.google.com/maps/support/). If your bug or feature request is not related to this particular library, please visit the Google Maps Platform [issue trackers](https://developers.google.com/maps/support/#issue_tracker). Check for answers on StackOverflow with the [google-maps](http://stackoverflow.com/questions/tagged/google-maps) tag. --- ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/pull_request_template.md ================================================ --- name: Pull request about: Create a pull request label: 'triage me' --- Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open a GitHub issue as a bug/feature request before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 ================================================ FILE: .github/dependabot.yml ================================================ # Copyright 2025 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. version: 2 updates: - package-ecosystem: gradle directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 commit-message: prefix: chore(deps) ================================================ FILE: .github/header-checker-lint.yml ================================================ # Copyright 2025 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. # Presubmit test that ensures that source files contain valid license headers # https://github.com/googleapis/repo-automation-bots/tree/main/packages/header-checker-lint # Install: https://github.com/apps/license-header-lint-gcf allowedCopyrightHolders: - 'Google LLC' allowedLicenses: - 'Apache-2.0' sourceFileExtensions: - 'yaml' - 'yml' - 'sh' - 'ts' - 'js' - 'java' - 'html' - 'txt' - 'kt' - 'kts' - 'xml' - 'gradle' ================================================ FILE: .github/pull_request_template.md ================================================ Thank you for opening a Pull Request! --- Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open a GitHub issue as a bug/feature request before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 ================================================ FILE: .github/snippet-bot.yml ================================================ # Copyright 2025 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. ================================================ FILE: .github/stale.yml ================================================ # Copyright 2020 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. # Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale daysUntilStale: 120 # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. daysUntilClose: 180 # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) onlyLabels: [] # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable exemptLabels: - pinned - "type: bug" # Set to true to ignore issues in a project (defaults to false) exemptProjects: false # Set to true to ignore issues in a milestone (defaults to false) exemptMilestones: false # Set to true to ignore issues with an assignee (defaults to false) exemptAssignees: false # Label to use when marking as stale staleLabel: "stale" # Comment to post when marking as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. Please comment here if it is still valid so that we can reprioritize. Thank you! # Comment to post when removing the stale label. # unmarkComment: > # Your comment here. # Comment to post when closing a stale Issue or Pull Request. closeComment: > Closing this. Please reopen if you believe it should be addressed. Thank you for your contribution. # Limit the number of actions per hour, from 1-30. Default is 30 limitPerRun: 10 # Limit to only `issues` or `pulls` only: issues # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': # pulls: # daysUntilStale: 30 # markComment: > # This pull request has been automatically marked as stale because it has not had # recent activity. It will be closed if no further activity occurs. Thank you # for your contributions. # issues: # exemptLabels: # - confirmed ================================================ FILE: .github/sync-repo-settings.yaml ================================================ # Copyright 2022 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. # https://github.com/googleapis/repo-automation-bots/tree/main/packages/sync-repo-settings rebaseMergeAllowed: true squashMergeAllowed: true mergeCommitAllowed: false deleteBranchOnMerge: true branchProtectionRules: - pattern: main isAdminEnforced: false requiresStrictStatusChecks: false requiredStatusCheckContexts: - 'cla/google' - 'test' - 'snippet-bot check' - 'header-check' requiredApprovingReviewCount: 1 requiresCodeOwnerReviews: true - pattern: master isAdminEnforced: false requiresStrictStatusChecks: false requiredStatusCheckContexts: - 'cla/google' - 'test' - 'snippet-bot check' - 'header-check' requiredApprovingReviewCount: 1 requiresCodeOwnerReviews: true permissionRules: - team: admin permission: admin ================================================ FILE: .github/workflows/build.yml ================================================ # Copyright 2020 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. name: Build demos # Controls when the action will run. Triggers the workflow on push or pull request # events but only for the main branch on: push: branches: [ main ] pull_request: branches: [ main ] repository_dispatch: types: [ build ] schedule: - cron: '0 0 * * 1' workflow_dispatch: jobs: build-all: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - name: set up JDK 17 uses: actions/setup-java@v4.2.1 with: java-version: '17' distribution: 'adopt' - name: Install NDK run: | sudo ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;20.0.5594570" - name: Create local.defaults.properties run: | echo "MAPS3D_API_KEY=YOUR_API_KEY" >> local.defaults.properties - name: Build and verify all modules run: ./gradlew assembleDebug lint --continue - name: Run tests run: ./gradlew test --continue - name: Upload build reports if: always() uses: actions/upload-artifact@v4 with: name: build-reports path: "**/build/reports" ================================================ FILE: .gitignore ================================================ # Build output build/ .gradle/ .kotlin/ # IDE files .idea/ *.iml # Local machine-specific configs **/local.properties **/secrets.properties # OS junk .DS_Store .java-version # This covers new IDEs, like Antigravity .vscode/ **/bin/ ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Google Open Source Community Guidelines At Google, we recognize and celebrate the creativity and collaboration of open source contributors and the diversity of skills, experiences, cultures, and opinions they bring to the projects and communities they participate in. Every one of Google's open source projects and communities are inclusive environments, based on treating all individuals respectfully, regardless of gender identity and expression, sexual orientation, disabilities, neurodiversity, physical appearance, body size, ethnicity, nationality, race, age, religion, or similar personal characteristic. We value diverse opinions, but we value respectful behavior more. Respectful behavior includes: * Being considerate, kind, constructive, and helpful. * Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or physically threatening behavior, speech, and imagery. * Not engaging in unwanted physical contact. Some Google open source projects [may adopt][] an explicit project code of conduct, which may have additional detailed expectations for participants. Most of those projects will use our [modified Contributor Covenant][]. [may adopt]: https://opensource.google/docs/releasing/preparing/#conduct [modified Contributor Covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/ ## Resolve peacefully We do not believe that all conflict is necessarily bad; healthy debate and disagreement often yields positive results. However, it is never okay to be disrespectful. If you see someone behaving disrespectfully, you are encouraged to address the behavior directly with those involved. Many issues can be resolved quickly and easily, and this gives people more control over the outcome of their dispute. If you are unable to resolve the matter for any reason, or if the behavior is threatening or harassing, report it. We are dedicated to providing an environment where participants feel welcome and safe. ## Reporting problems Some Google open source projects may adopt a project-specific code of conduct. In those cases, a Google employee will be identified as the Project Steward, who will receive and handle reports of code of conduct violations. In the event that a project hasn’t identified a Project Steward, you can report problems by emailing opensource@google.com. We will investigate every complaint, but you may not receive a direct response. We will use our discretion in determining when and how to follow up on reported incidents, which may range from not taking action to permanent expulsion from the project and project-sponsored spaces. We will notify the accused of the report and provide them an opportunity to discuss it before any action is taken. The identity of the reporter will be omitted from the details of the report supplied to the accused. In potentially harmful situations, such as ongoing harassment or threats to anyone's safety, we may take action without notice. *This document was adapted from the [IndieWeb Code of Conduct][] and can also be found at .* [IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct ================================================ FILE: CONTRIBUTING.md ================================================ # How to become a contributor and submit your own code ## Contributor License Agreements We'd love to accept your sample apps and patches! Before we can take them, we have to jump a couple of legal hurdles. Please fill out either the individual or corporate Contributor License Agreement (CLA). * If you are an individual writing original source code and you're sure you own the intellectual property, then you'll need to sign an [individual CLA] (http://code.google.com/legal/individual-cla-v1.0.html). * If you work for a company that wants to allow you to contribute your work, then you'll need to sign a [corporate CLA] (http://code.google.com/legal/corporate-cla-v1.0.html). Follow either of the two links above to access the appropriate CLA and instructions for how to sign and return it. Once we receive it, we'll be able to accept your pull requests. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2016 Google 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. ================================================ FILE: PlaceDetailsCompose/.gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: PlaceDetailsCompose/ARCHITECTURE.md ================================================ # Architecture This sample application demonstrates a modern **MVVM (Model-View-ViewModel)** architecture integrated with **Jetpack Compose** and **Google Maps Platform**. ## Overview The app is a single-screen application (`MapScreen`) that allows users to view a map, track their location, and select places to view detailed information using the Google Places UI Kit. ### Key Layers 1. **UI Layer (Compose)**: - **`MapScreen`**: The main entry point. Handles the Scaffold, Map rendering (via Maps Compose), and UI controls. - **`PlaceDetailsView`**: Bridging components (`PlaceDetailsCompactView`, `PlaceDetailsFullView`) that wrap the View-based Places fragments using `AndroidView`. - **`MapViewModel`**: Holds the UI state (`selectedPlace`, `isFullView`, `deviceLocation`) and handles business logic. 2. **Data Layer (Repository)**: - **`LocationRepository`**: A light wrapper around `FusedLocationProviderClient`. It exposes location updates as a Kotlin `Flow`, handling permission checks gracefully. ## Key Patterns ### 1. Compose Interoperability (`AndroidView`) The Google Places UI Kit components (`PlaceDetailsCompactFragment`, `PlaceDetailsFragment`) are standard Android Fragments. To use them in a pure Compose app, we use the `AndroidView` composable to host a `FragmentContainerView`. - **Unique IDs**: We generate unique View IDs using `View.generateViewId()` so the `FragmentManager` can correctly identify each container. - **Lifecycle Handling**: Fragments are added/removed via transactions inside the `AndroidView` factory. - **Updates**: Data updates (like changing the Place ID) are posted to the view queue (`view.post`) to ensure the Fragment is fully attached before loading data. ### 2. State Management with Flows The `MapViewModel` uses `StateFlow` to expose reactive state to the UI. - **`flatMapLatest`**: Used for location updates to automatically switch between "no location" and "location updates" based on permission state. - **`combine` / `map`**: derived state is computed reactively. ## Common Integration Challenges When integrating the Places UI Kit (View-based) into a Jetpack Compose app, there are a few specific implementation details to be aware of: 1. **"Context must be a FragmentActivity" Crash**: - *Why it happens*: The Places UI Kit fragments (`PlaceDetailsFragment`) rely on the legacy Android `FragmentManager`. This manager is only available in a `FragmentActivity` (or `AppCompatActivity`). - *The Fix*: We assume the Composable is hosted in an Activity that extends `AppCompatActivity` and cast the `Context` to it. 2. **`view.post { ... }`**: - *Why we do it*: Fragment transactions are asynchronous. If we try to call `fragment.loadWithPlaceId` immediately after adding the fragment, the fragment's view might not be created yet, leading to a crash. - *The Fix*: `view.post` schedules the action to run *after* the current message queue is processed, ensuring the view hierarchy is ready. 3. **Unique View IDs (`View.generateViewId()`)**: - *Why*: If you have multiple `AndroidView`s hosting fragments (even if one is hidden), they need distinct IDs so the `FragmentManager` doesn't get confused about which container holds which fragment. ================================================ FILE: PlaceDetailsCompose/README.md ================================================ # Place Details Compose Sample This sample demonstrates how to integrate the **Places UI Kit** (specifically `PlaceDetailsCompactFragment` and `PlaceDetailsFragment`) into a **Jetpack Compose** application. It showcases how to wrap these View-based fragments using `AndroidView` to create seamless Composable wrappers: `PlaceDetailsCompactView` and `PlaceDetailsFullView`. ## Features - **Jetpack Compose Integration**: Demonstrates the `AndroidView` pattern for embedding Places UI Kit fragments. - **Compact & Full Views**: Supports both the Compact (bottom sheet style) and Full (fullscreen style) variants of the UI Kit. - **Dynamic Toggling**: Users can switch between Compact and Full views at runtime using a toggle switch. - **Google Maps Integration**: Uses the Maps Compose library to display an interactive map. - **MVVM Architecture**: Manages state (selected place, view mode) using a `MapViewModel`. - **Secrets Management**: Securely handles API keys using the Secrets Gradle Plugin. ## Getting Started 1. **Clone the repository:** ```bash git clone https://github.com/googlemaps-samples/android-places-demos.git ``` 2. **Open in Android Studio:** Open the `PlaceDetailsCompose` directory. 3. **Add API Key:** - Create `secrets.properties` in the project root. - Add your key: `PLACES_API_KEY="YOUR_API_KEY"` (ensure Places API and Maps SDK are enabled). 4. **Run:** Build and run on a device/emulator. ## Code Highlights ### 1. Wrapping Fragments in Compose (`PlaceDetailsView.kt`) The core of this integration is wrapping the `PlaceDetailsCompactFragment` and `PlaceDetailsFragment` in a Composable. We use `AndroidView` to host a `FragmentContainerView`. **Key Steps:** - **Unique ID**: Generate a unique view ID (`View.generateViewId()`) for the container so `FragmentManager` can identify it. - **Fragment Management**: In the `update` block, check if the fragment exists. If not, create and add it. - **Safe Loading**: Use `view.post { ... }` to call `loadWithPlaceId`. This ensures the fragment's view is fully attached before data loading begins, preventing crashes. ```kotlin @Composable fun PlaceDetailsCompactView(place: PointOfInterest, ...) { val fragmentContainerId = remember { View.generateViewId() } AndroidView( factory = { context -> FragmentContainerView(context).apply { id = fragmentContainerId } }, update = { view -> // ... Fragment transaction logic ... view.post { fragment.loadWithPlaceId(place.placeId) } } ) } ``` ### 2. Switching Views (`MapScreen.kt`) The app demonstrates how to dynamically switch between the Compact and Full views while maintaining the selected place context. ```kotlin var isFullView by remember { mutableStateOf(false) } if (isFullView) { PlaceDetailsFullView(place = place, ...) } else { PlaceDetailsCompactView(place = place, ...) } ``` ### 3. Handling Events We use `PlaceLoadListener` attached to the fragment to listen for success/failure events. These are propagated back to the Compose layer via callbacks (e.g., `onDismiss`). ## License ``` Copyright 2025 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. ``` ================================================ FILE: PlaceDetailsCompose/build.gradle.kts ================================================ /* * Copyright 2025 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. */ // The `plugins` block is where we apply Gradle plugins to this module. // Plugins add new tasks and configurations to our build process. plugins { id("places-demo.android.application") // This plugin enables Kotlin support in the Android project, allowing us to write code in Kotlin. alias(libs.plugins.kotlin.android) // This plugin from Google helps manage API keys and other secrets by reading them from a `secrets.properties` // file (which should be in .gitignore) and exposing them in the `BuildConfig` file at compile time. // This is crucial for keeping sensitive data out of version control. id("places-demo.secrets") // This plugin provides the necessary integration for using Jetpack Compose with the Kotlin compiler. alias(libs.plugins.kotlin.compose) } // The `android` block is where we configure all the Android-specific build options. android { // The `namespace` is a unique identifier for the app's generated R class. It's also used // as the default `applicationId` if not specified in `defaultConfig`. namespace = "com.example.placedetailscompose" defaultConfig { // `applicationId` is the unique identifier for the app on the Google Play Store and on the device. applicationId = "com.example.placedetailscompose" // `minSdk` is the minimum API level required to run the app. Devices below this level cannot install it. minSdk = 27 versionCode = 1 versionName = "1.0" // Specifies the instrumentation runner for running Android tests. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { // The `release` block configures settings for the release build of the app. release { // `isMinifyEnabled` enables code shrinking with R8 to reduce the app's size. // It's disabled here for simplicity in a sample app, but highly recommended for production. isMinifyEnabled = false // `proguardFiles` specifies the files that define the R8 shrinking and obfuscation rules. proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } kotlin { // Configures Kotlin-specific compiler options. compilerOptions { freeCompilerArgs.addAll( "-opt-in=kotlin.RequiresOptIn", "-Xannotation-default-target=param-property" ) } } buildFeatures { // `compose` enables Jetpack Compose for the project. compose = true // `buildConfig` generates a `BuildConfig` class that contains constants from the build configuration, // such as the API key from the secrets plugin. buildConfig = true // `viewBinding` generates a binding class for each XML layout file. viewBinding = true } composeOptions { // Sets the version of the Kotlin compiler extension for Compose. This version must be // compatible with the Kotlin version used in the project. kotlinCompilerExtensionVersion = "1.5.1" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } // The `dependencies` block is where we declare all the external libraries the app needs. // These are fetched from repositories like Maven Central and Google's Maven repository. dependencies { // --- Core AndroidX & UI Libraries --- // These are foundational libraries for building modern Android apps. implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.compose.material.icons.core) // --- Google Play Services --- // These are the essential libraries for this sample, providing Maps and Places functionality. implementation(libs.google.maps.services) // The core SDK for embedding Google Maps. implementation(libs.places) // The SDK for the Places UI Kit (PlaceDetails fragments). implementation(libs.play.services.location) // Needed for the FusedLocationProviderClient to get the device's location. implementation(libs.maps.compose) implementation(libs.maps.compose.widgets) implementation(libs.maps.utils.ktx) implementation(libs.material) // For Material Design components (used in XML layouts). // --- Jetpack Compose --- // These libraries are for building UIs with Jetpack Compose. implementation(libs.androidx.material3) // The latest Material Design components for Compose. implementation(platform(libs.androidx.compose.bom)) // The Compose Bill of Materials (BOM) ensures all Compose libraries use compatible versions. implementation(libs.androidx.ui.tooling.preview) // For displaying @Preview composables in Android Studio. implementation(libs.androidx.ui.viewbinding) implementation(libs.androidx.material.icons.extended) debugImplementation(libs.androidx.ui.tooling) // Provides tools for inspecting Compose UIs. // --- Testing Libraries --- // These libraries are for writing and running tests. // `testImplementation` is for local unit tests (running on the JVM). testImplementation(libs.junit) // `androidTestImplementation` is for instrumented tests (running on an Android device or emulator). androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) // For UI testing with the View system. // AndroidX libraries for creating test rules and running tests. androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) // --- Compose Testing --- // These are specific to testing Jetpack Compose UIs. androidTestImplementation(platform(libs.androidx.compose.bom)) // BOM for testing libraries. androidTestImplementation(libs.androidx.ui.test.junit4) // The main library for Compose UI tests. debugImplementation(libs.androidx.ui.test.manifest) // Provides a manifest for UI tests. } ================================================ FILE: PlaceDetailsCompose/local.defaults.properties ================================================ PLACES_API_KEY=YOUR_API_KEY MAPS_API_KEY=YOUR_API_KEY MAP_ID=YOUR_MAP_ID ================================================ FILE: PlaceDetailsCompose/src/main/AndroidManifest.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/MainActivity.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose import android.Manifest import android.content.pm.PackageManager import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import com.example.placedetailscompose.ui.map.MapScreen import com.example.placedetailscompose.ui.theme.PlaceDetailsComposeTheme import com.google.android.libraries.places.api.Places class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Retrieve the API key from the local.properties file. // See https://github.com/googlemaps/android-places-demos#installation for more details. val apiKey = BuildConfig.PLACES_API_KEY if (apiKey.isEmpty() || apiKey == "YOUR_API_KEY") { Log.e("PlacesCompose", "No api key") Toast.makeText( this, "Add your own API_KEY in local.properties", Toast.LENGTH_LONG ).show() finish() return } // Initialize the Places SDK. This must be done before calling any other Places API methods. // The 'newPlacesApiEnabled' flag indicates that the new Places API should be used. // This can happen in an Activity or the Application. Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey) enableEdgeToEdge() setContent { val window = this.window val insetsController = WindowCompat.getInsetsController(window, window.decorView) // Educational Note: We are handling permissions directly within the Compose scope // here to keep this Place Details sample self-contained and easy to follow. // In a production app, you might prefer to hoist this logic to a ViewModel // or a dedicated permission handler class. // Check if we already have the permission. // Using ContextCompat.checkSelfPermission ensures we respect the state if the // user granted it previously via system settings. var hasPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED ) } // The standard, modern Compose way to register for Activity Results (like Permissions) // within a Composable scope. val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { granted -> if (granted) { hasPermission = true } else { Toast.makeText(this, "Location permission is required to use this app.", Toast.LENGTH_LONG).show() } } ) // Trigger the permission request when this Composable first enters the composition. // The 'Unit' key ensures this side-effect only runs once on mount. LaunchedEffect(Unit) { if (!hasPermission) { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } } SideEffect { insetsController.hide(WindowInsetsCompat.Type.systemBars()) insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } PlaceDetailsComposeTheme { if (hasPermission) { MapScreen() } else { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) { Text("Grant Location Permission") } } } } } } } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/PlaceDetailsComposeApplication.kt ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placedetailscompose import android.app.Application import android.content.pm.PackageManager import android.util.Log import android.widget.Toast import java.util.Objects import kotlin.text.isBlank /** * `PlaceDetailsComposeApplication` is a custom Application class. * * This class is responsible for application-wide initialization and setup, * such as checking for the presence and validity of the API key during the * application's startup. * * It extends the [Application] class and overrides the [.onCreate] * method to perform these initialization tasks. */ class PlaceDetailsComposeApplication : Application() { override fun onCreate() { super.onCreate() checkApiKey() } /** * Checks if the API key for Google Maps is properly configured in the application's metadata. * * This method retrieves the API key from the application's metadata, specifically looking for * a string value associated with the key "com.google.android.geo.API_KEY". * The key must be present, not blank, and not set to the placeholder value "DEFAULT_API_KEY". * * If any of these checks fail, a Toast message is displayed indicating that the API key is missing or * incorrectly configured, and a RuntimeException is thrown. */ private fun checkApiKey() { try { val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) val bundle = Objects.requireNonNull(appInfo.metaData) val apiKey = bundle.getString("com.google.android.geo.API_KEY") // Key name is important! if (apiKey == null || apiKey.isBlank() || apiKey == "DEFAULT_API_KEY") { Toast.makeText( this, getString(R.string.error_api_key_missing), Toast.LENGTH_LONG ).show() throw RuntimeException(getString(R.string.error_api_key_missing)) } } catch (e: PackageManager.NameNotFoundException) { Log.e(TAG, "Package name not found.", e) throw RuntimeException("Error getting package info.", e) } catch (e: NullPointerException) { Log.e(TAG, "Error accessing meta-data.", e) // Handle the case where meta-data is completely missing. throw RuntimeException("Error accessing meta-data in manifest", e) } } /** * Retrieves the map ID from the BuildConfig or string resource. * * @return The valid map ID or null if no valid map ID is found. */ val mapId: String? by lazy { if (BuildConfig.MAP_ID != "YOUR_MAP_ID") { BuildConfig.MAP_ID } else if (getString(R.string.map_id) != "YOUR_MAP_ID") { getString(R.string.map_id) } else { Log.w(TAG, "Map ID is not set. See README for instructions.") Toast.makeText(this, getString(R.string.error_map_id_missing), Toast.LENGTH_LONG) .show() null } } companion object { private const val TAG = "ApiDemoApplication" } } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/repository/LocationRepository.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.repository import android.annotation.SuppressLint import android.content.Context import android.location.Location import android.os.Looper import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow class LocationRepository(context: Context) { private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) @SuppressLint("MissingPermission") fun getDeviceLocation(): Flow = callbackFlow { val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 10000L) .setWaitForAccurateLocation(false) .setMinUpdateIntervalMillis(5000L) .build() val locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { trySend(it) } } } try { fusedLocationClient.requestLocationUpdates( locationRequest, locationCallback, Looper.getMainLooper() ) } catch (e: SecurityException) { // Permissions were likely denied. // In a real app, we might want to emit an error state or log this. // For now, we just close the flow to avoid a crash. close(e) } awaitClose { fusedLocationClient.removeLocationUpdates(locationCallback) } } } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/ui/map/MapScreen.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.ui.map import android.Manifest import android.content.pm.PackageManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.ui.draw.scale import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.example.placedetailscompose.R import com.example.placedetailscompose.viewmodels.MapViewModel import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.model.CameraPosition import com.google.maps.android.compose.Circle import com.google.maps.android.compose.ComposeMapColorScheme import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapProperties import com.google.maps.android.compose.MapType import com.google.maps.android.compose.MapUiSettings import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.ktx.utils.sphericalDistance import com.google.maps.android.ktx.utils.withSphericalOffset import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * The main screen of the app. This screen shows a map and allows the user to select a * point of interest to see details about it. */ @Composable fun MapScreen( viewModel: MapViewModel = viewModel() ) { val context = LocalContext.current val deviceLocation by viewModel.deviceLocation.collectAsState() val selectedPlace by viewModel.selectedPlace.collectAsState() val isMapFollowingUser by viewModel.isMapFollowingUser.collectAsState() val hasAnimatedToPlace by viewModel.hasAnimatedToPlace.collectAsState() // **View Mode State** var isFullView by rememberSaveable { mutableStateOf(false) } // **Coordinate Mode State** val isCoordinateMode by viewModel.isCoordinateMode.collectAsState() val cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(viewModel.sydney, 13f) } val permissionDeniedString = stringResource(R.string.location_permission_denied) val locationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true ) { viewModel.onPermissionGranted() } else { Toast.makeText(context, permissionDeniedString, Toast.LENGTH_SHORT).show() } } LaunchedEffect(Unit) { if (ActivityCompat.checkSelfPermission( context, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission( context, Manifest.permission.ACCESS_COARSE_LOCATION ) == PackageManager.PERMISSION_GRANTED ) { viewModel.onPermissionGranted() } else { locationPermissionLauncher.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } } LaunchedEffect(selectedPlace) { selectedPlace?.let { place -> val latLng = place.location if (latLng != null) { val focalPoint = latLng.withSphericalOffset(300.0, 180.0) val placeCameraPosition = CameraPosition.builder() .target(focalPoint) .zoom(15f) .build() if (hasAnimatedToPlace) { cameraPositionState.move(CameraUpdateFactory.newCameraPosition(placeCameraPosition)) } else { cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(placeCameraPosition), 1000) viewModel.onAnimateToPlaceFinish() } } } } LaunchedEffect(deviceLocation, isMapFollowingUser) { deviceLocation?.let { location -> if (isMapFollowingUser) { val currentPosition = cameraPositionState.position.target val distance = currentPosition.sphericalDistance(location) if (distance > 100) { cameraPositionState.animate(CameraUpdateFactory.newLatLngZoom(location, 15f)) } } } } val selectedCompactContent by viewModel.selectedCompactContent.collectAsState() val selectedFullContent by viewModel.selectedFullContent.collectAsState() var showContentSelectionDialog by rememberSaveable { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() var showSettingsButton by remember { mutableStateOf(true) } LaunchedEffect(showSettingsButton) { if (showSettingsButton) { delay(5000) showSettingsButton = false } } if (cameraPositionState.isMoving) { // Reset the timer whenever the user is dragging the map. showSettingsButton = true viewModel.onMapDragged() } Box(modifier = Modifier.fillMaxSize()) { GoogleMap( modifier = Modifier.fillMaxSize(), cameraPositionState = cameraPositionState, properties = MapProperties( isMyLocationEnabled = true, mapType = MapType.NORMAL ), uiSettings = MapUiSettings( myLocationButtonEnabled = true, zoomControlsEnabled = false ), onMapLoaded = { showSettingsButton = true }, onPOIClick = { poi -> showSettingsButton = true if (!isCoordinateMode) { coroutineScope.launch { val cameraPosition = CameraPosition.builder() .target(poi.latLng) .zoom(15f) .build() cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(cameraPosition), 2000) } viewModel.onPoiClicked(poi) } }, onMapClick = { latLng -> showSettingsButton = true viewModel.onMapClicked(latLng) }, mapColorScheme = ComposeMapColorScheme.FOLLOW_SYSTEM ) { selectedPlace?.location?.let { Circle( center = it, radius = 75.0, fillColor = Color(0x880088FF), strokeWidth = 2f, strokeColor = Color(0xAA000000) ) } } var isControlsExpanded by rememberSaveable { mutableStateOf(false) } AnimatedVisibility( visible = showSettingsButton, enter = fadeIn(), exit = fadeOut() ) { Column( modifier = Modifier .padding(top = 48.dp, start = 16.dp) .align(Alignment.TopStart), horizontalAlignment = Alignment.Start ) { FloatingActionButton( onClick = { isControlsExpanded = !isControlsExpanded // Keep the button visible while the controls are expanded showSettingsButton = true }, modifier = Modifier.padding(bottom = 8.dp), containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface ) { Icon( imageVector = if (isControlsExpanded) Icons.Default.ChevronLeft else Icons.Default.Settings, contentDescription = if (isControlsExpanded) stringResource(R.string.collapse_settings) else stringResource(R.string.expand_settings) ) } AnimatedVisibility( visible = isControlsExpanded, enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() ) { androidx.compose.material3.ElevatedCard( modifier = Modifier .padding(8.dp) .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) ) { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Row( modifier = Modifier.padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(end = 16.dp) ) { Text( text = if (isFullView) stringResource(R.string.full_view) else stringResource(R.string.compact_view), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(bottom = 4.dp) ) Switch( checked = isFullView, onCheckedChange = { isFullView = it }, modifier = Modifier.scale(0.8f) ) } Column( horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = if (isCoordinateMode) stringResource(R.string.coords_mode) else stringResource(R.string.poi_mode), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(bottom = 4.dp) ) Switch( checked = isCoordinateMode, onCheckedChange = { viewModel.onToggleCoordinateMode(it) }, modifier = Modifier.scale(0.8f) ) } } androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) androidx.compose.material3.FilledTonalButton( onClick = { showContentSelectionDialog = true }, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.select_fields)) } } } } } } selectedPlace?.let { place -> if (isFullView) { PlaceDetailsFullView( place = place, onDismiss = { viewModel.onDismissPlace() }, content = selectedFullContent, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .fillMaxHeight(0.9f) ) } else { PlaceDetailsCompactView( place = place, onDismiss = { viewModel.onDismissPlace() }, content = selectedCompactContent, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() ) } } if (showContentSelectionDialog) { if (isFullView) { PlaceContentSelectionDialog( title = stringResource(R.string.select_full_view_fields), allContent = com.google.android.libraries.places.widget.PlaceDetailsFragment.Content.values().toList(), selectedContent = selectedFullContent, onSelectionChanged = { viewModel.updateFullContent(it) }, onDismissRequest = { showContentSelectionDialog = false }, nameProvider = { it.name } ) } else { PlaceContentSelectionDialog( title = stringResource(R.string.select_compact_view_fields), allContent = com.google.android.libraries.places.widget.PlaceDetailsCompactFragment.Content.values().toList(), selectedContent = selectedCompactContent, onSelectionChanged = { viewModel.updateCompactContent(it) }, onDismissRequest = { showContentSelectionDialog = false }, nameProvider = { it.name } ) } } } } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/ui/map/PlaceContentSelectionDialog.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.ui.map import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource @Composable fun PlaceContentSelectionDialog( title: String, allContent: List, selectedContent: List, onSelectionChanged: (List) -> Unit, onDismissRequest: () -> Unit, nameProvider: (T) -> String ) { AlertDialog( onDismissRequest = onDismissRequest, title = { Text(text = title) }, text = { LazyColumn { items(allContent) { item -> val isSelected = selectedContent.contains(item) Row( modifier = Modifier .fillMaxWidth() .clickable { val newSelection = if (isSelected) { selectedContent - item } else { selectedContent + item } onSelectionChanged(newSelection) } .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Checkbox( checked = isSelected, onCheckedChange = null // Handled by Row click ) Text( text = nameProvider(item), modifier = Modifier.padding(start = 8.dp), style = MaterialTheme.typography.bodyMedium ) } } } }, confirmButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(com.example.placedetailscompose.R.string.done)) } } ) } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/ui/map/PlaceDetailsView.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.ui.map import android.content.res.Configuration import android.util.Log import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.fragment.app.FragmentContainerView import androidx.compose.ui.res.stringResource import com.google.android.libraries.places.api.model.Place import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment import com.google.android.libraries.places.widget.PlaceLoadListener import com.google.android.libraries.places.widget.PlaceDetailsFragment import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Alignment import androidx.compose.ui.unit.dp import androidx.compose.foundation.background import androidx.compose.foundation.shape.CircleShape import com.google.android.libraries.places.widget.model.Orientation /** * This composable displays the **Compact** version of the Place Details UI. * * **Why use `AndroidView`?** * The Places UI Kit components (`PlaceDetailsCompactFragment` and `PlaceDetailsFragment`) are currently * implemented as Android Fragments, not native Composables. To use them in a Jetpack Compose app, * we need to bridge the gap using `AndroidView`. This allows us to host a legacy View (in this case, * a `FragmentContainerView`) inside our Compose layout. * * @param place The point of interest to display details for. * @param onDismiss A callback to be invoked when the place details fragment is dismissed. */ @Composable fun PlaceDetailsCompactView( place: Place, onDismiss: () -> Unit, modifier: Modifier = Modifier, content: List = PlaceDetailsCompactFragment.ALL_CONTENT, ) { // We need to know the device orientation to tell the Fragment how to lay itself out. // Although Compose handles layout differently, the underlying Fragment still relies on this signal. val orientation = if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { Orientation.HORIZONTAL } else { Orientation.VERTICAL } val context = LocalContext.current // **Why generate a View ID?** // The FragmentManager needs a unique ID to identify the container where the fragment will be placed. // `View.generateViewId()` gives us a safe, unique integer that won't collide with other views. // We use `remember` so this ID persists across recompositions. val fragmentContainerId = remember { View.generateViewId() } // We need the FragmentManager to perform Fragment transactions (adding/removing the fragment). // We cast the Context to AppCompatActivity assuming this Composable is hosted within one. // In a production app, you might want a more robust way to provide the FragmentManager. val fragmentManager = remember(context) { (context as? AppCompatActivity)?.supportFragmentManager ?: throw IllegalStateException("Context must be a FragmentActivity") } val fragment = remember(fragmentManager, fragmentContainerId, orientation, content) { fragmentManager.findFragmentById(fragmentContainerId) as? PlaceDetailsCompactFragment ?: PlaceDetailsCompactFragment.newInstance( content, orientation, ).also { fragment -> // **Listening for Load Events** fragment.setPlaceLoadListener(object : PlaceLoadListener { override fun onSuccess(place: Place) { Log.d("PlaceDetails", "Loading details for: ${place.id} at ${place.location}") } override fun onFailure(e: Exception) { Log.d("PlaceDetailsView", "Place failed to load place: ${e.message}") onDismiss() } }) } } Box(modifier = modifier.fillMaxWidth()) { AndroidView( modifier = Modifier.fillMaxWidth(), factory = { context -> // **The Factory Block** // This runs only once when the AndroidView is first created. // We create the container view that will hold our Fragment. FragmentContainerView(context).apply { id = fragmentContainerId // Ensure the fragment is added. // We use commit() (async) to allow the view to be attached before the transaction runs. if (fragmentManager.findFragmentById(fragmentContainerId) == null) { fragmentManager.beginTransaction() .add(fragmentContainerId, fragment) .commit() } } }, update = { view -> // **The Update Block** // This runs whenever the Composable recomposes (e.g., when `place` changes). // We post the update to ensure it runs after the fragment transaction has completed // and the fragment's view hierarchy is fully initialized. view.post { // Load the place data if (place.id != null) { fragment.loadWithPlaceId(place.id!!) } else if (place.location != null) { fragment.loadWithCoordinates(place.location!!) } else { Log.e("PlaceDetailsView", "Place has no ID and no location: $place") } } } ) // Close Button IconButton( onClick = onDismiss, modifier = Modifier .align(Alignment.TopEnd) .padding(16.dp) .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), shape = CircleShape) ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(com.example.placedetailscompose.R.string.close), tint = MaterialTheme.colorScheme.onSurface ) } } } /** * This composable displays the **Full** version of the Place Details UI. * * It follows the same pattern as [PlaceDetailsCompactView], but wraps the [PlaceDetailsFragment] * instead. This fragment takes up more screen space and shows more detailed information. */ @Composable fun PlaceDetailsFullView( place: Place, onDismiss: () -> Unit, modifier: Modifier = Modifier, content: List = PlaceDetailsFragment.STANDARD_CONTENT, ) { val orientation = if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { Orientation.HORIZONTAL } else { Orientation.VERTICAL } val context = LocalContext.current val fragmentManager = remember(context) { (context as? AppCompatActivity)?.supportFragmentManager ?: throw IllegalStateException("Context must be a FragmentActivity") } val fragmentContainerId = remember { View.generateViewId() } val fragment = remember(fragmentManager, fragmentContainerId, orientation, content) { fragmentManager.findFragmentById(fragmentContainerId) as? PlaceDetailsFragment ?: PlaceDetailsFragment.newInstance( content, orientation, ).also { fragment -> fragment.setPlaceLoadListener(object : PlaceLoadListener { override fun onSuccess(place: Place) { Log.d("PlaceDetailsFullView", "Place loaded: $place") } override fun onFailure(e: Exception) { Log.d("PlaceDetailsFullView", "Place failed to load place: ${e.message}") onDismiss() } }) } } Box(modifier = modifier.fillMaxSize()) { // Container for the bottom sheet content Box( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) ) { AndroidView( modifier = Modifier.fillMaxWidth(), factory = { context -> FragmentContainerView(context).apply { id = fragmentContainerId if (fragmentManager.findFragmentById(fragmentContainerId) == null) { fragmentManager.beginTransaction() .add(fragmentContainerId, fragment) .commit() } } }, update = { view -> view.post { if (place.id != null) { fragment.loadWithPlaceId(place.id!!) } else if (place.location != null) { fragment.loadWithCoordinates(place.location!!) } else { Log.e("PlaceDetailsFullView", "Place has no ID and no location: $place") } } } ) // Close Button IconButton( onClick = onDismiss, modifier = Modifier .align(Alignment.TopEnd) .padding(16.dp) .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), shape = CircleShape) ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(com.example.placedetailscompose.R.string.close), tint = MaterialTheme.colorScheme.onSurface ) } } } } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/ui/theme/Color.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.ui.theme import androidx.compose.ui.graphics.Color val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/ui/theme/Theme.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.ui.theme import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80 ) private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 ) @Composable fun PlaceDetailsComposeTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { val colorScheme = when { darkTheme -> DarkColorScheme else -> LightColorScheme } val view = LocalView.current if (!view.isInEditMode) { SideEffect { val activity = view.context as Activity activity.window.statusBarColor = android.graphics.Color.TRANSPARENT WindowCompat.getInsetsController(activity.window, view).isAppearanceLightStatusBars = !darkTheme } } MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/ui/theme/Typography.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp ) ) ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/ui/theme/Utils.kt ================================================ /* * Copyright 2025 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placedetailscompose.ui.theme import android.app.Activity import android.view.View import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat /** * Sets the status bar color for the given Activity, handling different API levels and modern * best practices to avoid the deprecation warning. * * @param activity The target Activity. * @param color The color to set (e.g., Color.Red.toArgb()). * @param isLight True if the status bar content (icons/text) should be dark (for light backgrounds). */ fun setStatusBarColor(activity: Activity, color: Int, isLight: Boolean) { // Set the color directly on the Window // This property is used across many API levels, and while the deprecated method // is often the one that causes the linter warning, accessing the property directly // is the way to set it in a modern way for pre-API 35 devices. @Suppress("DEPRECATION") activity.window.statusBarColor = color // --- Modern System Insets & Light Status Bar Handling --- // 1. Get the WindowInsetsControllerCompat (backward-compatible controller) val controller = WindowCompat.getInsetsController(activity.window, activity.findViewById(android.R.id.content)) // 2. Set the appearance (dark icons/text for light status bar background) controller.isAppearanceLightStatusBars = isLight // Optional: Ensure the window content is drawn *behind* the status bar // This is the core of modern edge-to-edge handling and what the deprecation message suggests. WindowCompat.setDecorFitsSystemWindows(activity.window, false) } ================================================ FILE: PlaceDetailsCompose/src/main/java/com/example/placedetailscompose/viewmodels/MapViewModel.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailscompose.viewmodels import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.example.placedetailscompose.repository.LocationRepository import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PointOfInterest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn private const val TAG = "MapViewModel" class MapViewModel(application: Application) : AndroidViewModel(application) { private val locationRepository = LocationRepository(application) val sydney = LatLng(40.01833081193422, -105.27805050328878) // **Permission Handling** // We use a StateFlow to track whether location permissions have been granted. // This is crucial because we don't want to start collecting location updates // until we know we have the necessary permissions. private val _permissionGranted = MutableStateFlow(false) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val deviceLocation: StateFlow = _permissionGranted .flatMapLatest { hasPermission -> // **Lazy Location Collection** // We use `flatMapLatest` to switch between flows based on the permission state. // If permission is granted, we start collecting from the repository. // If not, we emit `null` (or keep the previous state). // This prevents `SecurityException` crashes and ensures we only ask for location // when it's safe to do so. if (hasPermission) { locationRepository.getDeviceLocation() } else { flowOf(null) } } .map { it?.let { loc -> LatLng(loc.latitude, loc.longitude) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) /** * Called when the UI has confirmed that location permissions are granted. * This triggers the [deviceLocation] flow to start fetching updates. */ fun onPermissionGranted() { _permissionGranted.value = true } private val _selectedPlace = MutableStateFlow(null) val selectedPlace = _selectedPlace.asStateFlow() // **User Tracking State** // This state determines if the map camera should automatically follow the user's device location. // It starts as `true` (following) but can be disabled by user interaction (dragging). private val _isMapFollowingUser = MutableStateFlow(true) val isMapFollowingUser: StateFlow = _isMapFollowingUser.asStateFlow() // **Coordinate Mode State** // This state determines whether clicking the map triggers Place Details for the clicked coordinates. private val _isCoordinateMode = MutableStateFlow(false) val isCoordinateMode: StateFlow = _isCoordinateMode.asStateFlow() private val _hasAnimatedToPlace = MutableStateFlow(false) val hasAnimatedToPlace: StateFlow = _hasAnimatedToPlace.asStateFlow() fun onAnimateToPlaceFinish() { _hasAnimatedToPlace.value = true } /** * Called when the user manually drags the map. * We disable user tracking so the map doesn't jump back to the user's location while they are exploring. */ fun onMapDragged() { _isMapFollowingUser.value = false } /** * Called when the "My Location" button is clicked. * We re-enable user tracking to snap the camera back to the user's location. */ fun onMyLocationClicked() { _isMapFollowingUser.value = true } fun onPoiClicked(poi: PointOfInterest) { // When a POI is clicked, we create a Place object with the ID and LatLng. // This allows us to load details using the Place ID. val place = com.google.android.libraries.places.api.model.Place.builder() .setId(poi.placeId) .setLocation(poi.latLng) .setDisplayName(poi.name) .build() _selectedPlace.value = place } fun onMapClicked(latLng: LatLng) { if (_isCoordinateMode.value) { // In Coordinate Mode, we create a Place object with just the LatLng. // The Place Details UI will load details for this location. val place = com.google.android.libraries.places.api.model.Place.builder() .setLocation(latLng) .build() _selectedPlace.value = place } } fun onToggleCoordinateMode(enabled: Boolean) { _isCoordinateMode.value = enabled // Clear selection when switching modes to avoid confusion _selectedPlace.value = null _hasAnimatedToPlace.value = false } // **Content Selection State** private val _selectedCompactContent = MutableStateFlow(com.google.android.libraries.places.widget.PlaceDetailsCompactFragment.ALL_CONTENT) val selectedCompactContent: StateFlow> = _selectedCompactContent.asStateFlow() private val _selectedFullContent = MutableStateFlow(com.google.android.libraries.places.widget.PlaceDetailsFragment.STANDARD_CONTENT) val selectedFullContent: StateFlow> = _selectedFullContent.asStateFlow() fun updateCompactContent(content: List) { _selectedCompactContent.value = content } fun updateFullContent(content: List) { _selectedFullContent.value = content } fun onDismissPlace() { _selectedPlace.value = null _hasAnimatedToPlace.value = false } } ================================================ FILE: PlaceDetailsCompose/src/main/res/drawable/close_button_background.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/drawable/ic_close.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/drawable/outline_my_location_24.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/drawable/outline_settings_24.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/layout/place_details_fragment.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/mipmap-anydpi/ic_launcher.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/mipmap-anydpi/ic_launcher_round.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/values/ids.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/values/strings.xml ================================================ Place Details Compose YOUR_MAP_ID Location permission denied Full View Compact View Coords Mode POI Mode Select Fields Select Full View Fields Select Compact View Fields Collapse Settings Expand Settings Close Done API Key was not set in secrets.properties Map ID is not set. Some features may not work. See README for instructions. ================================================ FILE: PlaceDetailsCompose/src/main/res/values/styles.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: PlaceDetailsCompose/src/main/res/xml/data_extraction_rules.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/.gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: PlaceDetailsUIKit/README.md ================================================ # **Place Details UI Kit Samples for Android** This Android application provides two distinct demonstrations of the **Places** UI Kit for **Android **, showcasing how to integrate and customize the PlaceDetailsCompactFragment. 1. **Simple Integration (MainActivity)**: A straightforward example of how to add the Place Details widget to an app. It focuses on handling map interactions, displaying the widget with a default set of content, and persisting its state across screen rotations using a ViewModel. 2. **Configurable Integration (ConfigurablePlaceDetailsActivity)**: A more advanced example that demonstrates how to dynamically configure the content sections displayed within the widget. It features a settings dialog, built with Jetpack Compose, that allows the user to select which place data fields (e.g., Photos, Rating, Website) they want to see. Both samples demonstrate best practices for handling runtime permissions, the Android Activity lifecycle (including configuration changes), and lifecycle-aware data loading to prevent common crashes. ## **Features** * **Google Map Integration**: Displays an interactive Google Map centered on the user's location. * **POI Click Handling**: Detects clicks on POIs and retrieves their unique Place ID. * **Place Details UI Kit**: Uses the modern PlaceDetailsCompactFragment to display rich details about a selected place. * **Dynamic Orientation**: The PlaceDetailsCompactFragment automatically adjusts its layout between VERTICAL and HORIZONTAL based on the device's orientation. * **Robust State Management**: Uses a ViewModel to retain the selected place and/or configuration across configuration changes (e.g., screen rotation), ensuring a seamless user experience. * **Advanced Customization**: Features a custom "Synthwave" theme for the PlaceDetailsCompactFragment to demonstrate how easily the widget's appearance can be modified. * **Dynamic Content Configuration**: The ConfigurablePlaceDetailsActivity shows how to let users choose which Place.Field sections are displayed in the widget at runtime. * **Jetpack Compose Integration**: The content selection dialog is built using Jetpack Compose, showcasing its use within a View-based project. * **Lifecycle-Aware Implementation**: Includes a robust solution to prevent common lifecycle-related crashes when loading the fragment. ## **Getting Started** To build and run this sample application, you will need an API key from the Google Cloud Console. ### **Set Up Your API Key** 1. Go to the [Google Cloud Console](https://console.cloud.google.com/). 2. Create a new project or select an existing one. 3. Enable the **Maps SDK for Android** and the **Places API**. 4. Create an API key. For security, it's highly recommended to restrict your API key to your Android app's package name and SHA-1 certificate fingerprint. 5. In the root directory of this project, create a file named secrets.properties. This file is already listed in .gitignore to prevent it from being checked into version control. 6. Add your API key to the secrets.properties file. The key should be assigned to both MAPS_API_KEY and PLACES_API_KEY: ```properties MAPS_API_KEY="YOUR_API_KEY_HERE" PLACES_API_KEY="YOUR_API_KEY_HERE" ``` ### **Build and Run** 1. Open the project in Android Studio. 2. Let Gradle sync the project dependencies. 3. Run the app on an Android emulator or a physical device. The app has two launcher activities. You can choose which one to run using the "Run/Debug Configurations" dropdown in Android Studio. * **MainActivity**: Launches the simple, non-configurable demo. * **ConfigurablePlaceDetailsActivity**: Launches the advanced demo with content selection. The app will request location permissions. Once granted, it will zoom to your current location. Tapping on any POI on the map (e.g., a restaurant, park, or shop) will display the PlaceDetailsCompactFragment at the bottom of the screen. In the configurable demo, a settings icon allows you to customize the widget's content. ## **Code Highlights** ### **MainActivity.kt** * **MainViewModel**: A simple ViewModel class defined at the top of the file. Its sole purpose is to store the selectedPlaceId so that it survives configuration changes. * **onCreate()**: * Initializes the ActivityResultLauncher for handling location permission requests. * Initializes the Places SDK and the FusedLocationProviderClient. * Crucially, it checks if viewModel.selectedPlaceId is not null. If it has a value (meaning the app was rotated while a place was selected), it calls showPlaceDetailsFragment() to restore the view. * **onPoiClick(poi: PointOfInterest)**: * This is the callback for when a user taps a POI on the map. * It saves the poi.placeId to the viewModel. * It then calls showPlaceDetailsFragment() to display the widget. * **showPlaceDetailsFragment(placeId: String)**: * This is the core function for displaying the widget. * It dynamically determines the orientation (HORIZONTAL or VERTICAL) based on the device's current configuration. * It creates a new instance of PlaceDetailsCompactFragment, passing it the content to display, the orientation, and a custom theme (R.style.CustomizedPlaceDetailsTheme). * It sets a PlaceLoadListener to handle onSuccess and onFailure events. The UI (loading indicator and fragment visibility) is updated in these callbacks. * It adds the fragment to the FragmentContainerView using the FragmentManager. * **Important**: The call to fragment.loadWithPlaceId(placeId) is wrapped in binding.root.post { ... }. This is a key fix that prevents a kotlin.UninitializedPropertyAccessException crash by ensuring the fragment's view is fully created and attached before its data is loaded. ### **ConfigurablePlaceDetailsActivity.kt** This activity demonstrates a more advanced use case where the content of the widget is user-configurable. * **ContentSelectionViewModel.kt**: This ViewModel is more complex. It holds both the selectedPlaceId and the state of the content configuration. It uses StateFlow to expose lists of selected and unselected content items, which the UI observes. * **Content Configuration Dialog**: * The configure\_button FAB opens an AlertDialog. * The dialog's view (content\_selector\_dialog.xml) contains a ComposeView. * The UI of the dialog is built declaratively with Jetpack Compose in the DialogContent composable function. It displays two lists with sticky headers for "Selected" and "Unselected" content. * Clicking an item calls viewModel.toggleSelection(), which atomically updates the state flows, causing the Compose UI to automatically re-render. * **showPlaceDetailsFragment(placeId: String)**: * This function is similar to the one in MainActivity, but with one key difference. * When creating the PlaceDetailsCompactFragment, it gets the list of content directly from the ViewModel: PlaceDetailsCompactFragment.newInstance(viewModel.selectedContent.value.map { it.content }, ...) * This ensures that whatever content the user has selected in the dialog is what the fragment will request and display. ### **Customization** The custom "Synthwave" theme is defined in [`themes.xml`](app/src/main/res/values/themes.xml) and [`colors.xml`](app/src/main/res/values/colors.xml). By overriding attributes like placesColorSurface, placesColorPrimary, and placesTextAppearanceBodyMedium, you can completely change the look and feel of the widget to match your app's branding. ```xml ``` This sample provides a complete and robust foundation for integrating the Places UI Kit into your own applications. ================================================ FILE: PlaceDetailsUIKit/build.gradle.kts ================================================ /* * Copyright 2025 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. */ // The `plugins` block is where we apply Gradle plugins to this module. // Plugins add new tasks and configurations to our build process. plugins { id("places-demo.android.application") // This plugin enables Kotlin support in the Android project, allowing us to write code in Kotlin. alias(libs.plugins.kotlin.android) // This plugin from Google helps manage API keys and other secrets by reading them from a `secrets.properties` // file (which should be in .gitignore) and exposing them in the `BuildConfig` file at compile time. // This is crucial for keeping sensitive data out of version control. id("places-demo.secrets") // This plugin provides the necessary integration for using Jetpack Compose with the Kotlin compiler. alias(libs.plugins.kotlin.compose) } // The `android` block is where we configure all the Android-specific build options. android { // The `namespace` is a unique identifier for the app's generated R class. It's also used // as the default `applicationId` if not specified in `defaultConfig`. namespace = "com.example.placedetailsuikit" defaultConfig { // `applicationId` is the unique identifier for the app on the Google Play Store and on the device. applicationId = "com.example.placedetailsuikit" // `minSdk` is the minimum API level required to run the app. Devices below this level cannot install it. minSdk = 27 versionCode = 1 versionName = "1.0" // Specifies the instrumentation runner for running Android tests. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { // The `release` block configures settings for the release build of the app. release { // `isMinifyEnabled` enables code shrinking with R8 to reduce the app's size. // It's disabled here for simplicity in a sample app, but highly recommended for production. isMinifyEnabled = false // `proguardFiles` specifies the files that define the R8 shrinking and obfuscation rules. proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } kotlin { // Configures Kotlin-specific compiler options. compilerOptions { freeCompilerArgs.addAll( "-opt-in=kotlin.RequiresOptIn", "-Xannotation-default-target=param-property" ) } } buildFeatures { // `viewBinding` generates a binding class for each XML layout file, providing a type-safe // way to access views without `findViewById`. This is used in the XML-based activities. viewBinding = true // `compose` enables Jetpack Compose for the project. compose = true // `buildConfig` generates a `BuildConfig` class that contains constants from the build configuration, // such as the API key from the secrets plugin. buildConfig = true } composeOptions { // Sets the version of the Kotlin compiler extension for Compose. This version must be // compatible with the Kotlin version used in the project. kotlinCompilerExtensionVersion = "1.5.1" } } // The `dependencies` block is where we declare all the external libraries the app needs. // These are fetched from repositories like Maven Central and Google's Maven repository. dependencies { // --- Core AndroidX & UI Libraries --- // These are foundational libraries for building modern Android apps. implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) // For Material Design components (used in XML layouts). implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx) // For the ViewModel architecture component. // --- Google Play Services --- // These are the essential libraries for this sample, providing Maps and Places functionality. implementation(libs.google.maps.services) // The core SDK for embedding Google Maps. implementation(libs.places) // The SDK for the Places UI Kit (PlaceDetails fragments). implementation(libs.play.services.location) // Needed for the FusedLocationProviderClient to get the device's location. // --- Jetpack Compose --- // These libraries are for building UIs with Jetpack Compose. implementation(libs.androidx.material3) // The latest Material Design components for Compose. implementation(libs.androidx.activity.compose) // Integration between Activity and Compose. implementation(platform(libs.androidx.compose.bom)) // The Compose Bill of Materials (BOM) ensures all Compose libraries use compatible versions. implementation(libs.androidx.ui.tooling.preview) // For displaying @Preview composables in Android Studio. debugImplementation(libs.androidx.ui.tooling) // Provides tools for inspecting Compose UIs. // --- Testing Libraries --- // These libraries are for writing and running tests. // `testImplementation` is for local unit tests (running on the JVM). testImplementation(libs.junit) // `androidTestImplementation` is for instrumented tests (running on an Android device or emulator). androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) // For UI testing with the View system. // AndroidX libraries for creating test rules and running tests. androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) // --- Compose Testing --- // These are specific to testing Jetpack Compose UIs. androidTestImplementation(platform(libs.androidx.compose.bom)) // BOM for testing libraries. androidTestImplementation(libs.androidx.ui.test.junit4) // The main library for Compose UI tests. debugImplementation(libs.androidx.ui.test.manifest) // Provides a manifest for UI tests. } demoApp { mainActivity.set(".LauncherActivity") } ================================================ FILE: PlaceDetailsUIKit/lint.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/local.defaults.properties ================================================ PLACES_API_KEY="YOUR_API_KEY" MAPS_API_KEY="YOUR_API_KEY" ================================================ FILE: PlaceDetailsUIKit/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: PlaceDetailsUIKit/src/androidTest/java/com/example/placedetailsuikit/MainActivityInstrumentedTest.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit import android.Manifest import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withParent import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PointOfInterest import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * Instrumented tests for [MainActivity] to verify UI behavior and state management. * These tests run on an Android device or emulator. */ @RunWith(AndroidJUnit4::class) class MainActivityInstrumentedTest { // A Rule to launch MainActivity before each test and clean it up afterward. @get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java) // A Rule to grant location permissions before each test. This prevents the permission dialog // from interrupting the test flow. @get:Rule val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) /** * Test to verify that the map container is displayed when the activity starts. */ @Test fun test_mapIsDisplayedOnLaunch() { onView(withId(R.id.map_fragment)).check(matches(isDisplayed())) } /** * Test the full user flow: * 1. Simulate a POI click on the map. * 2. Verify the Place Details UI appears (with a loader first, then the content). * 3. Click the dismiss button. * 4. Verify the Place Details UI is hidden. */ @Test fun test_poiClickAndDismissFlow() { // --- 1. Simulate a POI Click --- // We get the activity scenario and trigger onPoiClick directly on the UI thread. // This is a reliable way to test the UI logic without actually tapping the map. activityRule.scenario.onActivity { activity -> // A mock POI for "Google Sydney" val poi = PointOfInterest( LatLng(-33.865072, 151.1961474), "ChIJP3Sa8ziYEmsRUKgyFmh9AQM", "Google Sydney" ) activity.onPoiClick(poi) } // --- 2. Verify UI After Click --- // The wrapper view containing the fragment should now be visible. onView(withId(R.id.place_details_wrapper)).check(matches(isDisplayed())) // Check for the loading indicator that is a child of our wrapper. // This avoids ambiguity with the loader inside the PlaceDetailsCompactFragment. onView( allOf( withId(R.id.loading_indicator_main), withParent(withId(R.id.place_details_wrapper)) ) ).check(matches(isDisplayed())) // The Place Details fragment loads data asynchronously. For a simple sample, // a short sleep is a straightforward way to wait for the UI to update. // For a production app, using Espresso Idling Resources is the recommended approach. Thread.sleep(3000) // Wait for 3 seconds for the network call to complete. // Check that our specific loader is now gone. onView( allOf( withId(R.id.loading_indicator_main), withParent(withId(R.id.place_details_wrapper)) ) ).check(matches(not(isDisplayed()))) onView(withId(R.id.place_details_container)).check(matches(isDisplayed())) onView(withId(R.id.dismiss_button)).check(matches(isDisplayed())) // --- 3. Click the Dismiss Button --- onView(withId(R.id.dismiss_button)).perform(click()) // --- 4. Verify UI After Dismiss --- // The wrapper view should now be hidden. onView(withId(R.id.place_details_wrapper)).check(matches(not(isDisplayed()))) } /** * Test that the Place Details view's state is correctly restored after a configuration change * (e.g., screen rotation), thanks to the ViewModel. */ @Test fun test_stateRestoresOnConfigurationChange() { // --- 1. Show the Place Details Fragment --- activityRule.scenario.onActivity { activity -> val poi = PointOfInterest( LatLng(-33.865072, 151.1961474), "ChIJP3Sa8ziYEmsRUKgyFmh9AQM", "Google Sydney" ) activity.onPoiClick(poi) } // Wait for it to load. Thread.sleep(3000) onView(withId(R.id.place_details_wrapper)).check(matches(isDisplayed())) // --- 2. Recreate the Activity (Simulates Rotation) --- activityRule.scenario.recreate() // --- 3. Verify the UI is still visible --- // Add another wait after recreation for the fragment to reload and become visible. Thread.sleep(3000) // The wrapper should still be visible without needing another click because the // selected place ID was restored from the ViewModel. onView(withId(R.id.place_details_wrapper)).check(matches(isDisplayed())) onView(withId(R.id.place_details_container)).check(matches(isDisplayed())) onView(withId(R.id.dismiss_button)).check(matches(isDisplayed())) } } ================================================ FILE: PlaceDetailsUIKit/src/androidTest/java/com/example/placedetailsuikit/compact/ConfigurablePlaceDetailsActivityInstrumentedTest.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.compact import android.Manifest import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import com.example.placedetailsuikit.R import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PointOfInterest import org.hamcrest.CoreMatchers import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * Instrumented tests for [ConfigurablePlaceDetailsActivity]. * These tests verify the UI behavior related to the configuration dialog and state restoration. */ @RunWith(AndroidJUnit4::class) class ConfigurablePlaceDetailsActivityInstrumentedTest { /** * A rule to launch [ConfigurablePlaceDetailsActivity] and interact with its Compose content. */ @get:Rule val composeTestRule = createAndroidComposeRule() /** * A rule to grant location permissions before each test, preventing system dialogs * from interfering with the tests. */ @get:Rule val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) /** * Tests that the configuration dialog opens, displays content correctly, * and can be dismissed. */ @Test fun test_configureDialogOpensAndDismisses() { // 1. Click the "Configure" FAB to open the dialog Espresso.onView(ViewMatchers.withId(R.id.configure_button)).perform(ViewActions.click()) // 2. Verify that the Compose-based dialog content is displayed // We check for the sticky headers to confirm the LazyColumn is there. composeTestRule.onNodeWithText("Selected Content").assertIsDisplayed() composeTestRule.onNodeWithText("Unselected Content").assertIsDisplayed() // Check for a specific item in the list composeTestRule.onNodeWithText("Rating").assertIsDisplayed() // 3. Click the "Close" button on the AlertDialog Espresso.onView(ViewMatchers.withText("Close")).perform(ViewActions.click()) // 4. Verify the dialog is gone by checking that its content is no longer visible composeTestRule.onNodeWithText("Selected Content").assertDoesNotExist() } /** * Tests that toggling an item in the dialog and then rotating the screen * preserves the selection state. */ @Test fun test_selectionStatePersistsOnConfigurationChange() { // 1. Open the configuration dialog Espresso.onView(ViewMatchers.withId(R.id.configure_button)).perform(ViewActions.click()) // 2. Toggle an item (e.g., move "Rating" from selected to unselected) composeTestRule.onNodeWithText("Rating").performClick() // After the click, "Rating" should now be under the "Unselected Content" header. // We can verify this by checking its new position relative to the headers. composeTestRule.onNodeWithText("Rating").assertIsDisplayed() // 3. Close the dialog Espresso.onView(ViewMatchers.withText("Close")).perform(ViewActions.click()) // 4. Recreate the activity to simulate a screen rotation composeTestRule.activityRule.scenario.recreate() // 5. Re-open the dialog and verify the state was restored Espresso.onView(ViewMatchers.withId(R.id.configure_button)).perform(ViewActions.click()) // Check that "Rating" is still in the unselected list. composeTestRule.onNodeWithText("Unselected Content").assertIsDisplayed() composeTestRule.onNodeWithText("Rating").assertIsDisplayed() } /** * Test the full user flow: click POI, check that the Place Details card is displayed, * and dismiss it. This confirms the activity's core functionality. */ @Test fun test_poiClickAndDismissFlow() { // 1. Simulate a POI Click composeTestRule.activityRule.scenario.onActivity { activity -> val poi = PointOfInterest( LatLng(-33.865072, 151.1961474), "ChIJP3Sa8ziYEmsRUKgyFmh9AQM", "Google Sydney" ) activity.onPoiClick(poi) } // 2. Verify UI After Click // The wrapper view should be visible, and the loader should be showing initially. Espresso.onView(ViewMatchers.withId(R.id.place_details_wrapper)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) Espresso.onView(ViewMatchers.withId(R.id.loading_indicator_configurable)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) // Wait for the place to load. For a real app, use Espresso Idling Resources. Thread.sleep(3000) // The loader should be gone, and the fragment container should be visible. Espresso.onView(ViewMatchers.withId(R.id.loading_indicator_configurable)) .check(ViewAssertions.matches(CoreMatchers.not(ViewMatchers.isDisplayed()))) Espresso.onView(ViewMatchers.withId(R.id.place_details_container)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) Espresso.onView(ViewMatchers.withId(R.id.dismiss_button)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) // 3. Click the Dismiss Button Espresso.onView(ViewMatchers.withId(R.id.dismiss_button)).perform(ViewActions.click()) // 4. Verify UI After Dismiss Espresso.onView(ViewMatchers.withId(R.id.place_details_wrapper)) .check(ViewAssertions.matches(CoreMatchers.not(ViewMatchers.isDisplayed()))) } } ================================================ FILE: PlaceDetailsUIKit/src/androidTest/java/com/example/placedetailsuikit/full/FullConfigurablePlaceDetailsActivityInstrumentedTest.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.full import android.Manifest import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import com.example.placedetailsuikit.R import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PointOfInterest import org.hamcrest.CoreMatchers.not import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * Instrumented tests for [FullConfigurablePlaceDetailsActivity]. * These tests verify the UI behavior related to the configuration dialog and state restoration * for the full Place Details Fragment. */ @RunWith(AndroidJUnit4::class) class FullConfigurablePlaceDetailsActivityInstrumentedTest { /** * A rule to launch [FullConfigurablePlaceDetailsActivity] and interact with its Compose content. */ @get:Rule val composeTestRule = createAndroidComposeRule() /** * A rule to grant location permissions before each test, preventing system dialogs * from interfering with the tests. */ @get:Rule val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) /** * Tests that the configuration dialog opens, displays content correctly, * and can be dismissed. */ @Test fun test_configureDialogOpensAndDismisses() { // 1. Click the "Configure" FAB to open the dialog onView(withId(R.id.configure_button)).perform(click()) // 2. Verify that the Compose-based dialog content is displayed // We check for the sticky headers to confirm the LazyColumn is there. composeTestRule.onNodeWithText("Selected Content").assertIsDisplayed() composeTestRule.onNodeWithText("Unselected Content").assertIsDisplayed() // Check for a specific item in the list composeTestRule.onNodeWithText("Rating").assertIsDisplayed() // 3. Click the "Close" button on the AlertDialog onView(withText("Close")).perform(click()) // 4. Verify the dialog is gone by checking that its content is no longer visible composeTestRule.onNodeWithText("Selected Content").assertDoesNotExist() } /** * Tests that toggling an item in the dialog and then rotating the screen * preserves the selection state. */ @Test fun test_selectionStatePersistsOnConfigurationChange() { // 1. Open the configuration dialog onView(withId(R.id.configure_button)).perform(click()) // 2. Toggle an item (e.g., move "Rating" from selected to unselected) composeTestRule.onNodeWithText("Rating").performClick() // After the click, "Rating" should now be under the "Unselected Content" header. // We can verify this by checking its new position relative to the headers. // The item should still be displayed, but its "location" in the list has changed conceptually. // The check that follows is more specific to its new list. // 3. Close the dialog onView(withText("Close")).perform(click()) // 4. Recreate the activity to simulate a screen rotation composeTestRule.activityRule.scenario.recreate() // 5. Re-open the dialog and verify the state was restored onView(withId(R.id.configure_button)).perform(click()) // Check that "Rating" is still in the unselected list. composeTestRule.onNodeWithText("Unselected Content").assertIsDisplayed() composeTestRule.onNodeWithText("Rating").assertIsDisplayed() } /** * Test the full user flow: click POI, check that the Place Details card is displayed, * and dismiss it. This confirms the activity's core functionality. */ @Test fun test_poiClickAndDismissFlow() { // 1. Simulate a POI Click composeTestRule.activityRule.scenario.onActivity { activity -> val poi = PointOfInterest( LatLng(-33.865072, 151.1961474), "ChIJP3Sa8ziYEmsRUKgyFmh9AQM", "Google Sydney" ) activity.onPoiClick(poi) } // 2. Verify UI After Click // The wrapper view should be visible, and the loader should be showing initially. onView(withId(R.id.place_details_wrapper)).check(matches(isDisplayed())) onView(withId(R.id.loading_indicator_configurable)).check(matches(isDisplayed())) // Wait for the place to load. For a real app, use Espresso Idling Resources. Thread.sleep(3000) // The loader should be gone, and the fragment container should be visible. onView(withId(R.id.loading_indicator_configurable)).check(matches(not(isDisplayed()))) onView(withId(R.id.place_details_container)).check(matches(isDisplayed())) onView(withId(R.id.dismiss_button)).check(matches(isDisplayed())) // 3. Click the Dismiss Button onView(withId(R.id.dismiss_button)).perform(click()) // 4. Verify UI After Dismiss onView(withId(R.id.place_details_wrapper)).check(matches(not(isDisplayed()))) } } ================================================ FILE: PlaceDetailsUIKit/src/main/AndroidManifest.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/LauncherActivity.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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.padding import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import com.example.placedetailsuikit.compact.ConfigurablePlaceDetailsActivity import com.example.placedetailsuikit.full.FullConfigurablePlaceDetailsActivity import com.example.placedetailsuikit.ui.theme.PlaceDetailsUIKitTheme class LauncherActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { PlaceDetailsUIKitTheme { LauncherScreen() } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun LauncherScreen() { val context = LocalContext.current Scaffold( topBar = { TopAppBar(title = { Text("Place Details UIKit Demos") }) } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Button(onClick = { context.startActivity(Intent(context, MainActivity::class.java)) }) { Text("Main Activity") } Button(onClick = { context.startActivity(Intent(context, ConfigurablePlaceDetailsActivity::class.java)) }) { Text("Compact Place Details") } Button(onClick = { context.startActivity(Intent(context, FullConfigurablePlaceDetailsActivity::class.java)) }) { Text("Full Place Details") } } } } @Preview(showBackground = true) @Composable fun LauncherScreenPreview() { PlaceDetailsUIKitTheme { LauncherScreen() } } ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/MainActivity.kt ================================================ // Copyright 2025 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. // [START placessdkandroid_place_details_ui_kit_add_place_details_component_full] package com.example.placedetailsuikit import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager import android.content.res.Configuration import android.location.Location import android.os.Bundle import android.util.Log import android.view.View import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.lifecycle.ViewModel import com.example.placedetailsuikit.databinding.ActivityMainBinding import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.OnMapReadyCallback import com.google.android.gms.maps.SupportMapFragment import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PointOfInterest import com.google.android.libraries.places.api.Places import com.google.android.libraries.places.api.model.Place import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment import com.google.android.libraries.places.widget.PlaceLoadListener import com.google.android.libraries.places.widget.model.Orientation private const val TAG = "PlacesUiKit" /** * A simple ViewModel to store UI state that needs to survive configuration changes. * In this case, it holds the ID of the selected place. Using a ViewModel is good practice * as it prevents data loss during events like screen rotation, ensuring a * seamless user experience. */ class MainViewModel : ViewModel() { var selectedPlaceId: String? = null } /** * This activity serves as a basic example of integrating the Place Details UI Kit. * It demonstrates the fundamental steps required: * 1. Setting up a Google Map. * 2. Requesting location permissions to center the map. * 3. Handling clicks on Points of Interest (POIs) to get a Place ID. * 4. Using the Place ID to load and display place details in a [PlaceDetailsCompactFragment]. */ class MainActivity : AppCompatActivity(), OnMapReadyCallback, GoogleMap.OnPoiClickListener { // ViewBinding provides type-safe access to views defined in the XML layout, // eliminating the need for `findViewById` and preventing null pointer exceptions. private lateinit var binding: ActivityMainBinding private var googleMap: GoogleMap? = null // The FusedLocationProviderClient is the main entry point for interacting with the // fused location provider, which intelligently manages the underlying location technologies. private lateinit var fusedLocationClient: FusedLocationProviderClient // Using registerForActivityResult is the modern, recommended approach for handling // permission requests. It decouples the request from the handling logic, making the // code cleaner and easier to manage compared to the older `onRequestPermissionsResult` callback. private lateinit var requestPermissionLauncher: ActivityResultLauncher> // The `by viewModels()` delegate provides a lazy-initialized ViewModel scoped to this Activity. // This ensures that we get the same ViewModel instance across configuration changes. private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // The ActivityResultLauncher is initialized here. The lambda defines the callback // that will be executed once the user responds to the permission dialog. requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> // We check if either fine or coarse location permission was granted. if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { Log.d(TAG, "Location permission granted by user.") fetchLastLocation() } else { // If permission is denied, we inform the user and default to a known location. // This ensures the app remains functional even without location access. Log.d(TAG, "Location permission denied by user.") Toast.makeText( this, "Location permission denied. Showing default location.", Toast.LENGTH_LONG ).show() moveToSydney() } } // enableEdgeToEdge() allows the app to draw behind the system bars for a more immersive experience. enableEdgeToEdge() binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.dismissButton.setOnClickListener { dismissPlaceDetails() } // --- Crucial: Initialize Places SDK --- // It's essential to initialize the Places SDK before making any other Places API calls. // This should ideally be done once, for example, in the Application's `onCreate`. val apiKey = BuildConfig.PLACES_API_KEY if (apiKey.isEmpty() || apiKey == "YOUR_API_KEY") { // A valid API key is required for the Places SDK to function. Log.e(TAG, "No api key") Toast.makeText( this, "Add your own API_KEY in local.properties", Toast.LENGTH_LONG ).show() finish() return } // `initializeWithNewPlacesApiEnabled` is used to opt-in to the new SDK version. Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey) fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) // ------------------------------------ // The SupportMapFragment is the container for the map. `getMapAsync` allows us to // work with the GoogleMap object via a callback once it's fully initialized. val mapFragment = supportFragmentManager.findFragmentById(R.id.map_fragment) as SupportMapFragment? mapFragment?.getMapAsync(this) // This block handles restoration after a configuration change (e.g., screen rotation). // If a place was selected before the rotation, its ID is stored in the ViewModel. // We use this ID to immediately show the details fragment again. if (viewModel.selectedPlaceId != null) { viewModel.selectedPlaceId?.let { placeId -> Log.d(TAG, "Restoring PlaceDetailsFragment for place ID: $placeId") showPlaceDetailsFragment(placeId) } } } /** * This callback is triggered when the GoogleMap object is ready to be used. * All map setup logic should be placed here. */ override fun onMapReady(map: GoogleMap) { Log.d(TAG, "Map is ready") googleMap = map // Setting the OnPoiClickListener allows us to capture user taps on points of interest. googleMap?.setOnPoiClickListener(this) // After the map is ready, we determine the initial camera position based on location permissions. if (isLocationPermissionGranted()) { fetchLastLocation() } else { requestLocationPermissions() } } /** * A helper function to centralize the check for location permissions. */ private fun isLocationPermissionGranted(): Boolean { return ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_COARSE_LOCATION ) == PackageManager.PERMISSION_GRANTED } /** * This function triggers the permission request flow. The result is handled by the * ActivityResultLauncher defined in `onCreate`. */ private fun requestLocationPermissions() { Log.d(TAG, "Requesting location permissions.") requestPermissionLauncher.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } /** * Fetches the device's last known location. This is a fast and battery-efficient way * to get a location fix. It should only be called after verifying permissions. */ @SuppressLint("MissingPermission") private fun fetchLastLocation() { // Double-checking permissions here is a good practice, although the call sites are already guarded. if (isLocationPermissionGranted()) { fusedLocationClient.lastLocation .addOnSuccessListener { location: Location? -> if (location != null) { val userLocation = LatLng(location.latitude, location.longitude) googleMap?.moveCamera(CameraUpdateFactory.newLatLngZoom(userLocation, 13f)) Log.d(TAG, "Moved to user's last known location.") } else { // `lastLocation` can be null if the location has never been recorded. // In this case, we fall back to a default location. Log.d(TAG, "Last known location is null. Falling back to Sydney.") moveToSydney() } } .addOnFailureListener { // This listener handles errors in the location fetching process. Log.e(TAG, "Failed to get location.", it) moveToSydney() } } } /** * Moves the map camera to a default, hardcoded location (Sydney). * This serves as a reliable fallback. */ private fun moveToSydney() { val sydney = LatLng(-33.8688, 151.2093) googleMap?.moveCamera(CameraUpdateFactory.newLatLngZoom(sydney, 13f)) Log.d(TAG, "Moved to Sydney") } /** * This is the callback for the `OnPoiClickListener`. It's triggered when a user * taps a POI on the map. */ override fun onPoiClick(poi: PointOfInterest) { val placeId = poi.placeId Log.d(TAG, "Place ID: $placeId") // We save the selected place ID to the ViewModel. This is critical for surviving // configuration changes. If the user rotates the screen now, the `onCreate` // method will be able to restore the place details view. viewModel.selectedPlaceId = placeId showPlaceDetailsFragment(placeId) } /** * This function is the core of the integration. It creates, configures, and displays * the [PlaceDetailsCompactFragment]. * @param placeId The unique identifier for the place to be displayed. */ private fun showPlaceDetailsFragment(placeId: String) { Log.d(TAG, "Showing PlaceDetailsFragment for place ID: $placeId") // We manage the visibility of UI elements to provide feedback to the user. // The wrapper is shown, and a loading indicator is displayed while the data is fetched. binding.placeDetailsWrapper.visibility = View.VISIBLE binding.dismissButton.visibility = View.GONE binding.placeDetailsContainer.visibility = View.GONE binding.loadingIndicatorMain.visibility = View.VISIBLE // The Place Details widget can be displayed vertically or horizontally. // We dynamically choose the orientation based on the device's current configuration. val orientation = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { Orientation.HORIZONTAL } else { Orientation.VERTICAL } // [START placessdkandroid_place_details_ui_kit_add_place_details_component_snippet] // We create a new instance of the fragment using its factory method. // We can specify which content to show, the orientation, and a custom theme. val fragment = PlaceDetailsCompactFragment.newInstance( PlaceDetailsCompactFragment.ALL_CONTENT, // Show all available content. orientation, R.style.CustomizedPlaceDetailsTheme, ).apply { // The PlaceLoadListener provides callbacks for when the place data is successfully // loaded or when an error occurs. This is where we update our UI state. setPlaceLoadListener(object : PlaceLoadListener { override fun onSuccess(place: Place) { Log.d(TAG, "Place loaded: ${place.id}") // Once the data is loaded, we hide the loading indicator and show the fragment. binding.loadingIndicatorMain.visibility = View.GONE binding.placeDetailsContainer.visibility = View.VISIBLE binding.dismissButton.visibility = View.VISIBLE } override fun onFailure(e: Exception) { Log.e(TAG, "Place failed to load", e) // On failure, we hide the UI and notify the user. dismissPlaceDetails() Toast.makeText(this@MainActivity, "Failed to load place details.", Toast.LENGTH_SHORT).show() } }) } // We add the fragment to our layout's container view. // `commitNow()` is used to ensure the fragment is immediately added and available, // which is important because we need to call a method on it right after. supportFragmentManager .beginTransaction() .replace(binding.placeDetailsContainer.id, fragment) .commitNow() // **This is the key step**: After adding the fragment, we call `loadWithPlaceId` // to trigger the data loading process for the selected place. // We use `post` to ensure this runs after the layout has been measured, // which can prevent potential timing issues. binding.root.post { fragment.loadWithPlaceId(placeId) } } // [END placessdkandroid_place_details_ui_kit_add_place_details_component_snippet] /** * Hides the place details view and clears the selected place ID from the ViewModel. */ private fun dismissPlaceDetails() { binding.placeDetailsWrapper.visibility = View.GONE // Clearing the ID in the ViewModel is important so that if the user rotates the // screen after dismissing, the details view doesn't reappear. viewModel.selectedPlaceId = null } override fun onDestroy() { super.onDestroy() // It's a good practice to nullify references to objects that have a lifecycle // tied to the activity, like the GoogleMap object, to prevent potential memory leaks. googleMap = null } } // [END placessdkandroid_place_details_ui_kit_add_place_details_component_full] ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/compact/ConfigurablePlaceDetailsActivity.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.compact import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager import android.content.res.Configuration import android.location.Location import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.lifecycle.lifecycleScope import com.example.placedetailsuikit.BuildConfig import com.example.placedetailsuikit.R import com.example.placedetailsuikit.databinding.ActivityConfigurableMapBinding import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.OnMapReadyCallback import com.google.android.gms.maps.SupportMapFragment import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PointOfInterest import com.google.android.libraries.places.api.Places import com.google.android.libraries.places.api.model.Place import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment.Content import com.google.android.libraries.places.widget.PlaceLoadListener import com.google.android.libraries.places.widget.model.Orientation import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch private const val TAG = "ConfigurablePlaceDetailsActivity" /** * This activity demonstrates a more advanced use case of the Place Details UI Kit. * It showcases how to dynamically configure the content displayed in the * [PlaceDetailsCompactFragment] at runtime. * * Key features demonstrated: * - Dynamic content configuration via a Jetpack Compose-based dialog. * - Use of a [ContentSelectionViewModel] to manage UI state (selected place and content). * - Reactive UI updates using Kotlin Flows (`StateFlow` and `collectLatest`). * - Persistence of both selected place and content configuration across orientation changes. */ class ConfigurablePlaceDetailsActivity : AppCompatActivity(), OnMapReadyCallback, GoogleMap.OnPoiClickListener { private lateinit var binding: ActivityConfigurableMapBinding private var googleMap: GoogleMap? = null private lateinit var fusedLocationClient: FusedLocationProviderClient private lateinit var requestPermissionLauncher: ActivityResultLauncher> // The ViewModel holds all the state that needs to survive configuration changes. // This includes the ID of the selected place and the user's content preferences. private val viewModel: ContentSelectionViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { Log.d(TAG, "Location permission granted by user.") fetchLastLocation() } else { Log.d(TAG, "Location permission denied by user.") Toast.makeText( this, "Location permission denied. Showing default location.", Toast.LENGTH_LONG ).show() moveToSydney() } } enableEdgeToEdge() binding = ActivityConfigurableMapBinding.inflate(layoutInflater) setContentView(binding.root) binding.dismissButton.setOnClickListener { dismissPlaceDetails() } val apiKey = BuildConfig.PLACES_API_KEY if (apiKey.isEmpty() || apiKey == "YOUR_API_KEY") { Log.e(TAG, "No api key") Toast.makeText( this, "Add your own API_KEY in local.properties", Toast.LENGTH_LONG ).show() finish() return } Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey) fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) val mapFragment = supportFragmentManager.findFragmentById(R.id.map_fragment) as SupportMapFragment? mapFragment?.getMapAsync(this) // This is the core of the reactive UI. We launch a coroutine that is scoped // to the activity's lifecycle. It collects the latest list of selected content // from the ViewModel's StateFlow. lifecycleScope.launch { // `collectLatest` is used here to ensure that if the content selection changes // rapidly, we only process the most recent selection, canceling any ongoing // work for previous selections. This is efficient and prevents unnecessary UI updates. viewModel.selectedContent.collectLatest { // If a place is already selected, we immediately reload the fragment // to reflect the new content configuration. viewModel.selectedPlaceId?.let { placeId -> Log.d(TAG, "Content selection changed. Reloading PlaceDetailsFragment for place ID: $placeId") showPlaceDetailsFragment(placeId) } } } // Restore the fragment if a place was already selected before a configuration change. if (viewModel.selectedPlaceId != null) { viewModel.selectedPlaceId?.let { placeId -> Log.d(TAG, "Restoring PlaceDetailsFragment for place ID: $placeId") showPlaceDetailsFragment(placeId) } } binding.configureButton.setOnClickListener { showContentSelectionDialog() } binding.myLocationButton.setOnClickListener { fetchLastLocation() } } override fun onMapReady(map: GoogleMap) { Log.d(TAG, "Map is ready") googleMap = map googleMap?.setOnPoiClickListener(this) if (isLocationPermissionGranted()) { fetchLastLocation() } else { requestLocationPermissions() } } private fun isLocationPermissionGranted(): Boolean { return ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_COARSE_LOCATION ) == PackageManager.PERMISSION_GRANTED } private fun requestLocationPermissions() { Log.d(TAG, "Requesting location permissions.") requestPermissionLauncher.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } private fun handleLocationError() { Log.d(TAG, "Could not retrieve current location. Falling back to Sydney.") Toast.makeText( this, "Could not retrieve current location. Showing default location.", Toast.LENGTH_LONG ).show() moveToSydney() } @SuppressLint("MissingPermission") private fun fetchLastLocation() { if (isLocationPermissionGranted()) { fusedLocationClient.lastLocation .addOnSuccessListener { location: Location? -> if (location != null) { val latLng = LatLng(location.latitude, location.longitude) googleMap?.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 15f)) Log.d(TAG, "Moved to user's last known location.") } else { handleLocationError() } } .addOnFailureListener { Log.e(TAG, "Failed to get location.", it) handleLocationError() } } else { requestLocationPermissions() } } private fun moveToSydney() { val sydney = LatLng(-33.8688, 151.2093) googleMap?.moveCamera(CameraUpdateFactory.newLatLngZoom(sydney, 13f)) Log.d(TAG, "Moved to Sydney") } override fun onPoiClick(poi: PointOfInterest) { val placeId = poi.placeId Log.d(TAG, "Place ID: $placeId") viewModel.selectedPlaceId = placeId showPlaceDetailsFragment(placeId) } /** * Displays the [PlaceDetailsCompactFragment] for a given place ID. * This version is different from the basic example because it gets the list of * content to display directly from the ViewModel's state. * * @param placeId The ID of the place to display. */ private fun showPlaceDetailsFragment(placeId: String) { Log.d(TAG, "Showing PlaceDetailsFragment for place ID: $placeId") binding.placeDetailsWrapper.visibility = View.VISIBLE binding.dismissButton.visibility = View.GONE binding.placeDetailsContainer.visibility = View.GONE binding.loadingIndicatorConfigurable.visibility = View.VISIBLE val orientation = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { Orientation.HORIZONTAL } else { Orientation.VERTICAL } // Here is the key difference: we get the current value of the selected content // from the ViewModel's StateFlow and pass it to the fragment's factory method. // This ensures the fragment is always created with the user's latest preferences. val fragment = PlaceDetailsCompactFragment.newInstance( viewModel.selectedContent.value.map { it.content }, orientation, R.style.CustomizedPlaceDetailsTheme, ).apply { setPlaceLoadListener(object : PlaceLoadListener { override fun onSuccess(place: Place) { Log.d(TAG, "Place loaded: ${place.id}") binding.loadingIndicatorConfigurable.visibility = View.GONE binding.placeDetailsContainer.visibility = View.VISIBLE binding.dismissButton.visibility = View.VISIBLE } override fun onFailure(e: Exception) { Log.e(TAG, "Place failed to load", e) dismissPlaceDetails() Toast.makeText( this@ConfigurablePlaceDetailsActivity, "Failed to load place details.", Toast.LENGTH_SHORT ).show() } }) } supportFragmentManager .beginTransaction() .replace(binding.placeDetailsContainer.id, fragment) .commitNow() binding.root.post { fragment.loadWithPlaceId(placeId) } } private fun dismissPlaceDetails() { binding.placeDetailsWrapper.visibility = View.GONE viewModel.selectedPlaceId = null } override fun onDestroy() { super.onDestroy() googleMap = null } /** * Displays a dialog that allows the user to select which content types * should be visible in the [PlaceDetailsCompactFragment]. * * This method demonstrates how to embed a Jetpack Compose UI inside a * traditional View-based dialog. This is a powerful technique for gradually * adopting Compose in an existing application. */ private fun showContentSelectionDialog() { // We inflate a traditional XML layout that contains a ComposeView. val dialogView = LayoutInflater.from(this).inflate(R.layout.content_selector_dialog, null) val composeView = dialogView.findViewById(R.id.compose_view) // We then set the content of the ComposeView programmatically. composeView.setContent { // `collectAsState()` is a key Compose utility that observes a Flow // and recomposes the UI whenever a new value is emitted. val selectedContent by viewModel.selectedContent.collectAsState() val unselectedContent by viewModel.unselectedContent.collectAsState() // The dialog's UI is defined in this composable function. // We pass the state from the ViewModel down and hoist the event handling up. PlaceContentSelectionDialogContent(selectedContent, unselectedContent) { item -> // When an item is clicked, we simply call the ViewModel's method to // update the state. The StateFlows will emit new lists, and `collectAsState` // will trigger a recomposition, automatically updating the UI. viewModel.toggleSelection(item) } } AlertDialog.Builder(this) .setView(dialogView) .setPositiveButton("Close") { dialog, _ -> dialog.dismiss() } .create() .show() } } /** * A Composable that displays two lists of content: selected and unselected. * It uses sticky headers to keep the section titles visible during scrolling. * * @param selectedContent The list of items that are currently selected. * @param unselectedContent The list of items that are available but not selected. * @param onItemClick A callback function invoked when any item is clicked. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun PlaceContentSelectionDialogContent( selectedContent: List, unselectedContent: List, onItemClick: (PlaceDetailsCompactItem) -> Unit ) { LazyColumn { stickyHeader { SectionHeader("Selected Content") } items(selectedContent, key = { it.content.name }) { content -> ContentItem( item = content, onItemClick = onItemClick ) } stickyHeader { SectionHeader("Unselected Content") } items(unselectedContent, key = { it.content.name }) { content -> ContentItem( item = content, onItemClick = onItemClick ) } } } /** * A Composable that renders a styled header for a section in the list. * * @param title The text to display in the header. */ @Composable fun SectionHeader(title: String) { Text( text = title, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.tertiaryContainer) .padding(16.dp), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onTertiaryContainer, textAlign = TextAlign.Center ) } /** * A Composable that renders a single clickable item in the content selection list. * * @param item The content item to display. * @param onItemClick A callback function invoked when this item is clicked. */ @Composable fun ContentItem( item: PlaceDetailsCompactItem, onItemClick: (PlaceDetailsCompactItem) -> Unit ) { Text( text = item.displayName, modifier = Modifier .fillMaxWidth() .clickable { onItemClick(item) } .padding(16.dp), style = MaterialTheme.typography.bodyLarge ) } // --- Previews --- // Previews are a powerful feature of Jetpack Compose that allow developers to see // their UI components in Android Studio without running the app on a device or emulator. // They are essential for rapid UI development and testing different states. @Preview(name = "Both Sections", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_BothSections() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = standardContent.toPlaceDetailsCompactItems(), unselectedContent = standardNonContent.toPlaceDetailsCompactItems(), onItemClick = {} ) } } @Preview(name = "Only Selected", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_OnlySelected() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = Content.entries.toPlaceDetailsCompactItems(), unselectedContent = emptyList(), onItemClick = {} ) } } @Preview(name = "Only Unselected", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_OnlyUnselected() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = emptyList(), unselectedContent = Content.entries.toPlaceDetailsCompactItems(), onItemClick = {} ) } } @Preview(name = "Empty State", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_Empty() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = emptyList(), unselectedContent = emptyList(), onItemClick = {} ) } } ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/compact/ContentSelectionViewModel.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.compact import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment.Content import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update /** * A data class that represents a single configurable item for the [PlaceDetailsCompactFragment]. * It wraps the library's [Content] enum with additional properties needed for the UI, * such as a user-friendly display name and its current selection state. * * @param content The actual [Content] enum value from the Place Details library. * @param displayName A formatted, human-readable string for the content type. * @param isSelected A boolean indicating whether the user has selected this content to be displayed. */ data class PlaceDetailsCompactItem( val content: Content, val displayName: String, val isSelected: Boolean = false ) /** * An extension function to convert a [Content] enum into a [PlaceDetailsCompactItem]. * This simplifies the creation of UI models from the library's data model. */ private fun Content.toPlaceDetailsCompactItem() = PlaceDetailsCompactItem( content = this, displayName = this.getDisplayName(), isSelected = standardContent.contains(this) ) /** * An extension function that formats a [Content] enum name into a user-friendly, readable string. * For example, `ACCESSIBLE_ENTRANCE_ICON` becomes "Accessible entrance icon". * * @return A capitalized, space-separated string representation of the enum name. */ fun Content.getDisplayName(): String = this.name.replace('_', ' ').lowercase().replaceFirstChar { it.uppercase() } /** * An extension function to convert a collection of [Content] enums into a list of * [PlaceDetailsCompactItem]s, ready to be used by the UI. */ fun Iterable.toPlaceDetailsCompactItems(): List = this.map { it.toPlaceDetailsCompactItem() } /** * A default list of [Content] types that are commonly displayed in the Place Details view. * This is defined by the Places SDK. */ val standardContent: List = PlaceDetailsCompactFragment.STANDARD_CONTENT /** * A list containing all [Content] types that are *not* in the [standardContent] list. * This is used to populate the "Unselected" section of the configuration dialog initially. */ val standardNonContent: List = Content.entries.filter { !standardContent.contains(it) } /** * A [ViewModel] responsible for holding and managing the UI state for the * Place Details content selection feature. It uses Kotlin Flows to create a reactive * data layer that the UI can observe. * * Key responsibilities: * - Storing the `selectedPlaceId` across configuration changes. * - Maintaining the single source of truth for all available content items and their selection states. * - Exposing `StateFlow`s that the UI can collect to automatically update when the state changes. * - Providing a method (`toggleSelection`) to handle user interactions from the UI. */ class ContentSelectionViewModel : ViewModel() { /** * The ID of the place currently being displayed. This is a simple `var` because its * state is managed directly by the Activity, but it's placed in the ViewModel * to survive configuration changes. */ var selectedPlaceId: String? = null /** * The private, mutable state holder for the list of all content items. * This is the **single source of truth**. All other flows are derived from this one. * It's initialized with all possible `Content` types, with their `isSelected` * property determined by whether they are in the `standardContent` list. */ private val _contentItems = MutableStateFlow( Content.entries.map { PlaceDetailsCompactItem( content = it, displayName = it.getDisplayName(), isSelected = standardContent.contains(it) ) } ) /** * A read-only `StateFlow` that exposes the list of currently **selected** content items. * - It's derived from `_contentItems` using the `map` operator. * - `stateIn` converts this cold Flow into a hot `StateFlow`, meaning it will always have a value. * - `viewModelScope` ensures the flow is active as long as the ViewModel is alive. * - `SharingStarted.Eagerly` means the flow starts immediately and keeps its last value even if there are no collectors. * The UI will collect this flow to display the list of selected items. */ val selectedContent: StateFlow> = _contentItems.map { items -> items.filter { it.isSelected } } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) /** * A read-only `StateFlow` that exposes the list of currently **unselected** content items. * This is also derived from the single source of truth, `_contentItems`. * The UI will collect this flow to display the list of available items that the user can add. */ val unselectedContent: StateFlow> = _contentItems.map { items -> items.filter { !it.isSelected } } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) /** * This is the public function that the UI calls to modify the state. * It handles the business logic of toggling an item's selection status. * * @param itemToToggle The [PlaceDetailsCompactItem] that the user clicked. */ fun toggleSelection(itemToToggle: PlaceDetailsCompactItem) { // `update` is a thread-safe way to modify the value of a MutableStateFlow. _contentItems.update { currentItems -> // We create a new list by mapping over the old one. // This ensures that we are working with immutable state, which is a core // principle of modern Android development and reactive programming. currentItems.map { item -> if (item.content == itemToToggle.content) { // If we find the item that was clicked, we create a new `copy` of it // with the `isSelected` property flipped. item.copy(isSelected = !item.isSelected) } else { // Otherwise, we return the item unmodified. item } } } } } ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/full/FullConfigurablePlaceDetailsActivity.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.full import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager import android.content.res.Configuration import android.location.Location import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.lifecycle.lifecycleScope import com.example.placedetailsuikit.BuildConfig import com.example.placedetailsuikit.R import com.example.placedetailsuikit.databinding.ActivityFullConfigurableMapBinding import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.OnMapReadyCallback import com.google.android.gms.maps.SupportMapFragment import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PointOfInterest import com.google.android.libraries.places.api.Places import com.google.android.libraries.places.api.model.Place import com.google.android.libraries.places.widget.PlaceDetailsFragment import com.google.android.libraries.places.widget.PlaceLoadListener import com.google.android.libraries.places.widget.model.Orientation import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch private const val TAG = "FullConfigurablePlaceDetailsActivity" /** * This activity demonstrates an advanced use case of the **full-screen** Place Details UI Kit. * It is structurally similar to the "compact" example but uses the [PlaceDetailsFragment] * instead of the [com.google.android.libraries.places.widget.PlaceDetailsCompactFragment]. * * Key features demonstrated: * - Dynamic content configuration for the full-screen widget. * - Use of a [FullContentSelectionViewModel] to manage UI state. * - Reactive UI updates using Kotlin Flows. */ class FullConfigurablePlaceDetailsActivity : AppCompatActivity(), OnMapReadyCallback, GoogleMap.OnPoiClickListener { private lateinit var binding: ActivityFullConfigurableMapBinding private var googleMap: GoogleMap? = null private lateinit var fusedLocationClient: FusedLocationProviderClient private lateinit var requestPermissionLauncher: ActivityResultLauncher> // The ViewModel holds all the state that needs to survive configuration changes. private val viewModel: FullContentSelectionViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { Log.d(TAG, "Location permission granted by user.") fetchLastLocation() } else { Log.d(TAG, "Location permission denied by user.") Toast.makeText( this, "Location permission denied. Showing default location.", Toast.LENGTH_LONG ).show() moveToSydney() } } enableEdgeToEdge() binding = ActivityFullConfigurableMapBinding.inflate(layoutInflater) setContentView(binding.root) binding.dismissButton.setOnClickListener { dismissPlaceDetails() } val apiKey = BuildConfig.PLACES_API_KEY if (apiKey.isEmpty() || apiKey == "YOUR_API_KEY") { Log.e(TAG, "No api key") Toast.makeText( this, "Add your own API_KEY in local.properties", Toast.LENGTH_LONG ).show() finish() return } // Initialize the Places SDK. Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey) fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) val mapFragment = supportFragmentManager.findFragmentById(R.id.map_fragment) as SupportMapFragment? mapFragment?.getMapAsync(this) // This coroutine observes the content selection from the ViewModel. // `collectLatest` ensures that if the user changes the selection multiple times quickly, // only the latest selection is used to update the UI, preventing unnecessary work. lifecycleScope.launch { viewModel.selectedContent.collectLatest { viewModel.selectedPlaceId?.let { placeId -> Log.d(TAG, "Content selection changed. Reloading PlaceDetailsFragment for place ID: $placeId") showPlaceDetailsFragment(placeId) } } } // Restore the fragment if a place was already selected before a configuration change. if (viewModel.selectedPlaceId != null) { viewModel.selectedPlaceId?.let { placeId -> Log.d(TAG, "Restoring PlaceDetailsFragment for place ID: $placeId") showPlaceDetailsFragment(placeId) } } binding.configureButton.setOnClickListener { showContentSelectionDialog() } binding.myLocationButton.setOnClickListener { fetchLastLocation() } } override fun onMapReady(map: GoogleMap) { Log.d(TAG, "Map is ready") googleMap = map googleMap?.setOnPoiClickListener(this) if (isLocationPermissionGranted()) { fetchLastLocation() } else { requestLocationPermissions() } } private fun isLocationPermissionGranted(): Boolean { return ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_COARSE_LOCATION ) == PackageManager.PERMISSION_GRANTED } private fun requestLocationPermissions() { Log.d(TAG, "Requesting location permissions.") requestPermissionLauncher.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } private fun handleLocationError() { Log.d(TAG, "Could not retrieve current location. Falling back to Sydney.") Toast.makeText( this, "Could not retrieve current location. Showing default location.", Toast.LENGTH_LONG ).show() moveToSydney() } @SuppressLint("MissingPermission") private fun fetchLastLocation() { if (isLocationPermissionGranted()) { fusedLocationClient.lastLocation .addOnSuccessListener { location: Location? -> if (location != null) { val latLng = LatLng(location.latitude, location.longitude) googleMap?.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 15f)) Log.d(TAG, "Moved to user's last known location.") } else { handleLocationError() } } .addOnFailureListener { Log.e(TAG, "Failed to get location.", it) handleLocationError() } } else { requestLocationPermissions() } } private fun moveToSydney() { val sydney = LatLng(-33.8688, 151.2093) googleMap?.moveCamera(CameraUpdateFactory.newLatLngZoom(sydney, 13f)) Log.d(TAG, "Moved to Sydney") } override fun onPoiClick(poi: PointOfInterest) { val placeId = poi.placeId Log.d(TAG, "Place ID: $placeId") viewModel.selectedPlaceId = placeId showPlaceDetailsFragment(placeId) } /** * Displays the [PlaceDetailsFragment] for a given place ID. * The content shown in the fragment is determined by the user's selection, * which is retrieved from the [FullContentSelectionViewModel]. * * @param placeId The ID of the place to display. */ private fun showPlaceDetailsFragment(placeId: String) { Log.d(TAG, "Showing PlaceDetailsFragment for place ID: $placeId") binding.placeDetailsWrapper.visibility = View.VISIBLE binding.dismissButton.visibility = View.GONE binding.placeDetailsContainer.visibility = View.GONE binding.loadingIndicatorConfigurable.visibility = View.VISIBLE val orientation = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { Orientation.HORIZONTAL } else { Orientation.VERTICAL } // The key step: Create a new instance of the fragment, passing the list of // selected content from the ViewModel. This ensures the fragment respects the user's configuration. val fragment = PlaceDetailsFragment.newInstance( viewModel.selectedContent.value.map { it.content }, orientation, R.style.CustomizedPlaceDetailsTheme, ).apply { setPlaceLoadListener(object : PlaceLoadListener { override fun onSuccess(place: Place) { Log.d(TAG, "Place loaded: ${place.id}") binding.loadingIndicatorConfigurable.visibility = View.GONE binding.placeDetailsContainer.visibility = View.VISIBLE binding.dismissButton.visibility = View.VISIBLE } override fun onFailure(e: Exception) { Log.e(TAG, "Place failed to load", e) dismissPlaceDetails() Toast.makeText( this@FullConfigurablePlaceDetailsActivity, "Failed to load place details.", Toast.LENGTH_SHORT ).show() } }) } supportFragmentManager .beginTransaction() .replace(binding.placeDetailsContainer.id, fragment) .commitNow() binding.root.post { fragment.loadWithPlaceId(placeId) } } private fun dismissPlaceDetails() { binding.placeDetailsWrapper.visibility = View.GONE viewModel.selectedPlaceId = null } override fun onDestroy() { super.onDestroy() googleMap = null } /** * Displays a dialog that allows the user to select which content types * should be visible in the [PlaceDetailsFragment]. * This demonstrates embedding a Jetpack Compose UI inside a View-based dialog. */ private fun showContentSelectionDialog() { val dialogView = LayoutInflater.from(this).inflate(R.layout.content_selector_dialog, null) val composeView = dialogView.findViewById(R.id.compose_view) composeView.setContent { // `collectAsState` observes the ViewModel's Flows and triggers recomposition // automatically when the state changes. val selectedContent by viewModel.selectedContent.collectAsState() val unselectedContent by viewModel.unselectedContent.collectAsState() // We pass the state down to the Composable and hoist the events up to the ViewModel. PlaceContentSelectionDialogContent(selectedContent, unselectedContent) { viewModel.toggleSelection(it) } } AlertDialog.Builder(this) .setView(dialogView) .setPositiveButton("Close") { dialog, _ -> dialog.dismiss() } .create() .show() } } /** * A Composable that displays the content selection UI. * * @param selectedContent The list of items that are currently selected. * @param unselectedContent The list of items that are available but not selected. * @param onItemClick A callback function invoked when any item is clicked. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun PlaceContentSelectionDialogContent( selectedContent: List, unselectedContent: List, onItemClick: (PlaceDetailsFullItem) -> Unit ) { LazyColumn { stickyHeader { SectionHeader("Selected Content") } items(selectedContent, key = { it.content.name }) { content -> ContentItem( item = content, onItemClick = onItemClick ) } stickyHeader { SectionHeader("Unselected Content") } items(unselectedContent, key = { it.content.name }) { content -> ContentItem( item = content, onItemClick = onItemClick ) } } } /** * A Composable that renders a styled header for a section in the list. */ @Composable fun SectionHeader(title: String) { Text( text = title, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.tertiaryContainer) .padding(16.dp), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onTertiaryContainer, textAlign = TextAlign.Center ) } /** * A Composable that renders a single clickable item in the content selection list. */ @Composable fun ContentItem( item: PlaceDetailsFullItem, onItemClick: (PlaceDetailsFullItem) -> Unit ) { Text( text = item.displayName, modifier = Modifier .fillMaxWidth() .clickable { onItemClick(item) } .padding(16.dp), style = MaterialTheme.typography.bodyLarge ) } // --- Previews --- // These previews allow for rapid UI development of the dialog content in various states. @Preview(name = "Both Sections", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_BothSections() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = PlaceDetailsFullItem.standardContent, unselectedContent = PlaceDetailsFullItem.standardNonContent, onItemClick = {} ) } } @Preview(name = "Only Selected", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_OnlySelected() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = PlaceDetailsFragment.Content.entries.toPlaceDetailsFullItems(), unselectedContent = emptyList(), onItemClick = {} ) } } @Preview(name = "Only Unselected", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_OnlyUnselected() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = emptyList(), unselectedContent = PlaceDetailsFragment.Content.entries.toPlaceDetailsFullItems(), onItemClick = {} ) } } @Preview(name = "Empty State", showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun DialogContentPreview_Empty() { MaterialTheme { PlaceContentSelectionDialogContent( selectedContent = emptyList(), unselectedContent = emptyList(), onItemClick = {} ) } } ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/full/FullContentSelectionViewModel.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.full import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.libraries.places.widget.PlaceDetailsFragment import com.google.android.libraries.places.widget.PlaceDetailsFragment.Content import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update /** * A data class that represents a single configurable item for the [PlaceDetailsFragment]. * It wraps the library's [Content] enum with additional properties needed for the UI. * * @param content The actual [Content] enum value from the Place Details library. * @param displayName A formatted, human-readable string for the content type. * @param isSelected A boolean indicating whether the user has selected this content to be displayed. */ data class PlaceDetailsFullItem( val content: Content, val displayName: String, val isSelected: Boolean = false ) { companion object { /** * The default list of content fields displayed by the [PlaceDetailsFragment]. * We use this to set the initial state of the configuration dialog. */ val standardContent: List = PlaceDetailsFragment.STANDARD_CONTENT.toPlaceDetailsFullItems() /** * A list of all available content fields that are not included in the default set. */ val standardNonContent: List = Content.entries.filterNot { content -> standardContent.any { it.content == content } }.toPlaceDetailsFullItems() } } /** * An extension function to convert a [Content] enum into a [PlaceDetailsFullItem]. */ private fun Content.toPlaceDetailsFullItem(): PlaceDetailsFullItem = PlaceDetailsFullItem( content = this, displayName = this.getDisplayName(), isSelected = PlaceDetailsFragment.STANDARD_CONTENT.contains(this) ) /** * An extension function that formats a [Content] enum name into a user-friendly, readable string. * For example, `REVIEWS` becomes "Reviews". * * @return A capitalized, space-separated string representation of the enum name. */ fun Content.getDisplayName(): String = this.name.replace('_', ' ').lowercase().replaceFirstChar { it.uppercase() } /** * An extension function to convert a collection of [Content] enums into a list of * [PlaceDetailsFullItem]s, ready to be used by the UI. */ fun Iterable.toPlaceDetailsFullItems(): List = this.map { it.toPlaceDetailsFullItem() } /** * A [ViewModel] for the [FullConfigurablePlaceDetailsActivity] that manages the * state for the content selection UI. It follows the same reactive pattern as the * compact version's ViewModel. */ class FullContentSelectionViewModel : ViewModel() { /** * The ID of the place currently being displayed. Stored in the ViewModel to * survive configuration changes. */ var selectedPlaceId: String? = null /** * The private, mutable state holder for the list of all content items. This is the * single source of truth for the selection state. */ private val _contentItems = MutableStateFlow( Content.entries.map { PlaceDetailsFullItem( content = it, displayName = it.getDisplayName(), isSelected = PlaceDetailsFragment.STANDARD_CONTENT.contains(it) ) } ) /** * A read-only `StateFlow` that exposes the list of currently **selected** content items. * The UI collects this flow to display the list of selected items. */ val selectedContent: StateFlow> = _contentItems.map { items -> items.filter { it.isSelected } } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) /** * A read-only `StateFlow` that exposes the list of currently **unselected** content items. * The UI collects this flow to display the list of available items. */ val unselectedContent: StateFlow> = _contentItems.map { items -> items.filterNot { it.isSelected } } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) /** * This function handles the logic of toggling an item's selection status. It is called * from the UI when a user interacts with the selection dialog. * * @param itemToToggle The [PlaceDetailsFullItem] that the user clicked. */ fun toggleSelection(itemToToggle: PlaceDetailsFullItem) { _contentItems.update { currentItems -> currentItems.map { item -> if (item.content == itemToToggle.content) { item.copy(isSelected = !item.isSelected) } else { item } } } } } ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/ui/theme/Color.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.ui.theme import androidx.compose.ui.graphics.Color val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/ui/theme/Theme.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80 ) private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 /* Other default colors to override background = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE), onPrimary = Color.White, onSecondary = Color.White, onTertiary = Color.White, onBackground = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F), */ ) @Composable fun PlaceDetailsUIKitTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme else -> LightColorScheme } val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window window.statusBarColor = colorScheme.primary.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme } } MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) } ================================================ FILE: PlaceDetailsUIKit/src/main/java/com/example/placedetailsuikit/ui/theme/Type.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp ) /* Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp ), labelSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ) */ ) ================================================ FILE: PlaceDetailsUIKit/src/main/res/drawable/close_button_background.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/drawable/ic_close.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/drawable/outline_my_location_24.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/drawable/outline_settings_24.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/font/custom_font.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/layout/activity_configurable_map.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/layout/activity_full_configurable_map.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/layout/content_selector_dialog.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/mipmap-anydpi/ic_launcher.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/mipmap-anydpi/ic_launcher_round.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/values/colors.xml ================================================ #1A0A2D #E0218A #F0F8FF #9E8BBE #00E5FF #00E5FF #FF007F ================================================ FILE: PlaceDetailsUIKit/src/main/res/values/strings.xml ================================================ Place Details UI Kit Dismiss place details "Configure the Place Details component " Center map on user\'s current location ================================================ FILE: PlaceDetailsUIKit/src/main/res/values/themes.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/main/res/xml/data_extraction_rules.xml ================================================ ================================================ FILE: PlaceDetailsUIKit/src/test/java/com/example/placedetailsuikit/ExampleUnitTest.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placedetailsuikit import org.junit.Test import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: PlacesUIKit3D/.gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties /secrets.properties ================================================ FILE: PlacesUIKit3D/README.md ================================================ # Places UI Kit 3D Sample This sample demonstrates how to integrate the Places SDK for Android's `PlaceDetailsCompactFragment` with a 3D map view. It allows users to tap on a location on a 3D Google Map to display its details in a compact, embedded view. ## Features - **3D Google Map**: Utilizes the 3D Maps SDK to display a rich, interactive 3D map. - **Place Details**: On tapping a location on the map, the app displays the place's details using the `PlaceDetailsCompactFragment`. - **MVVM Architecture**: Follows the Model-View-ViewModel pattern, with a `MainViewModel` managing the UI state. - **Location-Aware**: Requests location permissions to center the map on the user's current location. - **Secrets Management**: Uses the Secrets Gradle Plugin for Android to securely handle the Google Maps API key. ## Screenshot ![App Screenshot](screenshots/places-ui-kit-3d-demo.png) ## Requirements - Android Studio - An Android device or emulator - A Google Maps API key ## Setup and Installation 1. **Clone the repository:** ```bash git clone https://github.com/googlemaps-samples/android-places-demos.git ``` 2. **Open the project in Android Studio:** Open the `PlacesUIKit3D` directory in Android Studio. 3. **Add your API Key:** - Create a file named `secrets.properties` in the root directory of the `PlacesUIKit3D` project (`/Users/dkhawk/AndroidStudioProjects/github-maps-code/android-places-demos/PlacesUIKit3D`). - Add your Google Maps API key to the `secrets.properties` file, making sure that the Maps SDK for Android and the Places API are enabled for the key. ``` MAPS3D_API_KEY="YOUR_API_KEY" PLACES_API_KEY="YOUR_API_KEY" ``` - Note: The `secrets.properties` file is included in the `.gitignore` file to prevent it from being checked into version control. ## Running the Application Once the project is set up and the API key is added, you can run the application on an Android device or emulator directly from Android Studio. - The app will request location permissions. - The map will center on the user's location if permission is granted, otherwise it will default to a location in Colorado. - Tap on any location on the map to see the Place Details view. ## License ``` Copyright 2025 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. ``` ================================================ FILE: PlacesUIKit3D/build.gradle.kts ================================================ /* * Copyright 2025 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. */ // The `plugins` block is where we apply Gradle plugins to this module. // Plugins add new tasks and configurations to our build process. plugins { id("places-demo.android.application") // This plugin enables Kotlin support in the Android project, allowing us to write code in Kotlin. alias(libs.plugins.kotlin.android) // This plugin from Google helps manage API keys and other secrets by reading them from a `secrets.properties` // file (which should be in .gitignore) and exposing them in the `BuildConfig` file at compile time. // This is crucial for keeping sensitive data out of version control. id("places-demo.secrets") // This plugin provides the necessary integration for using Jetpack Compose with the Kotlin compiler. alias(libs.plugins.kotlin.compose) // KSP (Kotlin Symbol Processing) is used for annotation processing. Hilt uses it to generate code. alias(libs.plugins.ksp) // The Hilt plugin integrates Dagger Hilt for dependency injection. alias(libs.plugins.hilt.android) // The parcelize plugin provides a @Parcelize annotation to automatically generate Parcelable implementations. alias(libs.plugins.jetbrains.kotlin.parcelize) } // The `android` block is where we configure all the Android-specific build options. android { // The `namespace` is a unique identifier for the app's generated R class. It's also used // as the default `applicationId` if not specified in `defaultConfig`. namespace = "com.example.placesuikit3d" defaultConfig { // `applicationId` is the unique identifier for the app on the Google Play Store and on the device. applicationId = "com.example.placesuikit3d" // `minSdk` is the minimum API level required to run the app. Devices below this level cannot install it. minSdk = 29 versionCode = 1 versionName = "1.0" // Specifies the instrumentation runner for running Android tests. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { // The `release` block configures settings for the release build of the app. release { // `isMinifyEnabled` enables code shrinking with R8 to reduce the app's size. // It's disabled here for simplicity in a sample app, but highly recommended for production. isMinifyEnabled = false // `proguardFiles` specifies the files that define the R8 shrinking and obfuscation rules. proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } kotlin { compilerOptions { freeCompilerArgs.addAll( "-opt-in=kotlin.RequiresOptIn", "-Xannotation-default-target=param-property" ) } } buildFeatures { // `viewBinding` generates a binding class for each XML layout file, providing a type-safe // way to access views without `findViewById`. This is used in the XML-based activities. viewBinding = true // `compose` enables Jetpack Compose for the project. compose = true // `buildConfig` generates a `BuildConfig` class that contains constants from the build configuration, // such as the API key from the secrets plugin. buildConfig = true } composeOptions { // Sets the version of the Kotlin compiler extension for Compose. This version must be // compatible with the Kotlin version used in the project. kotlinCompilerExtensionVersion = "1.5.1" } } // The `dependencies` block is where we declare all the external libraries the app needs. // These are fetched from repositories like Maven Central and Google's Maven repository. dependencies { // --- Core AndroidX & UI Libraries --- // These are foundational libraries for building modern Android apps. implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.fragment.ktx) implementation(libs.material) // For Material Design components (used in XML layouts). // --- Jetpack Compose --- // These libraries are for building UIs with Jetpack Compose. implementation(libs.androidx.activity.compose) // Integration between Activity and Compose. implementation(platform(libs.androidx.compose.bom)) // The Compose Bill of Materials (BOM) ensures all Compose libraries use compatible versions. implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) // For displaying @Preview composables in Android Studio. implementation(libs.androidx.material3) // The latest Material Design components for Compose. implementation(libs.androidx.fragment.compose) implementation(libs.androidx.material.icons.extended) debugImplementation(libs.androidx.ui.tooling) // Provides tools for inspecting Compose UIs. // --- Google Play Services --- // These are the essential libraries for this sample, providing Maps and Places functionality. implementation(libs.play.services.maps3d) // The core SDK for embedding 3D Google Maps. implementation(libs.places) // The SDK for the Places UI Kit (PlaceDetails fragments). implementation(libs.maps.utils.ktx) // Google Maps Utils for polyline decoding and other utilities. // --- Dependency Injection --- // Hilt is used for managing dependencies and object lifecycles. implementation(libs.dagger) ksp(libs.hilt.android.compiler) implementation(libs.hilt.android) // --- Miscellaneous --- implementation(libs.kotlinx.datetime) // --- Testing Libraries --- // These libraries are for writing and running tests. // `testImplementation` is for local unit tests (running on the JVM). testImplementation(libs.junit) testImplementation(libs.google.truth) // `androidTestImplementation` is for instrumented tests (running on an Android device or emulator). androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) // For UI testing with the View system. // --- Compose Testing --- // These are specific to testing Jetpack Compose UIs. androidTestImplementation(platform(libs.androidx.compose.bom)) // BOM for testing libraries. androidTestImplementation(libs.androidx.ui.test.junit4) // The main library for Compose UI tests. debugImplementation(libs.androidx.ui.test.manifest) // Provides a manifest for UI tests. } ================================================ FILE: PlacesUIKit3D/local.defaults.properties ================================================ MAPS3D_API_KEY=DEFAULT_API_KEY PLACES_API_KEY=DEFAULT_API_KEY ================================================ FILE: PlacesUIKit3D/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: PlacesUIKit3D/src/main/AndroidManifest.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Landmark.kt ================================================ // Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d import com.google.android.gms.maps3d.model.LatLngAltitude /** * A data class representing a landmark in the demo. * * @property id The unique Place ID for this landmark. * @property name The human-readable name of the landmark. * @property location The coordinates on the 3D map where the camera should point. */ data class Landmark( val id: String, val name: String, val location: LatLngAltitude ) ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/LandmarkList.kt ================================================ // Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Place import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon 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.unit.dp /** * A composable that displays a list of landmarks. * * @param landmarks The list of landmarks to display. * @param onLandmarkClick Callback invoked when a landmark is clicked. * @param modifier The modifier to apply to the list. */ @Composable fun LandmarkList( landmarks: List, onLandmarkClick: (Landmark) -> Unit, modifier: Modifier = Modifier ) { Column(modifier = modifier) { Text( text = "Locations", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(16.dp) ) LazyColumn(modifier = Modifier.weight(1f)) { items(landmarks) { landmark -> LandmarkItem( landmark = landmark, onClick = { onLandmarkClick(landmark) } ) HorizontalDivider() } } } } /** * A composable that displays a single landmark item. */ @Composable private fun LandmarkItem( landmark: Landmark, onClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.Place, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(end = 16.dp) ) Column { Text( text = landmark.name, style = MaterialTheme.typography.titleMedium ) Text( text = "Boulder, CO", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager import android.os.Bundle import android.util.Log import android.view.View import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import androidx.compose.ui.viewinterop.AndroidView import androidx.core.app.ActivityCompat import androidx.core.view.WindowCompat import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit import com.example.placesuikit3d.ui.theme.PlacesUIKit3DTheme import com.example.placesuikit3d.utils.feet import com.example.placesuikit3d.utils.toValidCamera import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.OnMap3DViewReadyCallback import com.google.android.gms.maps3d.model.Camera import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.camera import com.google.android.gms.maps3d.model.flyToOptions import com.google.android.gms.maps3d.model.latLngAltitude import com.google.android.libraries.places.api.model.Place import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment import com.google.android.libraries.places.widget.PlaceLoadListener import com.google.android.libraries.places.widget.model.Orientation /** * The main activity for the 3D map demo. * * This activity demonstrates how to integrate the Places UI Kit with a 3D map view using Jetpack Compose. * It handles map initialization, landmark selection, and displaying place details. */ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private val TAG = this::class.java.simpleName private var googleMap3D: GoogleMap3D? = null private lateinit var fusedLocationClient: FusedLocationProviderClient private lateinit var requestPermissionLauncher: ActivityResultLauncher> private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { fetchLastLocation() } else { Toast.makeText(this, "Location permission denied. Showing default location.", Toast.LENGTH_SHORT).show() moveToDefaultLocation() } } fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { PlacesUIKit3DTheme { MainScreen() } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen() { val landmarks = viewModel.landmarks val selectedPlaceId by viewModel.placeId.collectAsState() val scope = rememberCoroutineScope() val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( initialValue = SheetValue.PartiallyExpanded ) ) Box(modifier = Modifier.fillMaxSize()) { BottomSheetScaffold( scaffoldState = scaffoldState, sheetPeekHeight = 120.dp, sheetContent = { LandmarkList( landmarks = landmarks, onLandmarkClick = { landmark -> viewModel.selectLandmark(landmark) flyToLandmark(landmark) scope.launch { scaffoldState.bottomSheetState.partialExpand() } }, modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.6f) ) } ) { _ -> // Map occupies full screen, ignoring scaffold padding Box(modifier = Modifier.fillMaxSize()) { MapViewContainer() FloatingActionButton( onClick = { fetchLastLocation() }, modifier = Modifier .align(Alignment.TopEnd) .padding(top = 48.dp, end = 16.dp) ) { Icon(Icons.Default.MyLocation, contentDescription = "My Location") } } } // Overlay stays on top of the scaffold (outer Box) if (!selectedPlaceId.isNullOrEmpty()) { PlaceDetailsOverlay( placeId = selectedPlaceId!!, onDismiss = { viewModel.setSelectedPlaceId(null) }, modifier = Modifier .align(Alignment.BottomCenter) // Anchor above the bottom sheet peek height (120dp + 16dp margin) .padding(bottom = 136.dp, start = 16.dp, end = 16.dp) ) } } } @Composable fun MapViewContainer() { val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val map3DView = remember { com.google.android.gms.maps3d.Map3DView(context).apply { getMap3DViewAsync(this@MainActivity) } } androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_CREATE -> { map3DView.onCreate(null) } Lifecycle.Event.ON_RESUME -> { map3DView.onResume() } Lifecycle.Event.ON_PAUSE -> { map3DView.onPause() } Lifecycle.Event.ON_DESTROY -> { map3DView.onDestroy() } else -> {} } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } AndroidView( factory = { map3DView }, modifier = Modifier.fillMaxSize() ) } @Composable fun PlaceDetailsOverlay( placeId: String, onDismiss: () -> Unit, modifier: Modifier = Modifier ) { val containerId = remember { View.generateViewId() } Box( modifier = modifier .fillMaxWidth() .heightIn(max = 400.dp) .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium) ) { AndroidView( factory = { ctx -> FragmentContainerView(ctx).apply { id = containerId } }, update = { view -> val fragment = supportFragmentManager.findFragmentById(containerId) as? PlaceDetailsCompactFragment if (fragment == null) { val newFragment = PlaceDetailsCompactFragment.newInstance( PlaceDetailsCompactFragment.ALL_CONTENT, Orientation.VERTICAL, R.style.CustomizedPlaceDetailsTheme ).apply { setPlaceLoadListener(object : PlaceLoadListener { override fun onSuccess(place: Place) { Log.d(TAG, "Place loaded: ${place.id}") } override fun onFailure(e: Exception) { Log.e(TAG, "Place failed to load for ID: $placeId", e) // Don't auto-dismiss on failure to prevent "disappearing" components. // The fragment should handle its own error state. } }) } supportFragmentManager.commit { replace(containerId, newFragment) } // Tag the view with the current ID and post the load view.tag = placeId Log.e(TAG, "Loading new fragment for placeId: $placeId") view.post { newFragment.loadWithPlaceId(placeId) } } else { // Crucially, ONLY load if the place actually changed val currentlyLoaded = view.tag as? String if (currentlyLoaded != placeId) { view.tag = placeId Log.e(TAG, "Updating existing fragment for placeId: $placeId") fragment.loadWithPlaceId(placeId) } } }, modifier = Modifier.fillMaxWidth() ) FloatingActionButton( onClick = onDismiss, modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp), containerColor = MaterialTheme.colorScheme.secondaryContainer ) { Icon( painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_close), contentDescription = "Dismiss" ) } } // Clean up fragment when leaving composition androidx.compose.runtime.DisposableEffect(containerId) { onDispose { supportFragmentManager.findFragmentById(containerId)?.let { supportFragmentManager.commit { remove(it) } } } } } private fun flyToLandmark(landmark: Landmark) { googleMap3D?.flyCameraTo( flyToOptions { endCamera = camera { center = landmark.location range = 1000.0 tilt = 45.0 }.toValidCamera() durationInMillis = 2000 } ) } override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { this.googleMap3D = googleMap3D googleMap3D.setMapMode(Map3DMode.HYBRID) googleMap3D.setCamera(initialCamera) googleMap3D.setMap3DClickListener { _, placeId -> Log.e(TAG, "Map clicked: placeId=$placeId") if (!placeId.isNullOrEmpty()) { viewModel.setSelectedPlaceId(placeId) } } if (isLocationPermissionGranted()) { fetchLastLocation() } else { requestLocationPermissions() } } private fun isLocationPermissionGranted(): Boolean { return ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED } private fun requestLocationPermissions() { requestPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) } @SuppressLint("MissingPermission") private fun fetchLastLocation() { if (isLocationPermissionGranted()) { fusedLocationClient.lastLocation.addOnSuccessListener { location -> location?.let { val userLocation = latLngAltitude { latitude = it.latitude longitude = it.longitude altitude = it.altitude } googleMap3D?.flyCameraTo( flyToOptions { endCamera = camera { center = userLocation range = 5000.0 tilt = 60.0 }.toValidCamera() durationInMillis = 3000 } ) } ?: moveToDefaultLocation() }.addOnFailureListener { moveToDefaultLocation() } } } private fun moveToDefaultLocation() { googleMap3D?.flyCameraTo(flyToOptions { endCamera = initialCamera; durationInMillis = 3000 }) } override fun onError(error: Exception) { Log.e(TAG, "Error loading map", error) super.onError(error) } companion object { private val initialCamera: Camera = camera { center = latLngAltitude { latitude = 39.982129291022446 longitude = -105.30156359691158 altitude = 8148.feet.value } heading = 26.0 tilt = 67.0 range = 4000.0 }.toValidCamera() } } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainViewModel.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d import androidx.lifecycle.ViewModel import com.google.android.gms.maps3d.model.latLngAltitude import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** * A simple ViewModel to hold the selected place ID. * * Using a ViewModel allows the state to survive configuration changes, like screen rotations, * ensuring the selected place isn't lost. */ class MainViewModel : ViewModel() { /** * The list of landmarks to display in the list. */ val landmarks: List = listOf( Landmark( id = "ChIJwd_EEkfsa4cRqy6eShKXFXY", name = "Chautauqua Park", location = latLngAltitude { latitude = 39.9989 longitude = -105.2828 altitude = 1750.0 } ), Landmark( id = "ChIJiTEGLibsa4cRepH7ZMFEcJ8", name = "Pearl Street Mall", location = latLngAltitude { latitude = 40.0177 longitude = -105.2819 altitude = 1620.0 } ), Landmark( id = "ChIJwR6cajTsa4cR2TH0qKTVKAM", name = "University of Colorado Boulder", location = latLngAltitude { latitude = 40.0076 longitude = -105.2659 altitude = 1650.0 } ), Landmark( id = "ChIJAfFnzszva4cR04sAt0lSm1g", name = "Boulder Reservoir", location = latLngAltitude { latitude = 40.0780 longitude = -105.2220 altitude = 1580.0 } ), Landmark( id = "ChIJfXOTtWbsa4cRmW07qJRB6_8", name = "The Flatirons", location = latLngAltitude { latitude = 39.9880 longitude = -105.2930 altitude = 2100.0 } ) ) /** * Sets the selected place ID. * * This function updates the `_placeId` StateFlow with the provided `placeId`. * If `placeId` is null, it means no place is currently selected. * * @param placeId The ID of the selected place, or null if no place is selected. */ fun setSelectedPlaceId(placeId: String?) { _placeId.value = placeId } /** * The ID of the place to display. * This is a private mutable state flow that can be updated by the ViewModel. */ private val _placeId = MutableStateFlow(null) /** * The unique identifier of the place to display in the Place Details view. * This is a StateFlow that can be observed for changes. */ val placeId: StateFlow = _placeId.asStateFlow() private val _selectedLandmark = MutableStateFlow(null) val selectedLandmark: StateFlow = _selectedLandmark.asStateFlow() fun selectLandmark(landmark: Landmark) { _selectedLandmark.value = landmark setSelectedPlaceId(landmark.id) } } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Maps3DPlacesApplication.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d import android.app.Application import android.content.pm.PackageManager import android.util.Log import android.widget.Toast import com.google.android.libraries.places.api.Places import dagger.hilt.android.HiltAndroidApp import java.util.Objects @HiltAndroidApp class Maps3DPlacesApplication : Application() { val TAG = this::class.java.simpleName override fun onCreate() { super.onCreate() checkApiKey() initializePlaces() } private fun initializePlaces() { val apiKey = BuildConfig.PLACES_API_KEY if (apiKey == null || apiKey.isBlank() || apiKey == "DEFAULT_API_KEY") { Toast.makeText( this, "PLACES_API_KEY was not set in secrets.properties", Toast.LENGTH_LONG ).show() throw RuntimeException("API Key was not set in secrets.properties") } Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey) Places.createClient(this) } /** * Checks if the API key for Google Maps is properly configured in the application's metadata. * * This method retrieves the API key from the application's metadata, specifically looking for * a string value associated with the key "com.google.android.geo.maps3d.API_KEY". * The key must be present, not blank, and not set to the placeholder value "DEFAULT_API_KEY". * * If any of these checks fail, a Toast message is displayed indicating that the API key is missing or * incorrectly configured, and a RuntimeException is thrown. */ private fun checkApiKey() { try { val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) val bundle = Objects.requireNonNull(appInfo.metaData) val apiKey = bundle.getString("com.google.android.geo.maps3d.API_KEY") // Key name is important! if (apiKey == null || apiKey.isBlank() || apiKey == "DEFAULT_API_KEY") { Toast.makeText( this, "API Key was not set in secrets.properties", Toast.LENGTH_LONG ).show() throw RuntimeException("API Key was not set in secrets.properties") } } catch (e: PackageManager.NameNotFoundException) { Log.e(TAG, "Package name not found.", e) throw RuntimeException("Error getting package info.", e) } catch (e: NullPointerException) { Log.e(TAG, "Error accessing meta-data.", e) // Handle the case where meta-data is completely missing. throw RuntimeException("Error accessing meta-data in manifest", e) } } } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/ActiveMapObject.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // //   http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.common import com.google.android.gms.maps3d.model.Marker import com.google.android.gms.maps3d.model.Model import com.google.android.gms.maps3d.model.Polygon import com.google.android.gms.maps3d.model.Polyline internal sealed class ActiveMapObject { abstract fun remove() data class ActiveMarker(val marker: Marker) : ActiveMapObject() { override fun remove() { marker.remove() } } data class ActivePolyline(val polyline: Polyline) : ActiveMapObject() { override fun remove() { polyline.remove() } } data class ActivePolygon(val polygon: Polygon) : ActiveMapObject() { override fun remove() { polygon.remove() } } data class ActiveModel(val model: Model) : ActiveMapObject() { override fun remove() { model.remove() } } } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // //   http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.common import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.placesuikit3d.utils.CameraUpdate import com.example.placesuikit3d.utils.copy import com.example.placesuikit3d.utils.toCameraUpdate import com.example.placesuikit3d.utils.toHeading import com.example.placesuikit3d.utils.toRange import com.example.placesuikit3d.utils.toRoll import com.example.placesuikit3d.utils.toTilt import com.example.placesuikit3d.utils.toValidCamera import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.OnCameraChangedListener import com.google.android.gms.maps3d.model.Camera import com.google.android.gms.maps3d.model.Camera.DEFAULT_CAMERA import com.google.android.gms.maps3d.model.CameraRestriction import com.google.android.gms.maps3d.model.FlyAroundOptions import com.google.android.gms.maps3d.model.FlyToOptions import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.MarkerOptions import com.google.android.gms.maps3d.model.Model import com.google.android.gms.maps3d.model.ModelOptions import com.google.android.gms.maps3d.model.PolygonOptions import com.google.android.gms.maps3d.model.PolylineOptions import com.google.android.gms.maps3d.model.flyAroundOptions import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.time.Duration abstract class Map3dViewModel : ViewModel() { abstract val TAG: String /** * The internal state flow holding the GoogleMap3D controller instance. * * This flow is used internally to manage the lifecycle and access to the * GoogleMap3D object provided by the MapView. * It's updated via the `setGoogleMap3D` function. * * Consumers should use the `mapReady` flow to react to the availability of the map. */ private var _googleMap3D = MutableStateFlow(null) private val _cameraRestriction = MutableStateFlow(null) val cameraRestriction = _cameraRestriction.asStateFlow() private val _mapMode = MutableStateFlow(Map3DMode.SATELLITE) val mapMode = _mapMode.asStateFlow() // --- Camera Position from Map & Pending Requests --- // This is guaranteed to always be a valid camera private val _currentCamera = MutableStateFlow(DEFAULT_CAMERA) val currentCamera = _currentCamera.asStateFlow() private val mapObjects = mutableMapOf() /** * A [MutableSharedFlow] that buffers [CameraUpdate] requests. * * This flow is used to queue camera updates requested by the ViewModel's consumers. * When a new camera update is emitted to this flow, it's buffered with a replay of 1, * meaning the latest update is available to new collectors. If a new update arrives * before the previous one is processed, the older one is dropped (`BufferOverflow.DROP_OLDEST`). * * This allows the ViewModel to handle camera update requests asynchronously and * ensures that only the most recent request is processed if updates occur rapidly. * The actual camera update is performed within a separate coroutine that collects * from this flow. */ private val _pendingCameraUpdate = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) private val activeMapObjects = mutableMapOf() val mapReady = _googleMap3D.map { it != null } init { viewModelScope.launch { _googleMap3D.collect { controller -> stopAnimations() clearObjects() Log.d(TAG, "Map3D Controller attached") if (controller != null) { launch { Log.d(TAG, "Getting camera flow") getCameraFlow(controller).collect { camera -> _currentCamera.value = camera } } addMapObjects(mapObjects, controller) // Return to the last camera position if available controller.setCamera(currentCamera.value) // Process pending camera updates launch { _pendingCameraUpdate .filterNotNull() .collect { cameraUpdate -> Log.d(TAG, "Received camera update request: $cameraUpdate") cameraUpdate(controller) } } launch { _mapMode.collect { mapMode -> controller.setMapMode(mapMode) } } launch { _cameraRestriction.collect { cameraRestriction -> controller.setCameraRestriction(cameraRestriction) } } } } } } /** * Returns a Flow that emits the current camera position whenever it changes on the GoogleMap3D. * * This Flow is created using `callbackFlow` to bridge the callback-based API of * `OnCameraChangedListener` with Kotlin's coroutine Flows. It automatically attaches and * detaches the listener when collectors subscribe and unsubscribe. * * The Flow emits a validated `Camera` object, ensuring that the pitch, range, and bearing * are within acceptable limits using the `toValidCamera()` extension function. * * @param controller The GoogleMap3D instance to listen for camera changes on. * @return A Flow of `Camera` objects representing the current camera position. */ private fun getCameraFlow(controller: GoogleMap3D): Flow { // Public Flow that manages the listener lifecycle return callbackFlow { val cameraChangedListener = OnCameraChangedListener { cameraPosition -> val newPosition = cameraPosition.toValidCamera() // Send the new camera position to the flow's channel trySend(newPosition) // Also update the private state _currentCamera.value = newPosition } // Get the current map instance (ensure it's not null before setting listener) Log.d(TAG, "Attaching CameraChangeListener") controller.setCameraChangedListener(cameraChangedListener) // Ensure the initial camera position is emitted when the flow is collected // This handles cases where the map is ready before the flow is collected controller.getCamera()?.let { initial -> val newPosition = initial.toValidCamera() trySend(newPosition) _currentCamera.value = newPosition // Also update private state on collection } // The awaitClose block runs when the collector is cancelled awaitClose { // Remove the listener when the flow collection stops Log.d(TAG, "Detaching CameraChangeListener") controller.setCameraChangedListener(null) } } } /** * Adds a collection of map objects to the GoogleMap3D controller. * * This function iterates through a mutable map of MapObject instances and adds each one * to the provided `GoogleMap3D` controller. For each successfully added object, * it stores the resulting active map object in the `activeMapObjects` map * for later management (like removal). * * @param mapObjects A mutable map where keys are object IDs (String) and values * are MapObject instances to be added to the map. * @param controller The GoogleMap3D controller to which the objects will be added. */ private fun addMapObjects( mapObjects: MutableMap, controller: GoogleMap3D ) { mapObjects.forEach { (_, mapObject) -> mapObject.addToMap(controller)?.also { activeObject -> activeMapObjects[mapObject.id] = activeObject } } } /** * Sets the Map3DController instance. * * @param googleMap3d The GoogleMap3D instance, or null if it's being detached. */ open fun setGoogleMap3D(googleMap3d: GoogleMap3D?) { _googleMap3D.value = googleMap3d } private fun stopAnimations() { Log.d("Map3dViewModel", "stopAnimations: ") _googleMap3D.value?.stopCameraAnimation() } open fun releaseGoogleMap3D() { _googleMap3D.value = null } /** * Clears the ViewModel's internal tracking of active SDK map objects. * This is called when the controller is detached or changed, as the underlying * map instance those objects belonged to is no longer relevant. */ fun clearObjects() { activeMapObjects.forEach { (_, activeObject) -> activeObject.remove() } activeMapObjects.clear() } private fun addMapObject(mapObject: MapObject) { mapObjects[mapObject.id] = mapObject // No need to remove the old as the map will replace it _googleMap3D.value?.also { controller -> mapObject.addToMap(controller)?.also { activeObject -> activeMapObjects[mapObject.id] = activeObject } } } fun addMarker(options: MarkerOptions) { addMapObject(MapObject.Marker(options)) } fun removeMapObject(id: String) { mapObjects.remove(id) activeMapObjects.remove(id)?.also { activeObject -> activeObject.remove() } } fun addPolyline(polylineOptions: PolylineOptions) { addMapObject(MapObject.Polyline(polylineOptions)) } fun addPolygon(polygonOptions: PolygonOptions) { addMapObject(MapObject.Polygon(polygonOptions)) } fun addModel(modelOptions: ModelOptions) { addMapObject(MapObject.Model(modelOptions)) } fun setCamera(camera: Camera) { CameraUpdate.Move(camera).also { _pendingCameraUpdate.tryEmit(it) } } fun flyTo(flyToOptions: FlyToOptions) { CameraUpdate.FlyTo(flyToOptions).also { _pendingCameraUpdate.tryEmit(it) } } fun flyAround(flyAroundOptions: FlyAroundOptions) { CameraUpdate.FlyAround(flyAroundOptions).also { _pendingCameraUpdate.tryEmit(it) } } fun setCameraRestriction(cameraRestriction: CameraRestriction?) { _cameraRestriction.value = cameraRestriction } fun setMapMode(@Map3DMode mode: Int) { _mapMode.value = mode } override fun onCleared() { _googleMap3D.value = null super.onCleared() } open fun updateCameraAndMove(block: Camera.() -> Camera) { currentCamera.value.let { camera -> _pendingCameraUpdate.tryEmit( CameraUpdate.Move( camera.block() // .also { _currentCamera.value = it } ) ) } } open fun setCameraHeading(heading: Number) { updateCameraAndMove { copy(heading = heading.toHeading()) } } open fun setCameraTilt(tilt: Number) { updateCameraAndMove { copy(heading = tilt.toTilt()) } } open fun setCamaraRange(range: Number) { updateCameraAndMove { copy(range = range.toRange()) } } open fun setCamaraRoll(roll: Number) { updateCameraAndMove { copy(roll = roll.toRoll()) } } fun flyAroundCurrentCenter(rounds: Double, duration: Duration) { currentCamera.value.let { camera -> flyAround( flyAroundOptions { center = camera durationInMillis = duration.inWholeMilliseconds this.rounds = rounds } ) } } fun getModel(key: String): Model? { activeMapObjects[key]?.let { activeObject -> if (activeObject is ActiveMapObject.ActiveModel) { return activeObject.model } } return null } fun nextMapMode() { val newMapType = when (mapMode.value) { Map3DMode.SATELLITE -> Map3DMode.HYBRID else -> Map3DMode.SATELLITE } setMapMode(newMapType) } suspend fun awaitFlyTo(flyToOptions: FlyToOptions) { awaitCameraUpdate(flyToOptions.toCameraUpdate()) } suspend fun awaitFlyAround(flyAroundOptions: FlyAroundOptions) { awaitCameraUpdate(flyAroundOptions.toCameraUpdate()) } suspend fun awaitCameraUpdate(cameraUpdate: CameraUpdate) { _googleMap3D.value?.let { controller -> com.example.placesuikit3d.utils.awaitCameraUpdate(controller, cameraUpdate) } } } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/MapObject.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // //   http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.common import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.model.MarkerOptions import com.google.android.gms.maps3d.model.ModelOptions import com.google.android.gms.maps3d.model.PolygonOptions import com.google.android.gms.maps3d.model.PolylineOptions sealed class MapObject { internal abstract fun addToMap(controller: GoogleMap3D): ActiveMapObject? abstract val id: String data class Marker(val options: MarkerOptions) : MapObject() { override fun addToMap(controller: GoogleMap3D): ActiveMapObject? { return controller.addMarker(options)?.let { marker -> ActiveMapObject.ActiveMarker(marker) } } override val id: String get() = options.id } data class Polyline(val options: PolylineOptions) : MapObject() { override fun addToMap(controller: GoogleMap3D): ActiveMapObject { return ActiveMapObject.ActivePolyline(controller.addPolyline(options)) } override val id: String get() = options.id } data class Polygon(val options: PolygonOptions) : MapObject() { override fun addToMap(controller: GoogleMap3D): ActiveMapObject { return ActiveMapObject.ActivePolygon(controller.addPolygon(options)) } override val id: String get() = options.id } data class Model(val options: ModelOptions) : MapObject() { override fun addToMap(controller: GoogleMap3D): ActiveMapObject { return ActiveMapObject.ActiveModel(controller.addModel(options)) } override val id: String get() = options.id } } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Color.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.ui.theme import androidx.compose.ui.graphics.Color val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Theme.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80 ) private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 /* Other default colors to override background = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE), onPrimary = Color.White, onSecondary = Color.White, onTertiary = Color.White, onBackground = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F), */ ) @Composable fun PlacesUIKit3DTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme else -> LightColorScheme } MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Type.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp ) /* Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp ), labelSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ) */ ) ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/CameraUpdate.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // //   http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.utils import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.OnCameraAnimationEndListener import com.google.android.gms.maps3d.model.Camera import com.google.android.gms.maps3d.model.FlyAroundOptions import com.google.android.gms.maps3d.model.FlyToOptions import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume /** * Represents an update to the camera of a [GoogleMap3D]. * * This sealed class provides different ways to update the camera, such as flying to a specific location, * flying around a point, or simply moving the camera to a new position. * * The main advantage is to allow creation of the [awaitCameraUpdate] method. * * Each subclass of [CameraUpdate] defines how the camera should be updated through its `invoke` method. * * Subclasses: * - [FlyTo]: Represents a camera fly-to animation. * - [FlyAround]: Represents a camera fly-around animation. * - [Move]: Represents a direct camera move without animation. */ sealed class CameraUpdate { abstract operator fun invoke(controller: GoogleMap3D) data class FlyTo(val options: FlyToOptions) : CameraUpdate() { override fun invoke(controller: GoogleMap3D) { controller.flyCameraTo(options) } } data class FlyAround(val options: FlyAroundOptions) : CameraUpdate() { override fun invoke(controller: GoogleMap3D) { controller.flyCameraAround(options) } } data class Move(val camera: Camera) : CameraUpdate() { override fun invoke(controller: GoogleMap3D) { controller.setCamera(camera) } } } fun FlyToOptions.toCameraUpdate(): CameraUpdate { return CameraUpdate.FlyTo(this.toValidFlyToOptions()) } fun FlyAroundOptions.toCameraUpdate(): CameraUpdate { return CameraUpdate.FlyAround(this.toValidFlyAroundOptions()) } fun FlyToOptions.toValidFlyToOptions(): FlyToOptions { return this.copy( endCamera = this.endCamera.toValidCamera() ) } fun FlyAroundOptions.toValidFlyAroundOptions(): FlyAroundOptions { return this.copy( center = this.center.toValidCamera() ) } /** * Suspends the coroutine until the camera update animation is finished. * * If the [cameraUpdate] is a [CameraUpdate.Move], it will be applied immediately without waiting. * * Otherwise, it will wait for the camera animation to finish, then it will resume the coroutine. * * You can pass in an existing [cameraChangedListener] that will be invoked when the camera * animation finishes and also will be restored afterwards. * * @param controller The [GoogleMap3D] instance to apply the camera update to. * @param cameraUpdate The [CameraUpdate] to apply. * @param cameraChangedListener An optional existing listener to invoke and restore */ suspend fun awaitCameraUpdate( controller: GoogleMap3D, cameraUpdate: CameraUpdate, cameraChangedListener: OnCameraAnimationEndListener? = null ) = suspendCancellableCoroutine { continuation -> // No need to wait if the update is a move if (cameraUpdate is CameraUpdate.Move) { cameraUpdate.invoke(controller) return@suspendCancellableCoroutine } // If the coroutine is canceled, stop the camera animation as well. continuation.invokeOnCancellation { controller.stopCameraAnimation() } controller.setCameraAnimationEndListener { cameraChangedListener?.onCameraAnimationEnd() controller.setCameraAnimationEndListener(cameraChangedListener) if (continuation.isActive) { continuation.resume(Unit) } } cameraUpdate.invoke(controller) } ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Units.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // //   http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.utils import android.content.res.Resources import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.res.stringResource import com.example.placesuikit3d.R const val METERS_PER_FOOT = 3.28084 const val METERS_PER_KILOMETER = 1_000 const val FEET_PER_METER = 1 / METERS_PER_FOOT const val FEET_PER_MILE = 5_280 const val MILES_PER_METER = 0.000621371 /** A value class to wrap a value representing a measurement in meters. */ @Immutable @JvmInline value class Meters(val value: Double) : Comparable { override fun compareTo(other: Meters) = value.compareTo(other.value) operator fun minus(other: Meters) = Meters(value = this.value - other.value) } /** Create a Meters class from a [Number] */ @Stable inline val Number.meters: Meters get() = Meters(value = this.toDouble()) /** Create a Meters class from a [Number] */ @Stable inline val Number.m: Meters get() = Meters(value = this.toDouble()) /** Create a Meters class from a [Number] of kilometers */ @Stable inline val Number.km: Meters get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER) /** Create a Meters class from a [Number] of feet */ @Stable inline val Number.feet: Meters get() = Meters(value = this.toDouble() * FEET_PER_METER) /** Create a Meters class from a [Number] of miles */ @Stable inline val Number.miles: Meters get() = Meters(value = this.toDouble() / MILES_PER_METER) /** Gets the number of equivalent feet from a meters value class */ @Stable inline val Meters.toFeet: Double get() = value * METERS_PER_FOOT /** Gets the value of a meters class as a Double */ @Stable inline val Meters.toMeters: Double get() = value /** Gets the number of equivalent kilometers from a meters value class */ @Stable inline val Meters.toKilometers: Double get() = value / METERS_PER_KILOMETER /** Gets the number of equivalent kilometers from a meters value class */ @Stable inline val Meters.toMiles: Double get() = (value * MILES_PER_METER) @Stable operator fun Meters.plus(other: Meters) = Meters(value = this.value + other.value) /** * A data class representing a value with a string resource ID for its units template. * * @property value: The numerical value. * @property unitsTemplate: The string resource ID for the units. */ data class ValueWithUnitsTemplate(val value: Double, @StringRes val unitsTemplate: Int) /** Abstract base class for all units converters. */ abstract class UnitsConverter { abstract fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate abstract fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate @Composable fun toDistanceString(meters: Meters): String { val (value, resourceId) = toDistanceUnits(meters = meters) return stringResource(id = resourceId, value) } fun toDistanceString(resources: Resources, meters: Meters): String { val (value, resourceId) = toDistanceUnits(meters = meters) return resources.getString(resourceId, value) } @Composable fun toElevationString(meters: Meters): String { val (value, resourceId) = toElevationUnits(meters = meters) return stringResource(id = resourceId, value) } } /** * Returns the appropriate [UnitsConverter] based on the given country code. * * @param countryCode The country code to determine the units converter for. * @return The appropriate [UnitsConverter] for the specified country code. */ fun getUnitsConverter(countryCode: String?): UnitsConverter { // TODO: other counties that use imperial units for distances? return if (countryCode == "US") { ImperialUnitsConverter } else { MetricUnitsConverter } } /** Class to render measurements in imperial units. */ object ImperialUnitsConverter : UnitsConverter() { override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { return if (meters < 0.25.miles) { ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet) } else { ValueWithUnitsTemplate(meters.toMiles, R.string.in_miles) } } override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { return ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet) } } /** Class to render measurements in metric units. */ object MetricUnitsConverter : UnitsConverter() { override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { return if (meters < 1000.meters) { ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters) } else { ValueWithUnitsTemplate(meters.toKilometers, R.string.in_kilometers) } } override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { return ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters) } } /** A composition local that provides a [UnitsConverter] instance. */ val LocalUnitsConverter = compositionLocalOf { MetricUnitsConverter } /** Creates a string to show the distance formatted with units */ @Composable fun Meters.toDistanceString(): String { return LocalUnitsConverter.current.toDistanceString(this) } @Composable fun Meters.toElevationString(): String { return LocalUnitsConverter.current.toElevationString(this) } operator fun Meters.plus(value: Number) = Meters(this.value + value.toDouble()) ================================================ FILE: PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Utilities.kt ================================================ // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // //   http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesuikit3d.utils import com.google.android.gms.maps3d.model.Camera import com.google.android.gms.maps3d.model.FlyAroundOptions import com.google.android.gms.maps3d.model.FlyToOptions import com.google.android.gms.maps3d.model.LatLngAltitude import com.google.android.gms.maps3d.model.camera import com.google.android.gms.maps3d.model.flyAroundOptions import com.google.android.gms.maps3d.model.flyToOptions import com.google.android.gms.maps3d.model.latLngAltitude import java.util.Locale import kotlin.math.floor val headingRange = 0.0..360.0 val tiltRange = 0.0..90.0 val rangeRange = 0.0..63170000.0 val rollRange = -360.0..360.0 val latitudeRange = -90.0..90.0 val longitudeRange = -180.0..180.0 val altitudeRange = 0.0..LatLngAltitude.MAX_ALTITUDE_METERS const val DEFAULT_HEADING = 0.0 const val DEFAULT_TILT = 60.0 const val DEFAULT_RANGE = 1500.0 const val DEFAULT_ROLL = 0.0 /** * Converts a nullable Camera object into a valid, non-null Camera object. * If the input is null, returns the DEFAULT_CAMERA configuration. * If the input is non-null, validates its components (center, heading, tilt, roll, range) * using helper functions (toValidLocation, toHeading, toTilt, toRoll, toRange). * * @receiver The nullable Camera object to validate. * @return A valid, non-null Camera object. */ fun Camera?.toValidCamera(): Camera { // Use elvis operator for concise null handling val source = this ?: return Camera.DEFAULT_CAMERA // Return default camera if source is null // If source is not null, validate its components return camera { // Validate center using the provided toValidLocation function center = source.center.toValidLocation() // Validate orientation and range using the existing to...() functions heading = source.heading.toHeading() tilt = source.tilt.toTilt() roll = source.roll.toRoll() range = source.range.toRange() } } /** * Coerces the latitude, longitude, and altitude of a LatLngAltitude object * to be within their valid ranges. Longitude is clamped, not wrapped here. * * @receiver The LatLngAltitude to validate. * @return A new LatLngAltitude object with validated components. */ fun LatLngAltitude.toValidLocation(): LatLngAltitude { val objectToCopy = this return latLngAltitude { // Coerce latitude within -90.0 to 90.0 latitude = objectToCopy.latitude.coerceIn(latitudeRange) // Coerce longitude within -180.0 to 180.0 (Note: wrapping might be preferred sometimes) longitude = objectToCopy.longitude.coerceIn(longitudeRange) // Coerce altitude within 0.0 to MAX_ALTITUDE_METERS altitude = objectToCopy.altitude.coerceIn(altitudeRange) } } /** * Converts a Number? to a valid heading value (0.0 to 360.0). * Returns 0.0 if the input is null. * Uses wrapIn to ensure the value is within the headingRange. * * @receiver The Number? to convert. * @return The heading value as a Double within [0.0, 360.0). */ fun Number?.toHeading(): Double = this?.toDouble()?.wrapIn(headingRange.start, headingRange.endInclusive) ?: DEFAULT_HEADING /** * Converts a Number? to a valid tilt value (0.0 to 90.0). * Returns 0.0 if the input is null. * Clamps the value to the tiltRange, as tilt doesn't typically wrap. * * @receiver The Number? to convert. * @return The tilt value as a Double clamped within [0.0, 90.0]. */ fun Number?.toTilt(): Double = this?.toDouble()?.coerceIn(tiltRange) ?: DEFAULT_TILT /** * Converts a Number? to a valid roll value (-360.0 to 360.0 or often -180..180). * Returns 0.0 if the input is null. * Uses wrapIn to ensure the value is within the rollRange. * Consider using -180..180 range and wrapIn(lower, upper) for standard roll representation. * * @receiver The Number? to convert. * @return The roll value as a Double within the defined rollRange. */ fun Number?.toRoll(): Double = this?.toDouble()?.wrapIn(rollRange) ?: DEFAULT_ROLL /** * Converts a Number? to a valid range value (0.0 to ~63,170,000.0). * Returns 0.0 if the input is null. * Clamps the value to the rangeRange, as range/distance doesn't wrap. * * @receiver The Number? to convert. * @return The range value as a Double clamped within the defined rangeRange. */ fun Number?.toRange(): Double = this?.toDouble()?.coerceIn(rangeRange) ?: DEFAULT_RANGE // Assumes we are close to the range fun Double.wrapIn(range: ClosedFloatingPointRange): Double { var answer = this val delta = range.endInclusive - range.start while (answer > range.endInclusive) { answer -= delta } while (answer < range.start) { answer += delta } return answer } /** * Wraps a Float value within a specified range. * If the value is outside the range, it is adjusted by repeatedly adding or subtracting * the range's span (delta) until it falls within the range. * * @param range The ClosedFloatingPointRange within which to wrap the value. * @return The wrapped Float value, guaranteed to be within the specified range. */ fun Float.wrapIn(range: ClosedFloatingPointRange): Float { var answer = this val delta = range.endInclusive - range.start while (answer > range.endInclusive) { answer -= delta } while (answer < range.start) { answer += delta } return answer } /** * Wraps a Double value within the specified range [lower, upper). * This method ensures that the returned value always falls within the specified range. * If the value is outside the range, it will be "wrapped around" to fit within the range. * For example, if the range is [0.0, 360.0) and the input is 370.0, the output will be 10.0. * If the range is [0.0, 360.0) and the input is -10.0, the output will be 350.0. * * @param lower The lower bound of the range (inclusive). * @param upper The upper bound of the range (exclusive). * @return The wrapped value within the range [lower, upper). * @throws IllegalArgumentException if the upper bound is not greater than the lower bound. */ fun Double.wrapIn(lower: Double, upper: Double): Double { val range = upper - lower if (range <= 0) { throw IllegalArgumentException("Upper bound must be greater than lower bound") } val offset = this - lower return lower + (offset - floor(offset / range) * range) } /** * Extension function on Number to get the nearest compass direction string * from a given heading in degrees. * * 0 degrees is North, 90 is East, 180 is South, 270 is West. * Handles headings outside the standard 0-360 range (e.g., -90 or 450 degrees). * * @return A string representing the nearest compass direction (e.g., "N", "NNE", "NE"). */ fun Number.toCompassDirection(): String { val directions = listOf( "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" ) val headingDegrees = this.toDouble() // Normalize heading to 0-359.99... degrees val normalizedHeading = (headingDegrees % 360.0 + 360.0) % 360.0 // Each of the 16 directions covers an arc of 360/16 = 22.5 degrees. // We add half of this (11.25) to the normalized heading before dividing // to correctly align with the center of each compass arc. val segment = 22.5 val index = floor((normalizedHeading + (segment / 2)) / segment).toInt() % directions.size return directions[index] } /** * Creates a new [Camera] object by copying the current [Camera] and optionally overriding * its center, heading, tilt, range, and roll properties. * * @param center The new center [LatLngAltitude] to use, or null to keep the current center. * @param heading The new heading (bearing) to use, or null to keep the current heading. * @param tilt The new tilt (pitch) to use, or null to keep the current tilt. * @param range The new range (distance from the center) to use, or null to keep the current range. * @param roll The new roll to use, or null to keep the current roll. * @return A new [Camera] object with the specified properties updated. */ fun Camera.copy( center: LatLngAltitude? = null, heading: Double? = null, tilt: Double? = null, range: Double? = null, roll: Double? = null, ): Camera { val objectToCopy = this return camera { this.center = center ?: objectToCopy.center this.heading = heading ?: objectToCopy.heading this.tilt = tilt ?: objectToCopy.tilt this.range = range ?: objectToCopy.range this.roll = roll ?: objectToCopy.roll } } fun FlyAroundOptions.copy( center: Camera? = null, durationInMillis: Long? = null, rounds: Double? = null, ) : FlyAroundOptions { val objectToCopy = this return flyAroundOptions { this.center = (center ?: objectToCopy.center) this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis this.rounds = rounds ?: objectToCopy.rounds } } fun FlyToOptions.copy( endCamera: Camera? = null, durationInMillis: Long? = null, ) : FlyToOptions { val objectToCopy = this return flyToOptions { this.endCamera = (endCamera ?: objectToCopy.endCamera) this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis } } /** * Converts a [Camera] object to a formatted string representation. * * This function takes a [Camera] object, validates it using [toValidCamera], and then * constructs a multi-line string that represents the camera's properties in a human-readable * format. The string includes the camera's center (latitude, longitude, altitude), * heading, tilt, and range. * * The latitude, longitude, altitude, heading, tilt, and range are formatted to specific * decimal places for readability (6, 6, 1, 0, 0, 0 respectively). * * The output string is designed to be easily copied and pasted directly into code to recreate * a [Camera] object with the same parameters. This is especially useful for quickly positioning * the camera to a specific view. * * Example output: * ``` * camera { * center = latLngAltitude { * latitude = 34.052235 * longitude = -118.243685 * altitude = 100.0 * } * heading = 90 * tilt = 45 * range = 5000 * } * ``` * * @receiver The [Camera] object to convert. * @return A string representation of the [Camera] object, suitable for pasting into source code. */ fun Camera.toCameraString(): String { val camera = this.toValidCamera() return """ camera { center = latLngAltitude { latitude = ${camera.center.latitude.format(6)} longitude = ${camera.center.longitude.format(6)} altitude = ${camera.center.altitude.format(1)} } heading = ${camera.heading.format(0)} tilt = ${camera.tilt.format(0)} range = ${camera.range.format(0)} }""".trimIndent() } /** * Formats a nullable Double to a string with a specified number of decimal places. * * If the Double is null, returns "null". * If decimalPlaces is 0, it formats the number with no decimal places and appends ".0". * If decimalPlaces is greater than 0, it formats the number with the specified number of decimal places. * * Note, this is intended for logging and debugging not for display to the user. * * @receiver The nullable Double to format. * @param decimalPlaces The number of decimal places to include in the formatted string. * @return The formatted string representation of the Double, or "null" if the input is null. */ internal fun Double?.format(decimalPlaces: Int): String { if (this == null) return "null" return if (decimalPlaces == 0) { String.format(Locale.US, "%.0f.0", this) } else { String.format(Locale.US, "%.${decimalPlaces}f", this) } } ================================================ FILE: PlacesUIKit3D/src/main/res/drawable/close_button_background.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/drawable/ic_close.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/drawable/ic_my_location.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/drawable/loader_background.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/drawable/outline_my_location_24.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/font/custom_font.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher_round.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF #1A0A2D #E0218A #F0F8FF #9E8BBE #00E5FF #00E5FF #FF007F ================================================ FILE: PlacesUIKit3D/src/main/res/values/strings.xml ================================================ Places UI Kit 3D %1$,.0f ft %1$,.1f miles %1$,.0f m %1$,.1f km Dismiss place details Loading… My Location ================================================ FILE: PlacesUIKit3D/src/main/res/values/themes.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: PlacesUIKit3D/src/main/res/xml/data_extraction_rules.xml ================================================ ================================================ FILE: README.md ================================================ Google Places SDK for Android Demos ==================================== ![GitHub contributors](https://img.shields.io/github/contributors/googlemaps/android-places-demos) ![Apache-2.0](https://img.shields.io/badge/license-Apache-blue) [![Discord](https://img.shields.io/discord/676948200904589322)](https://discord.gg/hYsWbmk) This repo contains several standalone applications that demonstrate use of the [Google Places SDK for Android](https://developers.google.com/places/android-sdk/): 1. **[demo-java](demo-java):** Basic Java application demonstrating core Places SDK capabilities including Place Autocomplete (Intent and Programmatic), Place Details, and Current Place. 2. **[demo-kotlin](demo-kotlin):** The Kotlin equivalent of the standard Java demo, showing idiomatic usage of the base SDK. 3. **[kotlin-demos](kotlin-demos):** Demonstrates the use of the `android-places-ktx` library, highlighting Kotlin Coroutines support and modernized API responses for the Places SDK. 4. **[PlaceDetailsCompose](PlaceDetailsCompose):** Shows how to build modern, interactive Place Details UI screens leveraging Jetpack Compose and the New Places API. 5. **[PlaceDetailsUIKit](PlaceDetailsUIKit):** Shows how to build immersive Place Details UI screens using modern Android Views (UIKit) and the New Places API. 6. **[PlacesUIKit3D](PlacesUIKit3D):** Blends the Places API with the Photorealistic 3D Maps SDK, providing an immersive location-viewing experience with dynamic camera fly-alongs. Additionally, the **[snippets](snippets)** app contains code snippets used across the official [Google Places SDK developer documentation](https://developers.google.com/places/android-sdk). Getting Started --------------- These demos use the Gradle build system. First download the demos by cloning this repository or downloading an archived snapshot. (See the options on the right hand side.) In Android Studio, use "Open an existing Android Studio project", and select the root directory (`android-places-demos`). This will load all the demo modules at once. Alternatively use the `./gradlew assembleDebug` command from the root directory to build all projects simultaneously. The demos require that you provide your own API keys. The project enforces the presence of required keys before the build can even start to prevent runtime crashes. 1. [Get an API Key](https://developers.google.com/places/android-sdk/get-api-key) with the **Places API (New)** and **Maps SDK for Android** enabled. 2. In the root directory, create a `secrets.properties` file (this is git-ignored to prevent accidental commits). 3. Add your keys. See `local.defaults.properties` for the complete list of required and secondary optional keys. At minimum, you must add the required keys: ```properties PLACES_API_KEY=AIza... MAPS_API_KEY=AIza... ``` **Optional Keys:** There are also optional keys required for specific demos to function completely: * `MAPS3D_API_KEY`: Required only for the `PlacesUIKit3D` demo to load the Photorealistic 3D Maps tiles. * `MAP_ID`: Required only for the `PlaceDetailsCompose` demo to demonstrate cloud-based map styling. ```properties MAPS3D_API_KEY=AIza... MAP_ID=... ``` 4. Sync the Android Studio project, build, and run any of the application modules. ### Running Demos via Command Line Each runnable project includes a convenient `installAndLaunch` task. Instead of using Android Studio, you can natively build, install, and execute any demo directly on your connected device or emulator with a single command: ```bash # Launch the standard Java Demo ./gradlew :demo-java:installAndLaunch # Launch the Kotlin Demo ./gradlew :demo-kotlin:installAndLaunch # Launch the Kotlin Coroutines (KTX) Demo ./gradlew :kotlin-demos:installAndLaunch # Launch the Jetpack Compose Demo ./gradlew :PlaceDetailsCompose:installAndLaunch # Launch the UIKit Demo ./gradlew :PlaceDetailsUIKit:installAndLaunch # Launch the Photorealistic 3D Maps Demo ./gradlew :PlacesUIKit3D:installAndLaunch # Launch the Documentation Snippets app ./gradlew :snippets:installAndLaunch ``` ## Terms of Service This sample uses Google Maps Platform services. Use of Google Maps Platform services through this sample is subject to the Google Maps Platform [Terms of Service]. If your billing address is in the European Economic Area, effective on 8 July 2025, the [Google Maps Platform EEA Terms of Service](https://cloud.google.com/terms/maps-platform/eea) will apply to your use of the Services. Functionality varies by region. [Learn more](https://developers.google.com/maps/comms/eea/faq). This sample is not a Google Maps Platform Core Service. Therefore, the Google Maps Platform Terms of Service (e.g. Technical Support Services, Service Level Agreements, and Deprecation Policy) do not apply to the code in this sample. ## Support This sample is offered via an open source [license]. It is not governed by the Google Maps Platform Support [Technical Support Services Guidelines], the [SLA], or the [Deprecation Policy]. However, any Google Maps Platform services used by the sample remain subject to the Google Maps Platform Terms of Service. If you find a bug, or have a feature request, please [file an issue] on GitHub. If you would like to get answers to technical questions from other Google Maps Platform developers, ask through one of our [developer community channels]. If you'd like to contribute, please check the [contributing guide]. You can also discuss this sample on our [Discord server]. [API key]: https://developers.google.com/maps/documentation/android-sdk/get-api-key [API key instructions]: https://developers.google.com/maps/documentation/android-sdk/config#step_3_add_your_api_key_to_the_project [code of conduct]: CODE_OF_CONDUCT.md [contributing guide]: CONTRIBUTING.md [Deprecation Policy]: https://cloud.google.com/maps-platform/terms [developer community channels]: https://developers.google.com/maps/developer-community [Discord server]: https://discord.gg/hYsWbmk [file an issue]: https://github.com/googlemaps-samples/android-places-demos/issues/new/choose [license]: LICENSE [pull request]: https://github.com/googlemaps-samples/android-places-demos/compare [project]: https://developers.google.com/maps/documentation/android-sdk/cloud-setup#enabling-apis [Sign up with Google Maps Platform]: https://console.cloud.google.com/google/maps-apis/start [SLA]: https://cloud.google.com/maps-platform/terms/sla [Technical Support Services Guidelines]: https://cloud.google.com/maps-platform/terms/tssg [Terms of Service]: https://cloud.google.com/maps-platform/terms [Google Maps Platform EEA Terms of Service]: https://cloud.google.com/terms/maps-platform/eea [Learn more]: https://developers.google.com/maps/comms/eea/faq ================================================ FILE: SECURITY.md ================================================ # Report a security issue To report a security issue, please use https://g.co/vulnz. We use https://g.co/vulnz for our intake, and do coordination and disclosure here on GitHub (including using GitHub Security Advisory). The Google Security Team will respond within 5 working days of your report on g.co/vulnz. To contact us about other bugs, please open an issue on GitHub. > **Note**: This file is synchronized from the https://github.com/googlemaps/.github repository. ================================================ FILE: build-logic/convention/build.gradle.kts ================================================ /* * Copyright 2026 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. */ plugins { `kotlin-dsl` } dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.secrets.gradlePlugin) } kotlin { jvmToolchain(17) } ================================================ FILE: build-logic/convention/src/main/kotlin/places-demo.android.application.gradle.kts ================================================ /* * Copyright 2026 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. */ plugins { id("com.android.application") } interface DemoAppExtension { val mainActivity: Property } val demoApp = extensions.create("demoApp") demoApp.mainActivity.convention(".MainActivity") android { compileSdk = 36 defaultConfig { minSdk = 24 targetSdk = 36 } java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } buildFeatures { buildConfig = true } } afterEvaluate { val androidExt = project.extensions.getByType(com.android.build.api.dsl.ApplicationExtension::class.java) val appId = androidExt.defaultConfig.applicationId ?: androidExt.namespace ?: project.name val namespace = androidExt.namespace ?: appId val mainAct = demoApp.mainActivity.get() val componentName = if (mainAct.startsWith(".")) "$appId/$namespace$mainAct" else "$appId/$mainAct" val androidComponents = project.extensions.getByType(com.android.build.api.variant.AndroidComponentsExtension::class.java) val adbPath = androidComponents.sdkComponents.adb.get().asFile.absolutePath tasks.register("installAndLaunch") { description = "Installs the debug APK and launches the main activity." group = "application" dependsOn("installDebug") commandLine(adbPath, "shell", "am", "start", "-n", componentName) } } ================================================ FILE: build-logic/convention/src/main/kotlin/places-demo.secrets.gradle.kts ================================================ /* * Copyright 2026 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. */ import java.util.Properties import org.gradle.api.GradleException import org.gradle.api.provider.ListProperty interface SecretsVerificationExtension { val requiredKeys: ListProperty val optionalKeys: ListProperty } val secretsVerification = extensions.create("secretsVerification") secretsVerification.requiredKeys.convention(listOf("PLACES_API_KEY", "MAPS_API_KEY")) // Optional keys: // MAPS3D_API_KEY: Needed for the 'PlacesUIKit3D' demo. // MAP_ID: Needed for the 'PlaceDetailsCompose' demo. secretsVerification.optionalKeys.convention(listOf("MAPS3D_API_KEY", "MAP_ID")) // Check for secrets.properties file and valid API key before proceeding with build tasks. afterEvaluate { val requiredKeysToCheck = secretsVerification.requiredKeys.get() val optionalKeysToCheck = secretsVerification.optionalKeys.get() val secretsFile = rootProject.file("secrets.properties") val isCI = System.getenv("CI")?.toBoolean() ?: false if (!isCI) { val requestedTasks = gradle.startParameter.taskNames if (requestedTasks.isEmpty() && !secretsFile.exists()) { // It's likely an IDE sync if no tasks are specified, so just issue a warning. println("Warning: secrets.properties not found. Gradle sync may succeed, but building/running the app will fail.") } else if (requestedTasks.isNotEmpty()) { val buildTaskKeywords = listOf("build", "install", "assemble") val isBuildTask = requestedTasks.any { task -> buildTaskKeywords.any { keyword -> task.contains(keyword, ignoreCase = true) } } val testTaskKeywords = listOf("test", "report", "lint") val isTestTask = requestedTasks.any { task -> testTaskKeywords.any { keyword -> task.contains(keyword, ignoreCase = true) } } val isDebugTask = requestedTasks.any { task -> task.contains("Debug", ignoreCase = true) || task.contains("installAndLaunch", ignoreCase = true) } if (isBuildTask && !isTestTask && isDebugTask) { if (!secretsFile.exists()) { val defaultsFile = rootProject.file("local.defaults.properties") val requiredKeysMessage = if (defaultsFile.exists()) { defaultsFile.readText() } else { requiredKeysToCheck.joinToString("\n") { "$it=" } } throw GradleException("secrets.properties file not found. Please create a 'secrets.properties' file in the root project directory with the following content:\n\n$requiredKeysMessage") } val secrets = Properties() secretsFile.inputStream().use { secrets.load(it) } val isValidKey = { key: String? -> !key.isNullOrBlank() && key != "DEFAULT_API_KEY" && key!!.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$")) } val isPresentAndNotDefault = { key: String? -> !key.isNullOrBlank() && key != "DEFAULT_API_KEY" } requiredKeysToCheck.forEach { reqKey -> val keyValue = secrets.getProperty(reqKey) if (reqKey.endsWith("API_KEY", ignoreCase = true)) { if (!isValidKey(keyValue)) { throw GradleException("Invalid or missing $reqKey in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').") } } else { if (!isPresentAndNotDefault(keyValue)) { throw GradleException("Missing $reqKey in secrets.properties.") } } } optionalKeysToCheck.forEach { optKey -> val keyValue = secrets.getProperty(optKey) if (isPresentAndNotDefault(keyValue)) { if (optKey.endsWith("API_KEY", ignoreCase = true)) { if (!isValidKey(keyValue)) { val demoMsg = if (optKey == "MAPS3D_API_KEY") " (Required for PlacesUIKit3D demo)" else "" throw GradleException("Invalid $optKey in secrets.properties.$demoMsg Please provide a valid Google Maps API key (starts with 'AIza').") } } } else { if (optKey == "MAPS3D_API_KEY") { println("Warning: MAPS3D_API_KEY is missing or set to default in secrets.properties. The 'PlacesUIKit3D' demo will fail to load 3D maps at runtime.") } else if (optKey == "MAP_ID") { println("Warning: MAP_ID is missing or set to default in secrets.properties. The 'PlaceDetailsCompose' demo will fail to load custom map styling at runtime.") } } } } } } } plugins { id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } secrets { // To add your Google Maps Platform API key to this project: // 1. Copy local.defaults.properties to secrets.properties // 2. In the secrets.properties file, replace PLACES_API_KEY=DEFAULT_API_KEY with a key from a // project with Places API enabled // 3. In the secrets.properties file, replace MAPS_API_KEY=DEFAULT_API_KEY with a key from a // project with Maps SDK for Android enabled (can be the same project and key as in Step 2) defaultPropertiesFileName = "local.defaults.properties" // Optionally specify a different file name containing your secrets. // The plugin defaults to "local.properties" propertiesFileName = "secrets.properties" } ================================================ FILE: build-logic/settings.gradle.kts ================================================ /* * Copyright 2026 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. */ dependencyResolutionManagement { repositories { google { content { includeGroupByRegex("com\\.android.*") includeGroupByRegex("com\\.google.*") includeGroupByRegex("androidx.*") } } mavenCentral() } versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } } pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } rootProject.name = "build-logic" include(":convention") ================================================ FILE: build.gradle.kts ================================================ /* * Copyright 2026 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. */ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.secrets.gradle.plugin) apply false alias(libs.plugins.jetbrains.kotlin.parcelize) apply false alias(libs.plugins.hilt.android) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.kapt) apply false } allprojects { configurations.all { resolutionStrategy { force("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.10") } } } ================================================ FILE: demo-java/build.gradle.kts ================================================ /* * Copyright 2025 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. */ plugins { id("places-demo.android.application") id("places-demo.secrets") } android { namespace = "com.example.placesdemo" defaultConfig { applicationId = "com.example.placesdemo" versionCode = 1 versionName = "1.0" multiDexEnabled = true } buildFeatures { viewBinding = true buildConfig = true } } dependencies { implementation(libs.constraintlayout) implementation(libs.activity) implementation(libs.fragment) implementation(libs.navigation.fragment) implementation(libs.navigation.ui) implementation(libs.appcompat) implementation(libs.material) implementation(libs.volley) implementation(libs.glide) implementation(libs.viewbinding) implementation(libs.multidex) // Places and Maps SDKs implementation(libs.places) implementation(libs.play.services.maps) implementation(libs.android.maps.utils) } ================================================ FILE: demo-java/local.defaults.properties ================================================ PLACES_API_KEY=DEFAULT_API_KEY MAPS_API_KEY=DEFAULT_API_KEY ================================================ FILE: demo-java/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: demo-java/src/main/AndroidManifest.xml ================================================ ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/AutocompleteAddressActivity.java ================================================ /* * Copyright 2022 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; import android.os.Bundle; import android.util.Log; import android.view.View; import android.view.ViewStub; import android.widget.Button; import android.widget.CheckBox; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import com.example.placesdemo.databinding.AutocompleteAddressActivityBinding; import com.google.android.gms.location.FusedLocationProviderClient; import com.google.android.gms.location.LocationServices; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.GoogleMapOptions; import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.SupportMapFragment; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.MapStyleOptions; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; import com.google.android.libraries.places.api.Places; import com.google.android.libraries.places.api.model.AddressComponent; import com.google.android.libraries.places.api.model.AddressComponents; import com.google.android.libraries.places.api.model.Place; import com.google.android.libraries.places.api.model.PlaceTypes; import com.google.android.libraries.places.api.net.PlacesClient; import com.google.android.libraries.places.widget.Autocomplete; import com.google.android.libraries.places.widget.model.AutocompleteActivityMode; import java.util.Arrays; import java.util.List; import static android.Manifest.permission.ACCESS_FINE_LOCATION; import static com.google.maps.android.SphericalUtil.computeDistanceBetween; import androidx.activity.EdgeToEdge; /** * Activity for using Place Autocomplete to assist filling out an address form. */ @SuppressWarnings("FieldCanBeLocal") public class AutocompleteAddressActivity extends AppCompatActivity implements OnMapReadyCallback { private static final String TAG = "ADDRESS_AUTOCOMPLETE"; private static final String MAP_FRAGMENT_TAG = "MAP"; private LatLng coordinates; private boolean checkProximity = false; private SupportMapFragment mapFragment; private GoogleMap map; private Marker marker; private PlacesClient placesClient; private View mapPanel; private LatLng deviceLocation; private static final double acceptedProximity = 150; private AutocompleteAddressActivityBinding binding; View.OnClickListener startAutocompleteIntentListener = view -> { view.setOnClickListener(null); startAutocompleteIntent(); }; // [START maps_solutions_android_autocomplete_define] private final ActivityResultLauncher startAutocomplete = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() == Activity.RESULT_OK) { Intent intent = result.getData(); if (intent != null) { Place place = Autocomplete.getPlaceFromIntent(intent); // Write a method to read the address components from the Place // and populate the form with the address components Log.d(TAG, "Place: " + place.getAddressComponents()); fillInAddress(place); } } else if (result.getResultCode() == Activity.RESULT_CANCELED) { // The user canceled the operation. Log.i(TAG, "User canceled autocomplete"); } }); // [END maps_solutions_android_autocomplete_define] @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent intent) { super.onActivityResult(requestCode, resultCode, intent); binding.autocompleteAddress1.setOnClickListener(startAutocompleteIntentListener); } @Override protected void onCreate(Bundle savedInstanceState) { // Enable edge-to-edge display. This must be called before calling super.onCreate(). EdgeToEdge.enable(this); super.onCreate(savedInstanceState); binding = AutocompleteAddressActivityBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); // Retrieve a PlacesClient (previously initialized - see MainActivity) placesClient = Places.createClient(this); // Attach an Autocomplete intent to the Address 1 EditText field binding.autocompleteAddress1.setOnClickListener(startAutocompleteIntentListener); // Update checkProximity when user checks the checkbox CheckBox checkProximityBox = findViewById(R.id.checkbox_proximity); checkProximityBox.setOnCheckedChangeListener((view, isChecked) -> { // Set the boolean to match user preference for when the Submit button is clicked checkProximity = isChecked; }); // Submit and optionally check proximity Button saveButton = findViewById(R.id.autocomplete_save_button); saveButton.setOnClickListener(v -> saveForm()); // Reset the form Button resetButton = findViewById(R.id.autocomplete_reset_button); resetButton.setOnClickListener(v -> clearForm()); } // [START maps_solutions_android_autocomplete_intent] private void startAutocompleteIntent() { // Set the fields to specify which types of place data to // return after the user has made a selection. List fields = Arrays.asList(Place.Field.ADDRESS_COMPONENTS, Place.Field.LOCATION, Place.Field.VIEWPORT); // Build the autocomplete intent with field, country, and type filters applied Intent intent = new Autocomplete.IntentBuilder(AutocompleteActivityMode.OVERLAY, fields) .setCountries(List.of("US")) .setTypesFilter(List.of("establishment")) .build(this); startAutocomplete.launch(intent); } // [END maps_solutions_android_autocomplete_intent] // [START maps_solutions_android_autocomplete_map_ready] @Override public void onMapReady(@NonNull GoogleMap googleMap) { map = googleMap; try { // Customise the styling of the base map using a JSON object defined // in a string resource. boolean success = map.setMapStyle( MapStyleOptions.loadRawResourceStyle(this, R.raw.style_json)); if (!success) { Log.e(TAG, "Style parsing failed."); } } catch (Resources.NotFoundException e) { Log.e(TAG, "Can't find style. Error: ", e); } map.moveCamera(CameraUpdateFactory.newLatLngZoom(coordinates, 15f)); marker = map.addMarker(new MarkerOptions().position(coordinates)); } // [END maps_solutions_android_autocomplete_map_ready] private void fillInAddress(Place place) { AddressComponents components = place.getAddressComponents(); StringBuilder address1 = new StringBuilder(); StringBuilder postcode = new StringBuilder(); // Get each component of the address from the place details, // and then fill-in the corresponding field on the form. // Possible AddressComponent types are documented at https://goo.gle/32SJPM1 if (components != null) { for (AddressComponent component : components.asList()) { String type = component.getTypes().get(0); switch (type) { case "street_number": { address1.insert(0, component.getName()); break; } case "route": { address1.append(" "); address1.append(component.getShortName()); break; } case "postal_code": { postcode.insert(0, component.getName()); break; } case "postal_code_suffix": { postcode.append("-").append(component.getName()); break; } case "locality": binding.autocompleteCity.setText(component.getName()); break; case "administrative_area_level_1": { binding.autocompleteState.setText(component.getShortName()); break; } case "country": binding.autocompleteCountry.setText(component.getName()); break; } } } binding.autocompleteAddress1.setText(address1.toString()); binding.autocompletePostal.setText(postcode.toString()); // After filling the form with address components from the Autocomplete // prediction, set cursor focus on the second address line to encourage // entry of sub-premise information such as apartment, unit, or floor number. binding.autocompleteAddress2.requestFocus(); // Add a map for visual confirmation of the address showMap(place); } // [START maps_solutions_android_autocomplete_map_add] private void showMap(Place place) { coordinates = place.getLocation(); // It isn't possible to set a fragment's id programmatically so we set a tag instead and // search for it using that. mapFragment = (SupportMapFragment) getSupportFragmentManager().findFragmentByTag(MAP_FRAGMENT_TAG); // We only create a fragment if it doesn't already exist. if (mapFragment == null) { mapPanel = ((ViewStub) findViewById(R.id.stub_map)).inflate(); GoogleMapOptions mapOptions = new GoogleMapOptions(); mapOptions.mapToolbarEnabled(false); // To programmatically add the map, we first create a SupportMapFragment. mapFragment = SupportMapFragment.newInstance(mapOptions); // Then we add it using a FragmentTransaction. getSupportFragmentManager() .beginTransaction() .add(R.id.confirmation_map, mapFragment, MAP_FRAGMENT_TAG) .commit(); mapFragment.getMapAsync(this); } else { updateMap(coordinates); } } // [END maps_solutions_android_autocomplete_map_add] private void updateMap(LatLng latLng) { marker.setPosition(latLng); map.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 15f)); if (mapPanel.getVisibility() == View.GONE) { mapPanel.setVisibility(View.VISIBLE); } } private void saveForm() { Log.d(TAG, "checkProximity = " + checkProximity); if (checkProximity) { checkLocationPermissions(); } else { Toast.makeText( this, R.string.autocomplete_skipped_message, Toast.LENGTH_SHORT) .show(); } } private void clearForm() { binding.autocompleteAddress1.setText(""); binding.autocompleteAddress2.getText().clear(); binding.autocompleteCity.getText().clear(); binding.autocompleteState.getText().clear(); binding.autocompletePostal.getText().clear(); binding.autocompleteCountry.getText().clear(); if (mapPanel != null) { mapPanel.setVisibility(View.GONE); } binding.autocompleteAddress1.requestFocus(); } // [START maps_solutions_android_permission_request] // Register the permissions callback, which handles the user's response to the // system permissions dialog. Save the return value, an instance of // ActivityResultLauncher, as an instance variable. private final ActivityResultLauncher requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { if (isGranted) { // Since ACCESS_FINE_LOCATION is the only permission in this sample, // run the location comparison task once permission is granted. // Otherwise, check which permission is granted. getAndCompareLocations(); } else { // Fallback behavior if user denies permission Log.d(TAG, "User denied permission"); } }); // [END maps_solutions_android_permission_request] // [START maps_solutions_android_location_permissions] private void checkLocationPermissions() { if (ContextCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { getAndCompareLocations(); } else { requestPermissionLauncher.launch( ACCESS_FINE_LOCATION); } } // [END maps_solutions_android_location_permissions] @SuppressLint("MissingPermission") private void getAndCompareLocations() { // TODO: Detect and handle if user has entered or modified the address manually and update // the coordinates variable to the Lat/Lng of the manually entered address. May use // Geocoding API to convert the manually entered address to a Lat/Lng. LatLng enteredLocation = coordinates; map.setMyLocationEnabled(true); // [START maps_solutions_android_location_get] FusedLocationProviderClient fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); fusedLocationClient.getLastLocation() .addOnSuccessListener(this, location -> { // Got last known location. In some rare situations this can be null. if (location == null) { return; } deviceLocation = new LatLng(location.getLatitude(), location.getLongitude()); // [START_EXCLUDE] Log.d(TAG, "device location = " + deviceLocation); Log.d(TAG, "entered location = " + enteredLocation.toString()); // [START maps_solutions_android_location_distance] // Use the computeDistanceBetween function in the Maps SDK for Android Utility Library // to use spherical geometry to compute the distance between two Lat/Lng points. double distanceInMeters = computeDistanceBetween(deviceLocation, enteredLocation); if (distanceInMeters <= acceptedProximity) { Log.d(TAG, "location matched"); // TODO: Display UI based on the locations matching } else { Log.d(TAG, "location not matched"); // TODO: Display UI based on the locations not matching } // [END maps_solutions_android_location_distance] // [END_EXCLUDE] }); } // [END maps_solutions_android_location_get] } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/CurrentPlaceActivity.java ================================================ /* * 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import com.example.placesdemo.databinding.CurrentPlaceActivityBinding; import com.google.android.gms.tasks.Task; import com.google.android.libraries.places.api.Places; import com.google.android.libraries.places.api.model.Place.Field; import com.google.android.libraries.places.api.model.PlaceLikelihood; import com.google.android.libraries.places.api.net.FindCurrentPlaceRequest; import com.google.android.libraries.places.api.net.FindCurrentPlaceResponse; import com.google.android.libraries.places.api.net.PlacesClient; import android.Manifest.permission; import android.annotation.SuppressLint; import android.content.pm.PackageManager; import android.os.Bundle; import android.util.Log; import android.view.View; import java.util.List; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresPermission; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import static android.Manifest.permission.ACCESS_FINE_LOCATION; import static android.Manifest.permission.ACCESS_WIFI_STATE; import androidx.activity.EdgeToEdge; /** * Activity to demonstrate {@link PlacesClient#findCurrentPlace(FindCurrentPlaceRequest)}. */ public class CurrentPlaceActivity extends AppCompatActivity { private static final String TAG = "CURRENT_PLACE"; private PlacesClient placesClient; private FieldSelector fieldSelector; private CurrentPlaceActivityBinding binding; // [START maps_solutions_android_permission_request] // Register the permissions callback, which handles the user's response to the // system permissions dialog. Save the return value, an instance of // ActivityResultLauncher, as an instance variable. @SuppressLint("MissingPermission") private final ActivityResultLauncher requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), isGranted -> { if (Boolean.TRUE.equals(isGranted.get(permission.ACCESS_FINE_LOCATION)) && Boolean.TRUE.equals(isGranted.get(ACCESS_WIFI_STATE))) { findCurrentPlaceWithPermissions(); } else { // Fallback behavior if user denies permission Log.d(TAG, "User denied permission"); } }); // [END maps_solutions_android_permission_request] // [START maps_solutions_android_location_permissions] @SuppressLint("MissingPermission") private void checkLocationPermissions() { if (ContextCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { findCurrentPlaceWithPermissions(); } else { requestPermissionLauncher.launch(new String[]{permission.ACCESS_FINE_LOCATION, permission.ACCESS_WIFI_STATE}); } } // [END maps_solutions_android_location_permissions] @Override protected void onCreate(@Nullable Bundle savedInstanceState) { // Enable edge-to-edge display. This must be called before calling super.onCreate(). EdgeToEdge.enable(this); super.onCreate(savedInstanceState); binding = CurrentPlaceActivityBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); // Retrieve a PlacesClient (previously initialized - see MainActivity) placesClient = Places.createClient(this); // Set view objects List placeFields = FieldSelector.allExcept( Field.ADDRESS_COMPONENTS, Field.CURBSIDE_PICKUP, Field.CURRENT_OPENING_HOURS, Field.DELIVERY, Field.DINE_IN, Field.EDITORIAL_SUMMARY, Field.INTERNATIONAL_PHONE_NUMBER, Field.OPENING_HOURS, Field.RESERVABLE, Field.SECONDARY_OPENING_HOURS, Field.SERVES_BEER, Field.SERVES_BREAKFAST, Field.SERVES_BRUNCH, Field.SERVES_DINNER, Field.SERVES_LUNCH, Field.SERVES_VEGETARIAN_FOOD, Field.SERVES_WINE, Field.TAKEOUT, Field.UTC_OFFSET, Field.WEBSITE_URI ); fieldSelector = new FieldSelector( binding.useCustomFields, binding.customFieldsList, placeFields, savedInstanceState); setLoading(false); // Set listeners for programmatic Find Current Place binding.findCurrentPlaceButton.setOnClickListener((view) -> checkLocationPermissions()); } @Override protected void onSaveInstanceState(@NonNull Bundle bundle) { super.onSaveInstanceState(bundle); fieldSelector.onSaveInstanceState(bundle); } /** * Fetches a list of {@link PlaceLikelihood} instances that represent the Places the user is * most * likely to be at currently. */ @RequiresPermission(allOf = {ACCESS_FINE_LOCATION, ACCESS_WIFI_STATE}) private void findCurrentPlaceWithPermissions() { setLoading(true); FindCurrentPlaceRequest currentPlaceRequest = FindCurrentPlaceRequest.newInstance(getPlaceFields()); Task currentPlaceTask = placesClient.findCurrentPlace(currentPlaceRequest); currentPlaceTask.addOnSuccessListener( (response) -> binding.response.setText(StringUtil.stringify(response, isDisplayRawResultsChecked()))); currentPlaceTask.addOnFailureListener( (exception) -> { exception.printStackTrace(); binding.response.setText(exception.getMessage()); }); currentPlaceTask.addOnCompleteListener(task -> setLoading(false)); } ////////////////////////// // Helper methods below // ////////////////////////// private List getPlaceFields() { if (binding.useCustomFields.isChecked()) { return fieldSelector.getSelectedFields(); } else { return fieldSelector.getAllFields(); } } private boolean isDisplayRawResultsChecked() { return binding.displayRawResults.isChecked(); } private void setLoading(boolean loading) { binding.loading.setVisibility(loading ? View.VISIBLE : View.INVISIBLE); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/FieldSelector.java ================================================ /* * Copyright 2018 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import android.content.Context; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.ArrayAdapter; import android.widget.CheckBox; import android.widget.CheckedTextView; import android.widget.ListView; import android.widget.TextView; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import com.google.android.libraries.places.api.model.Place.Field; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; /** Helper class for selecting {@link Field} values. */ public final class FieldSelector { private static final String SELECTED_PLACE_FIELDS_KEY = "selected_place_fields"; private final Map fieldStates; private final TextView outputView; /** * Returns all {@link Field} values except those passed in. * *

Convenience method for when most {@link Field} values are desired. Useful for APIs that do * no support all {@link Field} values. */ static List allExcept(Field... placeFieldsToOmit) { // Arrays.asList is immutable, create a mutable list to allow removing fields List placeFields = new ArrayList<>(Arrays.asList(Field.values())); placeFields.removeAll(Arrays.asList(placeFieldsToOmit)); return placeFields; } public FieldSelector(CheckBox enableView, TextView outputView, @Nullable Bundle savedState) { this(enableView, outputView, Arrays.asList(Field.values()), savedState); } public FieldSelector( CheckBox enableView, TextView outputView, List validFields, @Nullable Bundle savedState) { fieldStates = new HashMap<>(); for (Field field : validFields) { fieldStates.put(field, new State(field)); } if (savedState != null) { List selectedFields = savedState.getIntegerArrayList(SELECTED_PLACE_FIELDS_KEY); if (selectedFields != null) { restoreState(selectedFields); } outputView.setText(getSelectedString()); } outputView.setOnClickListener( v -> { if (v.isEnabled()) { showDialog(v.getContext()); } }); enableView.setOnClickListener( view -> { boolean isChecked = enableView.isChecked(); outputView.setEnabled(isChecked); if (isChecked) { showDialog(view.getContext()); } else { outputView.setText(""); for (State state : fieldStates.values()) { state.checked = false; } } }); this.outputView = outputView; } /** * Shows dialog to allow user to select {@link Field} values they want. */ public void showDialog(Context context) { ListView listView = new ListView(context); PlaceFieldArrayAdapter adapter = new PlaceFieldArrayAdapter(context, fieldStates.values()); listView.setAdapter(adapter); listView.setOnItemClickListener(adapter); new AlertDialog.Builder(context) .setTitle("Select Place Fields") .setPositiveButton( "Done", (dialog, which) -> { outputView.setText(getSelectedString()); }) .setView(listView) .show(); } /** * Returns all {@link Field} that are selectable. */ public List getAllFields() { return new ArrayList<>(fieldStates.keySet()); } /** * Returns all {@link Field} values the user selected. */ public List getSelectedFields() { List selectedList = new ArrayList<>(); for (Map.Entry entry : fieldStates.entrySet()) { if (entry.getValue().checked) { selectedList.add(entry.getKey()); } } return selectedList; } /** * Returns a String representation of all selected {@link Field} values. See {@link * #getSelectedFields()}. */ public String getSelectedString() { StringBuilder builder = new StringBuilder(); for (Field field : getSelectedFields()) { builder.append(field).append("\n"); } return builder.toString(); } public void onSaveInstanceState(Bundle bundle) { List fields = getSelectedFields(); ArrayList serializedFields = new ArrayList<>(); for (Field field : fields) { serializedFields.add(field.ordinal()); } bundle.putIntegerArrayList(SELECTED_PLACE_FIELDS_KEY, serializedFields); } private void restoreState(List selectedFields) { for (Integer serializedField : selectedFields) { Field field = Field.values()[serializedField]; State state = fieldStates.get(field); if (state != null) { state.checked = true; } } } ////////////////////////// // Helper methods below // ////////////////////////// /** * Holds selection state for a place field. */ public static final class State { public final Field field; public boolean checked; public State(Field field) { this.field = field; } } private static final class PlaceFieldArrayAdapter extends ArrayAdapter implements OnItemClickListener { public PlaceFieldArrayAdapter(Context context, Collection states) { super(context, android.R.layout.simple_list_item_multiple_choice, new ArrayList<>(states)); } private static void updateView(View view, State state) { if (view instanceof CheckedTextView) { CheckedTextView checkedTextView = (CheckedTextView) view; checkedTextView.setText(state.field.toString()); checkedTextView.setChecked(state.checked); } } @Override public View getView(int position, @Nullable View convertView, ViewGroup parent) { View view = super.getView(position, convertView, parent); State state = getItem(position); updateView(view, state); return view; } @Override public void onItemClick(AdapterView parent, View view, int position, long id) { State state = getItem(position); state.checked = !state.checked; updateView(view, state); } } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/MainActivity.java ================================================ /* * Copyright 2022 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import android.content.Intent; import android.os.Bundle; import android.widget.Toast; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import com.example.placesdemo.programmatic_autocomplete.ProgrammaticAutocompleteToolbarActivity; import com.google.android.libraries.places.api.Places; import androidx.activity.EdgeToEdge; public class MainActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { // Enable edge-to-edge display. This must be called before calling super.onCreate(). EdgeToEdge.enable(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); setLaunchActivityClickListener(R.id.autocomplete_button, PlaceAutocompleteActivity.class); setLaunchActivityClickListener(R.id.autocomplete_address_button, AutocompleteAddressActivity.class); setLaunchActivityClickListener(R.id.programmatic_autocomplete_button, ProgrammaticAutocompleteToolbarActivity.class ); setLaunchActivityClickListener(R.id.place_and_photo_button, PlaceDetailsAndPhotosActivity.class); setLaunchActivityClickListener(R.id.is_open_button, PlaceIsOpenActivity.class); setLaunchActivityClickListener(R.id.current_place_button, CurrentPlaceActivity.class); } private void setLaunchActivityClickListener( int onClickResId, Class activityClassToLaunch) { findViewById(onClickResId) .setOnClickListener( v -> { Intent intent = new Intent(MainActivity.this, activityClassToLaunch); startActivity(intent); }); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/PlaceAutocompleteActivity.java ================================================ /* * Copyright 2022 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import android.content.Intent; import android.os.Bundle; import android.text.TextUtils; import android.view.View; import android.widget.CheckBox; import android.widget.EditText; import android.widget.TextView; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import com.example.placesdemo.databinding.PlaceAutocompleteActivityBinding; import com.google.android.gms.common.api.Status; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; import com.google.android.gms.tasks.Task; import com.google.android.libraries.places.api.Places; import com.google.android.libraries.places.api.model.AutocompleteSessionToken; import com.google.android.libraries.places.api.model.LocationBias; import com.google.android.libraries.places.api.model.LocationRestriction; import com.google.android.libraries.places.api.model.Place; import com.google.android.libraries.places.api.model.RectangularBounds; 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.android.libraries.places.widget.Autocomplete; import com.google.android.libraries.places.widget.AutocompleteActivity; import com.google.android.libraries.places.widget.AutocompleteSupportFragment; import com.google.android.libraries.places.widget.listener.PlaceSelectionListener; import com.google.android.libraries.places.widget.model.AutocompleteActivityMode; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import androidx.activity.EdgeToEdge; /** * Activity to demonstrate Place Autocomplete (activity widget intent, fragment widget, and * {@link PlacesClient#([PlacesClient.findAutocompletePredictions])}). */ public class PlaceAutocompleteActivity extends AppCompatActivity { private static final int AUTOCOMPLETE_REQUEST_CODE = 23487; private PlacesClient placesClient; private FieldSelector fieldSelector; private PlaceAutocompleteActivityBinding binding; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { // Enable edge-to-edge display. This must be called before calling super.onCreate(). EdgeToEdge.enable(this); super.onCreate(savedInstanceState); setContentView(R.layout.place_autocomplete_activity); binding = PlaceAutocompleteActivityBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); // Retrieve a PlacesClient (previously initialized - see MainActivity) placesClient = Places.createClient(this); // Set up view objects binding.autocompleteUseTypesFilterCheckbox.setOnCheckedChangeListener( (buttonView, isChecked) -> binding.autocompleteTypesFilterEdittext.setEnabled(isChecked)); fieldSelector = new FieldSelector( binding.useCustomFields, binding.customFieldsList, savedInstanceState); setupAutocompleteSupportFragment(); // Set listeners for Autocomplete activity binding.autocompleteActivityButton .setOnClickListener(view -> startAutocompleteActivity()); // Set listeners for programmatic Autocomplete binding.fetchAutocompletePredictionsButton.setOnClickListener(view -> findAutocompletePredictions()); // UI initialization setLoading(false); } @Override protected void onSaveInstanceState(@NonNull Bundle bundle) { super.onSaveInstanceState(bundle); fieldSelector.onSaveInstanceState(bundle); } private void setupAutocompleteSupportFragment() { final AutocompleteSupportFragment autocompleteSupportFragment = (AutocompleteSupportFragment) getSupportFragmentManager().findFragmentById(R.id.autocomplete_support_fragment); if (autocompleteSupportFragment != null) { autocompleteSupportFragment.setPlaceFields(getPlaceFields()); autocompleteSupportFragment.setOnPlaceSelectedListener(getPlaceSelectionListener()); } binding.autocompleteSupportFragmentUpdateButton .setOnClickListener( view -> autocompleteSupportFragment .setPlaceFields(getPlaceFields()) .setText(getQuery()) .setHint(getHint()) .setCountries(getCountries()) .setLocationBias(getLocationBias()) .setLocationRestriction(getLocationRestriction()) .setTypesFilter(getTypesFilter()) .setActivityMode(getMode())); } private PlaceSelectionListener getPlaceSelectionListener() { return new PlaceSelectionListener() { @Override public void onPlaceSelected(@NonNull Place place) { binding.response.setText( StringUtil.stringifyAutocompleteWidget(place, isDisplayRawResultsChecked())); } @Override public void onError(@NonNull Status status) { binding.response.setText(status.getStatusMessage()); } }; } /** * Called when AutocompleteActivity finishes */ @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent intent) { if (requestCode == AUTOCOMPLETE_REQUEST_CODE) { if (resultCode == AutocompleteActivity.RESULT_OK) { Place place = Autocomplete.getPlaceFromIntent(intent); binding.response.setText( StringUtil.stringifyAutocompleteWidget(place, isDisplayRawResultsChecked())); } else if (resultCode == AutocompleteActivity.RESULT_ERROR) { Status status = Autocomplete.getStatusFromIntent(intent); binding.response.setText(status.getStatusMessage()); } // The user canceled the operation. } // Required because this class extends AppCompatActivity which extends FragmentActivity // which implements this method to pass onActivityResult calls to child fragments // (eg AutocompleteFragment). super.onActivityResult(requestCode, resultCode, intent); } private void startAutocompleteActivity() { Intent autocompleteIntent = new Autocomplete.IntentBuilder(getMode(), getPlaceFields()) .setInitialQuery(getQuery()) .setHint(getHint()) .setCountries(getCountries()) .setLocationBias(getLocationBias()) .setLocationRestriction(getLocationRestriction()) .setTypesFilter(getTypesFilter()) .build(this); startActivityForResult(autocompleteIntent, AUTOCOMPLETE_REQUEST_CODE); } private void findAutocompletePredictions() { setLoading(true); FindAutocompletePredictionsRequest.Builder requestBuilder = FindAutocompletePredictionsRequest.builder() .setQuery(getQuery()) .setCountries(getCountries()) .setOrigin((getOrigin())) .setLocationBias(getLocationBias()) .setLocationRestriction(getLocationRestriction()) .setTypesFilter(getTypesFilter()); if (isUseSessionTokenChecked()) { requestBuilder.setSessionToken(AutocompleteSessionToken.newInstance()); } Task task = placesClient.findAutocompletePredictions(requestBuilder.build()); task.addOnSuccessListener( (response) -> binding.response.setText(StringUtil.stringify(response, isDisplayRawResultsChecked()))); task.addOnFailureListener( (exception) -> { exception.printStackTrace(); binding.response.setText(exception.getMessage()); }); task.addOnCompleteListener(response -> setLoading(false)); } ////////////////////////// // Helper methods below // ////////////////////////// private List getPlaceFields() { if (((CheckBox) findViewById(R.id.use_custom_fields)).isChecked()) { return fieldSelector.getSelectedFields(); } else { return fieldSelector.getAllFields(); } } @Nullable private String getQuery() { return getTextViewValue(R.id.autocomplete_query); } @Nullable private String getHint() { return getTextViewValue(R.id.autocomplete_hint); } private List getCountries() { String countryString = getTextViewValue(R.id.autocomplete_country); if (TextUtils.isEmpty(countryString)) { return new ArrayList<>(); } return StringUtil.countriesStringToArrayList(countryString); } @Nullable private String getTextViewValue(@IdRes int textViewResId) { String value = ((TextView) findViewById(textViewResId)).getText().toString(); return TextUtils.isEmpty(value) ? null : value; } @Nullable private LocationBias getLocationBias() { return getBounds( R.id.autocomplete_location_bias_south_west, R.id.autocomplete_location_bias_north_east); } @Nullable private LocationRestriction getLocationRestriction() { return getBounds( R.id.autocomplete_location_restriction_south_west, R.id.autocomplete_location_restriction_north_east); } @Nullable private RectangularBounds getBounds(int resIdSouthWest, int resIdNorthEast) { String southWest = ((TextView) findViewById(resIdSouthWest)).getText().toString(); String northEast = ((TextView) findViewById(resIdNorthEast)).getText().toString(); if (TextUtils.isEmpty(southWest) && TextUtils.isEmpty(northEast)) { return null; } LatLngBounds bounds = StringUtil.convertToLatLngBounds(southWest, northEast); if (bounds == null) { showErrorAlert(R.string.error_alert_message_invalid_bounds); return null; } return RectangularBounds.newInstance(bounds); } @Nullable private LatLng getOrigin() { String originStr = ((TextView) findViewById(R.id.autocomplete_location_origin)).getText().toString(); if (TextUtils.isEmpty(originStr)) { return null; } LatLng origin = StringUtil.convertToLatLng(originStr); if (origin == null) { showErrorAlert(R.string.error_alert_message_invalid_origin); return null; } return origin; } private List getTypesFilter() { EditText typesFilterEditText = findViewById(R.id.autocomplete_types_filter_edittext); return typesFilterEditText.isEnabled() ? Arrays.asList(typesFilterEditText.getText().toString().split("[\\s,]+")) : new ArrayList<>(); } private AutocompleteActivityMode getMode() { boolean isOverlayMode = ((CheckBox) findViewById(R.id.autocomplete_activity_overlay_mode)).isChecked(); return isOverlayMode ? AutocompleteActivityMode.OVERLAY : AutocompleteActivityMode.FULLSCREEN; } private boolean isDisplayRawResultsChecked() { return ((CheckBox) findViewById(R.id.display_raw_results)).isChecked(); } private boolean isUseSessionTokenChecked() { return ((CheckBox) findViewById(R.id.autocomplete_use_session_token)).isChecked(); } private void setLoading(boolean loading) { findViewById(R.id.loading).setVisibility(loading ? View.VISIBLE : View.INVISIBLE); } private void showErrorAlert(@StringRes int messageResId) { new AlertDialog.Builder(this) .setTitle(R.string.error_alert_title) .setMessage(messageResId) .show(); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/PlaceDetailsAndPhotosActivity.java ================================================ /* * Copyright 2022 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import com.bumptech.glide.Glide; import com.example.placesdemo.databinding.PlaceDetailsAndPhotosActivityBinding; import com.google.android.gms.tasks.Task; import com.google.android.libraries.places.api.Places; import com.google.android.libraries.places.api.model.PhotoMetadata; import com.google.android.libraries.places.api.model.Place; import com.google.android.libraries.places.api.model.Place.Field; import com.google.android.libraries.places.api.net.FetchPhotoRequest; import com.google.android.libraries.places.api.net.FetchPhotoResponse; import com.google.android.libraries.places.api.net.FetchPlaceRequest; import com.google.android.libraries.places.api.net.FetchPlaceResponse; import com.google.android.libraries.places.api.net.PlacesClient; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Color; import android.os.Bundle; import android.text.TextUtils; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.CheckBox; import android.widget.EditText; import android.widget.TextView; import java.util.List; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.activity.EdgeToEdge; /** * Activity to demonstrate {@link PlacesClient#fetchPlace(FetchPlaceRequest)}. */ public class PlaceDetailsAndPhotosActivity extends AppCompatActivity { private static final String FETCHED_PHOTO_KEY = "photo_image"; private PlacesClient placesClient; private PhotoMetadata photo; private FieldSelector fieldSelector; private PlaceDetailsAndPhotosActivityBinding binding; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { // Enable edge-to-edge display. This must be called before calling super.onCreate(). EdgeToEdge.enable(this); super.onCreate(savedInstanceState); binding = PlaceDetailsAndPhotosActivityBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); // Retrieve a PlacesClient (previously initialized - see MainActivity) placesClient = Places.createClient(this); if (savedInstanceState != null) { photo = savedInstanceState.getParcelable(FETCHED_PHOTO_KEY); } binding.fetchPhotoCheckbox.setOnCheckedChangeListener( (buttonView, isChecked) -> setPhotoSizingEnabled(isChecked)); binding.useCustomPhotoReference.setOnCheckedChangeListener( (buttonView, isChecked) -> setCustomPhotoReferenceEnabled(isChecked)); fieldSelector = new FieldSelector( binding.useCustomFields, binding.customFieldsList, savedInstanceState); // Set listeners for programmatic Fetch Place findViewById(R.id.fetch_place_and_photo_button).setOnClickListener(view -> fetchPlace()); // UI initialization setLoading(false); setPhotoSizingEnabled(binding.fetchPhotoCheckbox.isChecked()); setCustomPhotoReferenceEnabled(binding.useCustomPhotoReference.isChecked()); if (photo != null) { fetchPhoto(photo); } } @Override protected void onSaveInstanceState(@NonNull Bundle bundle) { super.onSaveInstanceState(bundle); fieldSelector.onSaveInstanceState(bundle); bundle.putParcelable(FETCHED_PHOTO_KEY, photo); } /** * Fetches the {@link Place} specified via the UI and displays it. May also trigger {@link * #fetchPhoto(PhotoMetadata)} if set in the UI. */ private void fetchPlace() { clearViews(); dismissKeyboard(binding.placeIdField); final boolean isFetchPhotoChecked = isFetchPhotoChecked(); final boolean isFetchIconChecked = isFetchIconChecked(); List placeFields = getPlaceFields(); String customPhotoReference = getCustomPhotoReference(); if (!validateInputs(isFetchPhotoChecked, isFetchIconChecked, placeFields, customPhotoReference)) { return; } setLoading(true); FetchPlaceRequest request = FetchPlaceRequest.newInstance(getPlaceId(), placeFields); Task placeTask = placesClient.fetchPlace(request); placeTask.addOnSuccessListener( (response) -> { binding.response.setText(StringUtil.stringify(response, isDisplayRawResultsChecked())); if (isFetchPhotoChecked) { attemptFetchPhoto(response.getPlace()); } if (isFetchIconChecked) { attemptFetchIcon(response.getPlace()); } }); placeTask.addOnFailureListener( (exception) -> { exception.printStackTrace(); binding.response.setText(exception.getMessage()); }); placeTask.addOnCompleteListener(response -> setLoading(false)); } private void attemptFetchPhoto(Place place) { List photoMetadatas = place.getPhotoMetadatas(); if (photoMetadatas != null && !photoMetadatas.isEmpty()) { fetchPhoto(photoMetadatas.get(0)); } } private void attemptFetchIcon(Place place) { binding.icon.setImageBitmap(null); Integer bc = place.getIconBackgroundColor(); binding.icon.setBackgroundColor(bc == null ? Color.TRANSPARENT : bc); String url = place.getIconMaskUrl(); Glide.with(this).load(url).into(binding.icon); } /** * Fetches a Bitmap using the Places API and displays it. * * @param photoMetadata from a {@link Place} instance. */ private void fetchPhoto(PhotoMetadata photoMetadata) { photo = photoMetadata; binding.photo.setImageBitmap(null); setLoading(true); String customPhotoReference = getCustomPhotoReference(); if (!TextUtils.isEmpty(customPhotoReference)) { photoMetadata = PhotoMetadata.builder(customPhotoReference).build(); } FetchPhotoRequest.Builder photoRequestBuilder = FetchPhotoRequest.builder(photoMetadata); Integer maxWidth = readIntFromTextView(R.id.photo_max_width); if (maxWidth != null) { photoRequestBuilder.setMaxWidth(maxWidth); } Integer maxHeight = readIntFromTextView(R.id.photo_max_height); if (maxHeight != null) { photoRequestBuilder.setMaxHeight(maxHeight); } Task photoTask = placesClient.fetchPhoto(photoRequestBuilder.build()); photoTask.addOnSuccessListener( response -> { Bitmap bitmap = response.getBitmap(); binding.photo.setImageBitmap(bitmap); StringUtil.prepend(binding.photoMetadata, StringUtil.stringify(bitmap)); }); photoTask.addOnFailureListener( exception -> { exception.printStackTrace(); StringUtil.prepend(binding.response, "Photo: " + exception.getMessage()); }); photoTask.addOnCompleteListener(response -> setLoading(false)); } ////////////////////////// // Helper methods below // ////////////////////////// private void dismissKeyboard(EditText focusedEditText) { InputMethodManager imm = (InputMethodManager) getSystemService( Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(focusedEditText.getWindowToken(), 0); } private boolean validateInputs(boolean isFetchPhotoChecked, boolean isFetchIconChecked, List placeFields, String customPhotoReference) { if (isFetchPhotoChecked) { if (!placeFields.contains(Field.PHOTO_METADATAS)) { binding.response.setText( "'Also fetch photo?' is selected, but PHOTO_METADATAS Place Field is not."); return false; } } else if (!TextUtils.isEmpty(customPhotoReference)) { binding.response.setText( "Using 'Custom photo reference', but 'Also fetch photo?' is not selected."); return false; } if (isFetchIconChecked && !placeFields.contains(Field.ICON_MASK_URL)) { binding.response.setText(R.string.fetch_icon_missing_fields_warning); return false; } return true; } private String getPlaceId() { return ((TextView) findViewById(R.id.place_id_field)).getText().toString(); } private List getPlaceFields() { if (((CheckBox) findViewById(R.id.use_custom_fields)).isChecked()) { return fieldSelector.getSelectedFields(); } else { return fieldSelector.getAllFields(); } } private boolean isDisplayRawResultsChecked() { return ((CheckBox) findViewById(R.id.display_raw_results)).isChecked(); } private boolean isFetchPhotoChecked() { return ((CheckBox) findViewById(R.id.fetch_photo_checkbox)).isChecked(); } private boolean isFetchIconChecked() { return ((CheckBox) findViewById(R.id.fetch_icon_checkbox)).isChecked(); } private String getCustomPhotoReference() { return ((TextView) findViewById(R.id.custom_photo_reference)).getText().toString(); } private void setPhotoSizingEnabled(boolean enabled) { setEnabled(R.id.photo_max_width, enabled); setEnabled(R.id.photo_max_height, enabled); } private void setCustomPhotoReferenceEnabled(boolean enabled) { setEnabled(R.id.custom_photo_reference, enabled); } private void setEnabled(@IdRes int resId, boolean enabled) { TextView view = findViewById(resId); view.setEnabled(enabled); view.setText(""); } @Nullable private Integer readIntFromTextView(@IdRes int resId) { Integer intValue = null; View view = findViewById(resId); if (view instanceof TextView) { CharSequence contents = ((TextView) view).getText(); if (!TextUtils.isEmpty(contents)) { try { intValue = Integer.parseInt(contents.toString()); } catch (NumberFormatException e) { showErrorAlert(R.string.error_alert_message_invalid_photo_size); } } } return intValue; } private void showErrorAlert(@StringRes int messageResId) { new AlertDialog.Builder(this) .setTitle(R.string.error_alert_title) .setMessage(messageResId) .show(); } private void setLoading(boolean loading) { findViewById(R.id.loading).setVisibility(loading ? View.VISIBLE : View.INVISIBLE); } private void clearViews() { binding.response.setText(null); binding.photo.setImageBitmap(null); binding.photoMetadata.setText(null); binding.icon.setImageBitmap(null); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/PlaceIsOpenActivity.java ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import android.annotation.SuppressLint; import android.app.DatePickerDialog; import android.app.TimePickerDialog; import android.content.Context; import android.os.Bundle; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView; import android.widget.AdapterView.OnItemSelectedListener; import android.widget.ArrayAdapter; import android.widget.CheckBox; import android.widget.EditText; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import com.example.placesdemo.databinding.PlaceIsOpenActivityBinding; import com.google.android.gms.tasks.Task; import com.google.android.libraries.places.api.Places; import com.google.android.libraries.places.api.model.Place; import com.google.android.libraries.places.api.model.Place.Field; import com.google.android.libraries.places.api.net.FetchPlaceRequest; import com.google.android.libraries.places.api.net.FetchPlaceResponse; import com.google.android.libraries.places.api.net.IsOpenRequest; import com.google.android.libraries.places.api.net.IsOpenResponse; import com.google.android.libraries.places.api.net.PlacesClient; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.List; import java.util.Locale; import java.util.TimeZone; import androidx.activity.EdgeToEdge; /** * Activity to demonstrate {@link PlacesClient#isOpen(IsOpenRequest)}. */ public class PlaceIsOpenActivity extends AppCompatActivity { private final String defaultTimeZone = "America/Los_Angeles"; @NonNull private Calendar isOpenCalendar = Calendar.getInstance(); private PlaceIsOpenActivityBinding binding; private FieldSelector fieldSelector; private PlacesClient placesClient; private Place place; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { // Enable edge-to-edge display. This must be called before calling super.onCreate(). EdgeToEdge.enable(this); super.onCreate(savedInstanceState); binding = PlaceIsOpenActivityBinding.inflate(getLayoutInflater()); View rootView = binding.getRoot(); setContentView(rootView); // Retrieve a PlacesClient (previously initialized - see MainActivity) placesClient = Places.createClient(/* context= */ this); fieldSelector = new FieldSelector( binding.checkBoxUseCustomFields, binding.textViewCustomFieldsList, savedInstanceState); binding.buttonFetchPlace.setOnClickListener(view -> fetchPlace()); binding.buttonIsOpen.setOnClickListener(view -> isOpenByPlaceId()); isOpenCalendar = Calendar.getInstance(TimeZone.getTimeZone(defaultTimeZone)); // UI initialization setLoading(false); initializeSpinnerAndAddListener(); addIsOpenDateSelectionListener(); addIsOpenTimeSelectionListener(); updateIsOpenDate(); updateIsOpenTime(); } @Override protected void onSaveInstanceState(@NonNull Bundle bundle) { super.onSaveInstanceState(bundle); fieldSelector.onSaveInstanceState(bundle); } /** * Get details about the Place ID listed in the input field, then check if the Place is open. */ private void fetchPlace() { clearViews(); dismissKeyboard(binding.editTextPlaceId); setLoading(true); List placeFields = getPlaceFields(); FetchPlaceRequest request = FetchPlaceRequest.newInstance(getPlaceId(), placeFields); Task placeTask = placesClient.fetchPlace(request); placeTask.addOnSuccessListener( (response) -> { place = response.getPlace(); isOpenByPlaceObject(place); }); placeTask.addOnFailureListener( (exception) -> { exception.printStackTrace(); binding.textViewResponse.setText(exception.getMessage()); }); placeTask.addOnCompleteListener(response -> setLoading(false)); } /** * Check if the place is open at the time specified in the input fields. * Requires a Place object that includes Place.Field.ID */ @SuppressLint("SetTextI18n") private void isOpenByPlaceObject(Place place) { clearViews(); dismissKeyboard(binding.editTextPlaceId); setLoading(true); IsOpenRequest request; try { request = IsOpenRequest.newInstance(place, isOpenCalendar.getTimeInMillis()); } catch (IllegalArgumentException e) { e.printStackTrace(); binding.textViewResponse.setText(e.getMessage()); setLoading(false); return; } Task placeTask = placesClient.isOpen(request); placeTask.addOnSuccessListener( (response) -> binding.textViewResponse.setText("Is place open? " + response.isOpen() + "\nExtra place details: \n" + StringUtil.stringify(place))); placeTask.addOnFailureListener( (exception) -> { exception.printStackTrace(); binding.textViewResponse.setText(exception.getMessage()); }); placeTask.addOnCompleteListener(response -> setLoading(false)); } /** * Check if the place is open at the time specified in the input fields. * Use the Place ID in the input field for the isOpenRequest. */ @SuppressLint("SetTextI18n") private void isOpenByPlaceId() { clearViews(); dismissKeyboard(binding.editTextPlaceId); setLoading(true); IsOpenRequest request; try { request = IsOpenRequest.newInstance(getPlaceId(), isOpenCalendar.getTimeInMillis()); } catch (IllegalArgumentException e) { e.printStackTrace(); binding.textViewResponse.setText(e.getMessage()); setLoading(false); return; } Task placeTask = placesClient.isOpen(request); placeTask.addOnSuccessListener( (response) -> binding.textViewResponse.setText("Is place open? " + response.isOpen())); placeTask.addOnFailureListener( (exception) -> { exception.printStackTrace(); binding.textViewResponse.setText(exception.getMessage()); }); placeTask.addOnCompleteListener(response -> setLoading(false)); } ////////////////////////// // Helper methods below // ////////////////////////// private void dismissKeyboard(EditText focusedEditText) { InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(focusedEditText.getWindowToken(), 0); } private String getPlaceId() { return ((TextView) binding.editTextPlaceId).getText().toString(); } /** * Fetch the fields necessary for an isOpen request, unless user has checked the box to * select a custom list of fields. Also fetches name and address for display text. */ private List getPlaceFields() { if (((CheckBox) binding.checkBoxUseCustomFields).isChecked()) { return fieldSelector.getSelectedFields(); } else { return new ArrayList<>(Arrays.asList( Field.FORMATTED_ADDRESS, Field.BUSINESS_STATUS, Field.CURRENT_OPENING_HOURS, Field.ID, Field.DISPLAY_NAME, Field.OPENING_HOURS, Field.UTC_OFFSET )); } } private void setLoading(boolean loading) { binding.progressBarLoading.setVisibility(loading ? View.VISIBLE : View.INVISIBLE); } private void clearViews() { binding.textViewResponse.setText(null); } private void initializeSpinnerAndAddListener() { ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, TimeZone.getAvailableIDs()); binding.spinnerTimeZones.setAdapter(adapter); binding.spinnerTimeZones.setSelection(adapter.getPosition(defaultTimeZone)); binding.spinnerTimeZones.setOnItemSelectedListener( new OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { String timeZone = parent.getItemAtPosition(position).toString(); isOpenCalendar.setTimeZone(TimeZone.getTimeZone(timeZone)); updateIsOpenDate(); updateIsOpenTime(); } @Override public void onNothingSelected(AdapterView parent) { } }); } private void addIsOpenDateSelectionListener() { DatePickerDialog.OnDateSetListener listener = (view, year, month, day) -> { isOpenCalendar.set(Calendar.YEAR, year); isOpenCalendar.set(Calendar.MONTH, month); isOpenCalendar.set(Calendar.DAY_OF_MONTH, day); updateIsOpenDate(); }; binding.editTextIsOpenDate.setOnClickListener( view -> new DatePickerDialog( PlaceIsOpenActivity.this, listener, isOpenCalendar.get(Calendar.YEAR), isOpenCalendar.get(Calendar.MONTH), isOpenCalendar.get(Calendar.DAY_OF_MONTH)) .show()); } private void updateIsOpenDate() { SimpleDateFormat dateFormat = new SimpleDateFormat("MM/dd/yy", Locale.US); binding.editTextIsOpenDate.setText(dateFormat.format(isOpenCalendar.getTime())); } private void addIsOpenTimeSelectionListener() { TimePickerDialog.OnTimeSetListener listener = (view, hourOfDay, minute) -> { isOpenCalendar.set(Calendar.HOUR_OF_DAY, hourOfDay); isOpenCalendar.set(Calendar.MINUTE, minute); updateIsOpenTime(); }; binding.editTextIsOpenTime.setOnClickListener( view -> new TimePickerDialog( PlaceIsOpenActivity.this, listener, isOpenCalendar.get(Calendar.HOUR_OF_DAY), isOpenCalendar.get(Calendar.MINUTE), true) .show()); } private void updateIsOpenTime() { String formattedHour = String.format(Locale.getDefault(), "%02d", isOpenCalendar.get(Calendar.HOUR_OF_DAY)); String formattedMinutes = String.format(Locale.getDefault(), "%02d", isOpenCalendar.get(Calendar.MINUTE)); binding.editTextIsOpenTime.setText( String.format(Locale.getDefault(), "%s:%s", formattedHour, formattedMinutes)); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/PlacesDemoApplication.java ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import android.app.Application; import android.widget.Toast; import com.google.android.libraries.places.api.Places; public class PlacesDemoApplication extends Application { @Override public void onCreate() { super.onCreate(); final String apiKey = BuildConfig.PLACES_API_KEY; if (apiKey.equals("")) { Toast.makeText(this, getString(R.string.error_api_key), Toast.LENGTH_LONG).show(); return; } Places.initialize(getApplicationContext(), apiKey); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/StringUtil.java ================================================ /* * Copyright 2018 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 * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo; import android.graphics.Bitmap; import android.text.TextUtils; import android.widget.TextView; import androidx.annotation.Nullable; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; import com.google.android.libraries.places.api.model.AutocompletePrediction; import com.google.android.libraries.places.api.model.Place; import com.google.android.libraries.places.api.model.PlaceLikelihood; import com.google.android.libraries.places.api.net.FetchPlaceResponse; import com.google.android.libraries.places.api.net.FindAutocompletePredictionsResponse; import com.google.android.libraries.places.api.net.FindCurrentPlaceResponse; import java.util.Arrays; import java.util.List; /** * Utility class for converting objects to viewable strings and back. */ public final class StringUtil { private static final String FIELD_SEPARATOR = "\n\t"; private static final String RESULT_SEPARATOR = "\n---\n\t"; static void prepend(TextView textView, String prefix) { textView.setText(prefix + "\n\n" + textView.getText()); } @Nullable static LatLngBounds convertToLatLngBounds( @Nullable String southWest, @Nullable String northEast) { LatLng soundWestLatLng = convertToLatLng(southWest); LatLng northEastLatLng = convertToLatLng(northEast); if (soundWestLatLng == null || northEast == null) { return null; } return new LatLngBounds(soundWestLatLng, northEastLatLng); } @Nullable static LatLng convertToLatLng(@Nullable String value) { if (TextUtils.isEmpty(value)) { return null; } String[] split = value.split(",", -1); if (split.length != 2) { return null; } try { return new LatLng(Double.parseDouble(split[0]), Double.parseDouble(split[1])); } catch (NullPointerException | NumberFormatException e) { return null; } } static List countriesStringToArrayList(String countriesString) { // Allow these delimiters: , ; | / \ return Arrays.asList(countriesString .replaceAll("\\s", "|") .split("[,;|/\\\\]",-1)); } static String stringify(FindAutocompletePredictionsResponse response, boolean raw) { StringBuilder builder = new StringBuilder(); builder .append(response.getAutocompletePredictions().size()) .append(" Autocomplete Predictions Results:"); if (raw) { builder.append(RESULT_SEPARATOR); appendListToStringBuilder(builder, response.getAutocompletePredictions()); } else { for (AutocompletePrediction autocompletePrediction : response.getAutocompletePredictions()) { builder .append(RESULT_SEPARATOR) .append(autocompletePrediction.getFullText(/* matchStyle */ null)); } } return builder.toString(); } static String stringify(FetchPlaceResponse response, boolean raw) { StringBuilder builder = new StringBuilder(); builder.append("Fetch Place Result:").append(RESULT_SEPARATOR); if (raw) { builder.append(response.getPlace()); } else { builder.append(stringify(response.getPlace())); } return builder.toString(); } static String stringify(FindCurrentPlaceResponse response, boolean raw) { StringBuilder builder = new StringBuilder(); builder.append(response.getPlaceLikelihoods().size()).append(" Current Place Results:"); if (raw) { builder.append(RESULT_SEPARATOR); appendListToStringBuilder(builder, response.getPlaceLikelihoods()); } else { for (PlaceLikelihood placeLikelihood : response.getPlaceLikelihoods()) { builder .append(RESULT_SEPARATOR) .append("Likelihood: ") .append(placeLikelihood.getLikelihood()) .append(FIELD_SEPARATOR) .append("Place: ") .append(stringify(placeLikelihood.getPlace())); } } return builder.toString(); } static String stringify(Place place) { return place.getDisplayName() + " (" + place.getFormattedAddress() + ")"; } static String stringify(Bitmap bitmap) { StringBuilder builder = new StringBuilder(); builder .append("Photo size (width x height)") .append(RESULT_SEPARATOR) .append(bitmap.getWidth()) .append(", ") .append(bitmap.getHeight()); return builder.toString(); } public static String stringifyAutocompleteWidget(Place place, boolean raw) { StringBuilder builder = new StringBuilder(); builder.append("Autocomplete Widget Result:").append(RESULT_SEPARATOR); if (raw) { builder.append(place); } else { builder.append(stringify(place)); } return builder.toString(); } private static void appendListToStringBuilder(StringBuilder builder, List items) { if (items.isEmpty()) { return; } builder.append(items.get(0)); for (int i = 1; i < items.size(); i++) { builder.append(RESULT_SEPARATOR); builder.append(items.get(i)); } } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/model/AddressType.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.model; /** * The Address types. Please see Address Types and * Address Component Types for more detail. Some addresses contain additional place categories. * Please see Place Types for * more detail. */ public enum AddressType { /** A precise street address. */ STREET_ADDRESS("street_address"), /** A precise street number. */ STREET_NUMBER("street_number"), /** The floor in the address of the building. */ FLOOR("floor"), /** The room in the address of the building */ ROOM("room"), /** A specific mailbox. */ POST_BOX("post_box"), /** A named route (such as "US 101"). */ ROUTE("route"), /** A major intersection, usually of two major roads. */ INTERSECTION("intersection"), /** A continent. */ CONTINENT("continent"), /** A political entity. Usually, this type indicates a polygon of some civil administration. */ POLITICAL("political"), /** The national political entity, typically the highest order type returned by the Geocoder. */ COUNTRY("country"), /** * A first-order civil entity below the country level. Within the United States, these * administrative levels are states. Not all nations exhibit these administrative levels. */ ADMINISTRATIVE_AREA_LEVEL_1("administrative_area_level_1"), /** * A second-order civil entity below the country level. Within the United States, these * administrative levels are counties. Not all nations exhibit these administrative levels. */ ADMINISTRATIVE_AREA_LEVEL_2("administrative_area_level_2"), /** * A third-order civil entity below the country level. This type indicates a minor civil division. * Not all nations exhibit these administrative levels. */ ADMINISTRATIVE_AREA_LEVEL_3("administrative_area_level_3"), /** * A fourth-order civil entity below the country level. This type indicates a minor civil * division. Not all nations exhibit these administrative levels. */ ADMINISTRATIVE_AREA_LEVEL_4("administrative_area_level_4"), /** * A fifth-order civil entity below the country level. This type indicates a minor civil division. * Not all nations exhibit these administrative levels. */ ADMINISTRATIVE_AREA_LEVEL_5("administrative_area_level_5"), /** A commonly-used alternative name for the entity. */ COLLOQUIAL_AREA("colloquial_area"), /** An incorporated city or town political entity. */ LOCALITY("locality"), /** * A specific type of Japanese locality, used to facilitate distinction between multiple locality * components within a Japanese address. */ WARD("ward"), /** * A first-order civil entity below a locality. Some locations may receive one of the additional * types: {@code SUBLOCALITY_LEVEL_1} to {@code SUBLOCALITY_LEVEL_5}. Each sublocality level is a * civil entity. Larger numbers indicate a smaller geographic area. */ SUBLOCALITY("sublocality"), SUBLOCALITY_LEVEL_1("sublocality_level_1"), SUBLOCALITY_LEVEL_2("sublocality_level_2"), SUBLOCALITY_LEVEL_3("sublocality_level_3"), SUBLOCALITY_LEVEL_4("sublocality_level_4"), SUBLOCALITY_LEVEL_5("sublocality_level_5"), /** A named neighborhood. */ NEIGHBORHOOD("neighborhood"), /** A named location, usually a building or collection of buildings with a common name. */ PREMISE("premise"), /** * A first-order entity below a named location, usually a singular building within a collection of * buildings with a common name. */ SUBPREMISE("subpremise"), /** A postal code as used to address postal mail within the country. */ POSTAL_CODE("postal_code"), /** A postal code prefix as used to address postal mail within the country. */ POSTAL_CODE_PREFIX("postal_code_prefix"), /** A postal code prefix as used to address postal mail within the country. */ POSTAL_CODE_SUFFIX("postal_code_suffix"), /** A prominent natural feature. */ NATURAL_FEATURE("natural_feature"), /** An airport. */ AIRPORT("airport"), /** A university. */ UNIVERSITY("university"), /** A named park. */ PARK("park"), /** A museum. */ MUSEUM("museum"), /** * A named point of interest. Typically, these "POI"s are prominent local entities that don't * easily fit in another category, such as "Empire State Building" or "Statue of Liberty." */ POINT_OF_INTEREST("point_of_interest"), /** A place that has not yet been categorized. */ ESTABLISHMENT("establishment"), /** The location of a bus stop. */ BUS_STATION("bus_station"), /** The location of a train station. */ TRAIN_STATION("train_station"), /** The location of a subway station. */ SUBWAY_STATION("subway_station"), /** The location of a transit station. */ TRANSIT_STATION("transit_station"), /** The location of a light rail station. */ LIGHT_RAIL_STATION("light_rail_station"), /** The location of a church. */ CHURCH("church"), /** The location of a primary school. */ PRIMARY_SCHOOL("primary_school"), /** The location of a secondary school. */ SECONDARY_SCHOOL("secondary_school"), /** The location of a finance institute. */ FINANCE("finance"), /** The location of a post office. */ POST_OFFICE("post_office"), /** The location of a place of worship. */ PLACE_OF_WORSHIP("place_of_worship"), /** * A grouping of geographic areas, such as locality and sublocality, used for mailing addresses in * some countries. */ POSTAL_TOWN("postal_town"), /** Currently not a documented return type. */ SYNAGOGUE("synagogue"), /** Currently not a documented return type. */ FOOD("food"), /** Currently not a documented return type. */ GROCERY_OR_SUPERMARKET("grocery_or_supermarket"), /** Currently not a documented return type. */ STORE("store"), /** The location of a drugstore. */ DRUGSTORE("drugstore"), /** Currently not a documented return type. */ LAWYER("lawyer"), /** Currently not a documented return type. */ HEALTH("health"), /** Currently not a documented return type. */ INSURANCE_AGENCY("insurance_agency"), /** Currently not a documented return type. */ GAS_STATION("gas_station"), /** Currently not a documented return type. */ CAR_DEALER("car_dealer"), /** Currently not a documented return type. */ CAR_REPAIR("car_repair"), /** Currently not a documented return type. */ MEAL_TAKEAWAY("meal_takeaway"), /** Currently not a documented return type. */ FURNITURE_STORE("furniture_store"), /** Currently not a documented return type. */ HOME_GOODS_STORE("home_goods_store"), /** Currently not a documented return type. */ SHOPPING_MALL("shopping_mall"), /** Currently not a documented return type. */ GYM("gym"), /** Currently not a documented return type. */ ACCOUNTING("accounting"), /** Currently not a documented return type. */ MOVING_COMPANY("moving_company"), /** Currently not a documented return type. */ LODGING("lodging"), /** Currently not a documented return type. */ STORAGE("storage"), /** Currently not a documented return type. */ CASINO("casino"), /** Currently not a documented return type. */ PARKING("parking"), /** Currently not a documented return type. */ STADIUM("stadium"), /** Currently not a documented return type. */ TRAVEL_AGENCY("travel_agency"), /** Currently not a documented return type. */ NIGHT_CLUB("night_club"), /** Currently not a documented return type. */ BEAUTY_SALON("beauty_salon"), /** Currently not a documented return type. */ HAIR_CARE("hair_care"), /** Currently not a documented return type. */ SPA("spa"), /** Currently not a documented return type. */ SHOE_STORE("shoe_store"), /** Currently not a documented return type. */ BAKERY("bakery"), /** Currently not a documented return type. */ PHARMACY("pharmacy"), /** Currently not a documented return type. */ SCHOOL("school"), /** Currently not a documented return type. */ BOOK_STORE("book_store"), /** Currently not a documented return type. */ DEPARTMENT_STORE("department_store"), /** Currently not a documented return type. */ RESTAURANT("restaurant"), /** Currently not a documented return type. */ REAL_ESTATE_AGENCY("real_estate_agency"), /** Currently not a documented return type. */ BAR("bar"), /** Currently not a documented return type. */ DOCTOR("doctor"), /** Currently not a documented return type. */ HOSPITAL("hospital"), /** Currently not a documented return type. */ FIRE_STATION("fire_station"), /** Currently not a documented return type. */ SUPERMARKET("supermarket"), /** Currently not a documented return type. */ CITY_HALL("city_hall"), /** Currently not a documented return type. */ LOCAL_GOVERNMENT_OFFICE("local_government_office"), /** Currently not a documented return type. */ ATM("atm"), /** Currently not a documented return type. */ BANK("bank"), /** Currently not a documented return type. */ LIBRARY("library"), /** Currently not a documented return type. */ CAR_WASH("car_wash"), /** Currently not a documented return type. */ HARDWARE_STORE("hardware_store"), /** Currently not a documented return type. */ AMUSEMENT_PARK("amusement_park"), /** Currently not a documented return type. */ AQUARIUM("aquarium"), /** Currently not a documented return type. */ ART_GALLERY("art_gallery"), /** Currently not a documented return type. */ BICYCLE_STORE("bicycle_store"), /** Currently not a documented return type. */ BOWLING_ALLEY("bowling_alley"), /** Currently not a documented return type. */ CAFE("cafe"), /** Currently not a documented return type. */ CAMPGROUND("campground"), /** Currently not a documented return type. */ CAR_RENTAL("car_rental"), /** Currently not a documented return type. */ CEMETERY("cemetery"), /** Currently not a documented return type. */ CLOTHING_STORE("clothing_store"), /** Currently not a documented return type. */ CONVENIENCE_STORE("convenience_store"), /** Currently not a documented return type. */ COURTHOUSE("courthouse"), /** Currently not a documented return type. */ DENTIST("dentist"), /** Currently not a documented return type. */ ELECTRICIAN("electrician"), /** Currently not a documented return type. */ ELECTRONICS_STORE("electronics_store"), /** Currently not a documented return type. */ EMBASSY("embassy"), /** Currently not a documented return type. */ FLORIST("florist"), /** Currently not a documented return type. */ FUNERAL_HOME("funeral_home"), /** Currently not a documented return type. */ GENERAL_CONTRACTOR("general_contractor"), /** Currently not a documented return type. */ HINDU_TEMPLE("hindu_temple"), /** Currently not a documented return type. */ JEWELRY_STORE("jewelry_store"), /** Currently not a documented return type. */ LAUNDRY("laundry"), /** Currently not a documented return type. */ LIQUOR_STORE("liquor_store"), /** Currently not a documented return type. */ LOCKSMITH("locksmith"), /** Currently not a documented return type. */ MEAL_DELIVERY("meal_delivery"), /** Currently not a documented return type. */ MOSQUE("mosque"), /** Currently not a documented return type. */ MOVIE_RENTAL("movie_rental"), /** Currently not a documented return type. */ MOVIE_THEATER("movie_theater"), /** Currently not a documented return type. */ PAINTER("painter"), /** Currently not a documented return type. */ PET_STORE("pet_store"), /** Currently not a documented return type. */ PHYSIOTHERAPIST("physiotherapist"), /** Currently not a documented return type. */ PLUMBER("plumber"), /** Currently not a documented return type. */ POLICE("police"), /** Currently not a documented return type. */ ROOFING_CONTRACTOR("roofing_contractor"), /** Currently not a documented return type. */ RV_PARK("rv_park"), /** Currently not a documented return type. */ TAXI_STAND("taxi_stand"), /** Currently not a documented return type. */ VETERINARY_CARE("veterinary_care"), /** Currently not a documented return type. */ ZOO("zoo"), /** An archipelago. */ ARCHIPELAGO("archipelago"), /** A tourist attraction */ TOURIST_ATTRACTION("tourist_attraction"), /** Currently not a documented return type. */ TOWN_SQUARE("town_square"), /** * Indicates an unknown address type returned by the server. The Java Client for Google Maps * Services should be updated to support the new value. */ UNKNOWN("unknown"); private final String addressType; AddressType(final String addressType) { this.addressType = addressType; } @Override public String toString() { return addressType; } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/model/AutocompleteEditText.java ================================================ /* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.placesdemo.model; import android.content.Context; import android.util.AttributeSet; import android.view.MotionEvent; public class AutocompleteEditText extends androidx.appcompat.widget.AppCompatEditText { public AutocompleteEditText(Context context) { super(context); } public AutocompleteEditText(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean onTouchEvent(MotionEvent event) { super.onTouchEvent(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: return true; case MotionEvent.ACTION_UP: performClick(); return true; } return false; } // Because we call this from onTouchEvent, this code will be executed for both // normal touch events and for when the system calls this using Accessibility @Override public boolean performClick() { super.performClick(); return true; } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/model/Bounds.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.model; import com.google.android.gms.maps.model.LatLng; import java.io.Serializable; /** The northeast and southwest points that delineate the outer bounds of a map. */ public class Bounds implements Serializable { private static final long serialVersionUID = 1L; /** The northeast corner of the bounding box. */ public LatLng northeast; /** The southwest corner of the bounding box. */ public LatLng southwest; @Override public String toString() { return String.format("[%s, %s]", northeast, southwest); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/model/GeocodingResult.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.model; import androidx.annotation.NonNull; import java.io.Serializable; import java.util.Arrays; public class GeocodingResult implements Serializable { private static final long serialVersionUID = 1L; /** * The human-readable address of this location. * *

Often this address is equivalent to the "postal address," which sometimes differs from * country to country. (Note that some countries, such as the United Kingdom, do not allow * distribution of true postal addresses due to licensing restrictions.) This address is generally * composed of one or more address components. For example, the address "111 8th Avenue, New York, * NY" contains separate address components for "111" (the street number, "8th Avenue" (the * route), "New York" (the city) and "NY" (the US state). These address components contain * additional information. */ public String formattedAddress; /** * All the localities contained in a postal code. This is only present when the result is a postal * code that contains multiple localities. */ public String[] postcodeLocalities; /** Location information for this result. */ public Geometry geometry; /** * The types of the returned result. This array contains a set of zero or more tags identifying * the type of feature returned in the result. For example, a geocode of "Chicago" returns * "locality" which indicates that "Chicago" is a city, and also returns "political" which * indicates it is a political entity. */ public AddressType[] types; /** * Indicates that the geocoder did not return an exact match for the original request, though it * was able to match part of the requested address. You may wish to examine the original request * for misspellings and/or an incomplete address. * *

Partial matches most often occur for street addresses that do not exist within the locality * you pass in the request. Partial matches may also be returned when a request matches two or * more locations in the same locality. For example, "21 Henr St, Bristol, UK" will return a * partial match for both Henry Street and Henrietta Street. Note that if a request includes a * misspelled address component, the geocoding service may suggest an alternate address. * Suggestions triggered in this way will not be marked as a partial match. */ public boolean partialMatch; /** A unique identifier for this place. */ public String placeId; /** The Plus Code identifier for this place. */ public PlusCode plusCode; @NonNull @Override public String toString() { StringBuilder sb = new StringBuilder("[GeocodingResult"); if (partialMatch) { sb.append(" PARTIAL MATCH"); } sb.append(" placeId=").append(placeId); sb.append(" ").append(geometry); sb.append(", formattedAddress=").append(formattedAddress); sb.append(", types=").append(Arrays.toString(types)); if (postcodeLocalities != null && postcodeLocalities.length > 0) { sb.append(", postcodeLocalities=").append(Arrays.toString(postcodeLocalities)); } sb.append("]"); return sb.toString(); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/model/Geometry.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.model; import androidx.annotation.NonNull; import com.google.android.gms.maps.model.LatLng; import java.io.Serializable; /** The geometry of a Geocoding result. */ public class Geometry implements Serializable { private static final long serialVersionUID = 1L; /** * The bounding box which can fully contain the returned result (optionally returned). Note that * these bounds may not match the recommended viewport. (For example, San Francisco includes the * Farallon islands, which are technically part of the city, but probably should not be returned * in the viewport.) */ public Bounds bounds; /** * The geocoded latitude/longitude value. For normal address lookups, this field is typically the * most important. */ public LatLng location; /** The level of certainty of this geocoding result. */ public LocationType locationType; /** * The recommended viewport for displaying the returned result. Generally the viewport is used to * frame a result when displaying it to a user. */ public Bounds viewport; @NonNull @Override public String toString() { return String.format( "[Geometry: %s (%s) bounds=%s, viewport=%s]", location, locationType, bounds, viewport); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/model/LocationType.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.model; /** * Location types for a reverse geocoding request. Please see Reverse * Geocoding for more detail. */ public enum LocationType { /** * Restricts the results to addresses for which we have location information accurate down to * street address precision. */ ROOFTOP, /** * Restricts the results to those that reflect an approximation (usually on a road) interpolated * between two precise points (such as intersections). An interpolated range generally indicates * that rooftop geocodes are unavailable for a street address. */ RANGE_INTERPOLATED, /** * Restricts the results to geometric centers of a location such as a polyline (for example, a * street) or polygon (region). */ GEOMETRIC_CENTER, /** Restricts the results to those that are characterized as approximate. */ APPROXIMATE, /** * Indicates an unknown location type returned by the server. The Java Client for Google Maps * Services should be updated to support the new value. */ UNKNOWN; } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/model/PlusCode.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.model; import java.io.Serializable; /** A Plus Code encoded location reference. */ public class PlusCode implements Serializable { private static final long serialVersionUID = 1L; /** The global Plus Code identifier. */ public String globalCode; /** The compound Plus Code identifier. May be null for locations in remote areas. */ public String compoundCode; @Override public String toString() { StringBuilder sb = new StringBuilder("[PlusCode: "); sb.append(globalCode); if (compoundCode != null) { sb.append(", compoundCode=").append(compoundCode); } sb.append("]"); return sb.toString(); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/programmatic_autocomplete/LatLngAdapter.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.programmatic_autocomplete; import com.google.android.gms.maps.model.LatLng; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import java.io.IOException; /** Handle conversion from varying types of latitude and longitude representations. */ public class LatLngAdapter extends TypeAdapter { /** * Reads in a JSON object and try to create a LatLng in one of the following formats. * *

{
     *   "lat" : -33.8353684,
     *   "lng" : 140.8527069
     * }
     *
     * {
     *   "latitude": -33.865257570508334,
     *   "longitude": 151.19287000481452
     * }
*/ @Override public LatLng read(JsonReader reader) throws IOException { if (reader.peek() == JsonToken.NULL) { reader.nextNull(); return null; } double lat = 0; double lng = 0; boolean hasLat = false; boolean hasLng = false; reader.beginObject(); while (reader.hasNext()) { String name = reader.nextName(); if ("lat".equals(name) || "latitude".equals(name)) { lat = reader.nextDouble(); hasLat = true; } else if ("lng".equals(name) || "longitude".equals(name)) { lng = reader.nextDouble(); hasLng = true; } } reader.endObject(); if (hasLat && hasLng) { return new LatLng(lat, lng); } else { return null; } } /** Not supported. */ @Override public void write(JsonWriter out, LatLng value) throws IOException { throw new UnsupportedOperationException("Unimplemented method."); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/programmatic_autocomplete/PlacePredictionAdapter.java ================================================ // Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.programmatic_autocomplete; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.example.placesdemo.R; import com.example.placesdemo.programmatic_autocomplete.PlacePredictionAdapter.PlacePredictionViewHolder; import com.google.android.libraries.places.api.model.AutocompletePrediction; import java.util.ArrayList; import java.util.List; /** * A {@link RecyclerView.Adapter} for a {@link com.google.android.libraries.places.api.model.AutocompletePrediction}. */ public class PlacePredictionAdapter extends RecyclerView.Adapter { private final List predictions = new ArrayList<>(); private OnPlaceClickListener onPlaceClickListener; @NonNull @Override public PlacePredictionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); return new PlacePredictionViewHolder( inflater.inflate(R.layout.place_prediction_item, parent, false)); } @Override public void onBindViewHolder(@NonNull PlacePredictionViewHolder holder, int position) { final AutocompletePrediction prediction = predictions.get(position); holder.setPrediction(prediction); holder.itemView.setOnClickListener(v -> { if (onPlaceClickListener != null) { onPlaceClickListener.onPlaceClicked(prediction); } }); } @Override public int getItemCount() { return predictions.size(); } public void setPredictions(List predictions) { this.predictions.clear(); this.predictions.addAll(predictions); notifyDataSetChanged(); } public void setPlaceClickListener(OnPlaceClickListener onPlaceClickListener) { this.onPlaceClickListener = onPlaceClickListener; } public static class PlacePredictionViewHolder extends RecyclerView.ViewHolder { private final TextView title; private final TextView address; public PlacePredictionViewHolder(@NonNull View itemView) { super(itemView); title = itemView.findViewById(R.id.text_view_title); address = itemView.findViewById(R.id.text_view_address); } public void setPrediction(AutocompletePrediction prediction) { title.setText(prediction.getPrimaryText(null)); address.setText(prediction.getSecondaryText(null)); } } interface OnPlaceClickListener { void onPlaceClicked(AutocompletePrediction place); } } ================================================ FILE: demo-java/src/main/java/com/example/placesdemo/programmatic_autocomplete/ProgrammaticAutocompleteToolbarActivity.java ================================================ // Copyright 2022 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.example.placesdemo.programmatic_autocomplete; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.widget.ProgressBar; import android.widget.SearchView; import android.widget.SearchView.OnQueryTextListener; import android.widget.ViewAnimator; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.android.volley.Request.Method; import com.android.volley.RequestQueue; import com.android.volley.toolbox.JsonObjectRequest; import com.android.volley.toolbox.Volley; import com.example.placesdemo.BuildConfig; import com.example.placesdemo.R; import com.example.placesdemo.model.GeocodingResult; import com.google.android.gms.common.api.ApiException; import com.google.android.gms.maps.model.LatLng; import com.google.android.libraries.places.api.Places; import com.google.android.libraries.places.api.model.AutocompletePrediction; import com.google.android.libraries.places.api.model.AutocompleteSessionToken; import com.google.android.libraries.places.api.model.LocationBias; import com.google.android.libraries.places.api.model.PlaceTypes; import com.google.android.libraries.places.api.model.RectangularBounds; import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest; import com.google.android.libraries.places.api.net.PlacesClient; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import org.json.JSONArray; import org.json.JSONException; import java.util.List; import androidx.activity.EdgeToEdge; /** * An Activity that demonstrates programmatic as-you-type place predictions. The parameters of the * request are currently hard coded in this Activity, to modify these parameters (e.g. location * bias, place types, etc.), see {@link ProgrammaticAutocompleteToolbarActivity#getPlacePredictions(String)}. * * @see documentation */ public class ProgrammaticAutocompleteToolbarActivity extends AppCompatActivity { private static final String TAG = ProgrammaticAutocompleteToolbarActivity.class.getSimpleName(); private final Handler handler = new Handler(); private final PlacePredictionAdapter adapter = new PlacePredictionAdapter(); private final Gson gson = new GsonBuilder().registerTypeAdapter(LatLng.class, new LatLngAdapter()) .create(); private RequestQueue queue; private PlacesClient placesClient; private AutocompleteSessionToken sessionToken; private ViewAnimator viewAnimator; private ProgressBar progressBar; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { // Enable edge-to-edge display. This must be called before calling super.onCreate(). EdgeToEdge.enable(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_programmatic_autocomplete); setSupportActionBar(findViewById(R.id.toolbar)); // Initialize members progressBar = findViewById(R.id.progress_bar); viewAnimator = findViewById(R.id.view_animator); placesClient = Places.createClient(this); queue = Volley.newRequestQueue(this); initRecyclerView(); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu, menu); final SearchView searchView = (SearchView) menu.findItem(R.id.search).getActionView(); assert searchView != null; initSearchView(searchView); return super.onCreateOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (item.getItemId() == R.id.search) { sessionToken = AutocompleteSessionToken.newInstance(); return false; } return super.onOptionsItemSelected(item); } private void initSearchView(SearchView searchView) { searchView.setQueryHint(getString(R.string.search_a_place)); searchView.setIconifiedByDefault(false); searchView.setFocusable(true); searchView.setIconified(false); searchView.requestFocusFromTouch(); searchView.setOnQueryTextListener(new OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { return false; } @Override public boolean onQueryTextChange(String newText) { progressBar.setIndeterminate(true); // Cancel any previous place prediction requests handler.removeCallbacksAndMessages(null); // Start a new place prediction request in 300 ms handler.postDelayed(() -> getPlacePredictions(newText), 300); return true; } }); } private void initRecyclerView() { final RecyclerView recyclerView = findViewById(R.id.recycler_view); final LinearLayoutManager layoutManager = new LinearLayoutManager(this); recyclerView.setLayoutManager(layoutManager); recyclerView.setAdapter(adapter); recyclerView .addItemDecoration(new DividerItemDecoration(this, layoutManager.getOrientation())); adapter.setPlaceClickListener(this::geocodePlaceAndDisplay); } /** * This method demonstrates the programmatic approach to getting place predictions. The * parameters in this request are currently biased to Boulder, Colorado. * * @param query the plus code query string (e.g. "85GP2Q2X+2R") */ private void getPlacePredictions(String query) { // The value of 'bias' biases prediction results to the rectangular region provided // (currently Kolkata). Modify these values to get results for another area. Make sure to // pass in the appropriate value/s for .setCountries() in the // FindAutocompletePredictionsRequest.Builder object as well. final LocationBias bias = RectangularBounds.newInstance( new LatLng(39.91, -105.75), // SW lat, lng new LatLng(40.26, -105.02) // NE lat, lng ); // Create a new programmatic Place Autocomplete request in Places SDK for Android final FindAutocompletePredictionsRequest newRequest = FindAutocompletePredictionsRequest .builder() .setSessionToken(sessionToken) .setLocationBias(bias) .setQuery(query) .setCountries(List.of("US")) .setTypesFilter(List.of(PlaceTypes.ESTABLISHMENT)) .build(); // Perform autocomplete predictions request placesClient.findAutocompletePredictions(newRequest).addOnSuccessListener((response) -> { List predictions = response.getAutocompletePredictions(); adapter.setPredictions(predictions); progressBar.setIndeterminate(false); viewAnimator.setDisplayedChild(predictions.isEmpty() ? 0 : 1); }).addOnFailureListener((exception) -> { progressBar.setIndeterminate(false); if (exception instanceof ApiException apiException) { Log.e(TAG, "Place not found: " + apiException.getStatusCode()); } }); } /** * Performs a Geocoding API request and displays the result in a dialog. * * @see documentation */ private void geocodePlaceAndDisplay(AutocompletePrediction placePrediction) { // Construct the request URL final String apiKey = BuildConfig.PLACES_API_KEY; final String url = "https://maps.googleapis.com/maps/api/geocode/json?place_id=%s&key=%s"; final String requestURL = String.format(url, placePrediction.getPlaceId(), apiKey); // Use the HTTP request URL for Geocoding API to get geographic coordinates for the place JsonObjectRequest request = new JsonObjectRequest(Method.GET, requestURL, null, response -> { try { // Inspect the value of "results" and make sure it's not empty JSONArray results = response.getJSONArray("results"); if (results.length() == 0) { Log.w(TAG, "No results from geocoding request."); return; } // Use Gson to convert the response JSON object to a POJO GeocodingResult result = gson.fromJson( results.getString(0), GeocodingResult.class); displayDialog(placePrediction, result); } catch (JSONException e) { e.printStackTrace(); } }, error -> Log.e(TAG, "Request failed")); // Add the request to the Request queue. queue.add(request); } private void displayDialog(AutocompletePrediction place, GeocodingResult result) { new AlertDialog.Builder(this) .setTitle(place.getPrimaryText(null)) .setMessage("Geocoding result:\n" + result.geometry.location) .setPositiveButton(android.R.string.ok, null) .show(); } } ================================================ FILE: demo-java/src/main/res/drawable/ic_search_black_24dp.xml ================================================ ================================================ FILE: demo-java/src/main/res/layout/activity_main.xml ================================================