diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7802de9b..364c27b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,4 +52,4 @@ jobs: distribution: 'adopt' java-version: '21' - name: Build advanced - run: cd Maps3DSamples/ApiDemos && ./gradlew buildDebugPreBundle \ No newline at end of file + run: cd Maps3DSamples/advanced && ./gradlew buildDebugPreBundle \ No newline at end of file diff --git a/Maps3DSamples/ApiDemos/java-app/README.md b/Maps3DSamples/ApiDemos/java-app/README.md new file mode 100644 index 00000000..ed45ffa2 --- /dev/null +++ b/Maps3DSamples/ApiDemos/java-app/README.md @@ -0,0 +1,20 @@ +# β˜• Java Samples Catalog + +This directory contains the Java samples using traditional Android Views for the Android Maps 3D SDK. + +## πŸ“Š Sample Status + +| Feature | Status | Source Code | +| :--- | :--- | :--- | +| **Hello Map** | βœ… Done | [HelloMapActivity.java](src/main/java/com/example/maps3djava/hellomap/HelloMapActivity.java) | +| **Polylines** | βœ… Done | [PolylinesActivity.java](src/main/java/com/example/maps3djava/polylines/PolylinesActivity.java) | +| **Map Interactions** | βœ… Done | [MapInteractionsActivity.java](src/main/java/com/example/maps3djava/mapinteractions/MapInteractionsActivity.java) | +| **Popovers** | βœ… Done | [PopoversActivity.java](src/main/java/com/example/maps3djava/popovers/PopoversActivity.java) | +| **Camera Controls** | βœ… Done | [CameraControlsActivity.java](src/main/java/com/example/maps3djava/cameracontrols/CameraControlsActivity.java) | +| **Polygons** | βœ… Done | [PolygonsActivity.java](src/main/java/com/example/maps3djava/polygons/PolygonsActivity.java) | +| **Models** | βœ… Done | [ModelsActivity.java](src/main/java/com/example/maps3djava/models/ModelsActivity.java) | +| **Markers** | βœ… Done | [MarkersActivity.java](src/main/java/com/example/maps3djava/markers/MarkersActivity.java) | + +--- +> [!NOTE] +> These samples are view-based and serve as a reference for Java developers. diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts index 19f427fb..a3132ef5 100644 --- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts @@ -88,6 +88,7 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY" } buildTypes { @@ -147,12 +148,11 @@ dependencies { } secrets { - // Optionally specify a different file name containing your secrets. - // The plugin defaults to "local.properties" - propertiesFileName = "secrets.properties" - - // A properties file containing default secret values. This file can be - // checked in version control. + // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI + val secretsFile = rootProject.file("secrets.properties") + if (secretsFile.exists()) { + propertiesFileName = "secrets.properties" + } defaultPropertiesFileName = "local.defaults.properties" } diff --git a/Maps3DSamples/ApiDemos/kotlin-app/README.md b/Maps3DSamples/ApiDemos/kotlin-app/README.md new file mode 100644 index 00000000..e2757baa --- /dev/null +++ b/Maps3DSamples/ApiDemos/kotlin-app/README.md @@ -0,0 +1,20 @@ +# 🧱 Kotlin + Views Samples Catalog + +This directory contains the Kotlin samples using traditional Android Views for the Android Maps 3D SDK. + +## πŸ“Š Sample Status + +| Feature | Status | Source Code | +| :--- | :--- | :--- | +| **Hello Map** | βœ… Done | [HelloMapActivity.kt](src/main/java/com/example/maps3dkotlin/hellomap/HelloMapActivity.kt) | +| **Polylines** | βœ… Done | [PolylinesActivity.kt](src/main/java/com/example/maps3dkotlin/polylines/PolylinesActivity.kt) | +| **Map Interactions** | βœ… Done | [MapInteractionsActivity.kt](src/main/java/com/example/maps3dkotlin/mapinteractions/MapInteractionsActivity.kt) | +| **Popovers** | βœ… Done | [PopoversActivity.kt](src/main/java/com/example/maps3dkotlin/popovers/PopoversActivity.kt) | +| **Camera Controls** | βœ… Done | [CameraControlsActivity.kt](src/main/java/com/example/maps3dkotlin/cameracontrols/CameraControlsActivity.kt) | +| **Polygons** | βœ… Done | [PolygonsActivity.kt](src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt) | +| **Models** | βœ… Done | [ModelsActivity.kt](src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt) | +| **Markers** | βœ… Done | [MarkersActivity.kt](src/main/java/com/example/maps3dkotlin/markers/MarkersActivity.kt) | + +--- +> [!NOTE] +> These samples are view-based and serve as a reference for non-Compose applications. diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts index 08387045..ef50b6e0 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts @@ -91,6 +91,7 @@ android { versionName = "1.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY" } buildTypes { @@ -155,12 +156,11 @@ dependencies { } secrets { - // Optionally specify a different file name containing your secrets. - // The plugin defaults to "local.properties" - propertiesFileName = "secrets.properties" - - // A properties file containing default secret values. This file can be - // checked in version control. + // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI + val secretsFile = rootProject.file("secrets.properties") + if (secretsFile.exists()) { + propertiesFileName = "secrets.properties" + } defaultPropertiesFileName = "local.defaults.properties" } diff --git a/Maps3DSamples/ComposeDemos/app/README.md b/Maps3DSamples/ComposeDemos/app/README.md new file mode 100644 index 00000000..f09a64b8 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/README.md @@ -0,0 +1,34 @@ +# πŸš€ Jetpack Compose Samples Catalog + +This directory contains the Compose samples for the Android Maps 3D SDK. We use a state-driven approach and lean on the `maps3d-compose` library. + +## πŸ“Š Sample Status + +| Feature | Status | Source Code | Screenshot | Description | +| :--- | :--- | :--- | :--- | :--- | +| **Basic Map** | βœ… Done | [HelloMapActivity.kt](src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt) | Screenshot | Displays a basic 3D map with standard satellite imagery and initial camera placement. | +| **Polylines** | βœ… Done | [PolylinesActivity.kt](src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt) | Screenshot | Demonstrates drawing 3D polylines on the map, including custom colors and widths. | +| **Map Interactions** | 🚧 Skeleton | [MapInteractionsActivity.kt](src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt) | | Will demonstrate handling click and drag events on map objects. | +| **Popovers** | βœ… Done | [PopoversActivity.kt](src/main/java/com/example/composedemos/popovers/PopoversActivity.kt) | Screenshot | Shows how to display interactive popover overlays at specific coordinates on the map. | +| **Camera Controls** | βœ… Done | [CameraControlsActivity.kt](src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt) | Screenshot | Demonstrates manual control of the camera center, heading, tilt, and range using UI controls. | +| **Polygons** | βœ… Done | [PolygonsActivity.kt](src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt) | Screenshot | Demonstrates drawing 3D polygons with fill colors and outlines on the map. | +| **Models** | βœ… Done | [ModelsActivity.kt](src/main/java/com/example/composedemos/models/ModelsActivity.kt) | Screenshot | Shows how to load and place custom 3D models (gLTF) on the map with position, scale, and orientation. | +| **Markers** | βœ… Done | [MarkersActivity.kt](src/main/java/com/example/composedemos/markers/MarkersActivity.kt) | Screenshot | Demonstrates adding 2D markers with custom icons and anchor points to the 3D map. | +| **Camera Restrictions** | βœ… Done | [CameraRestrictionsActivity.kt](src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt) | Screenshot | Shows how to restrict the camera range and center bounds to a specific area. | +| **Flight Simulator** | 🚧 Skeleton | [FlightSimulatorActivity.kt](src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt) | | Will demonstrate a first-person camera view simulating flight. | +| **Routes API** | βœ… Done | [RoutesActivity.kt](src/main/java/com/example/composedemos/routes/RoutesActivity.kt) | Screenshot | Demonstrates loading a route from file, rendering the polyline, and animating a 3D car model along the route using a flow-based engine. | +| **Path Following** | 🚧 Skeleton | [PathFollowingActivity.kt](src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt) | | Will demonstrate camera following a path. | +| **Path Styling** | 🚧 Skeleton | [PathStylingActivity.kt](src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt) | | Will demonstrate custom styling for paths. | +| **Animating Models** | 🚧 Skeleton | [AnimatingModelsActivity.kt](src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt) | | Will demonstrate animating 3D models. | +| **Place Search** | 🚧 Skeleton | [PlaceSearchActivity.kt](src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt) | | Will demonstrate programmatic place search. | +| **Place Autocomplete** | 🚧 Skeleton | [PlaceAutocompleteActivity.kt](src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt) | | Will demonstrate place autocomplete widget. | +| **Place Details** | βœ… Done | [PlaceDetailsActivity.kt](src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt) | Screenshot | Demonstrates loading place details using the modern Places UI Kit fragment (Fragment Interop) with a custom 'Boulder Nature Hippie' theme. | +| **Advanced Camera Animation** | 🚧 Skeleton | [AdvancedCameraAnimationActivity.kt](src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt) | | Will demonstrate complex camera animations. | +| **Data Visualization** | 🚧 Skeleton | [DataVisualizationActivity.kt](src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt) | | Will demonstrate visualizing data on 3D map. | +| **Cloud Map Styling** | 🚧 Skeleton | [CloudStylingActivity.kt](src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt) | | Will demonstrate styling map via Cloud console. | +| **Roadmap Mode** | 🚧 Skeleton | [RoadmapModeActivity.kt](src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt) | | Will demonstrate standard roadmap mode. | +| **Field Of View** | 🚧 Skeleton | [FieldOfViewActivity.kt](src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt) | | Will demonstrate changing field of view. | + +--- +> [!NOTE] +> Status `🚧 Skeleton` means the activity exists and can be launched from the main list, but contains a TODO placeholder UI. We are actively implementing these following a TDD approach. diff --git a/Maps3DSamples/ComposeDemos/app/build.gradle.kts b/Maps3DSamples/ComposeDemos/app/build.gradle.kts new file mode 100644 index 00000000..6c9b741f --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/build.gradle.kts @@ -0,0 +1,135 @@ +/* + * 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 { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.secrets.gradle.plugin) + alias(libs.plugins.spotless) +} + +configure { + kotlin { + target("**/*.kt") + ktlint().editorConfigOverride(mapOf("indent_size" to "4", "ktlint_function_naming_ignore_when_annotated_with" to "Composable")) + trimTrailingWhitespace() + endWithNewline() + } +} + +android { + namespace = "com.example.composedemos" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.example.composedemos" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(project(":maps3d-compose")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + // Maps 3D SDK + implementation(libs.play.services.maps3d) + implementation(libs.places) + implementation(libs.androidx.fragment.ktx) + + // Maps Utils + implementation(libs.maps.utils.ktx) + + // Material Icons Extended + implementation(libs.androidx.material.icons.extended) + + // Lifecycle ViewModel Compose + implementation("androidx.lifecycle:lifecycle-viewmodel-compose") + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.kotlinx.serialization.json) + androidTestImplementation(project(":visual-testing")) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + +secrets { + // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI + val secretsFile = rootProject.file("secrets.properties") + if (secretsFile.exists()) { + propertiesFileName = "secrets.properties" + } + defaultPropertiesFileName = "local.defaults.properties" +} + +tasks.register("installAndLaunch") { + description = "Installs and launches the demo app." + group = "install" + dependsOn("installDebug") + commandLine("adb", "shell", "am", "start", "-n", "com.example.composedemos/.MainActivity") +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/BaseVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/BaseVisualTest.kt new file mode 100644 index 00000000..4e40833e --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/BaseVisualTest.kt @@ -0,0 +1,69 @@ +/* + * 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.composedemos + +import android.app.Instrumentation +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.google.maps.android.visualtesting.GeminiVisualTestHelper +import org.junit.Assert.assertTrue +import java.io.File + +abstract class BaseVisualTest { + + protected val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() + protected val uiDevice = UiDevice.getInstance(instrumentation) + protected val context: Context = instrumentation.targetContext + protected val helper = GeminiVisualTestHelper() + + protected val geminiApiKey: String by lazy { + val key = BuildConfig.GEMINI_API_KEY + assertTrue( + "GEMINI_API_KEY is not set in secrets.properties. Please add GEMINI_API_KEY=YOUR_API_KEY to your secrets.properties file.", + key != "YOUR_GEMINI_API_KEY", + ) + key + } + + protected fun captureScreenshot(filename: String = "screenshot_${System.currentTimeMillis()}.png"): Bitmap { + val screenshotFile = File(context.filesDir, filename) + val screenshotTaken = uiDevice.takeScreenshot(screenshotFile) + assertTrue("Failed to take screenshot: $filename", screenshotTaken) + + val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath) + assertTrue("Failed to decode screenshot file: $filename", bitmap != null) + + android.util.Log.i("BaseVisualTest", "Screenshot saved to device: ${screenshotFile.absolutePath}") + + return bitmap + } + + /** + * Waits for the map to render. + * Since MapView content (tiles, markers) is rendered on a GL surface and not exposed as + * accessibility nodes, we cannot rely on UiAutomator looking for text/markers. + * We use a stable delay to ensure rendering is complete. + */ + protected fun waitForMapRendering(timeoutSeconds: Long = 30) { + val found = uiDevice.wait(Until.hasObject(By.desc("MapSteady")), timeoutSeconds * 1000) + assertTrue("Map did not become steady within $timeoutSeconds seconds", found) + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraControlsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraControlsVisualTest.kt new file mode 100644 index 00000000..cc2125df --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraControlsVisualTest.kt @@ -0,0 +1,58 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.cameracontrols.CameraControlsActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CameraControlsVisualTest : BaseVisualTest() { + + @Test + fun verifyCameraControlsRenders() { + runBlocking { + // Launch CameraControlsActivity + val intent = Intent(context, CameraControlsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Verify that a 3D map view is visible + // (Implicitly verified by waitForMapRendering) + + // Verify that controls (e.g., a slider or text for "Heading") are visible + val foundHeading = uiDevice.wait(Until.hasObject(By.textContains("Heading")), 5000) + assertTrue("Heading control not found", foundHeading) + + // Capture a screenshot for visual confirmation + captureScreenshot("camera_controls_screenshot.png") + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraRestrictionsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraRestrictionsVisualTest.kt new file mode 100644 index 00000000..1a318034 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraRestrictionsVisualTest.kt @@ -0,0 +1,80 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.camerarestrictions.CameraRestrictionsActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CameraRestrictionsVisualTest : BaseVisualTest() { + + @Test + fun verifyCameraRestrictionsRenders() { + runBlocking { + // Launch CameraRestrictionsActivity + val intent = Intent(context, CameraRestrictionsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Attempt to swipe/pan away from the center + val screenWidth = uiDevice.displayWidth + val screenHeight = uiDevice.displayHeight + uiDevice.swipe(screenWidth / 2, screenHeight / 2, 100, screenHeight / 2, 10) // Swipe left + + // Wait a bit for any correction or settling + kotlinx.coroutines.delay(2000) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("camera_restrictions_screenshot.png") + + // Define the verification prompt for Gemini + // We ask if the Space Needle is still visible, implying the restriction worked. + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that the Space Needle (or the immediate surrounding area) is still visible and central in the view, despite an attempt to pan away. + + If the map is visible and the Space Needle area is still centered, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/HelloMapVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/HelloMapVisualTest.kt new file mode 100644 index 00000000..8a6660a8 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/HelloMapVisualTest.kt @@ -0,0 +1,73 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.hellomap.HelloMapActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HelloMapVisualTest : BaseVisualTest() { + + @Test + fun verifyHelloMapRenders() { + runBlocking { + // Launch HelloMapActivity + val intent = Intent(context, HelloMapActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("hello_map_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly. + Check the image against the following criteria: + 1. Confirm that a 3D map view is visible. + 2. Confirm that the Delicate Arch itself is clearly visible and a prominent part of the scene (it should look like a large freestanding rock arch). + + If all elements are present and the Delicate Arch is clearly visible, reply with "PASSED". + If any element is missing or incorrect, please detail the discrepancy. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MainActivityTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MainActivityTest.kt new file mode 100644 index 00000000..4bdc56c4 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MainActivityTest.kt @@ -0,0 +1,44 @@ +/* + * 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.composedemos + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun verifyTitleIsDisplayed() { + // Check that the catalog title is displayed + composeTestRule.onNodeWithText("Compose Demos Catalog").assertExists() + } + + @Test + fun verifySampleItemsExist() { + // Check that at least some sample items are displayed + composeTestRule.onNodeWithText("Hello Map").assertExists() + composeTestRule.onNodeWithText("Polylines").assertExists() + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MapInteractionsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MapInteractionsVisualTest.kt new file mode 100644 index 00000000..4ea6ecc4 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MapInteractionsVisualTest.kt @@ -0,0 +1,82 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.mapinteractions.MapInteractionsActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MapInteractionsVisualTest : BaseVisualTest() { + + @Test + fun verifyMapInteractionsRenders() { + runBlocking { + // Launch MapInteractionsActivity directly + val intent = Intent(context, MapInteractionsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Wait a bit to ensure map is interactive + println("Waiting 5 seconds for map to be interactive...") + kotlinx.coroutines.delay(5000) + + // Strategy: Click the center of the screen and surrounding points + val screenWidth = uiDevice.displayWidth + val screenHeight = uiDevice.displayHeight + + val centerX = screenWidth / 2 + val centerY = screenHeight / 2 + + println("Clicking center and surrounding points...") + uiDevice.click(centerX, centerY) + kotlinx.coroutines.delay(500) + uiDevice.click(centerX + 50, centerY + 50) + kotlinx.coroutines.delay(500) + uiDevice.click(centerX - 50, centerY - 50) + kotlinx.coroutines.delay(500) + uiDevice.click(centerX + 50, centerY - 50) + kotlinx.coroutines.delay(500) + uiDevice.click(centerX - 50, centerY + 50) + + // Wait for the click info card to update with text containing "Clicked" + val textUpdated = uiDevice.wait( + Until.hasObject(By.descContains("Clicked")), + 10000, + ) + assertTrue("Card text did not update after click", textUpdated) + + // Capture a screenshot for visual confirmation + captureScreenshot("map_interactions_screenshot.png") + + // TODO: Implement Gemini verification if needed to check the look of the card. + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt new file mode 100644 index 00000000..f70a11ee --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt @@ -0,0 +1,71 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.markers.MarkersActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkersVisualTest : BaseVisualTest() { + + @Test + fun verifyMarkersRenders() { + runBlocking { + // Launch MarkersActivity + val intent = Intent(context, MarkersActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("markers_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that an alien icon or marker is visible on top of the prominent rock formation (Devils Tower). + + If the map is visible and the alien marker is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/ModelsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/ModelsVisualTest.kt new file mode 100644 index 00000000..b37377c5 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/ModelsVisualTest.kt @@ -0,0 +1,71 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.models.ModelsActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ModelsVisualTest : BaseVisualTest() { + + @Test + fun verifyModelsRenders() { + runBlocking { + // Launch ModelsActivity + val intent = Intent(context, ModelsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("models_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that a 3D model of an airplane is visible on the map. + + If the map is visible and the airplane model is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PlaceDetailsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PlaceDetailsVisualTest.kt new file mode 100644 index 00000000..65b700d5 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PlaceDetailsVisualTest.kt @@ -0,0 +1,72 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.placedetails.PlaceDetailsActivity +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlaceDetailsVisualTest : BaseVisualTest() { + + @Test + fun verifyPlaceDetailsLoads() { + runBlocking { + // Launch PlaceDetailsActivity with Intent extra for state injection + val intent = Intent(context, PlaceDetailsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("place_name", "The Flatirons") + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // 1. Wait for the map to load (using fixed delay as steady callback can be flaky) + println("Waiting 2 seconds for map to load...") + delay(2000) + + // 3. Wait for the Place Details fragment to load content (network call) + println("Waiting for Place Details to load...") + delay(15000) + + // 4. Capture screenshot + val screenshotBitmap = captureScreenshot("place_details_screenshot.png") + delay(2000) // Wait for file to be fully written + + // 5. Verify with Gemini - Stricter Prompt! + val prompt = """ + The screen should show a 3D map in the background. + At the bottom, there should be a sheet or card containing Place Details. + You MUST verify that the text "Flatirons" is clearly visible in the Place Details card. + If you can see the text "Flatirons" in the card, reply with YES. + If the card is missing, blank, still loading, or shows a different place, reply with NO followed by a detailed description of what is visible on the screen and in the card area. + """.trimIndent() + + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini result: ${geminiResponse?.trim()}") + assertTrue("Gemini verification failed: $geminiResponse", geminiResponse?.trim()?.contains("YES", ignoreCase = true) == true) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolygonsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolygonsVisualTest.kt new file mode 100644 index 00000000..8ba057a1 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolygonsVisualTest.kt @@ -0,0 +1,71 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.polygons.PolygonsActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PolygonsVisualTest : BaseVisualTest() { + + @Test + fun verifyPolygonsRenders() { + runBlocking { + // Launch PolygonsActivity + val intent = Intent(context, PolygonsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("polygons_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that a yellow translucent polygon with a green border is visible on the map (representing the Denver Zoo). + + If the map is visible and the yellow polygon is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolylinesVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolylinesVisualTest.kt new file mode 100644 index 00000000..25c60c29 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolylinesVisualTest.kt @@ -0,0 +1,71 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.polylines.PolylinesActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PolylinesVisualTest : BaseVisualTest() { + + @Test + fun verifyPolylinesRenders() { + runBlocking { + // Launch PolylinesActivity + val intent = Intent(context, PolylinesActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("polylines_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that a red polyline (line) is visible on the map, representing a trail. + + If the map is visible and the red polyline is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt new file mode 100644 index 00000000..d4587ae7 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt @@ -0,0 +1,90 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.popovers.PopoversActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PopoversVisualTest : BaseVisualTest() { + + @Test + fun verifyPopoversRenders() { + runBlocking { + // Launch PopoversActivity + val intent = Intent(context, PopoversActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot to find the marker + val searchScreenshot = captureScreenshot("popovers_search.png") + + // Define the prompt for Gemini to find coordinates + val promptFind = """ + Analyze this screenshot of a 3D map. + You should see a marker or label with the text "Click me for Popover". + Find that marker or label. + Return its center coordinates as a JSON object: {"x": , "y": } where x and y are normalized coordinates between 0.0 and 1.0 (0.0 is top/left, 1.0 is bottom/right). + Return ONLY the JSON object, nothing else. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(searchScreenshot, promptFind, geminiApiKey) + println("Gemini's coordinate response: $geminiResponse") + + // Parse JSON and click + try { + val jsonStr = geminiResponse?.substringAfter("{")?.substringBeforeLast("}")?.let { "{$it}" } ?: "" + val json = org.json.JSONObject(jsonStr) + val x = json.getDouble("x") + val y = json.getDouble("y") + + val clickX = (x * uiDevice.displayWidth).toInt() + val clickY = (y * uiDevice.displayHeight).toInt() + + println("Clicking at ($clickX, $clickY) based on Gemini response") + uiDevice.click(clickX, clickY) + } catch (e: Exception) { + org.junit.Assert.fail("Failed to parse coordinates from Gemini response: $geminiResponse. Error: ${e.message}") + } + + // Wait for the popover text to appear + val textFound = uiDevice.wait( + Until.hasObject(By.text("This is a Popover anchored to a marker!")), + 10000, + ) + assertTrue("Popover text not found", textFound) + + // Capture a screenshot for visual confirmation + captureScreenshot("popovers_screenshot.png") + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/RoutesVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/RoutesVisualTest.kt new file mode 100644 index 00000000..5b73a4f8 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/RoutesVisualTest.kt @@ -0,0 +1,93 @@ +/* + * 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.composedemos + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.composedemos.routes.RoutesActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoutesVisualTest : BaseVisualTest() { + + @Test + fun verifyRoutesRenders() { + runBlocking { + // Fake shared prefs to avoid security warning dialog + val sharedPrefs = context.getSharedPreferences("route_prefs", android.content.Context.MODE_PRIVATE) + sharedPrefs.edit().putInt("warning_count", 2).commit() + + // Launch RoutesActivity + val intent = Intent(context, RoutesActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for map to load tiles and network call to complete + println("Waiting 20 seconds for route to load...") + kotlinx.coroutines.delay(20000) + + // 1. Click "Fly Along" button to enter fly mode and show slider (defaults to Red Car) + val flyButton = uiDevice.wait(Until.hasObject(By.text("Fly Along")), 5000) + assertTrue("Fly Along button not found", flyButton) + uiDevice.findObject(By.text("Fly Along")).click() + kotlinx.coroutines.delay(2000) // Wait for slider to appear + + // 3. Set progress to halfway point by clicking center of the slider + val slider = uiDevice.wait(Until.hasObject(By.desc("Progress Slider")), 5000) + assertTrue("Progress slider not found", slider) + val sliderObj = uiDevice.findObject(By.desc("Progress Slider")) + val bounds = sliderObj.visibleBounds + uiDevice.click(bounds.centerX(), bounds.centerY()) + kotlinx.coroutines.delay(2000) // Wait for camera to jump to position + + // Capture a screenshot for the catalog showing the red car at halfway point! + val screenshotBitmap = captureScreenshot("routes_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that a blue polyline (line) is visible on the map, representing a route. + 3. Confirm that a RED CAR 3D MODEL is clearly visible on or near the blue polyline. + 4. The route should be in Hawaii (Oahu area). + + If and ONLY IF you can clearly see a red car model on or near the blue polyline, reply with "PASSED". + If you cannot see a red car model, reply with "FAILED: Red car model not visible". + Report what you see in detail. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1b9d47d8 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_controls_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_controls_screenshot.png new file mode 100644 index 00000000..34327456 Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_controls_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_restrictions_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_restrictions_screenshot.png new file mode 100644 index 00000000..62c9af67 Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_restrictions_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/hello_map_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/hello_map_screenshot.png new file mode 100644 index 00000000..20495194 Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/hello_map_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/markers_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/markers_screenshot.png new file mode 100644 index 00000000..97c0b591 Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/markers_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/models_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/models_screenshot.png new file mode 100644 index 00000000..5bfaf83b Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/models_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/place_details_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/place_details_screenshot.png new file mode 100644 index 00000000..1cdc9f2c Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/place_details_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polygons_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polygons_screenshot.png new file mode 100644 index 00000000..808e007a Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polygons_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polylines_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polylines_screenshot.png new file mode 100644 index 00000000..00731970 Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polylines_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/popovers_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/popovers_screenshot.png new file mode 100644 index 00000000..9a0ef6c7 Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/popovers_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/routes_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/routes_screenshot.png new file mode 100644 index 00000000..ebfc2e94 Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/routes_screenshot.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/ComposeDemosApplication.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/ComposeDemosApplication.kt new file mode 100644 index 00000000..50fd018c --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/ComposeDemosApplication.kt @@ -0,0 +1,31 @@ +/* + * 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.composedemos + +import android.app.Application +import com.google.android.libraries.places.api.Places + +class ComposeDemosApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Initialize Places SDK + if (!Places.isInitialized()) { + Places.initializeWithNewPlacesApiEnabled(applicationContext, BuildConfig.MAPS3D_API_KEY) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt new file mode 100644 index 00000000..d0461f0f --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt @@ -0,0 +1,237 @@ +/* + * 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.composedemos + +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.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.example.composedemos.advancedcameraanimation.AdvancedCameraAnimationActivity +import com.example.composedemos.animatingmodels.AnimatingModelsActivity +import com.example.composedemos.cameracontrols.CameraControlsActivity +import com.example.composedemos.camerarestrictions.CameraRestrictionsActivity +import com.example.composedemos.cloudstyling.CloudStylingActivity +import com.example.composedemos.datavisualization.DataVisualizationActivity +import com.example.composedemos.fieldofview.FieldOfViewActivity +import com.example.composedemos.flightsimulator.FlightSimulatorActivity +import com.example.composedemos.hellomap.HelloMapActivity +import com.example.composedemos.mapinteractions.MapInteractionsActivity +import com.example.composedemos.markers.MarkersActivity +import com.example.composedemos.models.ModelsActivity +import com.example.composedemos.pathfollowing.PathFollowingActivity +import com.example.composedemos.pathstyling.PathStylingActivity +import com.example.composedemos.placeautocomplete.PlaceAutocompleteActivity +import com.example.composedemos.placedetails.PlaceDetailsActivity +import com.example.composedemos.placesearch.PlaceSearchActivity +import com.example.composedemos.polygons.PolygonsActivity +import com.example.composedemos.polylines.PolylinesActivity +import com.example.composedemos.popovers.PopoversActivity +import com.example.composedemos.roadmapmode.RoadmapModeActivity +import com.example.composedemos.routes.RoutesActivity + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CatalogScreen() + } + } + } + } +} + +@Composable +fun CatalogScreen() { + val context = LocalContext.current + LazyColumn(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { + item { + Text( + text = "Compose Demos Catalog", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(16.dp), + ) + } + + // ApiDemos Parity + item { CategoryHeader("ApiDemos Parity") } + item { + SampleItem("Hello Map") { + context.startActivity(Intent(context, HelloMapActivity::class.java)) + } + } + item { + SampleItem("Polylines") { + context.startActivity(Intent(context, PolylinesActivity::class.java)) + } + } + item { + SampleItem("Map Interactions") { + context.startActivity(Intent(context, MapInteractionsActivity::class.java)) + } + } + item { + SampleItem("Popovers") { + context.startActivity(Intent(context, PopoversActivity::class.java)) + } + } + item { + SampleItem("Camera Controls") { + context.startActivity(Intent(context, CameraControlsActivity::class.java)) + } + } + item { + SampleItem("Polygons") { + context.startActivity(Intent(context, PolygonsActivity::class.java)) + } + } + item { + SampleItem("Models") { + context.startActivity(Intent(context, ModelsActivity::class.java)) + } + } + item { + SampleItem("Markers") { + context.startActivity(Intent(context, MarkersActivity::class.java)) + } + } + + // Additional Catalog Requirements + item { CategoryHeader("Additional Catalog Requirements") } + item { + SampleItem("Camera Restrictions") { + context.startActivity(Intent(context, CameraRestrictionsActivity::class.java)) + } + } + item { + SampleItem("Flight Simulator") { + context.startActivity(Intent(context, FlightSimulatorActivity::class.java)) + } + } + item { + SampleItem("Routes API") { + context.startActivity(Intent(context, RoutesActivity::class.java)) + } + } + item { + SampleItem("Path Following") { + context.startActivity(Intent(context, PathFollowingActivity::class.java)) + } + } + item { + SampleItem("Path Styling") { + context.startActivity(Intent(context, PathStylingActivity::class.java)) + } + } + item { + SampleItem("Animating Models") { + context.startActivity(Intent(context, AnimatingModelsActivity::class.java)) + } + } + item { + SampleItem("Place Search") { + context.startActivity(Intent(context, PlaceSearchActivity::class.java)) + } + } + item { + SampleItem("Place Autocomplete") { + context.startActivity(Intent(context, PlaceAutocompleteActivity::class.java)) + } + } + item { + SampleItem("Place Details") { + context.startActivity(Intent(context, PlaceDetailsActivity::class.java)) + } + } + item { + SampleItem("Advanced Camera Animation") { + context.startActivity(Intent(context, AdvancedCameraAnimationActivity::class.java)) + } + } + item { + SampleItem("Data Visualization (Flood Fill)") { + context.startActivity(Intent(context, DataVisualizationActivity::class.java)) + } + } + item { + SampleItem("Cloud Map Styling") { + context.startActivity(Intent(context, CloudStylingActivity::class.java)) + } + } + item { + SampleItem("Roadmap Mode") { + context.startActivity(Intent(context, RoadmapModeActivity::class.java)) + } + } + item { + SampleItem("Field Of View") { + context.startActivity(Intent(context, FieldOfViewActivity::class.java)) + } + } + } +} + +@Composable +fun CategoryHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +fun SampleItem(title: String, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable { onClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Text( + text = title, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt new file mode 100644 index 00000000..06b0d19a --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.advancedcameraanimation + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class AdvancedCameraAnimationActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Advanced Camera Animation sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt new file mode 100644 index 00000000..2f56d49d --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.animatingmodels + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class AnimatingModelsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Animating Models sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt new file mode 100644 index 00000000..4b24b679 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt @@ -0,0 +1,163 @@ +/* + * 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.composedemos.cameracontrols + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D + +class CameraControlsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CameraControlsScreen() + } + } + } + } +} + +@Composable +fun CameraControlsScreen() { + var isMapSteady by remember { mutableStateOf(false) } + + // Hoisted camera states + var heading by remember { mutableStateOf(153.2f) } + var tilt by remember { mutableStateOf(75.0f) } + var range by remember { mutableStateOf(584.2f) } + + // Recreate camera object when state changes + val dynamicCamera = remember(heading, tilt, range) { + camera { + center = latLngAltitude { + latitude = 47.620527586075134 + longitude = -122.34935779313246 + altitude = 155.2680153221576 + } + this.heading = heading.toDouble() + this.tilt = tilt.toDouble() + this.range = range.toDouble() + roll = 0.0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = dynamicCamera, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Camera Controls", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // 3. Controls Panel at the bottom + Card( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .padding(bottom = 32.dp) + .fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = "Heading: ${heading.toInt()}Β°", style = MaterialTheme.typography.bodyMedium) + Slider( + value = heading, + onValueChange = { heading = it }, + valueRange = 0f..360f, + modifier = Modifier.semantics { contentDescription = "Heading Slider" }, + ) + + Text(text = "Tilt: ${tilt.toInt()}Β°", style = MaterialTheme.typography.bodyMedium) + Slider( + value = tilt, + onValueChange = { tilt = it }, + valueRange = 0f..90f, + modifier = Modifier.semantics { contentDescription = "Tilt Slider" }, + ) + + Text(text = "Range: ${range.toInt()}m", style = MaterialTheme.typography.bodyMedium) + Slider( + value = range, + onValueChange = { range = it }, + valueRange = 100f..5000f, + modifier = Modifier.semantics { contentDescription = "Range Slider" }, + ) + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt new file mode 100644 index 00000000..b81264d3 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt @@ -0,0 +1,161 @@ +/* + * 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.composedemos.camerarestrictions + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.cameraRestriction +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.gms.maps3d.model.latLngBounds +import com.google.maps.android.compose3d.GoogleMap3D + +class CameraRestrictionsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CameraRestrictionsScreen() + } + } + } + } +} + +@Composable +fun CameraRestrictionsScreen() { + var isMapSteady by remember { mutableStateOf(false) } + + // Camera centered on Space Needle + val spaceNeedleCamera = remember { + camera { + center = latLngAltitude { + latitude = 47.620527586075134 + longitude = -122.34935779313246 + altitude = 155.2680153221576 + } + heading = 153.2 + tilt = 75.0 + range = 584.2 + roll = 0.0 + } + } + + // Define a bounding box around the Space Needle + val seattleBounds = remember { + latLngBounds { + northEastLat = 47.63 + northEastLng = -122.33 + southWestLat = 47.61 + southWestLng = -122.36 + } + } + + // Define camera restriction + val seattleRestriction = remember { + cameraRestriction { + bounds = seattleBounds + minAltitude = 100.0 + maxAltitude = 2000.0 + minHeading = 0.0 + maxHeading = 360.0 + minTilt = 0.0 + maxTilt = 90.0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen with camera restriction + GoogleMap3D( + camera = spaceNeedleCamera, + cameraRestriction = seattleRestriction, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Camera Restrictions", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // 3. Info Card + Card( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = "Camera is restricted to the Space Needle area (47.61..47.63, -122.36..-122.33) and altitude 100m..2000m.", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt new file mode 100644 index 00000000..802b7f50 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.cloudstyling + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class CloudStylingActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Cloud Map Styling sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt new file mode 100644 index 00000000..175e67f7 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.datavisualization + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class DataVisualizationActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Data Visualization sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt new file mode 100644 index 00000000..7eb586f9 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.fieldofview + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class FieldOfViewActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Field Of View sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt new file mode 100644 index 00000000..29a5face --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.flightsimulator + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class FlightSimulatorActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Flight Simulator sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt new file mode 100644 index 00000000..45caf24a --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt @@ -0,0 +1,122 @@ +/* + * 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.composedemos.hellomap + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D + +class HelloMapActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + HelloMapScreen() + } + } + } + } +} + +@Composable +fun HelloMapScreen() { + var isMapSteady by remember { mutableStateOf(false) } + + val delicateArchCamera = remember { + camera { + center = latLngAltitude { + latitude = 38.74349839523953 + longitude = -109.49930710001824 + altitude = 1467.1204001315878 + } + heading = 151.8340412978984 + tilt = 68.31411315130784 + range = 250.56850704659155 + roll = 0.0 + } + } + + // Layering the elements to allow the map to show through the top bar + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = delicateArchCamera, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + // 75% opaque surface color + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + // Respect status bar / cutout area for padding the content + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Hello Map", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt new file mode 100644 index 00000000..dddc866c --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt @@ -0,0 +1,148 @@ +/* + * 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.composedemos.mapinteractions + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D + +class MapInteractionsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + MapInteractionsScreen() + } + } + } + } +} + +@Composable +fun MapInteractionsScreen() { + var isMapSteady by remember { mutableStateOf(false) } + var clickedInfo by remember { mutableStateOf("Click on the map to see details") } + + // Calibrated camera centered around Colorado State Capitol + val calibratedCamera = remember { + camera { + center = latLngAltitude { + latitude = 39.73924812963158 + longitude = -104.98498430890453 + altitude = 1640.448817525612 + } + heading = 93.65938810195067 + tilt = 61.598097303273555 + range = 494.79807973388233 + roll = 0.0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = calibratedCamera, + modifier = Modifier.fillMaxSize(), + onMapReady = { instance -> + instance.setMap3DClickListener { location, placeId -> + clickedInfo = if (placeId != null) { + "Clicked Place ID: $placeId" + } else { + "Clicked Location: ${location.latitude}, ${location.longitude}" + } + } + }, + onMapSteady = { + isMapSteady = true + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Map Interactions", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // 3. Click Info Card + Card( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = clickedInfo, + modifier = Modifier + .padding(16.dp) + .semantics { contentDescription = clickedInfo }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt new file mode 100644 index 00000000..4c54d22a --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt @@ -0,0 +1,174 @@ +/* + * 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.composedemos.markers + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.example.composedemos.R +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.ImageView +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.maps.android.compose3d.PopoverConfig + +class MarkersActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + MarkersScreen() + } + } + } + } +} + +@Composable +fun MarkersScreen() { + var isMapSteady by remember { mutableStateOf(false) } + var popovers by remember { mutableStateOf(emptyList()) } + + // Camera centered on Devils Tower + val devilsTowerCamera = remember { + camera { + center = latLngAltitude { + latitude = 44.589994 + longitude = -104.715326 + altitude = 1508.9 + } + heading = 1.0 + tilt = 75.0 + range = 1635.0 + roll = 0.0 + } + } + + val alienMarker = remember { + MarkerConfig( + key = "alien", + position = latLngAltitude { + latitude = 44.59054845363309 + longitude = -104.715177415273 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + styleView = ImageView(R.drawable.alien), + label = "Devil's Tower Alien", + isExtruded = true, + isDrawnWhenOccluded = true, + onClick = { + // State-driven popover creation! No direct map manipulation. + popovers = listOf( + PopoverConfig( + key = "alien_popover", + positionAnchorKey = "alien", + autoPanEnabled = false, + autoCloseEnabled = true, + content = { + Surface( + color = Color.White, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding(8.dp), + ) { + Text( + text = "They didn't just come to sculpt mashed potatoes.", + modifier = Modifier.padding(16.dp), + color = Color.Black, + ) + } + }, + ), + ) + }, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = devilsTowerCamera, + mapMode = Map3DMode.HYBRID, + markers = listOf(alienMarker), + popovers = popovers, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + onMapClick = { + popovers = emptyList() + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Markers", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/models/ModelsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/models/ModelsActivity.kt new file mode 100644 index 00000000..c4baf59f --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/models/ModelsActivity.kt @@ -0,0 +1,250 @@ +/* + * 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.composedemos.models + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode +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.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.ModelConfig +import com.google.maps.android.compose3d.ModelScale +import com.google.maps.android.compose3d.utils.awaitCameraAnimation +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class ModelsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + ModelsScreen() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModelsScreen() { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var isMapSteady by remember { mutableStateOf(false) } + + val initialCamera = remember { + camera { + center = latLngAltitude { + latitude = 47.133971 + longitude = 11.333161 + altitude = 2200.0 + } + heading = 221.0 + tilt = 25.0 + range = 30000.0 + } + } + + var cameraState by remember { mutableStateOf(initialCamera) } + var mapInstance by remember { mutableStateOf(null) } + var animationJob by remember { mutableStateOf(null) } + + val models = remember { + listOf( + ModelConfig( + key = "plane_model", + position = latLngAltitude { + latitude = 47.133971 + longitude = 11.333161 + altitude = 2200.0 + }, + url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb", + altitudeMode = AltitudeMode.ABSOLUTE, + scale = ModelScale.Uniform(0.05f), + heading = 41.5, + tilt = -90.0, + roll = 0.0, + onClick = { + Toast.makeText(context, "Clicked on Airplane!", Toast.LENGTH_SHORT).show() + }, + ), + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = cameraState, + models = models, + mapMode = Map3DMode.SATELLITE, + modifier = Modifier.fillMaxSize(), + onMapReady = { googleMap3D -> + mapInstance = googleMap3D + + // Start animation sequence when map is ready + animationJob = coroutineScope.launch { + runAnimationSequence(googleMap3D) + } + }, + onMapSteady = { + isMapSteady = true + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Models", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // 3. UI Controls (FABs) + FloatingActionButton( + onClick = { + animationJob?.cancel() + animationJob = coroutineScope.launch { + mapInstance?.let { map -> + // Fly back to initial camera + map.flyCameraTo( + flyToOptions { + endCamera = initialCamera + durationInMillis = 2000 + }, + ) + map.awaitCameraAnimation() + runAnimationSequence(map) + } + } + }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + .padding(bottom = 32.dp), // Avoid system bars if visible + ) { + Text(text = "Reset") + } + + FloatingActionButton( + onClick = { + animationJob?.cancel() + animationJob = null + Toast.makeText(context, "Animation Stopped", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + .padding(bottom = 32.dp), + ) { + Text(text = "Stop") + } + } +} + +private suspend fun runAnimationSequence(googleMap3D: com.google.android.gms.maps3d.GoogleMap3D) { + delay(1500) + + val camera = camera { + center = latLngAltitude { + latitude = 47.133971 + longitude = 11.333161 + altitude = 2200.0 + } + heading = 221.4 + tilt = 75.0 + range = 700.0 + } + + // Fly to the plane model + googleMap3D.flyCameraTo( + flyToOptions { + endCamera = camera + durationInMillis = 3500 + }, + ) + googleMap3D.awaitCameraAnimation() + + delay(500) + + // Fly around the plane model + googleMap3D.flyCameraAround( + flyAroundOptions { + center = camera + durationInMillis = 3500 + rounds = 0.5 + }, + ) + googleMap3D.awaitCameraAnimation() +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt new file mode 100644 index 00000000..2d9e4825 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.pathfollowing + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class PathFollowingActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Path Following sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt new file mode 100644 index 00000000..f999a024 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.pathstyling + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class PathStylingActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Path Styling sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt new file mode 100644 index 00000000..e3e8f2d3 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.placeautocomplete + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class PlaceAutocompleteActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Place Autocomplete sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/Landmark.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/Landmark.kt new file mode 100644 index 00000000..b7b1ef87 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/Landmark.kt @@ -0,0 +1,32 @@ +/* + * 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.composedemos.placedetails + +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 +) diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/LandmarkList.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/LandmarkList.kt new file mode 100644 index 00000000..a3edb646 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/LandmarkList.kt @@ -0,0 +1,101 @@ +/* + * 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.composedemos.placedetails + +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 + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt new file mode 100644 index 00000000..80b08ad8 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt @@ -0,0 +1,352 @@ +/* + * 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.composedemos.placedetails + +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Button +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.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.composedemos.R +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.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 +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.utils.toValidCamera +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * A simple ViewModel to hold the selected place ID without using Hilt. + */ +class PlaceDetailsViewModel : ViewModel() { + companion object { + val initialCamera: Camera = camera { + center = latLngAltitude { + latitude = 39.982129291022446 + longitude = -105.30156359691158 + altitude = 2483.5 // Approx 8148 feet + } + heading = 26.0 + tilt = 67.0 + range = 2500.0 + }.toValidCamera() + } + + val landmarks: List = listOf( + Landmark( + id = "ChIJfXOTtWbsa4cRmW07qJRB6_8", + name = "The Flatirons", + location = latLngAltitude { + latitude = 39.9880 + longitude = -105.2930 + altitude = 2100.0 + }, + ), + 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 + }, + ), + ) + + private val _placeId = MutableStateFlow(null) + val placeId: StateFlow = _placeId.asStateFlow() + + private val _cameraState = MutableStateFlow(initialCamera) + val cameraState: StateFlow = _cameraState.asStateFlow() + + fun setSelectedPlaceId(placeId: String?) { + _placeId.value = placeId + } + + fun selectLandmark(landmark: Landmark) { + setSelectedPlaceId(landmark.id) + _cameraState.value = camera { + center = landmark.location + range = 1000.0 + tilt = 45.0 + }.toValidCamera() + } +} + +class PlaceDetailsActivity : FragmentActivity() { + companion object { + private const val TAG = "PlaceDetailsActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + val viewModel: PlaceDetailsViewModel = viewModel() + + // Handle Intent extra for non-UI state injection (testing) + LaunchedEffect(Unit) { + intent.getStringExtra("place_name")?.let { name -> + delay(2000) // Delay a couple of seconds before showing + val landmark = viewModel.landmarks.find { it.name == name } + if (landmark != null) { + viewModel.selectLandmark(landmark) + } + } + } + + MainScreen(viewModel) + } + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun MainScreen(viewModel: PlaceDetailsViewModel) { + val landmarks = viewModel.landmarks + val selectedPlaceId by viewModel.placeId.collectAsState() + val scope = rememberCoroutineScope() + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + ), + ) + val sheetPeekHeight = 120.dp + val cameraState by viewModel.cameraState.collectAsState() + + // Dismiss the place details overlay if the user fully expands the bottom sheet + LaunchedEffect(scaffoldState.bottomSheetState.currentValue) { + if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) { + viewModel.setSelectedPlaceId(null) + } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + BottomSheetScaffold( + scaffoldState = scaffoldState, + sheetPeekHeight = sheetPeekHeight, + sheetContent = { + LandmarkList( + landmarks = landmarks, + onLandmarkClick = { landmark -> + Log.d(TAG, "LANDMARK_CLICKED: ${landmark.name}") + viewModel.selectLandmark(landmark) + scope.launch { + scaffoldState.bottomSheetState.partialExpand() + } + }, + modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp), + ) + }, + ) { _ -> + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap3D( + camera = cameraState, + mapMode = Map3DMode.HYBRID, + modifier = Modifier.fillMaxSize(), + onPlaceClick = { placeId -> + Log.d(TAG, "Place clicked: placeId=$placeId") + viewModel.setSelectedPlaceId(placeId) + }, + ) + } + } + + // Overlay stays on top of the scaffold + if (!selectedPlaceId.isNullOrEmpty()) { + PlaceDetailsOverlay( + placeId = selectedPlaceId!!, + onDismiss = { viewModel.setSelectedPlaceId(null) }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = sheetPeekHeight + 16.dp, start = 16.dp, end = 16.dp), + ) + } + } + } + + @Composable + fun PlaceDetailsOverlay( + placeId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + ) { + val containerId = remember { View.generateViewId() } + + LaunchedEffect(placeId) { + val fragment = supportFragmentManager.findFragmentById(containerId) as? PlaceDetailsCompactFragment + if (fragment != null) { + Log.d(TAG, "Updating existing fragment for new placeId: $placeId") + fragment.loadWithPlaceId(placeId) + } + } + + Box( + modifier = modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium), + ) { + AndroidView( + factory = { ctx -> + FragmentContainerView(ctx).apply { + id = containerId + + val newFragment = PlaceDetailsCompactFragment.newInstance( + PlaceDetailsCompactFragment.ALL_CONTENT, + Orientation.VERTICAL, + R.style.BoulderNatureHippieTheme, // Use our custom theme! + ).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) + } + }) + } + + supportFragmentManager.commit { + replace(containerId, newFragment) + } + + post { newFragment.loadWithPlaceId(placeId) } + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + FloatingActionButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + ) + } + } + + androidx.compose.runtime.DisposableEffect(containerId) { + onDispose { + supportFragmentManager.findFragmentById(containerId)?.let { + supportFragmentManager.beginTransaction() + .remove(it) + .commitAllowingStateLoss() + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt new file mode 100644 index 00000000..bdff996a --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.placesearch + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class PlaceSearchActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Place Search sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt new file mode 100644 index 00000000..c75df91b --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt @@ -0,0 +1,297 @@ +/* + * 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.composedemos.polygons + +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.PolygonConfig +import kotlinx.coroutines.launch + +class PolygonsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PolygonsScreen() + } + } + } + } +} + +@Composable +fun PolygonsScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isMapSteady by remember { mutableStateOf(false) } + + // Define the camera position centered around Denver Zoo + val denverCamera = remember { + camera { + center = latLngAltitude { + latitude = 39.748477 + longitude = -104.947575 + altitude = 1609.34 // Denver is a mile high! + } + heading = -68.0 + tilt = 47.0 + roll = 0.0 + range = 2251.0 + } + } + + // Define the outline for the zoo + val zooOutline = remember { + """ + 39.7508987, -104.9565381 + 39.7502883, -104.9565489 + 39.7501976, -104.9563557 + 39.7501481, -104.955594 + 39.7499171, -104.9553043 + 39.7495872, -104.9551648 + 39.7492407, -104.954961 + 39.7489685, -104.9548859 + 39.7484488, -104.9548966 + 39.7481189, -104.9548859 + 39.7479539, -104.9547679 + 39.7479209, -104.9544567 + 39.7476487, -104.9535341 + 39.7475085, -104.9525792 + 39.7474095, -104.9519247 + 39.747525, -104.9513776 + 39.7476734, -104.9511844 + 39.7478137, -104.9506265 + 39.7477559, -104.9496395 + 39.7477477, -104.9486203 + 39.7478467, -104.9475796 + 39.7482344, -104.9465818 + 39.7486138, -104.9457878 + 39.7491005, -104.9454874 + 39.7495789, -104.945938 + 39.7500491, -104.9466998 + 39.7503213, -104.9474615 + 39.7505358, -104.9486954 + 39.7505111, -104.950648 + 39.7511215, -104.9506587 + 39.7511173, -104.9527187 + 39.7511091, -104.9546445 + 39.7508987, -104.9565381 + """.trimIndent() + .lines() + .map { line -> line.split(",").map { it.trim().toDouble() } } + .map { (lat, lng) -> + latLngAltitude { + latitude = lat + longitude = lng + altitude = 0.0 + } + } + } + + // Define a hole inside the zoo area + val zooHole = remember { + """ + 39.7498, -104.9535 + 39.7498, -104.9525 + 39.7488, -104.9525 + 39.7488, -104.9535 + 39.7498, -104.9535 + """.trimIndent() + .lines() + .map { line -> line.split(",").map { it.trim().toDouble() } } + .map { (lat, lng) -> + latLngAltitude { + latitude = lat + longitude = lng + altitude = 0.0 + } + } + } + + // Create a polygon config + val polygonConfig = remember { + PolygonConfig( + key = "denver_zoo", + path = zooOutline, + innerPaths = listOf(zooHole), + // Translucent yellow + fillColor = Color.argb(70, 255, 255, 0), + strokeColor = Color.GREEN, + strokeWidth = 3f, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + onClick = { polygon -> + Log.d("PolygonsActivity", "Polygon clicked: $polygon") + scope.launch { + Toast.makeText(context, "Zoo time!", Toast.LENGTH_SHORT).show() + } + }, + ) + } + + // Define the base face for the museum + val museumBaseFace = remember { + """ + 39.74812392425406, -104.94414971628434 + 39.7465307929639, -104.94370889409778 + 39.747031745033794, -104.9415078562927 + 39.74837320615968, -104.94194414397013 + 39.74812392425406, -104.94414971628434 + """.trimIndent() + .lines() + .map { line -> line.split(",").map { it.trim().toDouble() } } + .map { (lat, lng) -> + latLngAltitude { + latitude = lat + longitude = lng + altitude = 1609.34 // Denver is a mile high! + } + } + } + + // Create extruded polygons for the museum + val museumPolygons = remember { + extrudePolygon(museumBaseFace, 50.0).mapIndexed { index, outline -> + PolygonConfig( + key = "museum_face_$index", + path = outline, + // Semi-transparent magenta + fillColor = Color.argb(70, 255, 0, 255), + strokeColor = Color.MAGENTA, + strokeWidth = 3f, + altitudeMode = AltitudeMode.ABSOLUTE, + onClick = { polygon -> + Log.d("PolygonsActivity", "Museum face clicked: $polygon") + scope.launch { + Toast.makeText(context, "Museum time!", Toast.LENGTH_SHORT).show() + } + }, + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = denverCamera, + mapMode = Map3DMode.HYBRID, + polygons = listOf(polygonConfig) + museumPolygons, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Polygons", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +/** + * Extrudes a flat polygon (defined by basePoints, all at the same altitude) + * upwards by a given extrusionHeight to form a 3D prism. + */ +fun extrudePolygon( + basePoints: List, + extrusionHeight: Double, +): List> { + if (basePoints.size < 3) return emptyList() + if (extrusionHeight <= 0) return emptyList() + + val baseAltitude = basePoints.first().altitude + val topPoints = basePoints.map { basePoint -> + latLngAltitude { + latitude = basePoint.latitude + longitude = basePoint.longitude + altitude = baseAltitude + extrusionHeight + } + } + + val faces = mutableListOf>() + faces.add(basePoints.toList()) + faces.add(topPoints.toList().reversed()) + + for (i in basePoints.indices) { + val p1Base = basePoints[i] + val p2Base = basePoints[(i + 1) % basePoints.size] + val p1Top = topPoints[i] + val p2Top = topPoints[(i + 1) % basePoints.size] + faces.add(listOf(p1Base, p2Base, p2Top, p1Top)) + } + + return faces +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/Data.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/Data.kt new file mode 100644 index 00000000..21396bae --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/Data.kt @@ -0,0 +1,1554 @@ +// 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.composedemos.polylines + +/** + * A trail up Mount Sanitas in Boulder, Colorado. + */ +internal val sanitasLoop = """ + 40.0201040, -105.2976640 + 40.0201080, -105.2976450 + 40.0201640, -105.2975120 + 40.0202200, -105.2973740 + 40.0202500, -105.2972760 + 40.0202960, -105.2971410 + 40.0203080, -105.2970990 + 40.0203320, -105.2970070 + 40.0203640, -105.2969400 + 40.0203710, -105.2969250 + 40.0203770, -105.2969220 + 40.0203910, -105.2969130 + 40.0203940, -105.2969120 + 40.0204200, -105.2969130 + 40.0204630, -105.2968910 + 40.0205270, -105.2968280 + 40.0206030, -105.2967570 + 40.0206590, -105.2966100 + 40.0206990, -105.2964870 + 40.0207290, -105.2963090 + 40.0207300, -105.2963070 + 40.0207490, -105.2963130 + 40.0208050, -105.2963460 + 40.0208120, -105.2963490 + 40.0209090, -105.2963280 + 40.0209720, -105.2963220 + 40.0209820, -105.2963320 + 40.0209940, -105.2963650 + 40.0210100, -105.2963890 + 40.0210470, -105.2964310 + 40.0210580, -105.2964600 + 40.0210620, -105.2964670 + 40.0210700, -105.2964730 + 40.0210930, -105.2964810 + 40.0211260, -105.2964920 + 40.0211330, -105.2964940 + 40.0211460, -105.2964970 + 40.0211790, -105.2964880 + 40.0211850, -105.2964900 + 40.0211940, -105.2964990 + 40.0212070, -105.2965170 + 40.0212440, -105.2965230 + 40.0212690, -105.2965050 + 40.0212820, -105.2964860 + 40.0213030, -105.2964820 + 40.0213910, -105.2965070 + 40.0214170, -105.2965640 + 40.0214310, -105.2966060 + 40.0214510, -105.2966250 + 40.0214610, -105.2966250 + 40.0214900, -105.2966160 + 40.0215010, -105.2966140 + 40.0215420, -105.2966170 + 40.0215490, -105.2966240 + 40.0215680, -105.2966660 + 40.0215850, -105.2966580 + 40.0215670, -105.2967210 + 40.0215720, -105.2967320 + 40.0216080, -105.2967810 + 40.0216000, -105.2967920 + 40.0215890, -105.2968390 + 40.0215740, -105.2968660 + 40.0215710, -105.2968720 + 40.0215470, -105.2969270 + 40.0215430, -105.2969400 + 40.0215280, -105.2969570 + 40.0214980, -105.2970100 + 40.0214950, -105.2970270 + 40.0214980, -105.2970750 + 40.0215000, -105.2971370 + 40.0215080, -105.2971650 + 40.0215100, -105.2971650 + 40.0215240, -105.2971730 + 40.0215250, -105.2971720 + 40.0215290, -105.2971720 + 40.0215290, -105.2971710 + 40.0215290, -105.2971630 + 40.0215270, -105.2971670 + 40.0215180, -105.2972120 + 40.0215070, -105.2972370 + 40.0214640, -105.2973310 + 40.0214550, -105.2973440 + 40.0214360, -105.2973800 + 40.0214170, -105.2974290 + 40.0213830, -105.2974730 + 40.0213650, -105.2974980 + 40.0213680, -105.2975510 + 40.0213580, -105.2976040 + 40.0213680, -105.2976650 + 40.0213750, -105.2976830 + 40.0213900, -105.2976930 + 40.0213970, -105.2977160 + 40.0213920, -105.2977290 + 40.0213900, -105.2977480 + 40.0213930, -105.2977630 + 40.0214020, -105.2977960 + 40.0214160, -105.2978200 + 40.0214300, -105.2978340 + 40.0214340, -105.2978420 + 40.0214310, -105.2978830 + 40.0214340, -105.2979180 + 40.0214370, -105.2979360 + 40.0214660, -105.2979600 + 40.0214740, -105.2979790 + 40.0214800, -105.2979830 + 40.0215000, -105.2979910 + 40.0215080, -105.2980210 + 40.0215110, -105.2980290 + 40.0215200, -105.2980420 + 40.0215480, -105.2980680 + 40.0215530, -105.2980690 + 40.0215690, -105.2980540 + 40.0215930, -105.2980560 + 40.0215980, -105.2980760 + 40.0216060, -105.2980770 + 40.0216220, -105.2980850 + 40.0216290, -105.2980880 + 40.0216460, -105.2981000 + 40.0216670, -105.2981260 + 40.0216740, -105.2981480 + 40.0216770, -105.2981580 + 40.0216810, -105.2981680 + 40.0216980, -105.2981840 + 40.0217130, -105.2982030 + 40.0217270, -105.2982190 + 40.0217490, -105.2982340 + 40.0217780, -105.2982490 + 40.0217890, -105.2982530 + 40.0218190, -105.2982880 + 40.0218230, -105.2982990 + 40.0218330, -105.2983010 + 40.0218490, -105.2983030 + 40.0218840, -105.2982950 + 40.0218970, -105.2982890 + 40.0219190, -105.2982790 + 40.0219350, -105.2982620 + 40.0219670, -105.2982660 + 40.0219710, -105.2982700 + 40.0219930, -105.2983070 + 40.0220040, -105.2983340 + 40.0220350, -105.2983630 + 40.0220870, -105.2983850 + 40.0220870, -105.2983820 + 40.0221100, -105.2983540 + 40.0221360, -105.2983430 + 40.0221470, -105.2983430 + 40.0221620, -105.2983330 + 40.0221870, -105.2983190 + 40.0222350, -105.2983130 + 40.0222710, -105.2982950 + 40.0222850, -105.2982950 + 40.0223160, -105.2982870 + 40.0223250, -105.2982920 + 40.0223410, -105.2983040 + 40.0223890, -105.2983220 + 40.0224060, -105.2983260 + 40.0224260, -105.2983290 + 40.0224320, -105.2983270 + 40.0224440, -105.2983310 + 40.0224840, -105.2983290 + 40.0224890, -105.2983310 + 40.0224900, -105.2983330 + 40.0225380, -105.2983600 + 40.0225490, -105.2983650 + 40.0225830, -105.2983730 + 40.0225900, -105.2983740 + 40.0226190, -105.2983930 + 40.0226310, -105.2984710 + 40.0226420, -105.2984710 + 40.0226520, -105.2984700 + 40.0226630, -105.2984700 + 40.0226790, -105.2984670 + 40.0227100, -105.2984830 + 40.0227230, -105.2984930 + 40.0227370, -105.2984810 + 40.0227400, -105.2984820 + 40.0227550, -105.2984930 + 40.0227960, -105.2985050 + 40.0228030, -105.2985160 + 40.0228200, -105.2985270 + 40.0228320, -105.2985310 + 40.0228380, -105.2985310 + 40.0228600, -105.2985560 + 40.0228660, -105.2985600 + 40.0228770, -105.2985560 + 40.0228830, -105.2985570 + 40.0228900, -105.2985590 + 40.0229210, -105.2985790 + 40.0229390, -105.2985990 + 40.0229510, -105.2986240 + 40.0230030, -105.2986460 + 40.0230410, -105.2986660 + 40.0230680, -105.2986750 + 40.0231020, -105.2986900 + 40.0231320, -105.2986980 + 40.0231910, -105.2987450 + 40.0232120, -105.2987550 + 40.0232230, -105.2987590 + 40.0232660, -105.2987540 + 40.0232920, -105.2987570 + 40.0233090, -105.2987630 + 40.0233560, -105.2987770 + 40.0234730, -105.2987670 + 40.0235930, -105.2987510 + 40.0236340, -105.2987430 + 40.0236560, -105.2987430 + 40.0236810, -105.2987430 + 40.0237340, -105.2987310 + 40.0238230, -105.2987030 + 40.0238450, -105.2986990 + 40.0238800, -105.2987020 + 40.0239000, -105.2987190 + 40.0239100, -105.2987370 + 40.0239260, -105.2987690 + 40.0239460, -105.2988040 + 40.0239990, -105.2988670 + 40.0240350, -105.2989020 + 40.0240550, -105.2989150 + 40.0240600, -105.2989170 + 40.0240680, -105.2989230 + 40.0240750, -105.2989280 + 40.0240780, -105.2989360 + 40.0241120, -105.2989990 + 40.0242240, -105.2990910 + 40.0242650, -105.2990930 + 40.0242920, -105.2991000 + 40.0243420, -105.2991160 + 40.0244060, -105.2991360 + 40.0244160, -105.2991380 + 40.0244660, -105.2991390 + 40.0244770, -105.2991420 + 40.0244970, -105.2991480 + 40.0245320, -105.2991610 + 40.0245770, -105.2991810 + 40.0246660, -105.2991740 + 40.0246750, -105.2991730 + 40.0247120, -105.2991710 + 40.0247460, -105.2991620 + 40.0247690, -105.2991650 + 40.0248050, -105.2991710 + 40.0248510, -105.2991710 + 40.0248910, -105.2992000 + 40.0248980, -105.2992110 + 40.0249060, -105.2992220 + 40.0249230, -105.2992250 + 40.0249390, -105.2992230 + 40.0249620, -105.2992380 + 40.0249830, -105.2992570 + 40.0250010, -105.2992730 + 40.0250150, -105.2992790 + 40.0250530, -105.2992880 + 40.0250590, -105.2992950 + 40.0250680, -105.2993240 + 40.0250890, -105.2993520 + 40.0251040, -105.2993710 + 40.0251680, -105.2994320 + 40.0252780, -105.2995230 + 40.0253010, -105.2995500 + 40.0253350, -105.2995950 + 40.0253500, -105.2996020 + 40.0253880, -105.2996130 + 40.0254040, -105.2996150 + 40.0254370, -105.2996420 + 40.0254490, -105.2996530 + 40.0254680, -105.2996690 + 40.0255100, -105.2996920 + 40.0255470, -105.2997460 + 40.0255530, -105.2997600 + 40.0255700, -105.2997800 + 40.0256120, -105.2998030 + 40.0256440, -105.2998160 + 40.0256990, -105.2998290 + 40.0257590, -105.2998430 + 40.0257870, -105.2998510 + 40.0258060, -105.2998610 + 40.0258890, -105.2998780 + 40.0258940, -105.2998790 + 40.0259070, -105.2998880 + 40.0259410, -105.2998980 + 40.0260250, -105.2999160 + 40.0260350, -105.2999140 + 40.0260440, -105.2999140 + 40.0261120, -105.2999520 + 40.0261450, -105.2999620 + 40.0261780, -105.2999810 + 40.0262350, -105.3000140 + 40.0262880, -105.3000300 + 40.0263100, -105.3000360 + 40.0263190, -105.3000370 + 40.0263460, -105.3000490 + 40.0263640, -105.3000570 + 40.0264090, -105.3000870 + 40.0264460, -105.3001080 + 40.0264740, -105.3001430 + 40.0264780, -105.3001480 + 40.0265140, -105.3002100 + 40.0265290, -105.3002120 + 40.0265690, -105.3002070 + 40.0265760, -105.3002040 + 40.0266160, -105.3002220 + 40.0266560, -105.3002270 + 40.0266800, -105.3002210 + 40.0267120, -105.3001900 + 40.0267680, -105.3002060 + 40.0267830, -105.3002270 + 40.0268000, -105.3002380 + 40.0268170, -105.3002460 + 40.0268440, -105.3002490 + 40.0268590, -105.3002560 + 40.0268790, -105.3002710 + 40.0268880, -105.3002940 + 40.0269060, -105.3003340 + 40.0269160, -105.3003550 + 40.0269280, -105.3003730 + 40.0269340, -105.3003790 + 40.0269530, -105.3003920 + 40.0269680, -105.3004060 + 40.0269820, -105.3004160 + 40.0270260, -105.3004380 + 40.0270430, -105.3004500 + 40.0270900, -105.3005030 + 40.0271200, -105.3005240 + 40.0271280, -105.3005340 + 40.0271480, -105.3005630 + 40.0271760, -105.3005730 + 40.0271910, -105.3005830 + 40.0272370, -105.3006310 + 40.0272990, -105.3007520 + 40.0273310, -105.3007940 + 40.0273510, -105.3008320 + 40.0273780, -105.3008640 + 40.0274180, -105.3008810 + 40.0274410, -105.3008940 + 40.0275000, -105.3009660 + 40.0275080, -105.3009790 + 40.0275240, -105.3010140 + 40.0275310, -105.3010200 + 40.0275390, -105.3010270 + 40.0275460, -105.3010300 + 40.0275560, -105.3010330 + 40.0275630, -105.3010370 + 40.0275870, -105.3010660 + 40.0275950, -105.3010740 + 40.0276070, -105.3010950 + 40.0276220, -105.3011170 + 40.0276220, -105.3011250 + 40.0276330, -105.3011680 + 40.0276430, -105.3011910 + 40.0276640, -105.3012190 + 40.0276880, -105.3012260 + 40.0276970, -105.3012270 + 40.0277470, -105.3012490 + 40.0277520, -105.3012480 + 40.0278080, -105.3012660 + 40.0278980, -105.3013300 + 40.0279030, -105.3013400 + 40.0279340, -105.3013870 + 40.0279390, -105.3013920 + 40.0279540, -105.3014140 + 40.0279600, -105.3014230 + 40.0279700, -105.3014390 + 40.0279840, -105.3014580 + 40.0279860, -105.3014860 + 40.0279880, -105.3014980 + 40.0279890, -105.3015140 + 40.0279950, -105.3015160 + 40.0280040, -105.3015440 + 40.0280110, -105.3015530 + 40.0280130, -105.3015660 + 40.0280200, -105.3015770 + 40.0280360, -105.3015860 + 40.0280420, -105.3016010 + 40.0280690, -105.3016490 + 40.0281510, -105.3017030 + 40.0281560, -105.3017130 + 40.0281890, -105.3017560 + 40.0282470, -105.3017780 + 40.0282820, -105.3018160 + 40.0282880, -105.3018230 + 40.0283250, -105.3018550 + 40.0283430, -105.3018660 + 40.0283800, -105.3018950 + 40.0283930, -105.3019020 + 40.0284060, -105.3019120 + 40.0284130, -105.3019290 + 40.0284140, -105.3019380 + 40.0284200, -105.3019530 + 40.0284190, -105.3019610 + 40.0284230, -105.3019790 + 40.0284440, -105.3019960 + 40.0284470, -105.3020090 + 40.0284490, -105.3020250 + 40.0284780, -105.3020400 + 40.0284860, -105.3020410 + 40.0285160, -105.3020710 + 40.0285330, -105.3020870 + 40.0285850, -105.3020990 + 40.0286070, -105.3021040 + 40.0286350, -105.3021120 + 40.0286710, -105.3021290 + 40.0286790, -105.3021410 + 40.0286870, -105.3021450 + 40.0286940, -105.3021480 + 40.0287080, -105.3021580 + 40.0287170, -105.3021700 + 40.0287210, -105.3021760 + 40.0287280, -105.3021790 + 40.0287470, -105.3021870 + 40.0287510, -105.3021880 + 40.0287670, -105.3022000 + 40.0288070, -105.3022200 + 40.0288160, -105.3022240 + 40.0288210, -105.3022260 + 40.0288440, -105.3022450 + 40.0288470, -105.3022430 + 40.0288470, -105.3022430 + 40.0288470, -105.3022430 + 40.0288480, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022440 + 40.0288480, -105.3022440 + 40.0288480, -105.3022450 + 40.0288470, -105.3022460 + 40.0288470, -105.3022470 + 40.0288440, -105.3022490 + 40.0288410, -105.3022500 + 40.0288370, -105.3022510 + 40.0288370, -105.3022510 + 40.0288370, -105.3022510 + 40.0288370, -105.3022500 + 40.0288380, -105.3022480 + 40.0288790, -105.3022840 + 40.0288880, -105.3022960 + 40.0289260, -105.3023360 + 40.0289430, -105.3023540 + 40.0289500, -105.3023620 + 40.0289540, -105.3023670 + 40.0289670, -105.3023870 + 40.0289750, -105.3024050 + 40.0289880, -105.3024370 + 40.0290040, -105.3024630 + 40.0290070, -105.3024680 + 40.0290170, -105.3024840 + 40.0290190, -105.3024890 + 40.0290300, -105.3024980 + 40.0290350, -105.3025000 + 40.0290400, -105.3025030 + 40.0290410, -105.3025100 + 40.0290480, -105.3025270 + 40.0290650, -105.3025330 + 40.0290770, -105.3025430 + 40.0290980, -105.3025610 + 40.0291040, -105.3025960 + 40.0291180, -105.3026160 + 40.0291260, -105.3026180 + 40.0291380, -105.3026220 + 40.0291480, -105.3026290 + 40.0291580, -105.3026370 + 40.0291610, -105.3026440 + 40.0291680, -105.3026550 + 40.0291910, -105.3026680 + 40.0291980, -105.3026730 + 40.0292330, -105.3026690 + 40.0292520, -105.3026760 + 40.0293140, -105.3026920 + 40.0293330, -105.3026960 + 40.0293560, -105.3026990 + 40.0293910, -105.3027160 + 40.0294060, -105.3027310 + 40.0294250, -105.3027450 + 40.0294290, -105.3027490 + 40.0294330, -105.3027520 + 40.0294480, -105.3027580 + 40.0294710, -105.3027650 + 40.0294770, -105.3027690 + 40.0294910, -105.3027780 + 40.0295140, -105.3028000 + 40.0295260, -105.3028150 + 40.0295560, -105.3028290 + 40.0295620, -105.3028330 + 40.0295960, -105.3028470 + 40.0296070, -105.3028410 + 40.0296080, -105.3028370 + 40.0296070, -105.3028370 + 40.0296260, -105.3028580 + 40.0296530, -105.3028790 + 40.0297020, -105.3029200 + 40.0297270, -105.3029350 + 40.0297360, -105.3029350 + 40.0297710, -105.3029490 + 40.0297800, -105.3029530 + 40.0298070, -105.3029600 + 40.0298260, -105.3029870 + 40.0298310, -105.3029950 + 40.0298440, -105.3030660 + 40.0298400, -105.3030800 + 40.0298420, -105.3030950 + 40.0298490, -105.3031100 + 40.0299040, -105.3032390 + 40.0299150, -105.3032920 + 40.0299350, -105.3033310 + 40.0299430, -105.3033400 + 40.0299540, -105.3033470 + 40.0299780, -105.3033560 + 40.0300220, -105.3033750 + 40.0300390, -105.3033890 + 40.0300450, -105.3033930 + 40.0300670, -105.3034060 + 40.0300970, -105.3034200 + 40.0301070, -105.3034250 + 40.0301630, -105.3034100 + 40.0301980, -105.3034020 + 40.0302560, -105.3033930 + 40.0302950, -105.3033910 + 40.0304050, -105.3034310 + 40.0304170, -105.3034360 + 40.0304280, -105.3034400 + 40.0304520, -105.3034510 + 40.0305200, -105.3034770 + 40.0305290, -105.3034830 + 40.0305580, -105.3034980 + 40.0305960, -105.3035100 + 40.0306810, -105.3035960 + 40.0306940, -105.3036230 + 40.0307100, -105.3036550 + 40.0307220, -105.3036720 + 40.0307350, -105.3036940 + 40.0307400, -105.3037010 + 40.0307860, -105.3037390 + 40.0308050, -105.3037570 + 40.0308080, -105.3037660 + 40.0308220, -105.3038180 + 40.0308330, -105.3038520 + 40.0308350, -105.3038650 + 40.0308580, -105.3038900 + 40.0308650, -105.3039130 + 40.0308830, -105.3039360 + 40.0309350, -105.3039550 + 40.0309490, -105.3039580 + 40.0309850, -105.3039820 + 40.0310340, -105.3040170 + 40.0310490, -105.3040240 + 40.0310780, -105.3040320 + 40.0311160, -105.3040480 + 40.0311260, -105.3040560 + 40.0311850, -105.3040800 + 40.0311990, -105.3040850 + 40.0312340, -105.3041020 + 40.0312950, -105.3041110 + 40.0313040, -105.3041200 + 40.0313250, -105.3041390 + 40.0313580, -105.3041730 + 40.0314180, -105.3042230 + 40.0314310, -105.3042280 + 40.0314600, -105.3042380 + 40.0314920, -105.3042540 + 40.0315050, -105.3042580 + 40.0315270, -105.3042740 + 40.0315520, -105.3042890 + 40.0316390, -105.3043180 + 40.0317520, -105.3043350 + 40.0318500, -105.3043680 + 40.0318820, -105.3043760 + 40.0319260, -105.3043830 + 40.0319470, -105.3043890 + 40.0319950, -105.3044190 + 40.0320180, -105.3044350 + 40.0320730, -105.3044390 + 40.0320930, -105.3044460 + 40.0321170, -105.3044510 + 40.0321290, -105.3044550 + 40.0321710, -105.3044470 + 40.0322100, -105.3044430 + 40.0322180, -105.3044490 + 40.0322230, -105.3044590 + 40.0322690, -105.3044700 + 40.0322740, -105.3044580 + 40.0322800, -105.3044490 + 40.0323020, -105.3044360 + 40.0323260, -105.3044360 + 40.0323630, -105.3044390 + 40.0323690, -105.3044460 + 40.0323760, -105.3044550 + 40.0323890, -105.3044680 + 40.0324090, -105.3044750 + 40.0324300, -105.3044940 + 40.0324390, -105.3045030 + 40.0324510, -105.3045340 + 40.0324610, -105.3045350 + 40.0324860, -105.3045440 + 40.0325080, -105.3045620 + 40.0325220, -105.3045720 + 40.0325380, -105.3045800 + 40.0325470, -105.3045840 + 40.0325660, -105.3046050 + 40.0325930, -105.3046130 + 40.0326270, -105.3046180 + 40.0326560, -105.3046370 + 40.0326620, -105.3046470 + 40.0326650, -105.3046700 + 40.0326780, -105.3047240 + 40.0326870, -105.3047350 + 40.0327330, -105.3047650 + 40.0327540, -105.3047820 + 40.0327650, -105.3047880 + 40.0327760, -105.3047960 + 40.0327840, -105.3048020 + 40.0327970, -105.3048370 + 40.0328020, -105.3048600 + 40.0328110, -105.3048740 + 40.0328350, -105.3048980 + 40.0328450, -105.3049050 + 40.0328570, -105.3049110 + 40.0328700, -105.3049140 + 40.0329060, -105.3049140 + 40.0329620, -105.3049470 + 40.0329700, -105.3049560 + 40.0330310, -105.3049990 + 40.0330370, -105.3050040 + 40.0330600, -105.3050220 + 40.0330750, -105.3050360 + 40.0331270, -105.3050700 + 40.0332040, -105.3051260 + 40.0332150, -105.3051390 + 40.0332260, -105.3051530 + 40.0332340, -105.3051720 + 40.0332340, -105.3051900 + 40.0332500, -105.3052230 + 40.0332560, -105.3052280 + 40.0332870, -105.3052500 + 40.0332920, -105.3052730 + 40.0333040, -105.3052830 + 40.0333390, -105.3053040 + 40.0333500, -105.3053140 + 40.0333890, -105.3053370 + 40.0333960, -105.3053520 + 40.0334030, -105.3053620 + 40.0334310, -105.3053720 + 40.0334550, -105.3053800 + 40.0334740, -105.3053890 + 40.0335150, -105.3054040 + 40.0335210, -105.3054230 + 40.0335310, -105.3054340 + 40.0335490, -105.3054590 + 40.0335660, -105.3054740 + 40.0335780, -105.3054790 + 40.0336220, -105.3054800 + 40.0336310, -105.3054800 + 40.0336740, -105.3054740 + 40.0336850, -105.3054730 + 40.0337070, -105.3055020 + 40.0337200, -105.3054980 + 40.0337320, -105.3054990 + 40.0337490, -105.3055230 + 40.0337610, -105.3055690 + 40.0337600, -105.3055900 + 40.0337630, -105.3056010 + 40.0337950, -105.3056330 + 40.0338110, -105.3056400 + 40.0338290, -105.3056460 + 40.0338470, -105.3056840 + 40.0338730, -105.3057140 + 40.0339050, -105.3057320 + 40.0339410, -105.3057380 + 40.0339490, -105.3057360 + 40.0339640, -105.3057420 + 40.0339750, -105.3057520 + 40.0339870, -105.3057600 + 40.0340080, -105.3057550 + 40.0340230, -105.3057140 + 40.0340290, -105.3056880 + 40.0340500, -105.3056630 + 40.0340780, -105.3056370 + 40.0340860, -105.3056210 + 40.0340860, -105.3056130 + 40.0340870, -105.3056060 + 40.0341010, -105.3055920 + 40.0341100, -105.3055800 + 40.0341090, -105.3055690 + 40.0341170, -105.3055490 + 40.0341470, -105.3055410 + 40.0341500, -105.3055360 + 40.0341760, -105.3055150 + 40.0341820, -105.3055070 + 40.0341960, -105.3055060 + 40.0342170, -105.3054940 + 40.0342290, -105.3054910 + 40.0342340, -105.3054910 + 40.0342670, -105.3054760 + 40.0342910, -105.3054790 + 40.0342990, -105.3054790 + 40.0343220, -105.3054470 + 40.0343310, -105.3054030 + 40.0343320, -105.3053870 + 40.0343370, -105.3053570 + 40.0343520, -105.3053580 + 40.0343770, -105.3053620 + 40.0344000, -105.3053570 + 40.0344070, -105.3053290 + 40.0344080, -105.3053290 + 40.0344070, -105.3053210 + 40.0344070, -105.3053210 + 40.0344060, -105.3053210 + 40.0344050, -105.3053200 + 40.0344050, -105.3053200 + 40.0344040, -105.3053190 + 40.0344040, -105.3053190 + 40.0344040, -105.3053180 + 40.0344040, -105.3053140 + 40.0344030, -105.3053140 + 40.0344030, -105.3053050 + 40.0344070, -105.3053010 + 40.0344070, -105.3053010 + 40.0344050, -105.3053000 + 40.0344040, -105.3053000 + 40.0344030, -105.3052960 + 40.0344030, -105.3052950 + 40.0344030, -105.3052950 + 40.0344030, -105.3052950 + 40.0344030, -105.3052950 + 40.0344040, -105.3052950 + 40.0344040, -105.3052950 + 40.0344040, -105.3052950 + 40.0344040, -105.3052950 + 40.0344050, -105.3052950 + 40.0344020, -105.3052960 + 40.0344020, -105.3052950 + 40.0344010, -105.3052940 + 40.0343990, -105.3052880 + 40.0343990, -105.3052880 + 40.0343990, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052870 + 40.0344000, -105.3052870 + 40.0344050, -105.3052780 + 40.0344260, -105.3052210 + 40.0344320, -105.3051550 + 40.0344390, -105.3051470 + 40.0344460, -105.3051390 + 40.0344530, -105.3051310 + 40.0344600, -105.3051230 + 40.0344670, -105.3051160 + 40.0344740, -105.3051080 + 40.0344810, -105.3051000 + 40.0344880, -105.3050920 + 40.0344950, -105.3050840 + 40.0345020, -105.3050760 + 40.0345090, -105.3050680 + 40.0345170, -105.3050600 + 40.0345240, -105.3050520 + 40.0345310, -105.3050440 + 40.0345380, -105.3050370 + 40.0345450, -105.3050290 + 40.0345520, -105.3050210 + 40.0345590, -105.3050130 + 40.0345660, -105.3050050 + 40.0345730, -105.3049970 + 40.0345800, -105.3049890 + 40.0345870, -105.3049810 + 40.0345940, -105.3049730 + 40.0346010, -105.3049650 + 40.0346080, -105.3049570 + 40.0346150, -105.3049500 + 40.0346220, -105.3049420 + 40.0346290, -105.3049340 + 40.0346360, -105.3049260 + 40.0346430, -105.3049180 + 40.0346500, -105.3049100 + 40.0346570, -105.3049020 + 40.0346640, -105.3048940 + 40.0346710, -105.3048860 + 40.0346780, -105.3048780 + 40.0346860, -105.3048710 + 40.0346930, -105.3048630 + 40.0347000, -105.3048550 + 40.0347070, -105.3048470 + 40.0347140, -105.3048390 + 40.0347210, -105.3048310 + 40.0347280, -105.3048230 + 40.0347350, -105.3048150 + 40.0347420, -105.3048070 + 40.0347490, -105.3047990 + 40.0347560, -105.3047910 + 40.0347630, -105.3047840 + 40.0347700, -105.3047760 + 40.0347770, -105.3047680 + 40.0347840, -105.3047600 + 40.0347910, -105.3047520 + 40.0347980, -105.3047440 + 40.0348050, -105.3047360 + 40.0348120, -105.3047280 + 40.0348190, -105.3047200 + 40.0348260, -105.3047120 + 40.0348330, -105.3047040 + 40.0348400, -105.3046970 + 40.0348470, -105.3046890 + 40.0348540, -105.3046810 + 40.0348620, -105.3046730 + 40.0348690, -105.3046650 + 40.0348760, -105.3046570 + 40.0348830, -105.3046490 + 40.0348900, -105.3046410 + 40.0348970, -105.3046330 + 40.0349040, -105.3046250 + 40.0349110, -105.3046180 + 40.0349180, -105.3046100 + 40.0349250, -105.3046020 + 40.0349320, -105.3045940 + 40.0349390, -105.3045860 + 40.0349460, -105.3045780 + 40.0349690, -105.3045820 + 40.0350130, -105.3045850 + 40.0350690, -105.3045930 + 40.0351040, -105.3046080 + 40.0351160, -105.3046070 + 40.0351320, -105.3045960 + 40.0351360, -105.3045390 + 40.0351300, -105.3045160 + 40.0351160, -105.3044850 + 40.0350970, -105.3044110 + 40.0350990, -105.3044000 + 40.0350960, -105.3043820 + 40.0350910, -105.3043720 + 40.0350400, -105.3043410 + 40.0350290, -105.3043310 + 40.0350120, -105.3043120 + 40.0349950, -105.3042880 + 40.0349800, -105.3042380 + 40.0349770, -105.3042160 + 40.0349660, -105.3041800 + 40.0349280, -105.3041110 + 40.0348900, -105.3040480 + 40.0348720, -105.3040370 + 40.0348330, -105.3040260 + 40.0348040, -105.3040150 + 40.0347580, -105.3039720 + 40.0347430, -105.3039610 + 40.0347140, -105.3039560 + 40.0346960, -105.3039340 + 40.0346870, -105.3039260 + 40.0346840, -105.3039150 + 40.0346310, -105.3038530 + 40.0345880, -105.3038520 + 40.0345760, -105.3038380 + 40.0345580, -105.3038190 + 40.0345310, -105.3038000 + 40.0345240, -105.3037890 + 40.0345100, -105.3037740 + 40.0344840, -105.3037530 + 40.0344790, -105.3037480 + 40.0344750, -105.3037460 + 40.0344720, -105.3037150 + 40.0344660, -105.3037000 + 40.0344600, -105.3036940 + 40.0344560, -105.3036890 + 40.0344550, -105.3036990 + 40.0344500, -105.3036770 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344520, -105.3036630 + 40.0344510, -105.3036520 + 40.0344500, -105.3036450 + 40.0344450, -105.3036320 + 40.0344240, -105.3036160 + 40.0344300, -105.3035900 + 40.0344430, -105.3035590 + 40.0344500, -105.3035400 + 40.0344660, -105.3034550 + 40.0344690, -105.3034320 + 40.0344720, -105.3034110 + 40.0344940, -105.3034170 + 40.0345100, -105.3033910 + 40.0345100, -105.3033640 + 40.0345080, -105.3033490 + 40.0345070, -105.3033390 + 40.0345060, -105.3033330 + 40.0345030, -105.3033250 + 40.0345050, -105.3033150 + 40.0344830, -105.3032790 + 40.0344630, -105.3032580 + 40.0344450, -105.3032560 + 40.0344330, -105.3032320 + 40.0344290, -105.3031960 + 40.0344120, -105.3031550 + 40.0344030, -105.3031340 + 40.0344130, -105.3031340 + 40.0344590, -105.3031170 + 40.0344710, -105.3030840 + 40.0344770, -105.3030880 + 40.0344990, -105.3030880 + 40.0345150, -105.3030930 + 40.0345230, -105.3030940 + 40.0345320, -105.3031040 + 40.0345520, -105.3030910 + 40.0345430, -105.3030200 + 40.0345340, -105.3029230 + 40.0345280, -105.3028920 + 40.0344980, -105.3028620 + 40.0344810, -105.3028480 + 40.0344410, -105.3028210 + 40.0344250, -105.3028040 + 40.0344250, -105.3027930 + 40.0344290, -105.3027870 + 40.0344380, -105.3027690 + 40.0344570, -105.3027320 + 40.0344600, -105.3027170 + 40.0344710, -105.3026780 + 40.0344690, -105.3026660 + 40.0344680, -105.3026500 + 40.0344720, -105.3026240 + 40.0345040, -105.3025570 + 40.0345090, -105.3025410 + 40.0344910, -105.3024820 + 40.0344850, -105.3024620 + 40.0344840, -105.3024450 + 40.0344880, -105.3024280 + 40.0345190, -105.3023920 + 40.0345170, -105.3023730 + 40.0345250, -105.3023480 + 40.0345320, -105.3023110 + 40.0345250, -105.3022970 + 40.0345190, -105.3022840 + 40.0345310, -105.3022500 + 40.0345230, -105.3022010 + 40.0345130, -105.3021900 + 40.0344950, -105.3021690 + 40.0344790, -105.3021330 + 40.0344760, -105.3021110 + 40.0344810, -105.3020910 + 40.0344850, -105.3020640 + 40.0344780, -105.3020400 + 40.0344700, -105.3020130 + 40.0344820, -105.3019800 + 40.0344810, -105.3019510 + 40.0344850, -105.3019470 + 40.0345000, -105.3019310 + 40.0345030, -105.3019200 + 40.0345090, -105.3019130 + 40.0345150, -105.3019090 + 40.0345260, -105.3019050 + 40.0345410, -105.3019010 + 40.0345510, -105.3018980 + 40.0345680, -105.3018930 + 40.0345750, -105.3018900 + 40.0345840, -105.3018880 + 40.0346090, -105.3018610 + 40.0346070, -105.3018460 + 40.0346010, -105.3018270 + 40.0345890, -105.3018070 + 40.0345810, -105.3017890 + 40.0345700, -105.3017720 + 40.0345580, -105.3017380 + 40.0345450, -105.3017180 + 40.0345370, -105.3017100 + 40.0345060, -105.3016930 + 40.0344860, -105.3016770 + 40.0344840, -105.3016680 + 40.0344670, -105.3016340 + 40.0344540, -105.3016270 + 40.0344380, -105.3016040 + 40.0344140, -105.3015920 + 40.0343910, -105.3015830 + 40.0343700, -105.3015790 + 40.0343660, -105.3015770 + 40.0343520, -105.3015690 + 40.0343330, -105.3015560 + 40.0342880, -105.3015610 + 40.0342770, -105.3015600 + 40.0342580, -105.3015550 + 40.0342550, -105.3015470 + 40.0342570, -105.3015290 + 40.0342730, -105.3014840 + 40.0342870, -105.3014700 + 40.0342960, -105.3014630 + 40.0343140, -105.3014440 + 40.0343320, -105.3014270 + 40.0343530, -105.3014000 + 40.0343630, -105.3013870 + 40.0343830, -105.3013760 + 40.0344030, -105.3013610 + 40.0343980, -105.3013290 + 40.0343890, -105.3013150 + 40.0343630, -105.3013030 + 40.0343610, -105.3013000 + 40.0343870, -105.3012650 + 40.0343870, -105.3012590 + 40.0343830, -105.3012410 + 40.0343860, -105.3012300 + 40.0344030, -105.3012050 + 40.0344130, -105.3011920 + 40.0344330, -105.3011770 + 40.0344470, -105.3011650 + 40.0344580, -105.3011480 + 40.0344700, -105.3011390 + 40.0345070, -105.3011280 + 40.0345780, -105.3011060 + 40.0346210, -105.3011210 + 40.0346840, -105.3011290 + 40.0346690, -105.3010910 + 40.0346000, -105.3010300 + 40.0345560, -105.3010070 + 40.0345330, -105.3010040 + 40.0344580, -105.3009900 + 40.0344270, -105.3009860 + 40.0343370, -105.3009550 + 40.0343170, -105.3009870 + 40.0343040, -105.3009930 + 40.0342820, -105.3009920 + 40.0342610, -105.3009850 + 40.0342420, -105.3009910 + 40.0342340, -105.3009860 + 40.0342290, -105.3009710 + 40.0342370, -105.3009650 + 40.0342470, -105.3009590 + 40.0342900, -105.3009150 + 40.0343320, -105.3008830 + 40.0343490, -105.3008850 + 40.0343600, -105.3008820 + 40.0343850, -105.3008670 + 40.0343900, -105.3008600 + 40.0344020, -105.3008460 + 40.0344130, -105.3008310 + 40.0344260, -105.3007970 + 40.0344350, -105.3007810 + 40.0344730, -105.3007500 + 40.0345350, -105.3007350 + 40.0345500, -105.3007340 + 40.0346700, -105.3007530 + 40.0346820, -105.3007550 + 40.0347320, -105.3007820 + 40.0347880, -105.3007640 + 40.0347940, -105.3007510 + 40.0347960, -105.3007040 + 40.0347840, -105.3006740 + 40.0347580, -105.3006500 + 40.0347350, -105.3006270 + 40.0347280, -105.3006170 + 40.0346850, -105.3005680 + 40.0346610, -105.3005550 + 40.0346380, -105.3005320 + 40.0346120, -105.3005080 + 40.0345950, -105.3005040 + 40.0345810, -105.3005020 + 40.0345710, -105.3004940 + 40.0345490, -105.3004710 + 40.0345340, -105.3004620 + 40.0344880, -105.3004230 + 40.0344600, -105.3004030 + 40.0344340, -105.3003780 + 40.0344000, -105.3003520 + 40.0343720, -105.3003320 + 40.0343600, -105.3003160 + 40.0343380, -105.3002880 + 40.0343060, -105.3002800 + 40.0342900, -105.3002780 + 40.0342660, -105.3002620 + 40.0342630, -105.3002470 + 40.0342630, -105.3002320 + 40.0342720, -105.3002200 + 40.0342850, -105.3002040 + 40.0342990, -105.3001690 + 40.0343230, -105.3000990 + 40.0343360, -105.3000850 + 40.0343510, -105.3000810 + 40.0343970, -105.3000740 + 40.0344270, -105.3000630 + 40.0344570, -105.3000650 + 40.0345060, -105.3000550 + 40.0345410, -105.3000650 + 40.0345760, -105.3000650 + 40.0345950, -105.3000700 + 40.0346820, -105.3001080 + 40.0347480, -105.3000830 + 40.0347930, -105.3000780 + 40.0348360, -105.3000710 + 40.0348950, -105.3000540 + 40.0349310, -105.3000420 + 40.0349690, -105.3000360 + 40.0349920, -105.3000380 + 40.0350400, -105.3000500 + 40.0350770, -105.3000520 + 40.0351010, -105.3000550 + 40.0351340, -105.3000560 + 40.0351940, -105.3000680 + 40.0352310, -105.3000800 + 40.0352740, -105.3000780 + 40.0353610, -105.3000980 + 40.0354130, -105.3001010 + 40.0354740, -105.3000870 + 40.0355250, -105.3000620 + 40.0355750, -105.3000320 + 40.0355870, -105.3000220 + 40.0356210, -105.3000110 + 40.0356760, -105.2999770 + 40.0356910, -105.2999640 + 40.0357260, -105.2999400 + 40.0357330, -105.2999240 + 40.0357430, -105.2999040 + 40.0357670, -105.2998310 + 40.0357590, -105.2997830 + 40.0357530, -105.2997600 + 40.0357500, -105.2996880 + 40.0357600, -105.2996070 + 40.0357700, -105.2995770 + 40.0357840, -105.2995280 + 40.0358190, -105.2994390 + 40.0358500, -105.2993650 + 40.0358600, -105.2993400 + 40.0358630, -105.2992790 + 40.0358560, -105.2991780 + 40.0358540, -105.2990970 + 40.0358660, -105.2990400 + 40.0358750, -105.2990080 + 40.0358950, -105.2989530 + 40.0359020, -105.2989260 + 40.0359100, -105.2989010 + 40.0359160, -105.2988490 + 40.0359260, -105.2987620 + 40.0359300, -105.2987450 + 40.0359330, -105.2986890 + 40.0359340, -105.2985940 + 40.0359240, -105.2985090 + 40.0359120, -105.2984090 + 40.0358960, -105.2983000 + 40.0358790, -105.2982370 + 40.0358460, -105.2981180 + 40.0357880, -105.2980190 + 40.0357310, -105.2979480 + 40.0356840, -105.2979010 + 40.0356590, -105.2978730 + 40.0355460, -105.2977650 + 40.0354820, -105.2976970 + 40.0354390, -105.2976530 + 40.0354160, -105.2976320 + 40.0353140, -105.2975640 + 40.0352370, -105.2975200 + 40.0351720, -105.2974730 + 40.0351050, -105.2973880 + 40.0350680, -105.2973370 + 40.0350420, -105.2972930 + 40.0350050, -105.2972350 + 40.0349820, -105.2971930 + 40.0349470, -105.2971640 + 40.0348160, -105.2970830 + 40.0348030, -105.2970650 + 40.0347400, -105.2969750 + 40.0346940, -105.2969270 + 40.0346660, -105.2969030 + 40.0345570, -105.2968410 + 40.0345360, -105.2968370 + 40.0344320, -105.2968230 + 40.0343920, -105.2968140 + 40.0342360, -105.2967800 + 40.0340430, -105.2967070 + 40.0339020, -105.2966860 + 40.0338840, -105.2966810 + 40.0337840, -105.2966790 + 40.0337440, -105.2966780 + 40.0336550, -105.2966540 + 40.0335000, -105.2966370 + 40.0333980, -105.2966340 + 40.0333530, -105.2966190 + 40.0333150, -105.2965980 + 40.0332740, -105.2965830 + 40.0332290, -105.2965810 + 40.0331610, -105.2965700 + 40.0331200, -105.2965610 + 40.0330010, -105.2965110 + 40.0329780, -105.2965080 + 40.0328490, -105.2964770 + 40.0327910, -105.2964570 + 40.0326720, -105.2964400 + 40.0326540, -105.2964350 + 40.0325260, -105.2964060 + 40.0324480, -105.2964020 + 40.0323720, -105.2964050 + 40.0323210, -105.2963920 + 40.0321960, -105.2963850 + 40.0321540, -105.2963650 + 40.0321130, -105.2963570 + 40.0319710, -105.2963080 + 40.0319510, -105.2963000 + 40.0319310, -105.2962960 + 40.0318730, -105.2963040 + 40.0318340, -105.2962960 + 40.0317320, -105.2962710 + 40.0316930, -105.2962620 + 40.0316750, -105.2962650 + 40.0316320, -105.2962640 + 40.0316150, -105.2962650 + 40.0315120, -105.2962540 + 40.0314360, -105.2962390 + 40.0313820, -105.2962220 + 40.0313210, -105.2961890 + 40.0312850, -105.2961910 + 40.0312660, -105.2961880 + 40.0311810, -105.2961060 + 40.0310430, -105.2960390 + 40.0309710, -105.2960530 + 40.0309220, -105.2960430 + 40.0308970, -105.2960370 + 40.0307340, -105.2960110 + 40.0306940, -105.2960180 + 40.0306110, -105.2960530 + 40.0305640, -105.2960810 + 40.0305250, -105.2961100 + 40.0304390, -105.2961230 + 40.0304000, -105.2961330 + 40.0303370, -105.2961480 + 40.0302260, -105.2961670 + 40.0301300, -105.2961680 + 40.0300600, -105.2961190 + 40.0299570, -105.2961260 + 40.0299400, -105.2961420 + 40.0299100, -105.2961670 + 40.0298900, -105.2961710 + 40.0298260, -105.2961540 + 40.0298050, -105.2961490 + 40.0296840, -105.2960950 + 40.0296050, -105.2960690 + 40.0295240, -105.2960900 + 40.0294530, -105.2960970 + 40.0294110, -105.2960900 + 40.0292820, -105.2960810 + 40.0291940, -105.2960940 + 40.0291720, -105.2961040 + 40.0290570, -105.2961100 + 40.0290200, -105.2961020 + 40.0289470, -105.2960900 + 40.0289260, -105.2960960 + 40.0288870, -105.2961110 + 40.0288270, -105.2961050 + 40.0287980, -105.2961020 + 40.0286990, -105.2960880 + 40.0285460, -105.2960930 + 40.0285070, -105.2961010 + 40.0284440, -105.2961240 + 40.0283810, -105.2961380 + 40.0283670, -105.2961440 + 40.0282890, -105.2961640 + 40.0282350, -105.2961460 + 40.0282140, -105.2961350 + 40.0282020, -105.2961350 + 40.0281210, -105.2961120 + 40.0281070, -105.2960970 + 40.0280130, -105.2960340 + 40.0279960, -105.2960300 + 40.0279620, -105.2960340 + 40.0279240, -105.2960350 + 40.0278970, -105.2960400 + 40.0278940, -105.2960400 + 40.0278810, -105.2960300 + 40.0278570, -105.2960280 + 40.0278410, -105.2960260 + 40.0277940, -105.2959990 + 40.0277810, -105.2960060 + 40.0277630, -105.2960150 + 40.0277320, -105.2960430 + 40.0277230, -105.2960550 + 40.0276630, -105.2960980 + 40.0276190, -105.2960930 + 40.0275430, -105.2960670 + 40.0274950, -105.2960480 + 40.0274120, -105.2960170 + 40.0273360, -105.2959930 + 40.0272950, -105.2959930 + 40.0272710, -105.2959890 + 40.0271740, -105.2959660 + 40.0271370, -105.2959730 + 40.0271110, -105.2959670 + 40.0270980, -105.2959590 + 40.0270750, -105.2959600 + 40.0270290, -105.2959910 + 40.0270040, -105.2959950 + 40.0269630, -105.2959980 + 40.0269390, -105.2959940 + 40.0269030, -105.2959800 + 40.0268810, -105.2959760 + 40.0268120, -105.2959750 + 40.0267780, -105.2959680 + 40.0267160, -105.2959540 + 40.0266220, -105.2959860 + 40.0266040, -105.2959930 + 40.0265570, -105.2960470 + 40.0265360, -105.2960640 + 40.0265190, -105.2960710 + 40.0264970, -105.2960750 + 40.0264730, -105.2960800 + 40.0264620, -105.2960850 + 40.0264470, -105.2960850 + 40.0263900, -105.2960860 + 40.0263570, -105.2960810 + 40.0263250, -105.2960830 + 40.0263100, -105.2960820 + 40.0262520, -105.2960890 + 40.0262120, -105.2960950 + 40.0261190, -105.2961230 + 40.0260970, -105.2961220 + 40.0260760, -105.2961250 + 40.0259890, -105.2960960 + 40.0259780, -105.2960840 + 40.0259340, -105.2960810 + 40.0258340, -105.2960780 + 40.0258220, -105.2960790 + 40.0257550, -105.2960960 + 40.0257090, -105.2960680 + 40.0256680, -105.2960620 + 40.0256520, -105.2960480 + 40.0256390, -105.2960310 + 40.0256190, -105.2959970 + 40.0255740, -105.2959480 + 40.0255050, -105.2958840 + 40.0254730, -105.2958780 + 40.0254440, -105.2958870 + 40.0254150, -105.2959120 + 40.0254020, -105.2959160 + 40.0253730, -105.2959250 + 40.0253300, -105.2959360 + 40.0253050, -105.2959380 + 40.0252940, -105.2959370 + 40.0252840, -105.2959410 + 40.0252580, -105.2959480 + 40.0252260, -105.2959730 + 40.0252140, -105.2959810 + 40.0252050, -105.2959870 + 40.0251790, -105.2959900 + 40.0251520, -105.2959920 + 40.0251060, -105.2960140 + 40.0250920, -105.2960220 + 40.0250700, -105.2960310 + 40.0250480, -105.2960320 + 40.0249980, -105.2960380 + 40.0249620, -105.2960420 + 40.0249450, -105.2960390 + 40.0249040, -105.2960160 + 40.0248480, -105.2959830 + 40.0248280, -105.2959690 + 40.0247580, -105.2959590 + 40.0247170, -105.2959450 + 40.0246170, -105.2958810 + 40.0245990, -105.2958730 + 40.0245500, -105.2958450 + 40.0244740, -105.2958090 + 40.0244400, -105.2957950 + 40.0244000, -105.2957820 + 40.0242900, -105.2956960 + 40.0242720, -105.2956800 + 40.0242330, -105.2956590 + 40.0242180, -105.2956540 + 40.0241400, -105.2956220 + 40.0239730, -105.2956060 + 40.0239590, -105.2956030 + 40.0238420, -105.2955900 + 40.0238250, -105.2955850 + 40.0237110, -105.2955380 + 40.0237020, -105.2955280 + 40.0236250, -105.2954360 + 40.0235470, -105.2953140 + 40.0235330, -105.2952880 + 40.0234340, -105.2951490 + 40.0233580, -105.2950810 + 40.0233420, -105.2950790 + 40.0233310, -105.2950920 + 40.0233270, -105.2951070 + 40.0233260, -105.2951350 + 40.0233320, -105.2952590 + 40.0233720, -105.2953700 + 40.0234080, -105.2954670 + 40.0234230, -105.2955140 + 40.0234720, -105.2956300 + 40.0234900, -105.2956400 + 40.0235720, -105.2957480 + 40.0235880, -105.2957660 + 40.0236920, -105.2958760 + 40.0237080, -105.2958840 + 40.0237520, -105.2959150 + 40.0237610, -105.2959330 + 40.0237670, -105.2959570 + 40.0237670, -105.2960260 + 40.0237630, -105.2960570 + 40.0237230, -105.2961180 + 40.0236820, -105.2961250 + 40.0236580, -105.2961280 + 40.0236500, -105.2961400 + 40.0236280, -105.2961570 + 40.0236090, -105.2961600 + 40.0236060, -105.2961730 + 40.0236020, -105.2961790 + 40.0235470, -105.2961500 + 40.0235300, -105.2961440 + 40.0234280, -105.2961060 + 40.0233660, -105.2960700 + 40.0233360, -105.2960520 + 40.0232150, -105.2960090 + 40.0231960, -105.2960020 + 40.0230930, -105.2960610 + 40.0230680, -105.2960680 + 40.0229940, -105.2960820 + 40.0228820, -105.2960840 + 40.0227630, -105.2960860 + 40.0227290, -105.2960950 + 40.0227120, -105.2960990 + 40.0226960, -105.2961090 + 40.0226670, -105.2961250 + 40.0226510, -105.2961300 + 40.0226110, -105.2961420 + 40.0225940, -105.2961510 + 40.0225340, -105.2961560 + 40.0224130, -105.2962030 + 40.0223910, -105.2962050 + 40.0222950, -105.2962240 + 40.0222760, -105.2962310 + 40.0221810, -105.2962610 + 40.0220920, -105.2963130 + 40.0220660, -105.2963330 + 40.0220310, -105.2963790 + 40.0219240, -105.2964790 + 40.0218820, -105.2965520 + 40.0218770, -105.2965630 + 40.0218100, -105.2966340 + 40.0217380, -105.2966670 + 40.0217160, -105.2966900 + 40.0216770, -105.2967490 + 40.0216600, -105.2967830 + 40.0216550, -105.2968000 + 40.0216510, -105.2968200 + 40.0216400, -105.2968400 + 40.0215920, -105.2969220 + 40.0215720, -105.2969480 + 40.0215440, -105.2970120 + 40.0215380, -105.2970470 + 40.0215380, -105.2971040 + 40.0215290, -105.2971160 + 40.0215270, -105.2971190 + 40.0215320, -105.2971200 + 40.0215410, -105.2971100 + 40.0215440, -105.2971000 + 40.0215670, -105.2970410 + 40.0215740, -105.2970240 + 40.0216070, -105.2969910 + 40.0216350, -105.2969600 + 40.0216400, -105.2969440 + 40.0216540, -105.2969090 + 40.0216780, -105.2968650 + 40.0217040, -105.2968140 + 40.0217040, -105.2968000 + 40.0217050, -105.2967790 + 40.0217000, -105.2967360 + 40.0216850, -105.2967180 + 40.0216750, -105.2967110 + 40.0216530, -105.2966920 + 40.0216310, -105.2966500 + 40.0216320, -105.2966410 + 40.0216270, -105.2966050 + 40.0216100, -105.2965880 + 40.0216050, -105.2965610 + 40.0216020, -105.2965430 + 40.0215680, -105.2964980 + 40.0215550, -105.2964940 + 40.0215290, -105.2964860 + 40.0215080, -105.2964620 + 40.0215010, -105.2964440 + 40.0214880, -105.2964180 + 40.0214820, -105.2964100 + 40.0214350, -105.2963820 + 40.0213820, -105.2963700 + 40.0213570, -105.2963720 + 40.0213080, -105.2964050 + 40.0212920, -105.2964100 + 40.0212760, -105.2964020 + 40.0212630, -105.2963850 + 40.0212380, -105.2963640 + 40.0211680, -105.2963830 + 40.0211520, -105.2963820 + 40.0211380, -105.2963790 + 40.0211010, -105.2963510 + 40.0210890, -105.2963300 + 40.0210830, -105.2963200 + 40.0210730, -105.2963030 + 40.0210640, -105.2962860 + 40.0210550, -105.2962770 + 40.0210380, -105.2962560 + 40.0210170, -105.2962200 + 40.0209940, -105.2961930 + 40.0209820, -105.2961920 + 40.0209640, -105.2961920 + 40.0209500, -105.2962040 + 40.0208990, -105.2962220 + 40.0208880, -105.2962210 + 40.0208580, -105.2962220 + 40.0208460, -105.2962270 + 40.0207540, -105.2962410 + 40.0207370, -105.2962310 + 40.0207090, -105.2962250 + 40.0207080, -105.2962210 + 40.0206950, -105.2962290 + 40.0206880, -105.2962310 + 40.0206600, -105.2962330 + 40.0206430, -105.2962690 + 40.0206410, -105.2963240 + 40.0206190, -105.2964510 + 40.0206050, -105.2965010 + 40.0205950, -105.2965470 + 40.0205660, -105.2966680 + 40.0205570, -105.2966910 + 40.0205160, -105.2967500 + 40.0204830, -105.2967840 + 40.0204450, -105.2968090 + 40.0204260, -105.2968160 + 40.0203550, -105.2968520 + 40.0203440, -105.2968680 + 40.0203000, -105.2969500 + 40.0202780, -105.2970310 + 40.0202670, -105.2970960 + 40.0202650, -105.2971150 + 40.0202410, -105.2972730 + 40.0202330, -105.2972980 + 40.0201860, -105.2974740 + 40.0202110, -105.2976040 + 40.0202120, -105.2976260 + 40.0201700, -105.2977960 + 40.0201510, -105.2978440 +""".trimIndent() diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt new file mode 100644 index 00000000..cdbdc2c6 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt @@ -0,0 +1,166 @@ +/* + * 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.composedemos.polylines + +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.PolylineConfig +import kotlinx.coroutines.launch + +class PolylinesActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PolylinesScreen() + } + } + } + } +} + +@Composable +fun PolylinesScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isMapSteady by remember { mutableStateOf(false) } + + // Define the camera position centered around Mount Sanitas trailhead + val boulderCamera = remember { + camera { + center = latLngAltitude { + latitude = 40.029349 + longitude = -105.300354 + altitude = 1833.9 + } + heading = 326.0 + tilt = 75.0 + roll = 0.0 + range = 3757.0 + } + } + + // Parse the full trail data from sanitasLoop (defined in Data.kt) + val trailPoints = remember { + sanitasLoop.lines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { + val parts = it.split(",") + latLngAltitude { + latitude = parts[0].trim().toDouble() + longitude = parts[1].trim().toDouble() + altitude = 0.5 // Slight elevation to avoid clipping + } + } + } + + // Create a polyline config (State-driven approach) + val polylineConfig = remember { + PolylineConfig( + key = "sanitas_loop", + points = trailPoints, + color = Color.RED, + width = 10f, + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + zIndex = 10, + drawsOccludedSegments = true, + onClick = { polyline -> + Log.d("PolylinesActivity", "Polyline clicked: $polyline") + scope.launch { + Toast.makeText(context, "Hiking time!", Toast.LENGTH_SHORT).show() + } + }, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = boulderCamera, + mapMode = Map3DMode.HYBRID, + polylines = listOf(polylineConfig), + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Polylines", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt new file mode 100644 index 00000000..09290daa --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt @@ -0,0 +1,171 @@ +/* + * 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.composedemos.popovers + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.maps.android.compose3d.PopoverConfig + +class PopoversActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PopoversScreen() + } + } + } + } +} + +@Composable +fun PopoversScreen() { + var popovers by remember { mutableStateOf(emptyList()) } + var isMapSteady by remember { mutableStateOf(false) } + + // Camera centered on Devils Tower + val devilsTowerCamera = remember { + camera { + center = latLngAltitude { + latitude = 44.589994 + longitude = -104.715326 + altitude = 1508.9 + } + heading = 1.0 + tilt = 75.0 + range = 1635.0 + roll = 0.0 + } + } + + // Sample marker that will trigger the popover + val marker = remember { + MarkerConfig( + key = "popover_marker", + position = latLngAltitude { + latitude = 44.59054845363309 + longitude = -104.715177415273 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + label = "Click me for Popover", + isExtruded = true, + isDrawnWhenOccluded = true, + onClick = { + popovers = listOf( + PopoverConfig( + key = "popover_1", + positionAnchorKey = "popover_marker", + autoPanEnabled = false, + autoCloseEnabled = false, + content = { + Surface( + color = Color.White, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding(8.dp), + ) { + Text( + text = "This is a Popover anchored to a marker!", + modifier = Modifier.padding(16.dp), + color = Color.Black, + ) + } + }, + ), + ) + }, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + // 1. Map fills the entire screen + GoogleMap3D( + camera = devilsTowerCamera, + markers = listOf(marker), + popovers = popovers, + mapMode = Map3DMode.HYBRID, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + onMapClick = { + popovers = emptyList() + }, + ) + + // 2. Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Popovers", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt new file mode 100644 index 00000000..9275f79e --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt @@ -0,0 +1,57 @@ +/* + * 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.composedemos.roadmapmode + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class RoadmapModeActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "TODO: Roadmap Mode sample will be implemented here.", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteEngine.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteEngine.kt new file mode 100644 index 00000000..e80ec6f3 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteEngine.kt @@ -0,0 +1,71 @@ +/* + * 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.composedemos.routes + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose3d.utils.GeoMathUtils +import com.google.maps.android.compose3d.utils.calculateHeading +import com.google.maps.android.compose3d.utils.haversineDistance +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +data class PositionAndHeading( + val position: LatLng, + val heading: Float +) + +object RouteEngine { + fun calculatePositionAndHeading( + route: List, + cumulativeDistances: DoubleArray, + distance: Double, + lookaheadDistance: Double + ): PositionAndHeading { + val targetPos = GeoMathUtils.getInterpolatedPoint(distance, route, cumulativeDistances) + + // Calculate lookahead position for heading + val lookaheadPos = GeoMathUtils.getInterpolatedPoint(distance + lookaheadDistance, route, cumulativeDistances) + + val heading = if (targetPos == lookaheadPos && distance > 0.0) { + // If at the end, look back 1 meter to determine heading + val prevPos = GeoMathUtils.getInterpolatedPoint(distance - 1.0, route, cumulativeDistances) + calculateHeading(prevPos, targetPos).toFloat() + } else { + calculateHeading(targetPos, lookaheadPos).toFloat() + } + + return PositionAndHeading(targetPos, heading) + } + + fun getRouteTrackingFlow( + routeFlow: Flow>, + progressFlow: Flow, + lookaheadDistance: Double = 1000.0 + ): Flow = combine(routeFlow, progressFlow) { route, progress -> + if (route.size < 2) return@combine PositionAndHeading(LatLng(0.0, 0.0), 0f) + + val cumulativeDistances = DoubleArray(route.size) + cumulativeDistances[0] = 0.0 + for (i in 1 until route.size) { + cumulativeDistances[i] = cumulativeDistances[i - 1] + haversineDistance(route[i - 1], route[i]) + } + val totalDistance = cumulativeDistances.last() + val distance = totalDistance * progress.toDouble() + + calculatePositionAndHeading(route, cumulativeDistances, distance, lookaheadDistance) + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteRepository.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteRepository.kt new file mode 100644 index 00000000..54f77de5 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteRepository.kt @@ -0,0 +1,139 @@ +/* + * 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.composedemos.routes + +import com.google.android.gms.maps.model.LatLng +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL + +data class RouteData( + val encodedPolyline: String, + val navPoints: List, +) + +class RouteRepository { + + suspend fun fetchRoute( + apiKey: String, + origin: LatLng, + dest: LatLng, + ): RouteData = withContext(Dispatchers.IO) { + val url = URL("https://routes.googleapis.com/directions/v2:computeRoutes") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("X-Goog-Api-Key", apiKey) + connection.setRequestProperty("X-Goog-FieldMask", "routes.polyline.encodedPolyline,routes.legs.steps.startLocation") + connection.doOutput = true + + val requestBody = JSONObject().apply { + put( + "origin", + JSONObject().put( + "location", + JSONObject().put( + "latLng", + JSONObject().apply { + put("latitude", origin.latitude) + put("longitude", origin.longitude) + }, + ), + ), + ) + put( + "destination", + JSONObject().put( + "location", + JSONObject().put( + "latLng", + JSONObject().apply { + put("latitude", dest.latitude) + put("longitude", dest.longitude) + }, + ), + ), + ) + put("travelMode", "DRIVE") + } + + OutputStreamWriter(connection.outputStream).use { + it.write(requestBody.toString()) + it.flush() + } + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + val response = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + response.append(line) + } + reader.close() + + val jsonResponse = JSONObject(response.toString()) + val routes = jsonResponse.getJSONArray("routes") + if (routes.length() > 0) { + val route = routes.getJSONObject(0) + val polyline = route.getJSONObject("polyline") + val encodedPolyline = polyline.getString("encodedPolyline") + + val navPoints = mutableListOf() + val legs = route.optJSONArray("legs") + if (legs != null && legs.length() > 0) { + val leg = legs.getJSONObject(0) + val steps = leg.optJSONArray("steps") + if (steps != null) { + for (i in 0 until steps.length()) { + val step = steps.getJSONObject(i) + val startLocation = step.optJSONObject("startLocation") + if (startLocation != null) { + val latLng = startLocation.getJSONObject("latLng") + navPoints.add( + LatLng( + latLng.getDouble("latitude"), + latLng.getDouble("longitude"), + ), + ) + } + } + } + } + navPoints.add(dest) // Add destination as last point + + RouteData(encodedPolyline, navPoints) + } else { + throw Exception("No route returned from the Maps API.") + } + } else { + val reader = BufferedReader(InputStreamReader(connection.errorStream)) + val response = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + response.append(line) + } + reader.close() + throw Exception("API Error (HTTP $responseCode): $response") + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteViewModel.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteViewModel.kt new file mode 100644 index 00000000..4b99131b --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteViewModel.kt @@ -0,0 +1,70 @@ +/* + * 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.composedemos.routes + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.PolyUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +sealed interface RouteUiState { + object Idle : RouteUiState + object Loading : RouteUiState + data class Success( + val decodedPolyline: List, + val navigationPoints: List, + ) : RouteUiState + data class Error(val message: String) : RouteUiState +} + +class RouteViewModel( + private val routeRepository: RouteRepository = RouteRepository(), +) : ViewModel() { + + private val _uiState = MutableStateFlow(RouteUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + fun fetchRoute(apiKey: String, origin: LatLng, dest: LatLng) { + if (apiKey.isEmpty() || apiKey.contains("YOUR_API_KEY")) { + _uiState.value = RouteUiState.Error("Invalid API Key. Please provide a real key.") + return + } + + _uiState.value = RouteUiState.Loading + + viewModelScope.launch { + try { + val routeData = routeRepository.fetchRoute(apiKey, origin, dest) + + // Decode polyline on Default dispatcher (CPU heavy) + val decoded = withContext(Dispatchers.Default) { + PolyUtil.decode(routeData.encodedPolyline) + } + + _uiState.value = RouteUiState.Success(decoded, routeData.navPoints) + } catch (e: Exception) { + _uiState.value = RouteUiState.Error(e.message ?: "Unknown Error") + } + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RoutesActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RoutesActivity.kt new file mode 100644 index 00000000..7b5070d3 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RoutesActivity.kt @@ -0,0 +1,764 @@ +/* + * 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.composedemos.routes + +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.composedemos.BuildConfig +import com.example.composedemos.R +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.ImageView +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.maps.android.compose3d.ModelConfig +import com.google.maps.android.compose3d.ModelScale +import com.google.maps.android.compose3d.PolylineConfig +import com.google.maps.android.compose3d.PopoverConfig +import com.google.maps.android.compose3d.utils.haversineDistance +import com.google.maps.android.compose3d.utils.toHeading +import com.google.maps.android.compose3d.utils.toValidCamera +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlin.math.abs + +sealed interface RouteTracker { + val name: String + + data object Marker : RouteTracker { + override val name = "Marker" + } + + sealed class Model( + override val name: String, + val url: String, + val scale: Double, + val tilt: Double, + val hoverAltitude: Double, + val headingOffset: Double + ) : RouteTracker + + data object RedCar : Model( + name = "Red Car", + url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb", + scale = 50.0, + tilt = -90.0, + hoverAltitude = 25.0, + headingOffset = 0.0 + ) + + data object BananaCar : Model( + name = "Banana Car", + url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/banana_car.glb", + scale = 0.12, + tilt = -90.0, + hoverAltitude = 25.0, + headingOffset = 180.0 + ) +} + +class RoutesActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Hide system tray (immersive mode) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + val viewModel: RouteViewModel = viewModel() + RouteSampleScreen(viewModel) + } + } + } + } +} + +@OptIn(kotlinx.coroutines.FlowPreview::class) +@Composable +fun RouteSampleScreen(viewModel: RouteViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val sharedPrefs = remember { context.getSharedPreferences("route_prefs", Context.MODE_PRIVATE) } + val warningCount = remember { sharedPrefs.getInt("warning_count", 0) } + + // Display Warnings + var displayWarning by remember { mutableStateOf(warningCount < 2) } + + // User calibrated camera position + val initialCamera = remember { + camera { + center = latLngAltitude { + latitude = 21.348567086690373 + longitude = -157.80396135489252 + altitude = 0.0 + } + heading = 38.685134043475976 + tilt = 44.96296481747822 + range = 29158.68678946048 + roll = 0.0 + } + } + + // State-driven camera + var cameraState by remember { mutableStateOf(initialCamera) } + var polylines by remember { mutableStateOf(emptyList()) } + + // Dynamic tracking state + var cameraRange by remember { mutableFloatStateOf(1500f) } + var baseSpeedMps by remember { mutableFloatStateOf(150f) } + var currentTracker by remember { mutableStateOf(RouteTracker.RedCar) } + + // Playback controls state + var elapsedDistance by remember { mutableFloatStateOf(0f) } + var totalDistance by remember { mutableFloatStateOf(0f) } + var isPlaying by remember { mutableStateOf(false) } + var flyModeActive by remember { mutableStateOf(false) } + var cameraHeadingOffset by remember { mutableFloatStateOf(0f) } + + val cameraFlow = remember { MutableSharedFlow(replay = 0, extraBufferCapacity = 1) } + + LaunchedEffect(cameraFlow) { + cameraFlow + .debounce(1000) + .collect { camera -> + Log.d("CameraPosition", "Center: ${camera.center.latitude}, ${camera.center.longitude}, Heading: ${camera.heading}, Tilt: ${camera.tilt}, Range: ${camera.range}") + } + } + + // 1. State flows for inputs to the engine + val routeFlow = remember { MutableStateFlow(emptyList()) } + val progressFlow = remember { MutableStateFlow(0f) } + + // Automatically fetch route on start and update routeFlow + LaunchedEffect(Unit) { + val origin = LatLng(21.307043, -157.858984) + val dest = LatLng(21.390177, -157.719454) + viewModel.fetchRoute(BuildConfig.MAPS3D_API_KEY, origin, dest) + } + + LaunchedEffect(uiState) { + if (uiState is RouteUiState.Success) { + val state = uiState as RouteUiState.Success + routeFlow.value = state.decodedPolyline + + // Calculate total distance for progress calculation + val rawPath = state.decodedPolyline + if (rawPath.size >= 2) { + val cumulativeDistances = DoubleArray(rawPath.size) + cumulativeDistances[0] = 0.0 + for (i in 1 until rawPath.size) { + cumulativeDistances[i] = cumulativeDistances[i - 1] + haversineDistance(rawPath[i - 1], rawPath[i]) + } + totalDistance = cumulativeDistances.last().toFloat() + } + + // Draw polyline + polylines = listOf( + PolylineConfig( + key = "route_line", + points = state.decodedPolyline.map { latLngAltitude { latitude = it.latitude; longitude = it.longitude; altitude = 0.0 } }, + color = android.graphics.Color.BLUE, + width = 10f + ) + ) + } + } + + // 2. The Engine Flow + val trackingFlow = remember(routeFlow) { + RouteEngine.getRouteTrackingFlow(routeFlow, progressFlow, 1000.0) + } + + // 3. Collect the output state + val positionAndHeading by trackingFlow.collectAsState(initial = PositionAndHeading(LatLng(0.0, 0.0), 0f)) + + // 4. Update camera reactively during flight + LaunchedEffect(positionAndHeading, flyModeActive, cameraRange, cameraHeadingOffset) { + if (flyModeActive && positionAndHeading.position.latitude != 0.0) { + cameraState = camera { + center = latLngAltitude { + latitude = positionAndHeading.position.latitude + longitude = positionAndHeading.position.longitude + altitude = 0.0 + } + heading = (positionAndHeading.heading.toDouble() + cameraHeadingOffset).toHeading() + tilt = 65.0 + range = cameraRange.toDouble() + }.toValidCamera() + } + } + + // 5. Derive the models list (Combiner) + val currentModels = remember(positionAndHeading, currentTracker) { + val redCarConfig = ModelConfig( + key = "red_car", + url = RouteTracker.RedCar.url, + position = if (currentTracker == RouteTracker.RedCar && positionAndHeading.position.latitude != 0.0) { + latLngAltitude { + latitude = positionAndHeading.position.latitude + longitude = positionAndHeading.position.longitude + altitude = RouteTracker.RedCar.hoverAltitude + } + } else { + latLngAltitude { latitude = 0.0; longitude = 0.0; altitude = 0.0 } + }, + altitudeMode = if (currentTracker == RouteTracker.RedCar) AltitudeMode.RELATIVE_TO_GROUND else AltitudeMode.ABSOLUTE, + scale = if (currentTracker == RouteTracker.RedCar) ModelScale.Uniform(RouteTracker.RedCar.scale.toFloat()) else ModelScale.Uniform(0.001f), + heading = if (currentTracker == RouteTracker.RedCar) positionAndHeading.heading.toDouble() else 0.0, + tilt = if (currentTracker == RouteTracker.RedCar) RouteTracker.RedCar.tilt else 0.0, + roll = 0.0 + ) + + val bananaCarConfig = ModelConfig( + key = "banana_car", + url = RouteTracker.BananaCar.url, + position = if (currentTracker == RouteTracker.BananaCar && positionAndHeading.position.latitude != 0.0) { + latLngAltitude { + latitude = positionAndHeading.position.latitude + longitude = positionAndHeading.position.longitude + altitude = RouteTracker.BananaCar.hoverAltitude + } + } else { + latLngAltitude { latitude = 0.0; longitude = 0.0; altitude = 0.0 } + }, + altitudeMode = if (currentTracker == RouteTracker.BananaCar) AltitudeMode.RELATIVE_TO_GROUND else AltitudeMode.ABSOLUTE, + scale = if (currentTracker == RouteTracker.BananaCar) ModelScale.Uniform(RouteTracker.BananaCar.scale.toFloat()) else ModelScale.Uniform(0.001f), + heading = if (currentTracker == RouteTracker.BananaCar) positionAndHeading.heading.toDouble() else 0.0, + tilt = if (currentTracker == RouteTracker.BananaCar) RouteTracker.BananaCar.tilt else 0.0, + roll = 0.0 + ) + + listOf(redCarConfig, bananaCarConfig) + } + + // Derive markers list + val currentMarkers = remember(positionAndHeading, currentTracker) { + if (currentTracker == RouteTracker.Marker && positionAndHeading.position.latitude != 0.0) { + listOf( + MarkerConfig( + key = "route_marker", + position = latLngAltitude { + latitude = positionAndHeading.position.latitude + longitude = positionAndHeading.position.longitude + altitude = 0.0 + }, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + styleView = ImageView(R.drawable.car) + ) + ) + } else { + emptyList() + } + } + + // 6. Drive progress during animation + LaunchedEffect(isPlaying, totalDistance) { + if (!isPlaying || totalDistance <= 0f) return@LaunchedEffect + var lastFrameTime = withFrameMillis { it } + while (isPlaying) { + withFrameMillis { frameTime -> + val dtMs = frameTime - lastFrameTime + lastFrameTime = frameTime + val deltaDistance = baseSpeedMps * (dtMs / 1000.0).toFloat() + elapsedDistance += deltaDistance + + if (elapsedDistance >= totalDistance) { + elapsedDistance = totalDistance + isPlaying = false + } else if (elapsedDistance <= 0f) { + elapsedDistance = 0f + isPlaying = false + } + progressFlow.value = elapsedDistance / totalDistance + } + } + } + + // Sync progressFlow when elapsedDistance is changed manually via slider + LaunchedEffect(elapsedDistance, totalDistance) { + if (totalDistance > 0f) { + progressFlow.value = elapsedDistance / totalDistance + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // The Map Viewport Layer + GoogleMap3D( + camera = cameraState, + markers = currentMarkers, + models = currentModels, + polylines = polylines, + mapMode = Map3DMode.SATELLITE, + modifier = Modifier.fillMaxSize(), + onCameraChanged = { camera -> + cameraFlow.tryEmit(camera) + } + ) + + // Custom Translucent Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f)) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Routes API", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + IconButton(onClick = { + currentTracker = when (currentTracker) { + RouteTracker.Marker -> RouteTracker.RedCar + RouteTracker.RedCar -> RouteTracker.BananaCar + RouteTracker.BananaCar -> RouteTracker.Marker + } + }) { + Icon( + imageVector = when (currentTracker) { + RouteTracker.Marker -> Icons.Default.Place + RouteTracker.RedCar -> Icons.Default.DirectionsCar + RouteTracker.BananaCar -> Icons.Default.Star + }, + contentDescription = "Toggle Tracker Style", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // Interactive Overlay UI + if (!flyModeActive) { + StandardControlsOverlay(uiState = uiState, onFlyClicked = { + flyModeActive = true + isPlaying = true + }) + } else { + PlaybackControlsOverlay( + isPlaying = isPlaying, + onIsPlayingChange = { isPlaying = it }, + elapsedDistance = elapsedDistance, + onElapsedDistanceChange = { elapsedDistance = it }, + totalDistance = totalDistance, + onExitFlyMode = { + flyModeActive = false + isPlaying = false + cameraState = initialCamera // Reset camera + elapsedDistance = 0f + progressFlow.value = 0f + }) + } + + // Loading/Error Overlays + StateStatusOverlay(uiState = uiState) + + // Camera Manipulator Overlays + if (uiState is RouteUiState.Success) { + CameraControlsOverlay( + flyModeActive = flyModeActive, + cameraRange = cameraRange, + onCameraRangeChange = { cameraRange = it }, + baseSpeedMps = baseSpeedMps, + onBaseSpeedChange = { baseSpeedMps = it }, + cameraHeadingOffset = cameraHeadingOffset, + onCameraHeadingChange = { cameraHeadingOffset = it }) + } + + // Dialogs + if (displayWarning) { + SecurityWarningDialog( + onDismiss = { + displayWarning = false + sharedPrefs.edit { putInt("warning_count", warningCount + 1) } + }) + } + } +} + +@Composable +private fun BoxScope.StandardControlsOverlay( + uiState: RouteUiState, onFlyClicked: () -> Unit +) { + Button( + onClick = onFlyClicked, + enabled = uiState is RouteUiState.Success, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(32.dp) + ) { + Text("Fly Along") + } +} + +@Composable +private fun BoxScope.PlaybackControlsOverlay( + isPlaying: Boolean, + onIsPlayingChange: (Boolean) -> Unit, + elapsedDistance: Float, + onElapsedDistanceChange: (Float) -> Unit, + totalDistance: Float, + onExitFlyMode: () -> Unit +) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .wrapContentHeight() + .fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp + ) { + Row( + modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { onIsPlayingChange(!isPlaying) }) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = "Play/Pause" + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Slider( + value = elapsedDistance, + onValueChange = { + onElapsedDistanceChange(it) + }, + valueRange = 0f..kotlin.math.max(1f, totalDistance), + modifier = Modifier.weight(1f).semantics { contentDescription = "Progress Slider" }, + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = onExitFlyMode) { + Icon(Icons.Default.Close, contentDescription = "Exit Fly Mode") + } + } + } +} + +@Composable +private fun BoxScope.StateStatusOverlay(uiState: RouteUiState) { + when (uiState) { + is RouteUiState.Loading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + is RouteUiState.Error -> { + Box(modifier = Modifier + .align(Alignment.Center) + .padding(32.dp)) { + Text( + text = uiState.message, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + } + else -> { /* Do nothing */ } + } +} + +@Composable +private fun BoxScope.CameraControlsOverlay( + flyModeActive: Boolean, + cameraRange: Float, + onCameraRangeChange: (Float) -> Unit, + baseSpeedMps: Float, + onBaseSpeedChange: (Float) -> Unit, + cameraHeadingOffset: Float, + onCameraHeadingChange: (Float) -> Unit +) { + FadingVerticalSlider( + value = cameraRange, + onValueChange = onCameraRangeChange, + valueRange = 200f..10000f, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + ) + + FadingVerticalSlider( + value = baseSpeedMps, + onValueChange = { onBaseSpeedChange(if (abs(it) < 150f) 0f else it) }, + valueRange = -1500f..1500f, + drawCenterDeadZone = true, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + ) + + if (flyModeActive) { + FadingThumbWheel( + value = cameraHeadingOffset, + onValueChange = onCameraHeadingChange, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp) + .padding(horizontal = 16.dp) + ) + } +} + +@Composable +private fun SecurityWarningDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Filled.Warning, contentDescription = null) }, + title = { Text("Security Warning") }, + text = { Text("This sample makes a direct REST API call from a mobile client to the Google Maps Routes API. In a production application, doing this exposes your API key to malicious extraction.\n\nAlways proxy your Routes API requests through a secure backend server!") }, + confirmButton = { TextButton(onClick = onDismiss) { Text("I Understand") } }) +} + +@Composable +private fun FadingThumbWheel( + value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier +) { + val currentValue by rememberUpdatedState(value) + val currentOnValueChange by rememberUpdatedState(onValueChange) + + var sliderInteractionTime by androidx.compose.runtime.remember { mutableLongStateOf(System.currentTimeMillis()) } + var isSliderActive by androidx.compose.runtime.remember { mutableStateOf(true) } + + LaunchedEffect(sliderInteractionTime) { + isSliderActive = true + delay(3000) + isSliderActive = false + } + + val sliderAlpha by animateFloatAsState( + targetValue = if (isSliderActive) 0.9f else 0.3f, + animationSpec = tween(durationMillis = 500), + label = "sliderAlpha" + ) + + Box( + modifier = modifier + .width(250.dp) + .height(48.dp) + .alpha(sliderAlpha) + .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(24.dp)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + sliderInteractionTime = System.currentTimeMillis() + } + } + } + .pointerInput(Unit) { + var localValue = 0f + detectHorizontalDragGestures( + onDragStart = { localValue = currentValue }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + localValue += dragAmount * -0.2f + var wrapped = localValue + while (wrapped > 180f) wrapped -= 360f + while (wrapped <= -180f) wrapped += 360f + localValue = wrapped + currentOnValueChange(localValue) + }) + }) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(11) { + Box( + modifier = Modifier + .width(2.dp) + .height(12.dp) + .background(Color.White.copy(alpha = 0.4f)) + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.Center) + .width(4.dp) + .height(24.dp) + .background(Color.Red, RoundedCornerShape(2.dp)) + ) + + Text( + text = "${value.toInt()}Β°", + color = Color.White, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 2.dp) + ) + } +} + +@Composable +private fun FadingVerticalSlider( + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + drawCenterDeadZone: Boolean = false +) { + var sliderInteractionTime by androidx.compose.runtime.remember { mutableLongStateOf(System.currentTimeMillis()) } + var isSliderActive by androidx.compose.runtime.remember { mutableStateOf(true) } + + LaunchedEffect(sliderInteractionTime) { + isSliderActive = true + delay(3000) + isSliderActive = false + } + + val sliderAlpha by animateFloatAsState( + targetValue = if (isSliderActive) 0.9f else 0.3f, + animationSpec = tween(durationMillis = 500), + label = "sliderAlpha" + ) + + Box( + modifier = modifier + .requiredWidth(48.dp) + .requiredHeight(300.dp) + .alpha(sliderAlpha) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + sliderInteractionTime = System.currentTimeMillis() + } + } + }) { + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + modifier = Modifier + .requiredWidth(300.dp) + .requiredHeight(48.dp) + .graphicsLayer { + rotationZ = 270f + transformOrigin = TransformOrigin(0.5f, 0.5f) + } + .align(Alignment.Center)) + + if (drawCenterDeadZone) { + Box( + modifier = Modifier + .align(Alignment.Center) + .requiredWidth(16.dp) + .requiredHeight(4.dp) + .background(Color.White, shape = CircleShape) + ) + } + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/alien.png b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/alien.png new file mode 100644 index 00000000..2c38fc9e Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/alien.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/car.png b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/car.png new file mode 100644 index 00000000..894711cb Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/car.png differ diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/values/strings.xml b/Maps3DSamples/ComposeDemos/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..a38f229c --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Compose Demos + diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/values/themes.xml b/Maps3DSamples/ComposeDemos/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3f3b01b5 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/res/values/themes.xml @@ -0,0 +1,37 @@ + + + + + + diff --git a/Maps3DSamples/ComposeDemos/app/src/test/java/com/example/composedemos/routes/RouteEngineTest.kt b/Maps3DSamples/ComposeDemos/app/src/test/java/com/example/composedemos/routes/RouteEngineTest.kt new file mode 100644 index 00000000..0e980571 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/test/java/com/example/composedemos/routes/RouteEngineTest.kt @@ -0,0 +1,111 @@ +/* + * 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.composedemos.routes + +import com.google.android.gms.maps.model.LatLng +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RouteEngineTest { + + @Test + fun testInterpolationAtStart() { + val route = listOf( + LatLng(0.0, 0.0), + LatLng(1.0, 1.0) + ) + val cumulativeDistances = doubleArrayOf(0.0, 157000.0) // Approx distance in meters for 1 deg + + val result = RouteEngine.calculatePositionAndHeading( + route = route, + cumulativeDistances = cumulativeDistances, + distance = 0.0, + lookaheadDistance = 1000.0 + ) + + // We expect to be at the start + assertEquals(0.0, result.position.latitude, 0.001) + assertEquals(0.0, result.position.longitude, 0.001) + // And heading towards (1,1) which is approx 45 degrees + assertEquals(45.0, result.heading.toDouble(), 5.0) + } + + @Test + fun testGetRouteTrackingFlow() = runBlocking { + val routeFlow = MutableStateFlow(listOf(LatLng(0.0, 0.0), LatLng(1.0, 1.0))) + val progressFlow = MutableStateFlow(0f) + + val trackingFlow = RouteEngine.getRouteTrackingFlow(routeFlow, progressFlow, 1000.0) + + val result1 = trackingFlow.first() + assertEquals(0.0, result1.position.latitude, 0.001) + assertEquals(0.0, result1.position.longitude, 0.001) + assertEquals(45.0, result1.heading.toDouble(), 5.0) + + // Emit new progress (halfway) + progressFlow.value = 0.5f + val result2 = trackingFlow.first() + + // Halfway between (0,0) and (1,1) should be approx (0.5, 0.5) + assertEquals(0.5, result2.position.latitude, 0.1) + assertEquals(0.5, result2.position.longitude, 0.1) + } + + @Test + fun testComplexRouteOrientation() { + val route = listOf( + LatLng(0.0, 0.0), + LatLng(1.0, 0.0), // Moving North (Heading 0) + LatLng(1.0, 1.0) // Moving East (Heading 90) + ) + val cumulativeDistances = doubleArrayOf(0.0, 111000.0, 222000.0) // Approx 111km per degree + + // Test on first segment (moving North) + val result1 = RouteEngine.calculatePositionAndHeading( + route = route, + cumulativeDistances = cumulativeDistances, + distance = 50000.0, + lookaheadDistance = 1000.0 + ) + assertEquals(0.0, result1.heading.toDouble(), 5.0) + + // Test on second segment (moving East) + val result2 = RouteEngine.calculatePositionAndHeading( + route = route, + cumulativeDistances = cumulativeDistances, + distance = 160000.0, + lookaheadDistance = 1000.0 + ) + assertEquals(90.0, result2.heading.toDouble(), 5.0) + + // Test at the end of the route + val result3 = RouteEngine.calculatePositionAndHeading( + route = route, + cumulativeDistances = cumulativeDistances, + distance = 222000.0, + lookaheadDistance = 1000.0 + ) + // We expect it to keep the heading of the last segment (90 degrees) + assertEquals(90.0, result3.heading.toDouble(), 5.0) + } +} diff --git a/Maps3DSamples/ComposeDemos/automation_script.py b/Maps3DSamples/ComposeDemos/automation_script.py new file mode 100644 index 00000000..8f21b6a5 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/automation_script.py @@ -0,0 +1,181 @@ +import subprocess +import os +import sys +import re + +def run_command(cmd, cwd=None): + print(f"Running: {cmd}") + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd) + if result.returncode != 0: + print(f"Command failed with exit code {result.returncode}") + print(result.stderr) + return False, result.stdout + return True, result.stdout + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 automation_script.py ") + sys.exit(1) + + test_class = sys.argv[1] + + # Workspace root + result = subprocess.run("git rev-parse --show-toplevel", shell=True, capture_output=True, text=True) + if result.returncode != 0: + print("Failed to find git workspace root.") + sys.exit(1) + workspace_root = result.stdout.strip() + + # 1. Install and Run Test + # We use am instrument to keep the app installed so we can pull files with run-as. + print("Installing app...") + success, _ = run_command("./gradlew :Maps3DSamples:ComposeDemos:app:installDebug", cwd=workspace_root) + if not success: sys.exit(1) + + print("Installing test app...") + success, _ = run_command("./gradlew :Maps3DSamples:ComposeDemos:app:installDebugAndroidTest", cwd=workspace_root) + if not success: sys.exit(1) + + print("Running test...") + cmd = f"adb shell am instrument -w -e class com.example.composedemos.{test_class} com.example.composedemos.test/androidx.test.runner.AndroidJUnitRunner" + success, output = run_command(cmd, cwd=workspace_root) + print("Test Output:") + print(output) + if not success: + sys.exit(1) + + print("Test passed. Pulling screenshot...") + + # 2. Pull Screenshot + filename_mapping = { + "HelloMapVisualTest": "hello_map_screenshot.png", + "PolylinesVisualTest": "polylines_screenshot.png", + "MapInteractionsVisualTest": "map_interactions_screenshot.png", + "PopoversVisualTest": "popovers_screenshot.png", + "CameraControlsVisualTest": "camera_controls_screenshot.png", + "PlaceDetailsVisualTest": "place_details_screenshot.png", + "PolygonsVisualTest": "polygons_screenshot.png", + "ModelsVisualTest": "models_screenshot.png", + "MarkersVisualTest": "markers_screenshot.png", + "CameraRestrictionsVisualTest": "camera_restrictions_screenshot.png", + "RoutesVisualTest": "routes_screenshot.png", + } + + filename = filename_mapping.get(test_class, f"{test_class.lower()}_screenshot.png") + local_path = filename + + # Use run-as to read the file from the app's data directory and pipe it to a local file + cat_cmd = f"adb shell run-as com.example.composedemos cat files/{filename}" + print(f"Running: {cat_cmd}") + result = subprocess.run(cat_cmd, shell=True, capture_output=True, text=False) + if result.returncode != 0: + print(f"Failed to read screenshot via run-as. Error: {result.stderr.decode('utf-8')}") + sys.exit(1) + + with open(local_path, "wb") as f: + f.write(result.stdout) + print(f"Pulled screenshot to {local_path}") + + # 3. Scale Image using sips (macOS built-in) + # Get dimensions + dim_cmd = f"sips -g pixelWidth -g pixelHeight {local_path}" + success, dim_output = run_command(dim_cmd) + if not success: + print("Failed to get image dimensions.") + sys.exit(1) + + # Parse width and height + try: + width = int(re.search(r"pixelWidth: (\d+)", dim_output).group(1)) + height = int(re.search(r"pixelHeight: (\d+)", dim_output).group(1)) + except AttributeError: + print(f"Failed to parse dimensions from output: {dim_output}") + sys.exit(1) + + new_width = int(width * 0.5) + new_height = int(height * 0.5) + + print(f"Scaling from {width}x{height} to {new_width}x{new_height}") + scale_cmd = f"sips -z {new_height} {new_width} {local_path}" + success, _ = run_command(scale_cmd) + if not success: + print("Failed to scale image.") + sys.exit(1) + + # 4. Move to Source + # Target directory: Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/ + target_dir = f"{workspace_root}/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots" + os.makedirs(target_dir, exist_ok=True) + + target_path = f"{target_dir}/{local_path}" + os.rename(local_path, target_path) + print(f"Screenshot saved to {target_path}") + + # 5. Update Catalog + catalog_path = f"{workspace_root}/Maps3DSamples/ComposeDemos/app/README.md" + + # Mapping from test class to Feature Name in table + mapping = { + "HelloMapVisualTest": "Basic Map", + "PolylinesVisualTest": "Polylines", + "MapInteractionsVisualTest": "Map Interactions", + "PopoversVisualTest": "Popovers", + "CameraControlsVisualTest": "Camera Controls", + "PolygonsVisualTest": "Polygons", + "ModelsVisualTest": "Models", + "MarkersVisualTest": "Markers", + "CameraRestrictionsVisualTest": "Camera Restrictions", + "RoutesVisualTest": "Routes API", + "PlaceDetailsVisualTest": "Place Details", + } + + feature_name = mapping.get(test_class) + if feature_name: + with open(catalog_path, "r") as f: + catalog_content = f.read() + + # Mapping to get activity file path + activity_mapping = { + "Basic Map": "hellomap/HelloMapActivity.kt", + "Polylines": "polylines/PolylinesActivity.kt", + "Map Interactions": "mapinteractions/MapInteractionsActivity.kt", + "Popovers": "popovers/PopoversActivity.kt", + "Camera Controls": "cameracontrols/CameraControlsActivity.kt", + "Polygons": "polygons/PolygonsActivity.kt", + "Models": "models/ModelsActivity.kt", + "Markers": "markers/MarkersActivity.kt", + "Camera Restrictions": "camerarestrictions/CameraRestrictionsActivity.kt", + "Routes API": "routes/RoutesActivity.kt", + "Place Details": "placedetails/PlaceDetailsActivity.kt", + } + activity_path = activity_mapping.get(feature_name) + + image_link = f'Screenshot' + + # Find the line for this feature and replace it + lines = catalog_content.split("\n") + updated = False + for i, line in enumerate(lines): + if f"| **{feature_name}** |" in line: + # Get the activity file basename + file_basename = activity_path.split("/")[-1] + # Split to get description if exists + parts = line.split("|") + description = parts[5].strip() if len(parts) > 5 else "" + # Construct new line preserving description + lines[i] = f"| **{feature_name}** | βœ… Done | [{file_basename}](src/main/java/com/example/composedemos/{activity_path}) | {image_link} | {description} |" + updated = True + break + + if updated: + catalog_content = "\n".join(lines) + with open(catalog_path, "w") as f: + f.write(catalog_content) + print(f"Updated Compose Catalog README.md for {feature_name}") + else: + print(f"Feature {feature_name} not found in catalog.") + else: + print(f"No mapping found for {test_class} in catalog.") + +if __name__ == "__main__": + main() diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts index 93761b73..4ca67bda 100644 --- a/Maps3DSamples/advanced/app/build.gradle.kts +++ b/Maps3DSamples/advanced/app/build.gradle.kts @@ -94,6 +94,7 @@ android { versionName = "1.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY" } buildTypes { @@ -133,6 +134,7 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.google.truth) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) @@ -161,12 +163,11 @@ dependencies { } secrets { - // Optionally specify a different file name containing your secrets. - // The plugin defaults to "local.properties" - propertiesFileName = "secrets.properties" - - // A properties file containing default secret values. This file can be - // checked in version control. + // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI + val secretsFile = rootProject.file("secrets.properties") + if (secretsFile.exists()) { + propertiesFileName = "secrets.properties" + } defaultPropertiesFileName = "local.defaults.properties" } diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/MainActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/MainActivityTest.kt new file mode 100644 index 00000000..23704212 --- /dev/null +++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/MainActivityTest.kt @@ -0,0 +1,47 @@ +/* + * 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.advancedmaps3dsamples + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * UI tests for [MainActivity]. + */ +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun testMainScreenIsDisplayed() { + // Verify that the activity launches and the main screen is displayed. + composeTestRule.onNodeWithTag("main_screen").assertIsDisplayed() + + // Also verify that the activity is not finishing. + composeTestRule.activityRule.scenario.onActivity { activity -> + assertThat(activity.isFinishing).isFalse() + } + } +} diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivityTest.kt new file mode 100644 index 00000000..a58b56c9 --- /dev/null +++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivityTest.kt @@ -0,0 +1,49 @@ +/* + * 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.advancedmaps3dsamples.basicmap + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.advancedmaps3dsamples.basicmap.BasicComposeMapActivity +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * UI tests for [BasicComposeMapActivity]. + */ +@RunWith(AndroidJUnit4::class) +class BasicComposeMapActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun testMapIsDisplayed() { + // Verify that the activity launches and the map view is displayed. + // We use the testTag we added to the AndroidView. + composeTestRule.onNodeWithTag("map3d_view").assertIsDisplayed() + + // Also verify that the activity is not finishing. + composeTestRule.activityRule.scenario.onActivity { activity -> + assertThat(activity.isFinishing).isFalse() + } + } +} diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/route/RouteSampleActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/route/RouteSampleActivityTest.kt new file mode 100644 index 00000000..7d468778 --- /dev/null +++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/route/RouteSampleActivityTest.kt @@ -0,0 +1,47 @@ +/* + * 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.advancedmaps3dsamples.route + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * UI tests for [RouteSampleActivity]. + */ +@RunWith(AndroidJUnit4::class) +class RouteSampleActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun testMapIsDisplayed() { + // Verify that the activity launches and the map view is displayed. + composeTestRule.onNodeWithTag("map3d_view").assertIsDisplayed() + + // Also verify that the activity is not finishing. + composeTestRule.activityRule.scenario.onActivity { activity -> + assertThat(activity.isFinishing).isFalse() + } + } +} diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivityTest.kt new file mode 100644 index 00000000..14bca99a --- /dev/null +++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivityTest.kt @@ -0,0 +1,48 @@ +/* + * 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.advancedmaps3dsamples.scenarios + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.advancedmaps3dsamples.scenarios.ScenariosActivity +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * UI tests for [ScenariosActivity]. + */ +@RunWith(AndroidJUnit4::class) +class ScenariosActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun testScenarioPickerIsDisplayed() { + // Verify that the activity launches and the scenario picker is displayed. + composeTestRule.onNodeWithTag("scenario_picker").assertIsDisplayed() + + // Also verify that the activity is not finishing. + composeTestRule.activityRule.scenario.onActivity { activity -> + assertThat(activity.isFinishing).isFalse() + } + } +} diff --git a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml index 37cd046e..c573539a 100644 --- a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml @@ -54,6 +54,10 @@ android:name=".route.RouteSampleActivity" android:exported="true" /> + \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt index ad6645f1..cacda9af 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt @@ -37,8 +37,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.example.advancedmaps3dsamples.basicmap.BasicComposeMapActivity import com.example.advancedmaps3dsamples.route.RouteSampleActivity import com.example.advancedmaps3dsamples.scenarios.ScenariosActivity import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme @@ -48,6 +50,7 @@ data class MapSample(@StringRes val label: Int, val clazz: Class<*>) private val samples = listOf( + MapSample(R.string.map_sample_basic, BasicComposeMapActivity::class.java), MapSample(R.string.map_sample_scenarios, ScenariosActivity::class.java), MapSample(R.string.map_sample_route, RouteSampleActivity::class.java), ) @@ -61,7 +64,7 @@ class MainActivity : ComponentActivity() { setContent { AdvancedMaps3DSamplesTheme { Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().testTag("main_screen"), topBar = { CenterAlignedTopAppBar( title = { Text(stringResource(R.string.map_sample_title)) } diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivity.kt new file mode 100644 index 00000000..3a87888c --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivity.kt @@ -0,0 +1,112 @@ +package com.example.advancedmaps3dsamples.basicmap + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.Map3DOptions +import com.google.android.gms.maps3d.Map3DView +import com.google.android.gms.maps3d.OnMap3DViewReadyCallback + +/** + * A minimal reference example demonstrating how to integrate the Google Maps 3D SDK + * into a Jetpack Compose application. + * + * This activity serves as a pedagogical example for junior developers to understand + * the core concepts of using Map3DView with Compose, including state hoisting, + * lifecycle management, and View interop. + */ +class BasicComposeMapActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AdvancedMaps3DSamplesTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val context = androidx.compose.ui.platform.LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // 1. STATE HOISTING (The Compose Bridge) + // We create a mutable state holder to keep a reference to the GoogleMap3D object. + // This allows Compose to react to changes when the map becomes ready and gives us + // a handle to interact with the map (e.g., add markers, move camera) later. + val map3DState = remember { mutableStateOf(null) } + + // 2. VIEW INITIALIZATION + // We use `remember` to ensure the Map3DView is created ONLY ONCE when this + // Composable enters the composition. Without `remember`, a new Map3DView would + // be created on every recomposition, causing severe performance issues and losing state. + val map3DView = remember { + // Map3DOptions allows us to set the initial camera position and orientation. + val options = Map3DOptions( + centerLat = 21.350, + centerLng = -157.800, + centerAlt = 0.0, + tilt = 60.0, + range = 25000.0 + ) + Map3DView(context, options).apply { + // Map3DView loads its resources asynchronously. We must provide a callback + // to be notified when the map is fully loaded and ready for interaction. + getMap3DViewAsync(object : OnMap3DViewReadyCallback { + override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { + // Store the reference in our Compose state holder to "bridge" it. + map3DState.value = googleMap3D + } + override fun onError(error: Exception) { + // In a production app, handle this gracefully (e.g., show an error UI). + throw error + } + }) + } + } + + // 3. LIFECYCLE MANAGEMENT + // MapView (and Map3DView) are heavy components that manage native resources and rendering loops. + // They MUST be notified of the Activity lifecycle events to start/stop rendering, + // clean up memory, and respond to app backgrounding correctly. + // We use DisposableEffect to attach an observer to the Compose LifecycleOwner. + 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) + + // Clean up the observer when this Composable leaves the composition. + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // 4. THE BRIDGE + // Since Map3DView is a traditional Android View, we use AndroidView to + // inflate and display it within our Jetpack Compose layout. + AndroidView( + modifier = Modifier.fillMaxSize().padding(innerPadding).testTag("map3d_view"), + factory = { map3DView } + ) + } + } + } + } +} diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt index b179d54b..e597011f 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt @@ -82,6 +82,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.testTag import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.advancedmaps3dsamples.BuildConfig @@ -333,7 +334,7 @@ private fun Map3DViewport( onMapReady: (GoogleMap3D) -> Unit, onMapCleared: () -> Unit ) { val context = LocalContext.current - AndroidView(modifier = Modifier.fillMaxSize(), factory = { + AndroidView(modifier = Modifier.fillMaxSize().testTag("map3d_view"), factory = { val options = Map3DOptions( centerLat = 21.350, centerLng = -157.800, diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt index d93bb2b9..d7f87b90 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt @@ -54,6 +54,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -179,6 +180,7 @@ class ScenariosActivity : ComponentActivity() { fun ScenarioPicker(modifier: Modifier, onScenarioClicked: (Scenario) -> Unit) { Column( modifier = modifier + .testTag("scenario_picker") .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt index 2a53a7db..224d4706 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.viewinterop.AndroidView import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.Map3DOptions @@ -36,7 +37,7 @@ internal fun ThreeDMap( } AndroidView( - modifier = modifier, + modifier = modifier.testTag("map3d_view"), factory = { context -> val map3dView = Map3DView(context = context, options = options) map3dView.onCreate(null) diff --git a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml index 2b72c48a..0a9ce978 100644 --- a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml +++ b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ 3D Map Samples Scenarios (video visuals) Routes API Integration + Basic Map (Compose)