diff --git a/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties b/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties index 6a38a8c..c61a118 100644 --- a/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties +++ b/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts index 78cc3b2..648ead0 100644 --- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts @@ -145,7 +145,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) - androidTestImplementation(libs.truth) + androidTestImplementation(libs.google.truth) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts index 57560d0..8e30503 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts @@ -150,7 +150,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) - androidTestImplementation(libs.truth) + androidTestImplementation(libs.google.truth) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts index 2045ea4..bae493d 100644 --- a/Maps3DSamples/advanced/app/build.gradle.kts +++ b/Maps3DSamples/advanced/app/build.gradle.kts @@ -180,3 +180,5 @@ tasks.register("installAndLaunch") { dependsOn("installDebug") commandLine("adb", "shell", "am", "start", "-n", "com.example.advancedmaps3dsamples/.MainActivity") } + +tasks.register("prepareKotlinBuildScriptModel"){} \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/Map3dViewModel.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/Map3dViewModel.kt index d06a5f4..2b87229 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/Map3dViewModel.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/Map3dViewModel.kt @@ -348,7 +348,7 @@ abstract class Map3dViewModel : ViewModel() { open fun setCameraTilt(tilt: Number) { updateCameraAndMove { - copy(heading = tilt.toTilt()) + copy(tilt = tilt.toTilt()) } } diff --git a/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties b/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties index 6a38a8c..b27721f 100644 --- a/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties +++ b/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/PlacesUIKit3D/build.gradle.kts b/PlacesUIKit3D/build.gradle.kts new file mode 100644 index 0000000..67405d3 --- /dev/null +++ b/PlacesUIKit3D/build.gradle.kts @@ -0,0 +1,179 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The `plugins` block is where we apply Gradle plugins to this module. +// Plugins add new tasks and configurations to our build process. +plugins { + // The core plugin for building an Android application. It provides tasks like `assembleDebug`, `installDebug`, etc. + alias(libs.plugins.android.application) + // This plugin enables Kotlin support in the Android project, allowing us to write code in Kotlin. + alias(libs.plugins.kotlin.android) + // This plugin from Google helps manage API keys and other secrets by reading them from a `secrets.properties` + // file (which should be in .gitignore) and exposing them in the `BuildConfig` file at compile time. + // This is crucial for keeping sensitive data out of version control. + alias(libs.plugins.secrets.gradle.plugin) + // This plugin provides the necessary integration for using Jetpack Compose with the Kotlin compiler. + alias(libs.plugins.kotlin.compose) + // KSP (Kotlin Symbol Processing) is used for annotation processing. Hilt uses it to generate code. + alias(libs.plugins.ksp) + // The Hilt plugin integrates Dagger Hilt for dependency injection. + alias(libs.plugins.hilt.android) + // The parcelize plugin provides a @Parcelize annotation to automatically generate Parcelable implementations. + alias(libs.plugins.jetbrains.kotlin.parcelize) +} + +// The `android` block is where we configure all the Android-specific build options. +android { + // The `namespace` is a unique identifier for the app's generated R class. It's also used + // as the default `applicationId` if not specified in `defaultConfig`. + namespace = "com.example.placesuikit3d" + // `compileSdk` specifies the Android API level the app is compiled against. + // Using a recent version allows us to use the latest Android features. + compileSdk = 36 + + defaultConfig { + // `applicationId` is the unique identifier for the app on the Google Play Store and on the device. + applicationId = "com.example.placesuikit3d" + // `minSdk` is the minimum API level required to run the app. Devices below this level cannot install it. + minSdk = 29 + // `targetSdk` indicates the API level the app was tested against. Android may enable + // compatibility behaviors on newer OS versions if the target is lower. + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + // Specifies the instrumentation runner for running Android tests. + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // The `release` block configures settings for the release build of the app. + release { + // `isMinifyEnabled` enables code shrinking with R8 to reduce the app's size. + // It's disabled here for simplicity in a sample app, but highly recommended for production. + isMinifyEnabled = false + // `proguardFiles` specifies the files that define the R8 shrinking and obfuscation rules. + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + // Sets the Java language compatibility for the source code and compiled bytecode. + // Using Java 17 is required for modern Android development. + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } + + buildFeatures { + // `viewBinding` generates a binding class for each XML layout file, providing a type-safe + // way to access views without `findViewById`. This is used in the XML-based activities. + viewBinding = true + // `compose` enables Jetpack Compose for the project. + compose = true + // `buildConfig` generates a `BuildConfig` class that contains constants from the build configuration, + // such as the API key from the secrets plugin. + buildConfig = true + } + + java { + // Specifies the Java language version for the project's toolchain. + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + composeOptions { + // Sets the version of the Kotlin compiler extension for Compose. This version must be + // compatible with the Kotlin version used in the project. + kotlinCompilerExtensionVersion = "1.5.1" + } +} + +// The `dependencies` block is where we declare all the external libraries the app needs. +// These are fetched from repositories like Maven Central and Google's Maven repository. +dependencies { + // --- Core AndroidX & UI Libraries --- + // These are foundational libraries for building modern Android apps. + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.material) // For Material Design components (used in XML layouts). + + // --- Jetpack Compose --- + // These libraries are for building UIs with Jetpack Compose. + implementation(libs.androidx.activity.compose) // Integration between Activity and Compose. + implementation(platform(libs.androidx.compose.bom)) // The Compose Bill of Materials (BOM) ensures all Compose libraries use compatible versions. + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) // For displaying @Preview composables in Android Studio. + implementation(libs.androidx.material3) // The latest Material Design components for Compose. + implementation(libs.androidx.fragment.compose) + implementation(libs.androidx.material.icons.extended) + debugImplementation(libs.androidx.ui.tooling) // Provides tools for inspecting Compose UIs. + + // --- Google Play Services --- + // These are the essential libraries for this sample, providing Maps and Places functionality. + implementation(libs.play.services.maps3d) // The core SDK for embedding 3D Google Maps. + implementation(libs.places) // The SDK for the Places UI Kit (PlaceDetails fragments). + implementation(libs.maps.utils.ktx) // Google Maps Utils for polyline decoding and other utilities. + + // --- Dependency Injection --- + // Hilt is used for managing dependencies and object lifecycles. + implementation(libs.dagger) + ksp(libs.hilt.android.compiler) + implementation(libs.hilt.android) + + // --- Miscellaneous --- + implementation(libs.kotlinx.datetime) + + // --- Testing Libraries --- + // These libraries are for writing and running tests. + // `testImplementation` is for local unit tests (running on the JVM). + testImplementation(libs.junit) + testImplementation(libs.google.truth) + testImplementation(libs.robolectric) + // `androidTestImplementation` is for instrumented tests (running on an Android device or emulator). + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) // For UI testing with the View system. + + // --- Compose Testing --- + // These are specific to testing Jetpack Compose UIs. + androidTestImplementation(platform(libs.androidx.compose.bom)) // BOM for testing libraries. + androidTestImplementation(libs.androidx.ui.test.junit4) // The main library for Compose UI tests. + debugImplementation(libs.androidx.ui.test.manifest) // Provides a manifest for UI tests. +} + +// This block configures the Secrets Gradle Plugin. +secrets { + // Specifies a default properties file. This is useful for CI/CD environments where + // you might not have a local `secrets.properties` file. + defaultPropertiesFileName = "local.defaults.properties" + // Specifies the local properties file where secret keys (like the Places API key) are stored. + // This file should be added to .gitignore to prevent it from being committed to version control. + propertiesFileName = "secrets.properties" +} + +tasks.register("installAndLaunch") { + description = "Installs the debug APK and launches the main activity." + group = "application" + dependsOn("installDebug") + commandLine("adb", "shell", "am", "start", "-n", "com.example.placesuikit3d/com.example.placesuikit3d.MainActivity") +} diff --git a/PlacesUIKit3D/proguard-rules.pro b/PlacesUIKit3D/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/PlacesUIKit3D/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/AndroidManifest.xml b/PlacesUIKit3D/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d71d451 --- /dev/null +++ b/PlacesUIKit3D/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Landmark.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Landmark.kt new file mode 100644 index 0000000..6684745 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Landmark.kt @@ -0,0 +1,30 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d + +import com.google.android.gms.maps3d.model.LatLngAltitude + +/** + * A data class representing a landmark in the demo. + * + * @property id The unique Place ID for this landmark. + * @property name The human-readable name of the landmark. + * @property location The coordinates on the 3D map where the camera should point. + */ +data class Landmark( + val id: String, + val name: String, + val location: LatLngAltitude +) diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/LandmarkList.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/LandmarkList.kt new file mode 100644 index 0000000..a8df8d9 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/LandmarkList.kt @@ -0,0 +1,99 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Place +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A composable that displays a list of landmarks. + * + * @param landmarks The list of landmarks to display. + * @param onLandmarkClick Callback invoked when a landmark is clicked. + * @param modifier The modifier to apply to the list. + */ +@Composable +fun LandmarkList( + landmarks: List, + onLandmarkClick: (Landmark) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = "Locations", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(16.dp) + ) + LazyColumn(modifier = Modifier.weight(1f)) { + items(landmarks) { landmark -> + LandmarkItem( + landmark = landmark, + onClick = { onLandmarkClick(landmark) } + ) + HorizontalDivider() + } + } + } +} + +/** + * A composable that displays a single landmark item. + */ +@Composable +private fun LandmarkItem( + landmark: Landmark, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Place, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 16.dp) + ) + Column { + Text( + text = landmark.name, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Boulder, CO", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt new file mode 100644 index 0000000..acfb908 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt @@ -0,0 +1,432 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.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.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.app.ActivityCompat +import androidx.core.view.WindowCompat +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.example.placesuikit3d.ui.theme.PlacesUIKit3DTheme +import com.example.placesuikit3d.utils.feet +import com.example.placesuikit3d.utils.toValidCamera +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.OnMap3DViewReadyCallback +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyToOptions +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment +import com.google.android.libraries.places.widget.PlaceLoadListener +import com.google.android.libraries.places.widget.model.Orientation +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +/** + * The main activity for the 3D map demo. + * + * This activity demonstrates how to integrate the Places UI Kit with a 3D map view using Jetpack Compose. + * It handles map initialization, landmark selection, and displaying place details. + */ +@AndroidEntryPoint +class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { + private val TAG = this::class.java.simpleName + private var googleMap3D: GoogleMap3D? = null + + private lateinit var fusedLocationClient: FusedLocationProviderClient + private lateinit var requestPermissionLauncher: ActivityResultLauncher> + private val viewModel: MainViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { + fetchLastLocation() + } else { + Toast.makeText(this, getString(R.string.location_permission_denied), Toast.LENGTH_SHORT).show() + moveToDefaultLocation() + } + } + + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + PlacesUIKit3DTheme { + MainScreen() + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun MainScreen() { + val landmarks = viewModel.landmarks + val selectedPlaceId by viewModel.placeId.collectAsState() + val scope = rememberCoroutineScope() + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded + ) + ) + val sheetPeekHeight = 120.dp + + // 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) + } + } + + // Use contentAlignment to center the constrained BottomSheet on wide screens + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + BottomSheetScaffold( + scaffoldState = scaffoldState, + sheetPeekHeight = sheetPeekHeight, + sheetContent = { + LandmarkList( + landmarks = landmarks, + onLandmarkClick = { landmark -> + viewModel.selectLandmark(landmark) + flyToLandmark(landmark) + scope.launch { + scaffoldState.bottomSheetState.partialExpand() + } + }, + // Limit list width so the sheet doesn't stretch across a tablet + modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp) + ) + } + ) { _ -> + // Map occupies full screen, ignoring scaffold padding + Box(modifier = Modifier.fillMaxSize()) { + MapViewContainer() + + FloatingActionButton( + onClick = { fetchLastLocation() }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 48.dp, end = 16.dp) + ) { + Icon(Icons.Default.MyLocation, contentDescription = androidx.compose.ui.res.stringResource(id = R.string.my_location)) + } + } + } + + // Overlay stays on top of the scaffold (outer Box) + if (!selectedPlaceId.isNullOrEmpty()) { + PlaceDetailsOverlay( + placeId = selectedPlaceId!!, + onDismiss = { viewModel.setSelectedPlaceId(null) }, + modifier = Modifier + .align(Alignment.BottomCenter) + // Anchor dynamically above the bottom sheet + .padding(bottom = sheetPeekHeight + 16.dp, start = 16.dp, end = 16.dp) + ) + } + } + } + + @Composable + fun MapViewContainer() { + val context = LocalContext.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + + val map3DView = remember { + com.google.android.gms.maps3d.Map3DView(context).apply { + getMap3DViewAsync(this@MainActivity) + } + } + + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> { + map3DView.onCreate(null) + } + Lifecycle.Event.ON_RESUME -> { + map3DView.onResume() + } + Lifecycle.Event.ON_PAUSE -> { + map3DView.onPause() + } + Lifecycle.Event.ON_DESTROY -> { + map3DView.onDestroy() + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + AndroidView( + factory = { map3DView }, + modifier = Modifier.fillMaxSize() + ) + } + + /** + * A Composable overlay that hosts the Places UI Kit [PlaceDetailsCompactFragment]. + * + * IMPORTANT INTEROP PATTERN: + * This Composable demonstrates a critical architectural pattern for hosting Android Fragments + * inside Jetpack Compose safely: + * + * 1. The Fragment must NOT be instantiated or transacted within an `AndroidView`'s `update` block. + * The `update` block can be called unpredictably during recompositions, which would lead to + * multiple fragment transactions, memory leaks, or "No view found for id" errors if the + * container isn't fully attached during recomposition loops. + * 2. Instead, the [FragmentContainerView] and the [PlaceDetailsCompactFragment] are + * instantiated exactly ONCE inside the `AndroidView`'s `factory` block. + * 3. Subsequent state changes (like selecting a new [placeId]) are processed by a + * `LaunchedEffect` keyed on [placeId]. This acts as a decoupled state observer that + * updates the *existing* fragment, avoiding full fragment re-creations. + * 4. We use the Activity's native `supportFragmentManager` instead of casting `LocalContext.current`. + * Frameworks like Hilt often wrap the Compose context in a `ViewComponentManager`, which causes + * `ClassCastException`s if blindly cast to a `FragmentActivity`. + */ + @Composable + fun PlaceDetailsOverlay( + placeId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier + ) { + val containerId = remember { View.generateViewId() } + + // IMPORTANT: Use LaunchedEffect ONLY to handle subsequent placeId updates cleanly. + // This is decoupled from the rapid recomposition cycles that affect the AndroidView's update block. + // It guarantees that `fragment.loadWithPlaceId` is only triggered when the `placeId` actually changes. + 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 + + // IMPORTANT: Inflate and add the Fragment exactly *once* when the container is created. + // Do NOT attempt fragment transactions in an AndroidView `update` block. + val newFragment = PlaceDetailsCompactFragment.newInstance( + PlaceDetailsCompactFragment.ALL_CONTENT, + Orientation.VERTICAL, + R.style.CustomizedPlaceDetailsTheme + ).apply { + setPlaceLoadListener(object : PlaceLoadListener { + override fun onSuccess(place: Place) { + Log.d(TAG, "Place loaded: ${place.id}") + } + + override fun onFailure(e: Exception) { + Log.e(TAG, "Place failed to load for ID: $placeId", e) + } + }) + } + + supportFragmentManager.commit { + replace(containerId, newFragment) + } + + // Post the initial load so the fragment attaches first + Log.d(TAG, "Loading initial placeId via factory: $placeId") + post { newFragment.loadWithPlaceId(placeId) } + } + }, + modifier = Modifier.fillMaxWidth() + ) + + FloatingActionButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_close), + contentDescription = androidx.compose.ui.res.stringResource(id = R.string.dismiss_button_content_description) + ) + } + } + + // Clean up fragment when leaving composition + androidx.compose.runtime.DisposableEffect(containerId) { + onDispose { + supportFragmentManager.findFragmentById(containerId)?.let { + supportFragmentManager.commit { + remove(it) + } + } + } + } + } + + private fun flyToLandmark(landmark: Landmark) { + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = camera { + center = landmark.location + range = 1000.0 + tilt = 45.0 + }.toValidCamera() + durationInMillis = 2000 + } + ) + } + + override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { + this.googleMap3D = googleMap3D + googleMap3D.setMapMode(Map3DMode.HYBRID) + googleMap3D.setCamera(initialCamera) + + googleMap3D.setMap3DClickListener { _, placeId -> + Log.e(TAG, "Map clicked: placeId=$placeId") + if (!placeId.isNullOrEmpty()) { + viewModel.setSelectedPlaceId(placeId) + } + } + + if (isLocationPermissionGranted()) { + fetchLastLocation() + } else { + requestLocationPermissions() + } + } + + private fun isLocationPermissionGranted(): Boolean { + return ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + } + + private fun requestLocationPermissions() { + requestPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + } + + @SuppressLint("MissingPermission") + private fun fetchLastLocation() { + if (isLocationPermissionGranted()) { + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + location?.let { + val userLocation = latLngAltitude { + latitude = it.latitude + longitude = it.longitude + altitude = it.altitude + } + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = camera { + center = userLocation + range = 5000.0 + tilt = 60.0 + }.toValidCamera() + durationInMillis = 3000 + } + ) + } ?: run { + Toast.makeText(this, getString(R.string.location_services_disabled), Toast.LENGTH_LONG).show() + moveToDefaultLocation() + } + }.addOnFailureListener { + Toast.makeText(this, getString(R.string.location_services_disabled), Toast.LENGTH_LONG).show() + moveToDefaultLocation() + } + } + } + + private fun moveToDefaultLocation() { + googleMap3D?.flyCameraTo(flyToOptions { endCamera = initialCamera; durationInMillis = 3000 }) + } + + override fun onError(error: Exception) { + Log.e(TAG, "Error loading map", error) + super.onError(error) + } + + companion object { + private val initialCamera: Camera = camera { + center = latLngAltitude { + latitude = 39.982129291022446 + longitude = -105.30156359691158 + altitude = 8148.feet.value + } + heading = 26.0 + tilt = 67.0 + range = 4000.0 + }.toValidCamera() + } +} diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainViewModel.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainViewModel.kt new file mode 100644 index 0000000..e8279be --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainViewModel.kt @@ -0,0 +1,112 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.example.placesuikit3d + +import androidx.lifecycle.ViewModel +import com.google.android.gms.maps3d.model.latLngAltitude +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A simple ViewModel to hold the selected place ID. + * + * Using a ViewModel allows the state to survive configuration changes, like screen rotations, + * ensuring the selected place isn't lost. + */ +class MainViewModel : ViewModel() { + + /** + * The list of landmarks to display in the list. + */ + val landmarks: List = listOf( + Landmark( + id = "ChIJwd_EEkfsa4cRqy6eShKXFXY", + name = "Chautauqua Park", + location = latLngAltitude { + latitude = 39.9989 + longitude = -105.2828 + altitude = 1750.0 + } + ), + Landmark( + id = "ChIJiTEGLibsa4cRepH7ZMFEcJ8", + name = "Pearl Street Mall", + location = latLngAltitude { + latitude = 40.0177 + longitude = -105.2819 + altitude = 1620.0 + } + ), + Landmark( + id = "ChIJwR6cajTsa4cR2TH0qKTVKAM", + name = "University of Colorado Boulder", + location = latLngAltitude { + latitude = 40.0076 + longitude = -105.2659 + altitude = 1650.0 + } + ), + Landmark( + id = "ChIJAfFnzszva4cR04sAt0lSm1g", + name = "Boulder Reservoir", + location = latLngAltitude { + latitude = 40.0780 + longitude = -105.2220 + altitude = 1580.0 + } + ), + Landmark( + id = "ChIJfXOTtWbsa4cRmW07qJRB6_8", + name = "The Flatirons", + location = latLngAltitude { + latitude = 39.9880 + longitude = -105.2930 + altitude = 2100.0 + } + ) + ) + + /** + * Sets the selected place ID. + * + * This function updates the `_placeId` StateFlow with the provided `placeId`. + * If `placeId` is null, it means no place is currently selected. + * + * @param placeId The ID of the selected place, or null if no place is selected. + */ + fun setSelectedPlaceId(placeId: String?) { + _placeId.value = placeId + } + + /** + * The ID of the place to display. + * This is a private mutable state flow that can be updated by the ViewModel. + */ + private val _placeId = MutableStateFlow(null) + + /** + * The unique identifier of the place to display in the Place Details view. + * This is a StateFlow that can be observed for changes. + */ + val placeId: StateFlow = _placeId.asStateFlow() + + private val _selectedLandmark = MutableStateFlow(null) + val selectedLandmark: StateFlow = _selectedLandmark.asStateFlow() + + fun selectLandmark(landmark: Landmark) { + _selectedLandmark.value = landmark + setSelectedPlaceId(landmark.id) + } +} \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Maps3DPlacesApplication.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Maps3DPlacesApplication.kt new file mode 100644 index 0000000..e54e516 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Maps3DPlacesApplication.kt @@ -0,0 +1,85 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.example.placesuikit3d + +import android.app.Application +import android.content.pm.PackageManager +import android.util.Log +import android.widget.Toast +import com.google.android.libraries.places.api.Places +import dagger.hilt.android.HiltAndroidApp +import java.util.Objects + +@HiltAndroidApp +class Maps3DPlacesApplication : Application() { + val TAG = this::class.java.simpleName + + override fun onCreate() { + super.onCreate() + checkApiKey() + initializePlaces() + } + + private fun initializePlaces() { + val apiKey = BuildConfig.PLACES_API_KEY + + if (apiKey == null || apiKey.isBlank() || apiKey == "DEFAULT_API_KEY") { + Toast.makeText( + this, + "PLACES_API_KEY was not set in secrets.properties", + Toast.LENGTH_LONG + ).show() + throw RuntimeException("API Key was not set in secrets.properties") + } + + Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey) + Places.createClient(this) + } + + /** + * Checks if the API key for Google Maps is properly configured in the application's metadata. + * + * This method retrieves the API key from the application's metadata, specifically looking for + * a string value associated with the key "com.google.android.geo.maps3d.API_KEY". + * The key must be present, not blank, and not set to the placeholder value "DEFAULT_API_KEY". + * + * If any of these checks fail, a Toast message is displayed indicating that the API key is missing or + * incorrectly configured, and a RuntimeException is thrown. + */ + private fun checkApiKey() { + try { + val appInfo = + packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + val bundle = Objects.requireNonNull(appInfo.metaData) + + val apiKey = + bundle.getString("com.google.android.geo.maps3d.API_KEY") // Key name is important! + + if (apiKey == null || apiKey.isBlank() || apiKey == "DEFAULT_API_KEY") { + Toast.makeText( + this, + "API Key was not set in secrets.properties", + Toast.LENGTH_LONG + ).show() + throw RuntimeException("API Key was not set in secrets.properties") + } + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "Package name not found.", e) + throw RuntimeException("Error getting package info.", e) + } catch (e: NullPointerException) { + Log.e(TAG, "Error accessing meta-data.", e) // Handle the case where meta-data is completely missing. + throw RuntimeException("Error accessing meta-data in manifest", e) + } + } +} diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/ActiveMapObject.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/ActiveMapObject.kt new file mode 100644 index 0000000..22bd5f1 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/ActiveMapObject.kt @@ -0,0 +1,48 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//   http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d.common + +import com.google.android.gms.maps3d.model.Marker +import com.google.android.gms.maps3d.model.Model +import com.google.android.gms.maps3d.model.Polygon +import com.google.android.gms.maps3d.model.Polyline + +internal sealed class ActiveMapObject { + abstract fun remove() + + data class ActiveMarker(val marker: Marker) : ActiveMapObject() { + override fun remove() { + marker.remove() + } + } + + data class ActivePolyline(val polyline: Polyline) : ActiveMapObject() { + override fun remove() { + polyline.remove() + } + } + + data class ActivePolygon(val polygon: Polygon) : ActiveMapObject() { + override fun remove() { + polygon.remove() + } + } + + data class ActiveModel(val model: Model) : ActiveMapObject() { + override fun remove() { + model.remove() + } + } +} \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt new file mode 100644 index 0000000..fbbc125 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt @@ -0,0 +1,379 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//   http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d.common + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.placesuikit3d.utils.CameraUpdate +import com.example.placesuikit3d.utils.copy +import com.example.placesuikit3d.utils.toCameraUpdate +import com.example.placesuikit3d.utils.toHeading +import com.example.placesuikit3d.utils.toRange +import com.example.placesuikit3d.utils.toRoll +import com.example.placesuikit3d.utils.toTilt +import com.example.placesuikit3d.utils.toValidCamera +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.OnCameraChangedListener +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.Camera.DEFAULT_CAMERA +import com.google.android.gms.maps3d.model.CameraRestriction +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.MarkerOptions +import com.google.android.gms.maps3d.model.Model +import com.google.android.gms.maps3d.model.ModelOptions +import com.google.android.gms.maps3d.model.PolygonOptions +import com.google.android.gms.maps3d.model.PolylineOptions +import com.google.android.gms.maps3d.model.flyAroundOptions +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.time.Duration + +abstract class Map3dViewModel : ViewModel() { + abstract val TAG: String + + /** + * The internal state flow holding the GoogleMap3D controller instance. + * + * This flow is used internally to manage the lifecycle and access to the + * GoogleMap3D object provided by the MapView. + * It's updated via the `setGoogleMap3D` function. + * + * Consumers should use the `mapReady` flow to react to the availability of the map. + */ + private var _googleMap3D = MutableStateFlow(null) + + private val _cameraRestriction = MutableStateFlow(null) + val cameraRestriction = _cameraRestriction.asStateFlow() + + private val _mapMode = MutableStateFlow(Map3DMode.SATELLITE) + val mapMode = _mapMode.asStateFlow() + + // --- Camera Position from Map & Pending Requests --- + // This is guaranteed to always be a valid camera + private val _currentCamera = MutableStateFlow(DEFAULT_CAMERA) + val currentCamera = _currentCamera.asStateFlow() + + private val mapObjects = mutableMapOf() + + /** + * A [MutableSharedFlow] that buffers [CameraUpdate] requests. + * + * This flow is used to queue camera updates requested by the ViewModel's consumers. + * When a new camera update is emitted to this flow, it's buffered with a replay of 1, + * meaning the latest update is available to new collectors. If a new update arrives + * before the previous one is processed, the older one is dropped (`BufferOverflow.DROP_OLDEST`). + * + * This allows the ViewModel to handle camera update requests asynchronously and + * ensures that only the most recent request is processed if updates occur rapidly. + * The actual camera update is performed within a separate coroutine that collects + * from this flow. + */ + private val _pendingCameraUpdate = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val activeMapObjects = mutableMapOf() + + val mapReady = _googleMap3D.map { it != null } + + init { + viewModelScope.launch { + _googleMap3D.collect { controller -> + stopAnimations() + clearObjects() + Log.d(TAG, "Map3D Controller attached") + if (controller != null) { + launch { + Log.d(TAG, "Getting camera flow") + getCameraFlow(controller).collect { camera -> + _currentCamera.value = camera + } + } + addMapObjects(mapObjects, controller) + + // Return to the last camera position if available + controller.setCamera(currentCamera.value) + + // Process pending camera updates + launch { + _pendingCameraUpdate + .filterNotNull() + .collect { cameraUpdate -> + Log.d(TAG, "Received camera update request: $cameraUpdate") + cameraUpdate(controller) + } + } + + launch { + _mapMode.collect { mapMode -> + controller.setMapMode(mapMode) + } + } + + launch { + _cameraRestriction.collect { cameraRestriction -> + controller.setCameraRestriction(cameraRestriction) + } + } + } + } + } + } + + /** + * Returns a Flow that emits the current camera position whenever it changes on the GoogleMap3D. + * + * This Flow is created using `callbackFlow` to bridge the callback-based API of + * `OnCameraChangedListener` with Kotlin's coroutine Flows. It automatically attaches and + * detaches the listener when collectors subscribe and unsubscribe. + * + * The Flow emits a validated `Camera` object, ensuring that the pitch, range, and bearing + * are within acceptable limits using the `toValidCamera()` extension function. + * + * @param controller The GoogleMap3D instance to listen for camera changes on. + * @return A Flow of `Camera` objects representing the current camera position. + */ + private fun getCameraFlow(controller: GoogleMap3D): Flow { + // Public Flow that manages the listener lifecycle + return callbackFlow { + val cameraChangedListener = OnCameraChangedListener { cameraPosition -> + val newPosition = cameraPosition.toValidCamera() + // Send the new camera position to the flow's channel + trySend(newPosition) + // Also update the private state + _currentCamera.value = newPosition + } + + // Get the current map instance (ensure it's not null before setting listener) + Log.d(TAG, "Attaching CameraChangeListener") + controller.setCameraChangedListener(cameraChangedListener) + + // Ensure the initial camera position is emitted when the flow is collected + // This handles cases where the map is ready before the flow is collected + controller.getCamera()?.let { initial -> + val newPosition = initial.toValidCamera() + trySend(newPosition) + _currentCamera.value = newPosition // Also update private state on collection + } + + // The awaitClose block runs when the collector is cancelled + awaitClose { + // Remove the listener when the flow collection stops + Log.d(TAG, "Detaching CameraChangeListener") + controller.setCameraChangedListener(null) + } + }.conflate() + } + + /** + * Adds a collection of map objects to the GoogleMap3D controller. + * + * This function iterates through a mutable map of MapObject instances and adds each one + * to the provided `GoogleMap3D` controller. For each successfully added object, + * it stores the resulting active map object in the `activeMapObjects` map + * for later management (like removal). + * + * @param mapObjects A mutable map where keys are object IDs (String) and values + * are MapObject instances to be added to the map. + * @param controller The GoogleMap3D controller to which the objects will be added. + */ + private fun addMapObjects( + mapObjects: MutableMap, + controller: GoogleMap3D + ) { + mapObjects.forEach { (_, mapObject) -> + mapObject.addToMap(controller)?.also { activeObject -> + activeMapObjects[mapObject.id] = activeObject + } + } + } + + /** + * Sets the Map3DController instance. + * + * @param googleMap3d The GoogleMap3D instance, or null if it's being detached. + */ + open fun setGoogleMap3D(googleMap3d: GoogleMap3D?) { + _googleMap3D.value = googleMap3d + } + + private fun stopAnimations() { + Log.d("Map3dViewModel", "stopAnimations: ") + _googleMap3D.value?.stopCameraAnimation() + } + + open fun releaseGoogleMap3D() { + _googleMap3D.value = null + } + + /** + * Clears the ViewModel's internal tracking of active SDK map objects. + * This is called when the controller is detached or changed, as the underlying + * map instance those objects belonged to is no longer relevant. + */ + fun clearObjects() { + activeMapObjects.forEach { (_, activeObject) -> + activeObject.remove() + } + activeMapObjects.clear() + } + + private fun addMapObject(mapObject: MapObject) { + mapObjects[mapObject.id] = mapObject // No need to remove the old as the map will replace it + _googleMap3D.value?.also { controller -> + mapObject.addToMap(controller)?.also { activeObject -> + activeMapObjects[mapObject.id] = activeObject + } + } + } + + fun addMarker(options: MarkerOptions) { + addMapObject(MapObject.Marker(options)) + } + + fun removeMapObject(id: String) { + mapObjects.remove(id) + activeMapObjects.remove(id)?.also { activeObject -> + activeObject.remove() + } + } + + fun addPolyline(polylineOptions: PolylineOptions) { + addMapObject(MapObject.Polyline(polylineOptions)) + } + + fun addPolygon(polygonOptions: PolygonOptions) { + addMapObject(MapObject.Polygon(polygonOptions)) + } + + fun addModel(modelOptions: ModelOptions) { + addMapObject(MapObject.Model(modelOptions)) + } + + fun setCamera(camera: Camera) { + CameraUpdate.Move(camera).also { _pendingCameraUpdate.tryEmit(it) } + } + + fun flyTo(flyToOptions: FlyToOptions) { + CameraUpdate.FlyTo(flyToOptions).also { _pendingCameraUpdate.tryEmit(it) } + } + + fun flyAround(flyAroundOptions: FlyAroundOptions) { + CameraUpdate.FlyAround(flyAroundOptions).also { _pendingCameraUpdate.tryEmit(it) } + } + + fun setCameraRestriction(cameraRestriction: CameraRestriction?) { + _cameraRestriction.value = cameraRestriction + } + + fun setMapMode(@Map3DMode mode: Int) { + _mapMode.value = mode + } + + override fun onCleared() { + _googleMap3D.value = null + super.onCleared() + } + + open fun updateCameraAndMove(block: Camera.() -> Camera) { + currentCamera.value.let { camera -> + _pendingCameraUpdate.tryEmit( + CameraUpdate.Move( + camera.block() // .also { _currentCamera.value = it } + ) + ) + } + } + + open fun setCameraHeading(heading: Number) { + updateCameraAndMove { + copy(heading = heading.toHeading()) + } + } + + open fun setCameraTilt(tilt: Number) { + updateCameraAndMove { + copy(tilt = tilt.toTilt()) + } + } + + open fun setCameraRange(range: Number) { + updateCameraAndMove { + copy(range = range.toRange()) + } + } + + open fun setCameraRoll(roll: Number) { + updateCameraAndMove { + copy(roll = roll.toRoll()) + } + } + + fun flyAroundCurrentCenter(rounds: Double, duration: Duration) { + currentCamera.value.let { camera -> + flyAround( + flyAroundOptions { + center = camera + durationInMillis = duration.inWholeMilliseconds + this.rounds = rounds + } + ) + } + } + + fun getModel(key: String): Model? { + activeMapObjects[key]?.let { activeObject -> + if (activeObject is ActiveMapObject.ActiveModel) { + return activeObject.model + } + } + return null + } + + fun nextMapMode() { + val newMapType = when (mapMode.value) { + Map3DMode.SATELLITE -> Map3DMode.HYBRID + else -> Map3DMode.SATELLITE + } + setMapMode(newMapType) + } + + suspend fun awaitFlyTo(flyToOptions: FlyToOptions) { + awaitCameraUpdate(flyToOptions.toCameraUpdate()) + } + + suspend fun awaitFlyAround(flyAroundOptions: FlyAroundOptions) { + awaitCameraUpdate(flyAroundOptions.toCameraUpdate()) + } + + suspend fun awaitCameraUpdate(cameraUpdate: CameraUpdate) { + _googleMap3D.value?.let { controller -> + com.example.placesuikit3d.utils.awaitCameraUpdate(controller, cameraUpdate) + } + } +} diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/MapObject.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/MapObject.kt new file mode 100644 index 0000000..d383c61 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/MapObject.kt @@ -0,0 +1,64 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//   http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d.common + +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.MarkerOptions +import com.google.android.gms.maps3d.model.ModelOptions +import com.google.android.gms.maps3d.model.PolygonOptions +import com.google.android.gms.maps3d.model.PolylineOptions + +sealed class MapObject { + internal abstract fun addToMap(controller: GoogleMap3D): ActiveMapObject? + abstract val id: String + + data class Marker(val options: MarkerOptions) : MapObject() { + override fun addToMap(controller: GoogleMap3D): ActiveMapObject? { + return controller.addMarker(options)?.let { marker -> + ActiveMapObject.ActiveMarker(marker) + } + } + + override val id: String + get() = options.id + } + + data class Polyline(val options: PolylineOptions) : MapObject() { + override fun addToMap(controller: GoogleMap3D): ActiveMapObject { + return ActiveMapObject.ActivePolyline(controller.addPolyline(options)) + } + + override val id: String + get() = options.id + } + + data class Polygon(val options: PolygonOptions) : MapObject() { + override fun addToMap(controller: GoogleMap3D): ActiveMapObject { + return ActiveMapObject.ActivePolygon(controller.addPolygon(options)) + } + + override val id: String + get() = options.id + } + + data class Model(val options: ModelOptions) : MapObject() { + override fun addToMap(controller: GoogleMap3D): ActiveMapObject { + return ActiveMapObject.ActiveModel(controller.addModel(options)) + } + + override val id: String + get() = options.id + } +} \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Color.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Color.kt new file mode 100644 index 0000000..96e7d3a --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Color.kt @@ -0,0 +1,24 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.example.placesuikit3d.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Theme.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Theme.kt new file mode 100644 index 0000000..2dcb00f --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Theme.kt @@ -0,0 +1,70 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.example.placesuikit3d.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun PlacesUIKit3DTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Type.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Type.kt new file mode 100644 index 0000000..391837d --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Type.kt @@ -0,0 +1,47 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.example.placesuikit3d.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/CameraUpdate.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/CameraUpdate.kt new file mode 100644 index 0000000..7097283 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/CameraUpdate.kt @@ -0,0 +1,121 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//   http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d.utils + +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.OnCameraAnimationEndListener +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Represents an update to the camera of a [GoogleMap3D]. + * + * This sealed class provides different ways to update the camera, such as flying to a specific location, + * flying around a point, or simply moving the camera to a new position. + * + * The main advantage is to allow creation of the [awaitCameraUpdate] method. + * + * Each subclass of [CameraUpdate] defines how the camera should be updated through its `invoke` method. + * + * Subclasses: + * - [FlyTo]: Represents a camera fly-to animation. + * - [FlyAround]: Represents a camera fly-around animation. + * - [Move]: Represents a direct camera move without animation. + */ +sealed class CameraUpdate { + abstract operator fun invoke(controller: GoogleMap3D) + + data class FlyTo(val options: FlyToOptions) : CameraUpdate() { + override fun invoke(controller: GoogleMap3D) { + controller.flyCameraTo(options) + } + } + + data class FlyAround(val options: FlyAroundOptions) : CameraUpdate() { + override fun invoke(controller: GoogleMap3D) { + controller.flyCameraAround(options) + } + } + + data class Move(val camera: Camera) : CameraUpdate() { + override fun invoke(controller: GoogleMap3D) { + controller.setCamera(camera) + } + } +} + +fun FlyToOptions.toCameraUpdate(): CameraUpdate { + return CameraUpdate.FlyTo(this.toValidFlyToOptions()) +} + +fun FlyAroundOptions.toCameraUpdate(): CameraUpdate { + return CameraUpdate.FlyAround(this.toValidFlyAroundOptions()) +} + +fun FlyToOptions.toValidFlyToOptions(): FlyToOptions { + return this.copy( + endCamera = this.endCamera.toValidCamera() + ) +} + +fun FlyAroundOptions.toValidFlyAroundOptions(): FlyAroundOptions { + return this.copy( + center = this.center.toValidCamera() + ) +} + +/** + * Suspends the coroutine until the camera update animation is finished. + * + * If the [cameraUpdate] is a [CameraUpdate.Move], it will be applied immediately without waiting. + * + * Otherwise, it will wait for the camera animation to finish, then it will resume the coroutine. + * + * You can pass in an existing [cameraChangedListener] that will be invoked when the camera + * animation finishes and also will be restored afterwards. + * + * @param controller The [GoogleMap3D] instance to apply the camera update to. + * @param cameraUpdate The [CameraUpdate] to apply. + * @param cameraChangedListener An optional existing listener to invoke and restore + */ +suspend fun awaitCameraUpdate( + controller: GoogleMap3D, + cameraUpdate: CameraUpdate, + cameraChangedListener: OnCameraAnimationEndListener? = null +) = suspendCancellableCoroutine { continuation -> + // No need to wait if the update is a move + if (cameraUpdate is CameraUpdate.Move) { + cameraUpdate.invoke(controller) + return@suspendCancellableCoroutine + } + + // If the coroutine is canceled, stop the camera animation as well. + continuation.invokeOnCancellation { + controller.stopCameraAnimation() + } + + controller.setCameraAnimationEndListener { + cameraChangedListener?.onCameraAnimationEnd() + controller.setCameraAnimationEndListener(cameraChangedListener) + if (continuation.isActive) { + continuation.resume(Unit) + } + } + + cameraUpdate.invoke(controller) +} diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Units.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Units.kt new file mode 100644 index 0000000..c92066d --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Units.kt @@ -0,0 +1,181 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//   http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d.utils + + +import android.content.res.Resources +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.res.stringResource +import com.example.placesuikit3d.R + +const val METERS_PER_FOOT = 3.28084 +const val METERS_PER_KILOMETER = 1_000 +const val FEET_PER_METER = 1 / METERS_PER_FOOT +const val FEET_PER_MILE = 5_280 +const val MILES_PER_METER = 0.000621371 + +/** A value class to wrap a value representing a measurement in meters. */ +@Immutable +@JvmInline +value class Meters(val value: Double) : Comparable { + override fun compareTo(other: Meters) = value.compareTo(other.value) + + operator fun minus(other: Meters) = Meters(value = this.value - other.value) +} + +/** Create a Meters class from a [Number] */ +@Stable +inline val Number.meters: Meters + get() = Meters(value = this.toDouble()) + +/** Create a Meters class from a [Number] */ +@Stable +inline val Number.m: Meters + get() = Meters(value = this.toDouble()) + +/** Create a Meters class from a [Number] of kilometers */ +@Stable +inline val Number.km: Meters + get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER) + +/** Create a Meters class from a [Number] of feet */ +@Stable +inline val Number.feet: Meters + get() = Meters(value = this.toDouble() * FEET_PER_METER) + +/** Create a Meters class from a [Number] of miles */ +@Stable +inline val Number.miles: Meters + get() = Meters(value = this.toDouble() / MILES_PER_METER) + +/** Gets the number of equivalent feet from a meters value class */ +@Stable +inline val Meters.toFeet: Double + get() = value * METERS_PER_FOOT + +/** Gets the value of a meters class as a Double */ +@Stable +inline val Meters.toMeters: Double + get() = value + +/** Gets the number of equivalent kilometers from a meters value class */ +@Stable +inline val Meters.toKilometers: Double + get() = value / METERS_PER_KILOMETER + +/** Gets the number of equivalent kilometers from a meters value class */ +@Stable +inline val Meters.toMiles: Double + get() = (value * MILES_PER_METER) + +@Stable +operator fun Meters.plus(other: Meters) = Meters(value = this.value + other.value) + +/** + * A data class representing a value with a string resource ID for its units template. + * + * @property value: The numerical value. + * @property unitsTemplate: The string resource ID for the units. + */ +data class ValueWithUnitsTemplate(val value: Double, @StringRes val unitsTemplate: Int) + +/** Abstract base class for all units converters. */ +abstract class UnitsConverter { + abstract fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate + + abstract fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate + + @Composable + fun toDistanceString(meters: Meters): String { + val (value, resourceId) = toDistanceUnits(meters = meters) + return stringResource(id = resourceId, value) + } + + fun toDistanceString(resources: Resources, meters: Meters): String { + val (value, resourceId) = toDistanceUnits(meters = meters) + return resources.getString(resourceId, value) + } + + @Composable + fun toElevationString(meters: Meters): String { + val (value, resourceId) = toElevationUnits(meters = meters) + return stringResource(id = resourceId, value) + } +} + +/** + * Returns the appropriate [UnitsConverter] based on the given country code. + * + * @param countryCode The country code to determine the units converter for. + * @return The appropriate [UnitsConverter] for the specified country code. + */ +fun getUnitsConverter(countryCode: String?): UnitsConverter { + // TODO: other counties that use imperial units for distances? + return if (countryCode == "US") { + ImperialUnitsConverter + } else { + MetricUnitsConverter + } +} + +/** Class to render measurements in imperial units. */ +object ImperialUnitsConverter : UnitsConverter() { + override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { + return if (meters < 0.25.miles) { + ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet) + } else { + ValueWithUnitsTemplate(meters.toMiles, R.string.in_miles) + } + } + + override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { + return ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet) + } +} + +/** Class to render measurements in metric units. */ +object MetricUnitsConverter : UnitsConverter() { + override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { + return if (meters < 1000.meters) { + ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters) + } else { + ValueWithUnitsTemplate(meters.toKilometers, R.string.in_kilometers) + } + } + + override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { + return ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters) + } +} + +/** A composition local that provides a [UnitsConverter] instance. */ +val LocalUnitsConverter = compositionLocalOf { MetricUnitsConverter } + +/** Creates a string to show the distance formatted with units */ +@Composable +fun Meters.toDistanceString(): String { + return LocalUnitsConverter.current.toDistanceString(this) +} + +@Composable +fun Meters.toElevationString(): String { + return LocalUnitsConverter.current.toElevationString(this) +} + +operator fun Meters.plus(value: Number) = Meters(this.value + value.toDouble()) diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Utilities.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Utilities.kt new file mode 100644 index 0000000..bea3e48 --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Utilities.kt @@ -0,0 +1,337 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//   http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d.utils + +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyAroundOptions +import com.google.android.gms.maps3d.model.flyToOptions +import com.google.android.gms.maps3d.model.latLngAltitude +import java.util.Locale +import kotlin.math.floor + +val headingRange = 0.0..360.0 +val tiltRange = 0.0..90.0 +val rangeRange = 0.0..63170000.0 +val rollRange = -360.0..360.0 + +val latitudeRange = -90.0..90.0 +val longitudeRange = -180.0..180.0 +val altitudeRange = 0.0..LatLngAltitude.MAX_ALTITUDE_METERS + +const val DEFAULT_HEADING = 0.0 +const val DEFAULT_TILT = 60.0 +const val DEFAULT_RANGE = 1500.0 +const val DEFAULT_ROLL = 0.0 + +/** + * Converts a nullable Camera object into a valid, non-null Camera object. + * If the input is null, returns the DEFAULT_CAMERA configuration. + * If the input is non-null, validates its components (center, heading, tilt, roll, range) + * using helper functions (toValidLocation, toHeading, toTilt, toRoll, toRange). + * + * @receiver The nullable Camera object to validate. + * @return A valid, non-null Camera object. + */ +fun Camera?.toValidCamera(): Camera { + // Use elvis operator for concise null handling + val source = this ?: return Camera.DEFAULT_CAMERA // Return default camera if source is null + + // If source is not null, validate its components + return camera { + // Validate center using the provided toValidLocation function + center = source.center.toValidLocation() + // Validate orientation and range using the existing to...() functions + heading = source.heading.toHeading() + tilt = source.tilt.toTilt() + roll = source.roll.toRoll() + range = source.range.toRange() + } +} + +/** + * Coerces the latitude, longitude, and altitude of a LatLngAltitude object + * to be within their valid ranges. Longitude is clamped, not wrapped here. + * + * @receiver The LatLngAltitude to validate. + * @return A new LatLngAltitude object with validated components. + */ +fun LatLngAltitude.toValidLocation(): LatLngAltitude { + val objectToCopy = this + return latLngAltitude { + // Coerce latitude within -90.0 to 90.0 + latitude = objectToCopy.latitude.coerceIn(latitudeRange) + // Coerce longitude within -180.0 to 180.0 (Note: wrapping might be preferred sometimes) + longitude = objectToCopy.longitude.coerceIn(longitudeRange) + // Coerce altitude within 0.0 to MAX_ALTITUDE_METERS + altitude = objectToCopy.altitude.coerceIn(altitudeRange) + } +} + +/** + * Converts a Number? to a valid heading value (0.0 to 360.0). + * Returns 0.0 if the input is null. + * Uses wrapIn to ensure the value is within the headingRange. + * + * @receiver The Number? to convert. + * @return The heading value as a Double within [0.0, 360.0). + */ +fun Number?.toHeading(): Double = + this?.toDouble()?.wrapIn(headingRange.start, headingRange.endInclusive) ?: DEFAULT_HEADING + +/** + * Converts a Number? to a valid tilt value (0.0 to 90.0). + * Returns 0.0 if the input is null. + * Clamps the value to the tiltRange, as tilt doesn't typically wrap. + * + * @receiver The Number? to convert. + * @return The tilt value as a Double clamped within [0.0, 90.0]. + */ +fun Number?.toTilt(): Double = this?.toDouble()?.coerceIn(tiltRange) ?: DEFAULT_TILT + +/** + * Converts a Number? to a valid roll value (-360.0 to 360.0 or often -180..180). + * Returns 0.0 if the input is null. + * Uses wrapIn to ensure the value is within the rollRange. + * Consider using -180..180 range and wrapIn(lower, upper) for standard roll representation. + * + * @receiver The Number? to convert. + * @return The roll value as a Double within the defined rollRange. + */ +fun Number?.toRoll(): Double = this?.toDouble()?.wrapIn(rollRange) ?: DEFAULT_ROLL + +/** + * Converts a Number? to a valid range value (0.0 to ~63,170,000.0). + * Returns 0.0 if the input is null. + * Clamps the value to the rangeRange, as range/distance doesn't wrap. + * + * @receiver The Number? to convert. + * @return The range value as a Double clamped within the defined rangeRange. + */ +fun Number?.toRange(): Double = this?.toDouble()?.coerceIn(rangeRange) ?: DEFAULT_RANGE + +// Assumes we are close to the range +fun Double.wrapIn(range: ClosedFloatingPointRange): Double { + var answer = this + val delta = range.endInclusive - range.start + while (answer > range.endInclusive) { + answer -= delta + } + while (answer < range.start) { + answer += delta + } + + return answer +} + +/** + * Wraps a Float value within a specified range. + * If the value is outside the range, it is adjusted by repeatedly adding or subtracting + * the range's span (delta) until it falls within the range. + * + * @param range The ClosedFloatingPointRange within which to wrap the value. + * @return The wrapped Float value, guaranteed to be within the specified range. + */ +fun Float.wrapIn(range: ClosedFloatingPointRange): Float { + var answer = this + val delta = range.endInclusive - range.start + while (answer > range.endInclusive) { + answer -= delta + } + while (answer < range.start) { + answer += delta + } + + return answer +} + +/** + * Wraps a Double value within the specified range [lower, upper). + * This method ensures that the returned value always falls within the specified range. + * If the value is outside the range, it will be "wrapped around" to fit within the range. + * For example, if the range is [0.0, 360.0) and the input is 370.0, the output will be 10.0. + * If the range is [0.0, 360.0) and the input is -10.0, the output will be 350.0. + * + * @param lower The lower bound of the range (inclusive). + * @param upper The upper bound of the range (exclusive). + * @return The wrapped value within the range [lower, upper). + * @throws IllegalArgumentException if the upper bound is not greater than the lower bound. + */ +fun Double.wrapIn(lower: Double, upper: Double): Double { + val range = upper - lower + if (range <= 0) { + throw IllegalArgumentException("Upper bound must be greater than lower bound") + } + val offset = this - lower + return lower + (offset - floor(offset / range) * range) +} + +/** + * Extension function on Number to get the nearest compass direction string + * from a given heading in degrees. + * + * 0 degrees is North, 90 is East, 180 is South, 270 is West. + * Handles headings outside the standard 0-360 range (e.g., -90 or 450 degrees). + * + * @return A string representing the nearest compass direction (e.g., "N", "NNE", "NE"). + */ +fun Number.toCompassDirection(): String { + val directions = listOf( + "N", "NNE", "NE", "ENE", + "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", + "W", "WNW", "NW", "NNW" + ) + + val headingDegrees = this.toDouble() + + // Normalize heading to 0-359.99... degrees + val normalizedHeading = (headingDegrees % 360.0 + 360.0) % 360.0 + + // Each of the 16 directions covers an arc of 360/16 = 22.5 degrees. + // We add half of this (11.25) to the normalized heading before dividing + // to correctly align with the center of each compass arc. + val segment = 22.5 + val index = floor((normalizedHeading + (segment / 2)) / segment).toInt() % directions.size + + return directions[index] +} + +/** + * Creates a new [Camera] object by copying the current [Camera] and optionally overriding + * its center, heading, tilt, range, and roll properties. + * + * @param center The new center [LatLngAltitude] to use, or null to keep the current center. + * @param heading The new heading (bearing) to use, or null to keep the current heading. + * @param tilt The new tilt (pitch) to use, or null to keep the current tilt. + * @param range The new range (distance from the center) to use, or null to keep the current range. + * @param roll The new roll to use, or null to keep the current roll. + * @return A new [Camera] object with the specified properties updated. + */ +fun Camera.copy( + center: LatLngAltitude? = null, + heading: Double? = null, + tilt: Double? = null, + range: Double? = null, + roll: Double? = null, +): Camera { + val objectToCopy = this + return camera { + this.center = center ?: objectToCopy.center + this.heading = heading ?: objectToCopy.heading + this.tilt = tilt ?: objectToCopy.tilt + this.range = range ?: objectToCopy.range + this.roll = roll ?: objectToCopy.roll + } +} + +fun FlyAroundOptions.copy( + center: Camera? = null, + durationInMillis: Long? = null, + rounds: Double? = null, +) : FlyAroundOptions { + val objectToCopy = this + + return flyAroundOptions { + this.center = (center ?: objectToCopy.center) + this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis + this.rounds = rounds ?: objectToCopy.rounds + } +} + +fun FlyToOptions.copy( + endCamera: Camera? = null, + durationInMillis: Long? = null, +) : FlyToOptions { + val objectToCopy = this + + return flyToOptions { + this.endCamera = (endCamera ?: objectToCopy.endCamera) + this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis + } +} + +/** + * Converts a [Camera] object to a formatted string representation. + * + * This function takes a [Camera] object, validates it using [toValidCamera], and then + * constructs a multi-line string that represents the camera's properties in a human-readable + * format. The string includes the camera's center (latitude, longitude, altitude), + * heading, tilt, and range. + * + * The latitude, longitude, altitude, heading, tilt, and range are formatted to specific + * decimal places for readability (6, 6, 1, 0, 0, 0 respectively). + * + * The output string is designed to be easily copied and pasted directly into code to recreate + * a [Camera] object with the same parameters. This is especially useful for quickly positioning + * the camera to a specific view. + * + * Example output: + * ``` + * camera { + * center = latLngAltitude { + * latitude = 34.052235 + * longitude = -118.243685 + * altitude = 100.0 + * } + * heading = 90 + * tilt = 45 + * range = 5000 + * } + * ``` + * + * @receiver The [Camera] object to convert. + * @return A string representation of the [Camera] object, suitable for pasting into source code. + */ +fun Camera.toCameraString(): String { + val camera = this.toValidCamera() + return """ + camera { + center = latLngAltitude { + latitude = ${camera.center.latitude.format(6)} + longitude = ${camera.center.longitude.format(6)} + altitude = ${camera.center.altitude.format(1)} + } + heading = ${camera.heading.format(0)} + tilt = ${camera.tilt.format(0)} + range = ${camera.range.format(0)} + }""".trimIndent() +} + +/** + * Formats a nullable Double to a string with a specified number of decimal places. + * + * If the Double is null, returns "null". + * If decimalPlaces is 0, it formats the number with no decimal places and appends ".0". + * If decimalPlaces is greater than 0, it formats the number with the specified number of decimal places. + * + * Note, this is intended for logging and debugging not for display to the user. + * + * @receiver The nullable Double to format. + * @param decimalPlaces The number of decimal places to include in the formatted string. + * @return The formatted string representation of the Double, or "null" if the input is null. + */ +internal fun Double?.format(decimalPlaces: Int): String { + if (this == null) return "null" + + return if (decimalPlaces == 0) { + String.format(Locale.US, "%.0f.0", this) + } else { + String.format(Locale.US, "%.${decimalPlaces}f", this) + } +} diff --git a/PlacesUIKit3D/src/main/res/drawable/close_button_background.xml b/PlacesUIKit3D/src/main/res/drawable/close_button_background.xml new file mode 100644 index 0000000..1aa72bc --- /dev/null +++ b/PlacesUIKit3D/src/main/res/drawable/close_button_background.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/drawable/ic_close.xml b/PlacesUIKit3D/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..2b42f66 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/drawable/ic_close.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/drawable/ic_launcher_background.xml b/PlacesUIKit3D/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..bdf5ba8 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PlacesUIKit3D/src/main/res/drawable/ic_launcher_foreground.xml b/PlacesUIKit3D/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..1aca001 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/drawable/ic_my_location.xml b/PlacesUIKit3D/src/main/res/drawable/ic_my_location.xml new file mode 100644 index 0000000..deb4264 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/drawable/ic_my_location.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/PlacesUIKit3D/src/main/res/drawable/loader_background.xml b/PlacesUIKit3D/src/main/res/drawable/loader_background.xml new file mode 100644 index 0000000..ddf3936 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/drawable/loader_background.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/PlacesUIKit3D/src/main/res/drawable/outline_my_location_24.xml b/PlacesUIKit3D/src/main/res/drawable/outline_my_location_24.xml new file mode 100644 index 0000000..0ca85cf --- /dev/null +++ b/PlacesUIKit3D/src/main/res/drawable/outline_my_location_24.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/PlacesUIKit3D/src/main/res/font/custom_font.xml b/PlacesUIKit3D/src/main/res/font/custom_font.xml new file mode 100644 index 0000000..afb28c1 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/font/custom_font.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/layout/activity_main.xml b/PlacesUIKit3D/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..795d0f6 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/layout/activity_main.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher.xml b/PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..4293905 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..4293905 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher.webp b/PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher.webp b/PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher.webp b/PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/PlacesUIKit3D/src/main/res/values/colors.xml b/PlacesUIKit3D/src/main/res/values/colors.xml new file mode 100644 index 0000000..9d8a9ca --- /dev/null +++ b/PlacesUIKit3D/src/main/res/values/colors.xml @@ -0,0 +1,33 @@ + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + + #1A0A2D + #E0218A + #F0F8FF + #9E8BBE + #00E5FF + #00E5FF + #FF007F + diff --git a/PlacesUIKit3D/src/main/res/values/strings.xml b/PlacesUIKit3D/src/main/res/values/strings.xml new file mode 100644 index 0000000..ebbccdf --- /dev/null +++ b/PlacesUIKit3D/src/main/res/values/strings.xml @@ -0,0 +1,51 @@ + + + + + Places UI Kit 3D + + + %1$,.0f ft + + + %1$,.1f miles + + + %1$,.0f m + + + %1$,.1f km + + Dismiss place details + Loading… + My Location + Location permission denied. Showing default location. + Location services disabled on device. Showing default location. + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/values/themes.xml b/PlacesUIKit3D/src/main/res/values/themes.xml new file mode 100644 index 0000000..b54e9d0 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/values/themes.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/xml/backup_rules.xml b/PlacesUIKit3D/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..636cad6 --- /dev/null +++ b/PlacesUIKit3D/src/main/res/xml/backup_rules.xml @@ -0,0 +1,29 @@ + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/main/res/xml/data_extraction_rules.xml b/PlacesUIKit3D/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..333deed --- /dev/null +++ b/PlacesUIKit3D/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/PlacesUIKit3D/src/test/java/com/example/placesuikit3d/MainViewModelTest.kt b/PlacesUIKit3D/src/test/java/com/example/placesuikit3d/MainViewModelTest.kt new file mode 100644 index 0000000..d6f66c9 --- /dev/null +++ b/PlacesUIKit3D/src/test/java/com/example/placesuikit3d/MainViewModelTest.kt @@ -0,0 +1,60 @@ +package com.example.placesuikit3d + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MainViewModelTest { + + @Test + fun `initial state should have null selected place and landmark`() { + val viewModel = MainViewModel() + + assertThat(viewModel.placeId.value).isNull() + assertThat(viewModel.selectedLandmark.value).isNull() + } + + @Test + fun `landmarks list is not empty on initialization`() { + val viewModel = MainViewModel() + + assertThat(viewModel.landmarks).isNotEmpty() + } + + @Test + fun `setSelectedPlaceId updates placeId state`() { + val viewModel = MainViewModel() + val testPlaceId = "ChIJT_test_place_id" + + viewModel.setSelectedPlaceId(testPlaceId) + + assertThat(viewModel.placeId.value).isEqualTo(testPlaceId) + // selectedLandmark should remain untouched when purely setting place ID + assertThat(viewModel.selectedLandmark.value).isNull() + } + + @Test + fun `selectLandmark updates both selectedLandmark and placeId states`() { + val viewModel = MainViewModel() + val landmark = viewModel.landmarks.first() + + viewModel.selectLandmark(landmark) + + assertThat(viewModel.selectedLandmark.value).isEqualTo(landmark) + assertThat(viewModel.placeId.value).isEqualTo(landmark.id) + } + + @Test + fun `setSelectedPlaceId to null clears placeId state`() { + val viewModel = MainViewModel() + val landmark = viewModel.landmarks.first() + + viewModel.selectLandmark(landmark) + assertThat(viewModel.placeId.value).isEqualTo(landmark.id) + + viewModel.setSelectedPlaceId(null) + + assertThat(viewModel.placeId.value).isNull() + // verify selectedLandmark remains the last selected one, just the place details overlay dismissed + assertThat(viewModel.selectedLandmark.value).isEqualTo(landmark) + } +} diff --git a/PlacesUIKit3D/src/test/java/com/example/placesuikit3d/common/Map3dViewModelTest.kt b/PlacesUIKit3D/src/test/java/com/example/placesuikit3d/common/Map3dViewModelTest.kt new file mode 100644 index 0000000..015a7db --- /dev/null +++ b/PlacesUIKit3D/src/test/java/com/example/placesuikit3d/common/Map3dViewModelTest.kt @@ -0,0 +1,95 @@ +package com.example.placesuikit3d.common + +import com.example.placesuikit3d.utils.CameraUpdate +import com.example.placesuikit3d.utils.toHeading +import com.example.placesuikit3d.utils.toRange +import com.example.placesuikit3d.utils.toRoll +import com.example.placesuikit3d.utils.toTilt +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class Map3dViewModelTest { + + private class TestMap3dViewModel : Map3dViewModel() { + override val TAG = "TestMap3dViewModel" + + fun getLastEmittedCameraUpdate(): CameraUpdate? { + val field = Map3dViewModel::class.java.getDeclaredField("_pendingCameraUpdate") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + val flow = field.get(this) as MutableSharedFlow + return flow.replayCache.lastOrNull() + } + } + + @Test + fun `setCameraTilt emits Move update with updated tilt`() { + val viewModel = TestMap3dViewModel() + val initialCamera = viewModel.currentCamera.value + val newTilt = 45.0 + + viewModel.setCameraTilt(newTilt) + + val update = viewModel.getLastEmittedCameraUpdate() + assertThat(update).isInstanceOf(CameraUpdate.Move::class.java) + + val moveUpdate = update as CameraUpdate.Move + assertThat(moveUpdate.camera.tilt).isEqualTo(newTilt.toTilt()) + // Ensure other properties remain unchanged + assertThat(moveUpdate.camera.heading).isEqualTo(initialCamera.heading) + assertThat(moveUpdate.camera.range).isEqualTo(initialCamera.range) + assertThat(moveUpdate.camera.roll).isEqualTo(initialCamera.roll) + } + + @Test + fun `setCameraHeading emits Move update with updated heading`() { + val viewModel = TestMap3dViewModel() + val initialCamera = viewModel.currentCamera.value + val newHeading = 180.0 + + viewModel.setCameraHeading(newHeading) + + val update = viewModel.getLastEmittedCameraUpdate() + assertThat(update).isInstanceOf(CameraUpdate.Move::class.java) + + val moveUpdate = update as CameraUpdate.Move + assertThat(moveUpdate.camera.heading).isEqualTo(newHeading.toHeading()) + assertThat(moveUpdate.camera.tilt).isEqualTo(initialCamera.tilt) + } + + @Test + fun `setCameraRange emits Move update with updated range`() { + val viewModel = TestMap3dViewModel() + val initialCamera = viewModel.currentCamera.value + val newRange = 2500.0 + + viewModel.setCameraRange(newRange) + + val update = viewModel.getLastEmittedCameraUpdate() + assertThat(update).isInstanceOf(CameraUpdate.Move::class.java) + + val moveUpdate = update as CameraUpdate.Move + assertThat(moveUpdate.camera.range).isEqualTo(newRange.toRange()) + assertThat(moveUpdate.camera.tilt).isEqualTo(initialCamera.tilt) + } + + @Test + fun `setCameraRoll emits Move update with updated roll`() { + val viewModel = TestMap3dViewModel() + val initialCamera = viewModel.currentCamera.value + val newRoll = 90.0 + + viewModel.setCameraRoll(newRoll) + + val update = viewModel.getLastEmittedCameraUpdate() + assertThat(update).isInstanceOf(CameraUpdate.Move::class.java) + + val moveUpdate = update as CameraUpdate.Move + assertThat(moveUpdate.camera.roll).isEqualTo(newRoll.toRoll()) + assertThat(moveUpdate.camera.tilt).isEqualTo(initialCamera.tilt) + } +} diff --git a/gradle.properties b/gradle.properties index 20e2a01..4540b75 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,14 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5401545..64e6441 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,31 +7,33 @@ core = "1.7.0" json = "20251224" robolectric = "4.16.1" -activityCompose = "1.12.4" -agp = "8.13.2" +activityCompose = "1.13.0" +agp = "9.1.0" appcompat = "1.7.1" -composeBom = "2026.02.01" -coreKtx = "1.17.0" +composeBom = "2026.03.01" +coreKtx = "1.18.0" desugar_jdk_libs = "2.1.5" espressoCore = "3.7.0" junit = "4.13.2" junitVersion = "1.3.0" -kotlin = "2.2.20" +kotlin = "2.3.20" lifecycleRuntimeKtx = "2.10.0" material = "1.13.0" playServicesBase = "18.10.0" playServicesMaps3d = "0.2.0" +places = "5.1.1" secretsGradlePlugin = "2.0.1" truth = "1.4.5" uiautomator = "2.3.0" kotlinxDatetime = "0.7.1" -kotlinxSerialization = "1.7.3" -ktor = "3.0.3" -hilt = "2.57.2" -ksp = "2.2.20-2.0.2" -mapsUtilsKtx = "6.0.0" +kotlinxSerialization = "1.10.0" +ktor = "3.4.1" +hilt = "2.59.2" +ksp = "2.3.2" +mapsUtilsKtx = "6.0.1" +androidx-core-ktx = "1.8.9" [libraries] androidx-core = { module = "androidx.test:core", version.ref = "core" } @@ -55,8 +57,11 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidx-core-ktx" } +androidx-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "androidx-core-ktx" } play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "playServicesBase" } play-services-maps3d = { module = "com.google.android.gms:play-services-maps3d", version.ref = "playServicesMaps3d" } +places = { module = "com.google.android.libraries.places:places", version.ref = "places" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } truth = { module = "com.google.truth:truth", version.ref = "truth" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } @@ -82,4 +87,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsGradlePlugin" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +jetbrains-kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8bdaf60..61285a6 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6a38a8c..b27721f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 53a350d..8477ae3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -56,3 +56,6 @@ include(":Maps3DSamples:advanced:app") include(":Maps3DSamples:ApiDemos:kotlin-app") include(":Maps3DSamples:ApiDemos:java-app") include(":Maps3DSamples:ApiDemos:common") + +// PlacesUIKit3D +include(":PlacesUIKit3D") diff --git a/snippets/gradle/wrapper/gradle-wrapper.properties b/snippets/gradle/wrapper/gradle-wrapper.properties index 6a38a8c..b27721f 100644 --- a/snippets/gradle/wrapper/gradle-wrapper.properties +++ b/snippets/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME