From 45eb0d26206075d5d5e98e12b4b868755c91e0ed Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:11:11 -0600 Subject: [PATCH 01/11] feat: integrate PlacesUIKit3D sample module --- .../ApiDemos/java-app/build.gradle.kts | 2 +- .../ApiDemos/kotlin-app/build.gradle.kts | 2 +- PlacesUIKit3D/build.gradle.kts | 178 ++++++++ PlacesUIKit3D/proguard-rules.pro | 21 + PlacesUIKit3D/src/main/AndroidManifest.xml | 56 +++ .../com/example/placesuikit3d/Landmark.kt | 30 ++ .../com/example/placesuikit3d/LandmarkList.kt | 99 +++++ .../com/example/placesuikit3d/MainActivity.kt | 393 ++++++++++++++++++ .../example/placesuikit3d/MainViewModel.kt | 112 +++++ .../placesuikit3d/Maps3DPlacesApplication.kt | 85 ++++ .../placesuikit3d/common/ActiveMapObject.kt | 48 +++ .../placesuikit3d/common/Map3dViewModel.kt | 378 +++++++++++++++++ .../example/placesuikit3d/common/MapObject.kt | 64 +++ .../example/placesuikit3d/ui/theme/Color.kt | 24 ++ .../example/placesuikit3d/ui/theme/Theme.kt | 70 ++++ .../example/placesuikit3d/ui/theme/Type.kt | 47 +++ .../placesuikit3d/utils/CameraUpdate.kt | 121 ++++++ .../com/example/placesuikit3d/utils/Units.kt | 181 ++++++++ .../example/placesuikit3d/utils/Utilities.kt | 337 +++++++++++++++ .../res/drawable/close_button_background.xml | 21 + .../src/main/res/drawable/ic_close.xml | 27 ++ .../res/drawable/ic_launcher_background.xml | 186 +++++++++ .../res/drawable/ic_launcher_foreground.xml | 47 +++ .../src/main/res/drawable/ic_my_location.xml | 27 ++ .../main/res/drawable/loader_background.xml | 22 + .../res/drawable/outline_my_location_24.xml | 22 + .../src/main/res/font/custom_font.xml | 28 ++ .../src/main/res/layout/activity_main.xml | 121 ++++++ .../main/res/mipmap-anydpi/ic_launcher.xml | 22 + .../res/mipmap-anydpi/ic_launcher_round.xml | 22 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes PlacesUIKit3D/src/main/res/values/colors.xml | 33 ++ PlacesUIKit3D/src/main/res/values/strings.xml | 49 +++ PlacesUIKit3D/src/main/res/values/themes.xml | 61 +++ .../src/main/res/xml/backup_rules.xml | 29 ++ .../main/res/xml/data_extraction_rules.xml | 35 ++ gradle/libs.versions.toml | 16 +- settings.gradle.kts | 3 + 47 files changed, 3012 insertions(+), 7 deletions(-) create mode 100644 PlacesUIKit3D/build.gradle.kts create mode 100644 PlacesUIKit3D/proguard-rules.pro create mode 100644 PlacesUIKit3D/src/main/AndroidManifest.xml create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Landmark.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/LandmarkList.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainViewModel.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/Maps3DPlacesApplication.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/ActiveMapObject.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/MapObject.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Color.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Theme.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/ui/theme/Type.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/CameraUpdate.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Units.kt create mode 100644 PlacesUIKit3D/src/main/java/com/example/placesuikit3d/utils/Utilities.kt create mode 100644 PlacesUIKit3D/src/main/res/drawable/close_button_background.xml create mode 100644 PlacesUIKit3D/src/main/res/drawable/ic_close.xml create mode 100644 PlacesUIKit3D/src/main/res/drawable/ic_launcher_background.xml create mode 100644 PlacesUIKit3D/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 PlacesUIKit3D/src/main/res/drawable/ic_my_location.xml create mode 100644 PlacesUIKit3D/src/main/res/drawable/loader_background.xml create mode 100644 PlacesUIKit3D/src/main/res/drawable/outline_my_location_24.xml create mode 100644 PlacesUIKit3D/src/main/res/font/custom_font.xml create mode 100644 PlacesUIKit3D/src/main/res/layout/activity_main.xml create mode 100644 PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 PlacesUIKit3D/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 PlacesUIKit3D/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 PlacesUIKit3D/src/main/res/values/colors.xml create mode 100644 PlacesUIKit3D/src/main/res/values/strings.xml create mode 100644 PlacesUIKit3D/src/main/res/values/themes.xml create mode 100644 PlacesUIKit3D/src/main/res/xml/backup_rules.xml create mode 100644 PlacesUIKit3D/src/main/res/xml/data_extraction_rules.xml diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts index 73cccaa..450801e 100644 --- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts @@ -136,7 +136,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 59e7df4..ae43cb8 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts @@ -141,7 +141,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/PlacesUIKit3D/build.gradle.kts b/PlacesUIKit3D/build.gradle.kts new file mode 100644 index 0000000..a6f61ca --- /dev/null +++ b/PlacesUIKit3D/build.gradle.kts @@ -0,0 +1,178 @@ +/* + * 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) + // `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..33004ce --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt @@ -0,0 +1,393 @@ +// 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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.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.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 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. + */ +class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { + private val TAG = this::class.java.simpleName + private var googleMap3D: GoogleMap3D? = null + + private lateinit var fusedLocationClient: FusedLocationProviderClient + private lateinit var requestPermissionLauncher: ActivityResultLauncher> + private val viewModel: MainViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { + fetchLastLocation() + } else { + Toast.makeText(this, "Location permission denied. Showing default location.", Toast.LENGTH_SHORT).show() + moveToDefaultLocation() + } + } + + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + PlacesUIKit3DTheme { + MainScreen() + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun MainScreen() { + val landmarks = viewModel.landmarks + val selectedPlaceId by viewModel.placeId.collectAsState() + val scope = rememberCoroutineScope() + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded + ) + ) + + Box(modifier = Modifier.fillMaxSize()) { + BottomSheetScaffold( + scaffoldState = scaffoldState, + sheetPeekHeight = 120.dp, + sheetContent = { + LandmarkList( + landmarks = landmarks, + onLandmarkClick = { landmark -> + viewModel.selectLandmark(landmark) + flyToLandmark(landmark) + scope.launch { + scaffoldState.bottomSheetState.partialExpand() + } + }, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.6f) + ) + } + ) { _ -> + // Map occupies full screen, ignoring scaffold padding + Box(modifier = Modifier.fillMaxSize()) { + MapViewContainer() + + FloatingActionButton( + onClick = { fetchLastLocation() }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 48.dp, end = 16.dp) + ) { + Icon(Icons.Default.MyLocation, contentDescription = "My Location") + } + } + } + + // Overlay stays on top of the scaffold (outer Box) + if (!selectedPlaceId.isNullOrEmpty()) { + PlaceDetailsOverlay( + placeId = selectedPlaceId!!, + onDismiss = { viewModel.setSelectedPlaceId(null) }, + modifier = Modifier + .align(Alignment.BottomCenter) + // Anchor above the bottom sheet peek height (120dp + 16dp margin) + .padding(bottom = 136.dp, start = 16.dp, end = 16.dp) + ) + } + } + } + + @Composable + fun MapViewContainer() { + val context = LocalContext.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + + val map3DView = remember { + com.google.android.gms.maps3d.Map3DView(context).apply { + getMap3DViewAsync(this@MainActivity) + } + } + + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> { + map3DView.onCreate(null) + } + Lifecycle.Event.ON_RESUME -> { + map3DView.onResume() + } + Lifecycle.Event.ON_PAUSE -> { + map3DView.onPause() + } + Lifecycle.Event.ON_DESTROY -> { + map3DView.onDestroy() + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + AndroidView( + factory = { map3DView }, + modifier = Modifier.fillMaxSize() + ) + } + + @Composable + fun PlaceDetailsOverlay( + placeId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier + ) { + val containerId = remember { View.generateViewId() } + + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium) + ) { + AndroidView( + factory = { ctx -> + FragmentContainerView(ctx).apply { + id = containerId + } + }, + update = { view -> + val fragment = supportFragmentManager.findFragmentById(containerId) as? PlaceDetailsCompactFragment + if (fragment == null) { + val newFragment = PlaceDetailsCompactFragment.newInstance( + PlaceDetailsCompactFragment.ALL_CONTENT, + Orientation.VERTICAL, + R.style.CustomizedPlaceDetailsTheme + ).apply { + setPlaceLoadListener(object : PlaceLoadListener { + override fun onSuccess(place: Place) { + Log.d(TAG, "Place loaded: ${place.id}") + } + + override fun onFailure(e: Exception) { + Log.e(TAG, "Place failed to load for ID: $placeId", e) + // Don't auto-dismiss on failure to prevent "disappearing" components. + // The fragment should handle its own error state. + } + }) + } + supportFragmentManager.commit { + replace(containerId, newFragment) + } + // Tag the view with the current ID and post the load + view.tag = placeId + Log.e(TAG, "Loading new fragment for placeId: $placeId") + view.post { newFragment.loadWithPlaceId(placeId) } + } else { + // Crucially, ONLY load if the place actually changed + val currentlyLoaded = view.tag as? String + if (currentlyLoaded != placeId) { + view.tag = placeId + Log.e(TAG, "Updating existing fragment for placeId: $placeId") + fragment.loadWithPlaceId(placeId) + } + } + }, + modifier = Modifier.fillMaxWidth() + ) + + FloatingActionButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_close), + contentDescription = "Dismiss" + ) + } + } + + // Clean up fragment when leaving composition + androidx.compose.runtime.DisposableEffect(containerId) { + onDispose { + supportFragmentManager.findFragmentById(containerId)?.let { + supportFragmentManager.commit { + remove(it) + } + } + } + } + } + + private fun flyToLandmark(landmark: Landmark) { + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = camera { + center = landmark.location + range = 1000.0 + tilt = 45.0 + }.toValidCamera() + durationInMillis = 2000 + } + ) + } + + override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { + this.googleMap3D = googleMap3D + googleMap3D.setMapMode(Map3DMode.HYBRID) + googleMap3D.setCamera(initialCamera) + + googleMap3D.setMap3DClickListener { _, placeId -> + Log.e(TAG, "Map clicked: placeId=$placeId") + if (!placeId.isNullOrEmpty()) { + viewModel.setSelectedPlaceId(placeId) + } + } + + if (isLocationPermissionGranted()) { + fetchLastLocation() + } else { + requestLocationPermissions() + } + } + + private fun isLocationPermissionGranted(): Boolean { + return ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + } + + private fun requestLocationPermissions() { + requestPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + } + + @SuppressLint("MissingPermission") + private fun fetchLastLocation() { + if (isLocationPermissionGranted()) { + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + location?.let { + val userLocation = latLngAltitude { + latitude = it.latitude + longitude = it.longitude + altitude = it.altitude + } + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = camera { + center = userLocation + range = 5000.0 + tilt = 60.0 + }.toValidCamera() + durationInMillis = 3000 + } + ) + } ?: moveToDefaultLocation() + }.addOnFailureListener { moveToDefaultLocation() } + } + } + + private fun moveToDefaultLocation() { + googleMap3D?.flyCameraTo(flyToOptions { endCamera = initialCamera; durationInMillis = 3000 }) + } + + override fun onError(error: Exception) { + Log.e(TAG, "Error loading map", error) + super.onError(error) + } + + companion object { + private val initialCamera: Camera = camera { + center = latLngAltitude { + latitude = 39.982129291022446 + longitude = -105.30156359691158 + altitude = 8148.feet.value + } + heading = 26.0 + tilt = 67.0 + range = 4000.0 + }.toValidCamera() + } +} 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..efca63a --- /dev/null +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt @@ -0,0 +1,378 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +//   http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.placesuikit3d.common + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.placesuikit3d.utils.CameraUpdate +import com.example.placesuikit3d.utils.copy +import com.example.placesuikit3d.utils.toCameraUpdate +import com.example.placesuikit3d.utils.toHeading +import com.example.placesuikit3d.utils.toRange +import com.example.placesuikit3d.utils.toRoll +import com.example.placesuikit3d.utils.toTilt +import com.example.placesuikit3d.utils.toValidCamera +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.OnCameraChangedListener +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.Camera.DEFAULT_CAMERA +import com.google.android.gms.maps3d.model.CameraRestriction +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.MarkerOptions +import com.google.android.gms.maps3d.model.Model +import com.google.android.gms.maps3d.model.ModelOptions +import com.google.android.gms.maps3d.model.PolygonOptions +import com.google.android.gms.maps3d.model.PolylineOptions +import com.google.android.gms.maps3d.model.flyAroundOptions +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.time.Duration + +abstract class Map3dViewModel : ViewModel() { + abstract val TAG: String + + /** + * The internal state flow holding the GoogleMap3D controller instance. + * + * This flow is used internally to manage the lifecycle and access to the + * GoogleMap3D object provided by the MapView. + * It's updated via the `setGoogleMap3D` function. + * + * Consumers should use the `mapReady` flow to react to the availability of the map. + */ + private var _googleMap3D = MutableStateFlow(null) + + private val _cameraRestriction = MutableStateFlow(null) + val cameraRestriction = _cameraRestriction.asStateFlow() + + private val _mapMode = MutableStateFlow(Map3DMode.SATELLITE) + val mapMode = _mapMode.asStateFlow() + + // --- Camera Position from Map & Pending Requests --- + // This is guaranteed to always be a valid camera + private val _currentCamera = MutableStateFlow(DEFAULT_CAMERA) + val currentCamera = _currentCamera.asStateFlow() + + private val mapObjects = mutableMapOf() + + /** + * A [MutableSharedFlow] that buffers [CameraUpdate] requests. + * + * This flow is used to queue camera updates requested by the ViewModel's consumers. + * When a new camera update is emitted to this flow, it's buffered with a replay of 1, + * meaning the latest update is available to new collectors. If a new update arrives + * before the previous one is processed, the older one is dropped (`BufferOverflow.DROP_OLDEST`). + * + * This allows the ViewModel to handle camera update requests asynchronously and + * ensures that only the most recent request is processed if updates occur rapidly. + * The actual camera update is performed within a separate coroutine that collects + * from this flow. + */ + private val _pendingCameraUpdate = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val activeMapObjects = mutableMapOf() + + val mapReady = _googleMap3D.map { it != null } + + init { + viewModelScope.launch { + _googleMap3D.collect { controller -> + stopAnimations() + clearObjects() + Log.d(TAG, "Map3D Controller attached") + if (controller != null) { + launch { + Log.d(TAG, "Getting camera flow") + getCameraFlow(controller).collect { camera -> + _currentCamera.value = camera + } + } + addMapObjects(mapObjects, controller) + + // Return to the last camera position if available + controller.setCamera(currentCamera.value) + + // Process pending camera updates + launch { + _pendingCameraUpdate + .filterNotNull() + .collect { cameraUpdate -> + Log.d(TAG, "Received camera update request: $cameraUpdate") + cameraUpdate(controller) + } + } + + launch { + _mapMode.collect { mapMode -> + controller.setMapMode(mapMode) + } + } + + launch { + _cameraRestriction.collect { cameraRestriction -> + controller.setCameraRestriction(cameraRestriction) + } + } + } + } + } + } + + /** + * Returns a Flow that emits the current camera position whenever it changes on the GoogleMap3D. + * + * This Flow is created using `callbackFlow` to bridge the callback-based API of + * `OnCameraChangedListener` with Kotlin's coroutine Flows. It automatically attaches and + * detaches the listener when collectors subscribe and unsubscribe. + * + * The Flow emits a validated `Camera` object, ensuring that the pitch, range, and bearing + * are within acceptable limits using the `toValidCamera()` extension function. + * + * @param controller The GoogleMap3D instance to listen for camera changes on. + * @return A Flow of `Camera` objects representing the current camera position. + */ + private fun getCameraFlow(controller: GoogleMap3D): Flow { + // Public Flow that manages the listener lifecycle + return callbackFlow { + val cameraChangedListener = OnCameraChangedListener { cameraPosition -> + val newPosition = cameraPosition.toValidCamera() + // Send the new camera position to the flow's channel + trySend(newPosition) + // Also update the private state + _currentCamera.value = newPosition + } + + // Get the current map instance (ensure it's not null before setting listener) + Log.d(TAG, "Attaching CameraChangeListener") + controller.setCameraChangedListener(cameraChangedListener) + + // Ensure the initial camera position is emitted when the flow is collected + // This handles cases where the map is ready before the flow is collected + controller.getCamera()?.let { initial -> + val newPosition = initial.toValidCamera() + trySend(newPosition) + _currentCamera.value = newPosition // Also update private state on collection + } + + // The awaitClose block runs when the collector is cancelled + awaitClose { + // Remove the listener when the flow collection stops + Log.d(TAG, "Detaching CameraChangeListener") + controller.setCameraChangedListener(null) + } + } + } + + /** + * Adds a collection of map objects to the GoogleMap3D controller. + * + * This function iterates through a mutable map of MapObject instances and adds each one + * to the provided `GoogleMap3D` controller. For each successfully added object, + * it stores the resulting active map object in the `activeMapObjects` map + * for later management (like removal). + * + * @param mapObjects A mutable map where keys are object IDs (String) and values + * are MapObject instances to be added to the map. + * @param controller The GoogleMap3D controller to which the objects will be added. + */ + private fun addMapObjects( + mapObjects: MutableMap, + controller: GoogleMap3D + ) { + mapObjects.forEach { (_, mapObject) -> + mapObject.addToMap(controller)?.also { activeObject -> + activeMapObjects[mapObject.id] = activeObject + } + } + } + + /** + * Sets the Map3DController instance. + * + * @param googleMap3d The GoogleMap3D instance, or null if it's being detached. + */ + open fun setGoogleMap3D(googleMap3d: GoogleMap3D?) { + _googleMap3D.value = googleMap3d + } + + private fun stopAnimations() { + Log.d("Map3dViewModel", "stopAnimations: ") + _googleMap3D.value?.stopCameraAnimation() + } + + open fun releaseGoogleMap3D() { + _googleMap3D.value = null + } + + /** + * Clears the ViewModel's internal tracking of active SDK map objects. + * This is called when the controller is detached or changed, as the underlying + * map instance those objects belonged to is no longer relevant. + */ + fun clearObjects() { + activeMapObjects.forEach { (_, activeObject) -> + activeObject.remove() + } + activeMapObjects.clear() + } + + private fun addMapObject(mapObject: MapObject) { + mapObjects[mapObject.id] = mapObject // No need to remove the old as the map will replace it + _googleMap3D.value?.also { controller -> + mapObject.addToMap(controller)?.also { activeObject -> + activeMapObjects[mapObject.id] = activeObject + } + } + } + + fun addMarker(options: MarkerOptions) { + addMapObject(MapObject.Marker(options)) + } + + fun removeMapObject(id: String) { + mapObjects.remove(id) + activeMapObjects.remove(id)?.also { activeObject -> + activeObject.remove() + } + } + + fun addPolyline(polylineOptions: PolylineOptions) { + addMapObject(MapObject.Polyline(polylineOptions)) + } + + fun addPolygon(polygonOptions: PolygonOptions) { + addMapObject(MapObject.Polygon(polygonOptions)) + } + + fun addModel(modelOptions: ModelOptions) { + addMapObject(MapObject.Model(modelOptions)) + } + + fun setCamera(camera: Camera) { + CameraUpdate.Move(camera).also { _pendingCameraUpdate.tryEmit(it) } + } + + fun flyTo(flyToOptions: FlyToOptions) { + CameraUpdate.FlyTo(flyToOptions).also { _pendingCameraUpdate.tryEmit(it) } + } + + fun flyAround(flyAroundOptions: FlyAroundOptions) { + CameraUpdate.FlyAround(flyAroundOptions).also { _pendingCameraUpdate.tryEmit(it) } + } + + fun setCameraRestriction(cameraRestriction: CameraRestriction?) { + _cameraRestriction.value = cameraRestriction + } + + fun setMapMode(@Map3DMode mode: Int) { + _mapMode.value = mode + } + + override fun onCleared() { + _googleMap3D.value = null + super.onCleared() + } + + open fun updateCameraAndMove(block: Camera.() -> Camera) { + currentCamera.value.let { camera -> + _pendingCameraUpdate.tryEmit( + CameraUpdate.Move( + camera.block() // .also { _currentCamera.value = it } + ) + ) + } + } + + open fun setCameraHeading(heading: Number) { + updateCameraAndMove { + copy(heading = heading.toHeading()) + } + } + + open fun setCameraTilt(tilt: Number) { + updateCameraAndMove { + copy(heading = tilt.toTilt()) + } + } + + open fun setCamaraRange(range: Number) { + updateCameraAndMove { + copy(range = range.toRange()) + } + } + + open fun setCamaraRoll(roll: Number) { + updateCameraAndMove { + copy(roll = roll.toRoll()) + } + } + + fun flyAroundCurrentCenter(rounds: Double, duration: Duration) { + currentCamera.value.let { camera -> + flyAround( + flyAroundOptions { + center = camera + durationInMillis = duration.inWholeMilliseconds + this.rounds = rounds + } + ) + } + } + + fun getModel(key: String): Model? { + activeMapObjects[key]?.let { activeObject -> + if (activeObject is ActiveMapObject.ActiveModel) { + return activeObject.model + } + } + return null + } + + fun nextMapMode() { + val newMapType = when (mapMode.value) { + Map3DMode.SATELLITE -> Map3DMode.HYBRID + else -> Map3DMode.SATELLITE + } + setMapMode(newMapType) + } + + suspend fun awaitFlyTo(flyToOptions: FlyToOptions) { + awaitCameraUpdate(flyToOptions.toCameraUpdate()) + } + + suspend fun awaitFlyAround(flyAroundOptions: FlyAroundOptions) { + awaitCameraUpdate(flyAroundOptions.toCameraUpdate()) + } + + suspend fun awaitCameraUpdate(cameraUpdate: CameraUpdate) { + _googleMap3D.value?.let { controller -> + com.example.placesuikit3d.utils.awaitCameraUpdate(controller, cameraUpdate) + } + } +} 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 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 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..d19814b --- /dev/null +++ b/PlacesUIKit3D/src/main/res/values/strings.xml @@ -0,0 +1,49 @@ + + + + + Places UI Kit 3D + + + %1$,.0f ft + + + %1$,.1f miles + + + %1$,.0f m + + + %1$,.1f km + + Dismiss place details + Loading… + My 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index f483db1..d77eaa0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,11 +3,11 @@ compileSdk = "36" minSdk = "26" targetSdk = "36" -activityCompose = "1.12.4" +activityCompose = "1.13.0" agp = "8.13.2" appcompat = "1.7.1" -composeBom = "2026.02.01" -coreKtx = "1.17.0" +composeBom = "2026.03.00" +coreKtx = "1.18.0" desugar_jdk_libs = "2.1.5" espressoCore = "3.7.0" junit = "4.13.2" @@ -18,6 +18,7 @@ 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" @@ -25,7 +26,8 @@ uiautomator = "2.3.0" kotlinxDatetime = "0.7.1" hilt = "2.57.2" ksp = "2.2.20-2.0.2" -mapsUtilsKtx = "6.0.0" +mapsUtilsKtx = "6.0.1" +androidx-core-ktx = "1.8.9" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -47,9 +49,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" } -truth = { module = "com.google.truth:truth", version.ref = "truth" } +places = { module = "com.google.android.libraries.places:places", version.ref = "places" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } @@ -68,3 +72,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" } + diff --git a/settings.gradle.kts b/settings.gradle.kts index a554202..b5f7836 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") From 95a6d877bd82aa982208b9b8b1f2b52e3e173b85 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:51:05 -0600 Subject: [PATCH 02/11] refactor: apply DevRel Code Review recommendations and document fragment interop --- .agents/workflows/devrel-code-reviewer.md | 66 +++++++++++++++++++ .../com/example/placesuikit3d/MainActivity.kt | 62 +++++++++++------ .../placesuikit3d/common/Map3dViewModel.kt | 3 +- 3 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 .agents/workflows/devrel-code-reviewer.md diff --git a/.agents/workflows/devrel-code-reviewer.md b/.agents/workflows/devrel-code-reviewer.md new file mode 100644 index 0000000..5b68195 --- /dev/null +++ b/.agents/workflows/devrel-code-reviewer.md @@ -0,0 +1,66 @@ +--- +description: Evaluates Android samples, snippets, and codelabs for teachability, idiomatic Kotlin, and documentation quality. +--- +# Skill: DevRel Android Code Reviewer + +## Role +You are a Principal Android Developer Advocate and Technical Editor at Google. Your specialty is creating "Gold Standard" open-source reference implementations. Your audience is human developers—ranging from junior to advanced—who are looking at this repository to learn how to build production-grade apps. + +## Core Philosophy +When reviewing sample code, demos, or codelabs, you must adhere to the following principles: +* **The Balancing Act:** The code must be descriptive and straightforward enough to teach a junior-to-average developer, but not written in a way that talks down to advanced developers. Advanced use cases are acceptable if they are exceptionally well-explained. +* **Humility:** You must acknowledge that the code presented is *one* valid approach. Maintain a tone of humility, recognizing that there are often other (and sometimes superior) ways to solve a problem depending on the exact context. +* **The "Why" over the "What":** Comments should explain architectural decisions and intent, not basic syntax. + +## Instructions + +### Phase 1: Intake & Focus +If the user provides code without context, immediately ask: +1. "Please provide the Kotlin files, XML/Compose files, and any documentation (README/ARCHITECTURE.md) you want reviewed." +2. "Are there specific areas you are worried about? (e.g., Coroutine safety, clarity of the tutorial aspect, architecture?)" + +### Phase 2: Evaluation Criteria +Evaluate the diff or files based on these four pillars: + +1. **Teachability (The "Modest Skill" Test):** + * Can a developer read this top-to-bottom and understand the logical flow without excessive cognitive load? + * Is the code free of unnecessary boilerplate or over-engineering? Do not suggest adding complex Jetpack libraries (like Hilt) to simple samples unless they are already present or strictly necessary for the lesson. +2. **Context-Aware Modernity:** + * Is the Kotlin idiomatic? (Proper use of extension functions, scope functions, coroutines/Flow). + * Are the SDKs (e.g., Maps, Firebase) used according to the latest best practices? + * Does the code respect the existing UI framework (Compose vs. XML)? +3. **Literate Programming & Documentation:** + * Is the code self-documenting through clear, unambiguous variable and function names? + * Do public functions have KDoc? + * Does the README.md explain how to run the code, and does the ARCHITECTURE.md explain the structural choices? +4. **Robustness:** + * Are edge cases, explicit nulls, offline states, and permissions handled in a way that teaches the user how to build resilient apps? + +## Output Format +Your response must strictly follow this Markdown structure. Do not use JSON. + +--- + +### 🧐 Scope of Review +*[Briefly confirm what files were analyzed and note any specific focus areas requested by the user.]* + +### 🌟 High-Level Assessment +*[A 1-2 paragraph summary answering: "Does this code teach the developer the right way to do things?" State whether the change is approved or needs revision. Include a humble acknowledgment that while this is a solid approach, alternative architectures exist.]* + +### 📚 Documentation & Readability Check +* **README/Architecture Status:** [Pass / Fail / Not Provided] - *[Brief reasoning]* +* **Comment Quality:** [Pass / Fail] - *[Are we explaining the 'Why'?]* +* **Naming Conventions:** [Pass / Fail] - *[Are variables descriptive?]* + +### 🛠️ Actionable Suggestions +*[Use this section for specific, line-by-line feedback. If there are no issues, state: "No suggestions, this looks great!". Otherwise, format EACH suggestion exactly like this:]* + +**Location:** `[File:Line(s)]` +**Critique:** *[Explain WHY this is problematic for a learner. Reference the core criteria (e.g., "This nesting is hard to follow for a junior dev" or "This comment just repeats the code").]* +**Suggestion:** *[Provide a clear instruction for the fix.]* +```kotlin +// [Provide the exact refactored, commented, idiomatic code block here] +``` + +💡 "Best of Breed" Suggestions (Optional) +[If the code is already good, suggest one "Pro Tip" that would take it from "Good" to "Great" without over-complicating the tutorial aspect.] diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt index 33004ce..8930f51 100644 --- a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt @@ -55,6 +55,7 @@ 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.FragmentActivity import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle @@ -62,6 +63,7 @@ import androidx.lifecycle.LifecycleEventObserver import com.example.placesuikit3d.ui.theme.PlacesUIKit3DTheme import com.example.placesuikit3d.utils.feet import com.example.placesuikit3d.utils.toValidCamera +import dagger.hilt.android.AndroidEntryPoint import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import com.google.android.gms.maps3d.GoogleMap3D @@ -76,6 +78,7 @@ 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 kotlinx.coroutines.launch +import androidx.compose.runtime.LaunchedEffect /** * The main activity for the 3D map demo. @@ -83,6 +86,7 @@ import kotlinx.coroutines.launch * 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 @@ -217,6 +221,26 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { ) } + /** + * 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, @@ -225,6 +249,17 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { ) { 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 .fillMaxWidth() @@ -235,11 +270,9 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { factory = { ctx -> FragmentContainerView(ctx).apply { id = containerId - } - }, - update = { view -> - val fragment = supportFragmentManager.findFragmentById(containerId) as? PlaceDetailsCompactFragment - if (fragment == null) { + + // 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, @@ -252,26 +285,17 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { override fun onFailure(e: Exception) { Log.e(TAG, "Place failed to load for ID: $placeId", e) - // Don't auto-dismiss on failure to prevent "disappearing" components. - // The fragment should handle its own error state. } }) } + supportFragmentManager.commit { replace(containerId, newFragment) } - // Tag the view with the current ID and post the load - view.tag = placeId - Log.e(TAG, "Loading new fragment for placeId: $placeId") - view.post { newFragment.loadWithPlaceId(placeId) } - } else { - // Crucially, ONLY load if the place actually changed - val currentlyLoaded = view.tag as? String - if (currentlyLoaded != placeId) { - view.tag = placeId - Log.e(TAG, "Updating existing fragment for placeId: $placeId") - fragment.loadWithPlaceId(placeId) - } + + // 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() diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt index efca63a..5941c0e 100644 --- a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt @@ -46,6 +46,7 @@ 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 @@ -186,7 +187,7 @@ abstract class Map3dViewModel : ViewModel() { Log.d(TAG, "Detaching CameraChangeListener") controller.setCameraChangedListener(null) } - } + }.conflate() } /** From a1d13e6f7436592f30ae8cc2354afb7be8c93039 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:01:26 -0600 Subject: [PATCH 03/11] refactor: apply DevRel Code Review UI Polish (extract strings, fix brittle layout padding, handle explicit location failure, remove magic numbers) --- .../com/example/placesuikit3d/MainActivity.kt | 27 +++++++++++-------- PlacesUIKit3D/src/main/res/values/strings.xml | 2 ++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt index 8930f51..9f4c5bd 100644 --- a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt @@ -103,7 +103,7 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { fetchLastLocation() } else { - Toast.makeText(this, "Location permission denied. Showing default location.", Toast.LENGTH_SHORT).show() + Toast.makeText(this, getString(R.string.location_permission_denied), Toast.LENGTH_SHORT).show() moveToDefaultLocation() } } @@ -130,11 +130,12 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { initialValue = SheetValue.PartiallyExpanded ) ) + val sheetPeekHeight = 120.dp Box(modifier = Modifier.fillMaxSize()) { BottomSheetScaffold( scaffoldState = scaffoldState, - sheetPeekHeight = 120.dp, + sheetPeekHeight = sheetPeekHeight, sheetContent = { LandmarkList( landmarks = landmarks, @@ -145,9 +146,7 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { scaffoldState.bottomSheetState.partialExpand() } }, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.6f) + modifier = Modifier.fillMaxWidth() ) } ) { _ -> @@ -161,7 +160,7 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { .align(Alignment.TopEnd) .padding(top = 48.dp, end = 16.dp) ) { - Icon(Icons.Default.MyLocation, contentDescription = "My Location") + Icon(Icons.Default.MyLocation, contentDescription = androidx.compose.ui.res.stringResource(id = R.string.my_location)) } } } @@ -173,8 +172,8 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { onDismiss = { viewModel.setSelectedPlaceId(null) }, modifier = Modifier .align(Alignment.BottomCenter) - // Anchor above the bottom sheet peek height (120dp + 16dp margin) - .padding(bottom = 136.dp, start = 16.dp, end = 16.dp) + // Anchor dynamically above the bottom sheet + .padding(bottom = sheetPeekHeight + 16.dp, start = 16.dp, end = 16.dp) ) } } @@ -310,7 +309,7 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { ) { Icon( painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_close), - contentDescription = "Dismiss" + contentDescription = androidx.compose.ui.res.stringResource(id = R.string.dismiss_button_content_description) ) } } @@ -388,8 +387,14 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { durationInMillis = 3000 } ) - } ?: moveToDefaultLocation() - }.addOnFailureListener { moveToDefaultLocation() } + } ?: 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() + } } } diff --git a/PlacesUIKit3D/src/main/res/values/strings.xml b/PlacesUIKit3D/src/main/res/values/strings.xml index d19814b..ebbccdf 100644 --- a/PlacesUIKit3D/src/main/res/values/strings.xml +++ b/PlacesUIKit3D/src/main/res/values/strings.xml @@ -45,5 +45,7 @@ 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 From dcfd0f7ebe0f9e97c703340fce3127cc3bc4a452 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:15:11 -0600 Subject: [PATCH 04/11] style: fix foldable layout constraints for BottomSheet and PlaceDetailsOverlay * Limits BottomSheet width to 600dp on large screens * Centers the BottomSheet modifier to look balanced on foldables * Allows PlaceDetailsCompactFragment to properly wrap its internal height * Automatically dismisses PlaceDetailsOverlay when bottom sheet is expanded --- .../com/example/placesuikit3d/MainActivity.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt index 9f4c5bd..acfb908 100644 --- a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/MainActivity.kt @@ -28,11 +28,10 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material3.BottomSheetScaffold @@ -44,6 +43,7 @@ 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 @@ -55,7 +55,6 @@ 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.FragmentActivity import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle @@ -63,7 +62,6 @@ import androidx.lifecycle.LifecycleEventObserver import com.example.placesuikit3d.ui.theme.PlacesUIKit3DTheme import com.example.placesuikit3d.utils.feet import com.example.placesuikit3d.utils.toValidCamera -import dagger.hilt.android.AndroidEntryPoint import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import com.google.android.gms.maps3d.GoogleMap3D @@ -77,8 +75,8 @@ 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 -import androidx.compose.runtime.LaunchedEffect /** * The main activity for the 3D map demo. @@ -132,7 +130,18 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { ) val sheetPeekHeight = 120.dp - Box(modifier = Modifier.fillMaxSize()) { + // 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, @@ -146,7 +155,8 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { scaffoldState.bottomSheetState.partialExpand() } }, - modifier = Modifier.fillMaxWidth() + // Limit list width so the sheet doesn't stretch across a tablet + modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp) ) } ) { _ -> @@ -261,8 +271,8 @@ class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback { Box( modifier = modifier + .widthIn(max = 600.dp) .fillMaxWidth() - .heightIn(max = 400.dp) .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium) ) { AndroidView( From ac045a03ec4b1d9df6764bd9f517f1f5f39892bd Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:23:04 -0600 Subject: [PATCH 05/11] test: add MainViewModelTest to satisfy zero-trust testing directive * Verifies initial StateFlow states * Verifies Landmark and PlaceId selection flows * Proves PlaceDetails dismissal behavior when setting placeId to null --- .../placesuikit3d/MainViewModelTest.kt | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 PlacesUIKit3D/src/test/java/com/example/placesuikit3d/MainViewModelTest.kt 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) + } +} From 150b633fa2c47ae9230369bd4c3c10e0d9766107 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:23:52 -0600 Subject: [PATCH 06/11] chore: remove devrel-code-reviewer workflow --- .agents/workflows/devrel-code-reviewer.md | 66 ----------------------- 1 file changed, 66 deletions(-) delete mode 100644 .agents/workflows/devrel-code-reviewer.md diff --git a/.agents/workflows/devrel-code-reviewer.md b/.agents/workflows/devrel-code-reviewer.md deleted file mode 100644 index 5b68195..0000000 --- a/.agents/workflows/devrel-code-reviewer.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -description: Evaluates Android samples, snippets, and codelabs for teachability, idiomatic Kotlin, and documentation quality. ---- -# Skill: DevRel Android Code Reviewer - -## Role -You are a Principal Android Developer Advocate and Technical Editor at Google. Your specialty is creating "Gold Standard" open-source reference implementations. Your audience is human developers—ranging from junior to advanced—who are looking at this repository to learn how to build production-grade apps. - -## Core Philosophy -When reviewing sample code, demos, or codelabs, you must adhere to the following principles: -* **The Balancing Act:** The code must be descriptive and straightforward enough to teach a junior-to-average developer, but not written in a way that talks down to advanced developers. Advanced use cases are acceptable if they are exceptionally well-explained. -* **Humility:** You must acknowledge that the code presented is *one* valid approach. Maintain a tone of humility, recognizing that there are often other (and sometimes superior) ways to solve a problem depending on the exact context. -* **The "Why" over the "What":** Comments should explain architectural decisions and intent, not basic syntax. - -## Instructions - -### Phase 1: Intake & Focus -If the user provides code without context, immediately ask: -1. "Please provide the Kotlin files, XML/Compose files, and any documentation (README/ARCHITECTURE.md) you want reviewed." -2. "Are there specific areas you are worried about? (e.g., Coroutine safety, clarity of the tutorial aspect, architecture?)" - -### Phase 2: Evaluation Criteria -Evaluate the diff or files based on these four pillars: - -1. **Teachability (The "Modest Skill" Test):** - * Can a developer read this top-to-bottom and understand the logical flow without excessive cognitive load? - * Is the code free of unnecessary boilerplate or over-engineering? Do not suggest adding complex Jetpack libraries (like Hilt) to simple samples unless they are already present or strictly necessary for the lesson. -2. **Context-Aware Modernity:** - * Is the Kotlin idiomatic? (Proper use of extension functions, scope functions, coroutines/Flow). - * Are the SDKs (e.g., Maps, Firebase) used according to the latest best practices? - * Does the code respect the existing UI framework (Compose vs. XML)? -3. **Literate Programming & Documentation:** - * Is the code self-documenting through clear, unambiguous variable and function names? - * Do public functions have KDoc? - * Does the README.md explain how to run the code, and does the ARCHITECTURE.md explain the structural choices? -4. **Robustness:** - * Are edge cases, explicit nulls, offline states, and permissions handled in a way that teaches the user how to build resilient apps? - -## Output Format -Your response must strictly follow this Markdown structure. Do not use JSON. - ---- - -### 🧐 Scope of Review -*[Briefly confirm what files were analyzed and note any specific focus areas requested by the user.]* - -### 🌟 High-Level Assessment -*[A 1-2 paragraph summary answering: "Does this code teach the developer the right way to do things?" State whether the change is approved or needs revision. Include a humble acknowledgment that while this is a solid approach, alternative architectures exist.]* - -### 📚 Documentation & Readability Check -* **README/Architecture Status:** [Pass / Fail / Not Provided] - *[Brief reasoning]* -* **Comment Quality:** [Pass / Fail] - *[Are we explaining the 'Why'?]* -* **Naming Conventions:** [Pass / Fail] - *[Are variables descriptive?]* - -### 🛠️ Actionable Suggestions -*[Use this section for specific, line-by-line feedback. If there are no issues, state: "No suggestions, this looks great!". Otherwise, format EACH suggestion exactly like this:]* - -**Location:** `[File:Line(s)]` -**Critique:** *[Explain WHY this is problematic for a learner. Reference the core criteria (e.g., "This nesting is hard to follow for a junior dev" or "This comment just repeats the code").]* -**Suggestion:** *[Provide a clear instruction for the fix.]* -```kotlin -// [Provide the exact refactored, commented, idiomatic code block here] -``` - -💡 "Best of Breed" Suggestions (Optional) -[If the code is already good, suggest one "Pro Tip" that would take it from "Good" to "Great" without over-complicating the tutorial aspect.] From f80ecfddb88b9b928f8fc10ad1174445659299ca Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:17:51 -0600 Subject: [PATCH 07/11] fix(ui-kit): correct Map3dViewModel camera logic and method typos * Corrects a critical logic error where `setCameraTilt` incorrectly assigned its value to the `heading` property. * Resolves spelling errors in camera method signatures (`setCamaraRange` -> `setCameraRange`, `setCamaraRoll` -> `setCameraRoll`). * Adds comprehensive unit testing via `Map3dViewModelTest` to verify the immutability and accuracy of map CameraUpdate emissions. * Introduces the Robolectric test dependency to support the ViewModel coroutine scope in the test suite. --- PlacesUIKit3D/build.gradle.kts | 1 + .../placesuikit3d/common/Map3dViewModel.kt | 6 +- .../common/Map3dViewModelTest.kt | 95 +++++++++++++++++++ gradle.properties | 12 ++- gradle/libs.versions.toml | 14 +-- gradle/wrapper/gradle-wrapper.properties | 4 +- 6 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 PlacesUIKit3D/src/test/java/com/example/placesuikit3d/common/Map3dViewModelTest.kt diff --git a/PlacesUIKit3D/build.gradle.kts b/PlacesUIKit3D/build.gradle.kts index a6f61ca..67405d3 100644 --- a/PlacesUIKit3D/build.gradle.kts +++ b/PlacesUIKit3D/build.gradle.kts @@ -149,6 +149,7 @@ dependencies { // `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. diff --git a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt index 5941c0e..fbbc125 100644 --- a/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt +++ b/PlacesUIKit3D/src/main/java/com/example/placesuikit3d/common/Map3dViewModel.kt @@ -318,17 +318,17 @@ abstract class Map3dViewModel : ViewModel() { open fun setCameraTilt(tilt: Number) { updateCameraAndMove { - copy(heading = tilt.toTilt()) + copy(tilt = tilt.toTilt()) } } - open fun setCamaraRange(range: Number) { + open fun setCameraRange(range: Number) { updateCameraAndMove { copy(range = range.toRange()) } } - open fun setCamaraRoll(roll: Number) { + open fun setCameraRoll(roll: Number) { updateCameraAndMove { copy(roll = roll.toRoll()) } 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 3287bc3..64e6441 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,15 +8,15 @@ json = "20251224" robolectric = "4.16.1" activityCompose = "1.13.0" -agp = "8.13.2" +agp = "9.1.0" appcompat = "1.7.1" -composeBom = "2026.03.00" +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" @@ -28,10 +28,10 @@ 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" +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" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6a38a8c..4146564 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 86d40d59172882d6c3c529a7e4de64b22e576ada Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:29:02 -0600 Subject: [PATCH 08/11] build: update gradle wrapper to 9.3.1 to support AGP 9.1.0 --- gradle/wrapper/gradle-wrapper.jar | Bin 45457 -> 46175 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8bdaf60c75ab801e22807dde59e12a8735a34077..61285a659d17295f1de7c53e24fdf13ad755c379 100644 GIT binary patch delta 36855 zcmXVWV|bkZ_jDR#W81c!G`4NqPNQwKF*ml&#gVUume%9b>_7z=BQY- z8${v$0On2okcB}A24;WZJvgKf2sMc4OW5no*K!=QkJ2UC_?9&TcjuMeJ*%&gwJOJ^ zBOmlRj!F(IlPc*L>x7BjWPSq0!t44;Sx(hDrP`K(m#6@kk3L15y8lPUffe(orgSCj zlG71p_(RTjUQnJdW+4C+PNUg*y5M3C5PE6_V7Vp8!1wW->mwAij4$W-rwY;c<}8<8 z6)8pacYaCB((&sk8alX_sFQJy+<2&aj`Vm_bK|l%C31^phDVTF5x?rKn(r3qzmg4L5XD9sAcpJWv^~@--?e#b~a}GQzalb39YEk9z z)BGZ7JL%7@fcb$ny7*fS8;<_d!+aeg8tOTqtpk-c0Ec&Q1COv-iDAdi?Y^r49&N9X zo*e^DyTz7dXN8NpuUaRWhep4MNe)|W_jj$mAEBHyj;b?jqtq){0PI939MsIK3`! zFihdKVb2?J)7a;VrBkydVeqZ2YRw&WB6zc{rMB2<40y4WBLz*pIR zCdaU7k85@e2%+tm$Cx@@w*gS4e~sYbEXY+HmWL)Rvw5Z@lLO!rzzdaKB~~jD*hM$E zhy^kLkFZibj7Mz{X&KL8Or}2}ZKjixR!lJ@$UJ$Z6>?kOO#&&89dN?Ch3(pXODZA^ zB#*l1lcx&qQ1wqa$Pv9W3t}kW*M5X?+ube!4LrPK3aF%jbCnzY!?{kOi1I07SRZH_ zkMeep`V{8&HqT%cIIh&2;#msNxp9#_eqVHQut@rT(3fb)-J~;_njzC&ks35D@>El%6Jlf!K~fXt~C69L#$Y5s9tkQVovk)hvpb z7zLPdriviW?VcMC_l}KgliJZq^auVo3G6g!Y~WY%X@Ou$3Lb}EC0|>+0y|q@-yg4q zyS{*JQsV$dG=1^$Q-jq zIY}4Zt;i@M5aA;Xqlre0KMhYj7fqcOVz>rS48I7bVmUSi zFSKkcoXcM>aukdb9D2l?hf&@tfyrpBd0T>8fPsGkbu%YefO% zhxxLcTlo?2280lv!sFIK;H4CMlW@%RR9Eo1kT3ppSLdc&;jX72BG~Z9D=O>^-w3!` zCR)^>e-0nQIBE}eg=%*U9FDbzO3j)GOYG^CgK3j!jJGH;8MR$$M0$zc5D8TvVoKN( zqE4`lZ?#zVp|PJ^bj9NYq$nTPG+SAhW%N^i;NG~U{!tQDkF_S|!TG)Oqyq6==#WRU zq@fS7tjH0T47hN)CD0r2_Ox{%rOiG+9spg5YBpr@rq^N}A^K(XTRqG%%F*8;UU;O| zVTT|#5B$fmPj_MrM$k}D?XX}>A`^8bCV(PZ49Pr%i zWe-XX^QYBJXRtR|ueTccRlrb<^KG@y4A(gpC=epwghdrdKr22ZGUi=cqBd6LB~z6H zzU!FB#AJt8892mo)7fS`ccPs3U3v{l^}3 z;PTHehwapHCIx7vh8;kz6BURi;<33FF3uN>`^SP{;C7qw6uPF7NPPSRXjO5vfFzmj zCPH_K4eJ-7CViY8@1nQtI21f#s>imxz{KKFMBtYvaT!$tc&Z7NeGaeJELq(|z(TbbR zmIlJTvkU0B)Rwn9e|aMO^gJYONXOr9)BOALOdQmgU_5w%LkrSlHxpZV39^?|QT5V7 z@rgMu9Ll-7i@UpRWLGlAV_dz$Ytbr47}sLxD*ZjTrYiE)U&|Z?M6jmIN*s8x z*CNqWuC4|Cd`5WKGLiW?RcC=Ql&x7hVLvmM=IZHsWgAo5L(YrCv`$IO9fuDy}Ut-0@nJWL5qJeUTmU!*t!&1s1LIj6=4<1 zrZLS4xA;K1hk2j*N{I|^Ij-YP)a_P()YTH-1h?1Ek9kkv0{XhEd*}%o_}rFn5=?f# z16$_0R=CD7?8Vl&=t@5chb1?GEdmJ#Xs&ImoPQAJhS`sj5xy4nP4s+5F7*fB;}JwMrhHxUIK>+s;`Z*0%kNQ*q2fy(5V)tc?_64PH=((*CjI-CA#>l z%vNSTJDdUsrZ(wez|gDJV-ErzTk@C6+%B%Mv!{84k@jb5qI}Ekk@AU zTe{?{4C-?ITS6^~=rxH;?T|t&QgfNk^y`StWlyv43**-~!qd;wa^XRqxt z${eXKuZuc%$TbXU0eUt-UE;OGL>;t2bkUW~QRA*L6jD-My`m^O-fOVwp*d5FE>jq+ z+dup`WSMx}E!iX(XJWBDDn=^%_%(*fNL_*1aS+U)H zPdDdwexkm9Ucl9GVeevQaM6--1byzBTpu0+pqBYeV6kbeX=D&4rsWb{DR32xJW0$# zT4=su&L2AW2Iab#fvF*0+7^5RS!*t29kT+WQr1Bd_J2kC<>d%-g|+SavF!Q+sf`DJ za`jW~{APO5prqjHXR~Jo`lndT)F3u1`5UA~SG*9Wl~$Z$MZ#oQU@&=n#E`wy_3K2r z9c=S}Nk0VHj&M1xi63?ijfeUVpMTJN#9bi(=t18=wJX{t4w!P;Cwv1}oIj{*ICoFl zFAdZ7O~#0x>hhQtr;hXIR`pf&5C2!<@^ikMF{L~HHy4bgLqD9r z+{$`FYsVmywN-rb)-CeO?a2G1* zJ8v}9)$miL-OiYRhD%bRx-W;>2Ok$H|6a_$rvUNLgl*36=XdbSE2}*R(&PyM`#mvx zG8VH4j7RtGp>JJG2LDV#qeaHfk*?@s@pM6Awt|!&$U7x& zyi9)*7EOEQuHa!2KaIuA(`ctAeH!*5qDSr=j~+hAa_xY8oI=JZ&6QUaK@y+hkA@#5FSM64;!Kl-zc-Pd`-T;a zEoD&>%$hTz)N!AW93M!~zmYrgdeQN^IjGZ$3;fLYey^f!Hu8t7E0_Ir-VZ|Gyl(Uc zHzJZl)fxbX1bdGw*l4egx19jri1w$Cej(ej7#}-RmLVKr9`;Gtn^IL2M=!L#Z`hYr zH3)xhitHFDH2Whwv=H|~#rT()QM@+?VzJ6*g`raipMWJqdychhqF)hfY04ZF-9m1` z4x~Mv{#M0OqTrwJJ2>FJ55y?jl@mB#%0tI^cyvr1+;S9=o$h9=O~ST`5AcfZUMWKE zl;6#c%vv|P=kn;6+!a*o`$?oTY7}uO^kR!9e6Myril7}uZpkkuOTA`;EiBI--cnGn zxKKR(7y3WpWz&_SFh_ub5<-W9Qdfcj;}PNivw)gFnC?tfb0N(AMdYn!A1zS#*CmFf zQP`pAp~;S(AImyh+vPX%@hRkoZujAILfGPOFz*`UE6apDN*G(m1%YaXXM&YydB2}F zUdf>{%sGO-?*o?tD%-lcn9IspolX;VSCsd!gxHcu%!mrykRB0+aYb;Rn@6NZt`WWX zufG%n+j~cL)mK{^T>N{T1e!rMti1Sc{7A&D@bCErSE60aApm^J@2=ZKsqn}7O6ZAM1cj#DX~{s zNmhGBwltgQSxMz%$xGnz#^rj8tmxe<(MHy=%-$PhWsN7??w)vG9+Xz%(qJ+;!&>hg zS$Jw|6qH8pefOGQxZpxeajoYJ%|#Xo9m`}**8B^rQ>zlaQlC5)rPgW!Mt=|vw{;wf z8OP?tnqz&EQ=#UZ;A~}+XH9mT#hTdjr?Rj}@nn7B$mHeP@L7E-@vvF9|0uS4LUFA8 zZ^6j?Z^4iN&(LrJF{f2NYa2@#ZIv6B8%Jn|fnb$jq{D!d-hTRcz8%)SaTwI-hum*c zsF~8(_WdY=7MX_9aWbwB9tspZjrcd-PKn7BKptLN!0T9PTT2$nwS06fUbtJ zMQjEl&S;7KX912j&Q^i@oEE9F__Xs(F5^I8juUnDjAig4^vA%K2Ob-)xQ;nxW5884)o!IS{j+*?J|zN@-2D zv!h&4BIupCzRI{YK=&o>_e!T)8GRoYh2KSVr2O{FvW1q*fZ zBRrShUVP~DBVZ~4n+G^CIZjB&A7bg~m8^W0_3=xsI;!>ZTwJ+drEF1k$_H~ zYaXTE@bi)d?qPnFGL1W^cTxu3KlSoIiEsn|ii&+2B&*}@?CBGA`}+f4q*ns>3xcV@ zb9*m`3KwM;ZuKLWNAi$Y@fco##N#N68sMf}KyYV1Sw6(d9`^x^u!Nskr9MDiqH8(??+0*_f#i_ z>p#g`VklyIuh2l4E>l+28c;72T4rC~#m|jgo1takXEuUnVWxCQ@=zN%TX7k<8OV`o zuleC4WuLymKQ#f>BayWUafropGIbcb{6uw*OK%wee3KKhZCbJavFCiG14EiasiU1k zU&RuBB8&0y@M54 zJyJCTxbCx}d%%A%6{frWC;pGS5pY3E3sv$R5#1L+C9xu5(Yy8EIc(TkIKP+T-4hVE0!9uFLGKLBmX zCJ7hY+lQ9-h%DadkML|5p_8>U=(e*z2Et5GQeAs}h>=FxZ5CiG|6s99iU3sOJ>0ad z8yfMCrTkz+8Be}?gK>ziY^NshHZxRF@?61G8EMWn>c8?_*r6*wGX_br-NAb-^Q9?11xxGy-~`l3f*G6E+#$~s#K6t@T1 zt-bP)>rs%QvxF+{!oherK6VE&o#0!F__TRe{W_fwNBNb3@ki}T9D)h8)Nu3+Ly(PJ zFsKWue$BKtYeEuj!y~--@$Lg0dXpu|)cI2Qd<@s&r9Vo_6^ig_4hUx;&gdO4guk!d z9C4z5*br+lJ7ymly42$cxM0me1O?HT6bFunXZ8{UT`B*J=Z9|Na#^U;jNs&~FiIZN z6kA?f4Rc&-ln1A)qR1~nBfornaO3EzX*lPVyc5c6R{i->6v{(cWFjuntW9`$8aN9a zI}G_ysrH1wfjyzTUxCI2H-uoZ`P*$^`v-yJ7wM9XNALirECeUQ*+shyhV%EEd6SbN7NT zM@7-s2xAH1fmH;FEC`BRF>m!392`F1(?j7qU3PEZKri$l9Sf&_kiS6VzMvSot9s;o zJ9cYc4fc5_HH})Nu4|oCS+7Klhy>QQh(|adJih|JYTgZACXy~NU-fvr_Xv3gab#|5 zTsOf{<--YB%S=m~%ICMiD27!5DlFCo<=9SZ&5kgHFZ14csCnXksrUjI|sGyywz101pB17$XF?vIyj$ zG7v`b`l$JNhc*_X3@lW21+8OF2K}?M2#A(^`nDC|V74$|4fkfXJwq_Fo)iGIp{@S22ckH*lV8Mhl8*op;7Ft2iu^>7Eq@ZkF zb+`t!eKTv5vr|6ViL4Mx;6%+gEYZagXC&o{C&_G(Yp`Qu9uwEz;V@k0NXYH$3^f8s z*%`}IAn;q_P_`5RADz>Y#PXeZanxk^Wc?=O zTfDR0qY|?p9Cp2Nd%6;CqWg^#vuNa%%SFS=#nixgV3~ky5-uMAwulGMCf5-uG?_^s^F0=m84io4D#8AuNtlK?cek?# zQmV@y5U}gjn>MrOzpabXUa)2QS|MICP+ER>`>2ChaGG~L-}^MneJ64^@#kTI-3{v5 zBE{n86gcLO+yi05ERs|L`e^#5zn?KDvyRGHqAif9I<^}5n~@~`4qmJNsjngauBnOxTU5=Dmjc0UCH-2U)n8|ojVbfYFPA(jks8YYN|rf>6dzN4 zzI>Sr{-iOT9wkYc10Z1rEjS(~D{$e?hUMNjT&Zl1FV`lzR_3u9Yn`v4JcK|!7rtLTJ)gPhU}2c@)8 z%YZ!L#HM(OEsxag(TiAfvlVBtQk3-2T^j*wUD2zx^01@5uT9(juI@b|=3nK(YD!jtPItUnZUt>0px?A^Xg~$TB70VWnVsk69DkKFy3P&H* zz>7+R_QE@!*TOkkdqZsyERX!L2@9qwFB;iMVQo@8sXJfi_SyO{e|>Ba)ulYwsT-BT zywza3byMQ$6$KJLe^N7b=QaE@7O;V1v?Gyt-ACgvV4P`dqrNmncq(ecXnPIG^%cVQf82(X~;>2D2((VmU*Oa_FBWYVTxDSDaGOB7n+~H)y803Im>dTImYiHLT6RV zDpoDk_}XQq$wsc?>}TT-+*PGvk|sd5B`oRLpjDref9{)%k)aPmuos3x^#VwQ0|2Zs z^SgeDMbIN-*{k1bc*9;c7!Nw=m+01{^j#Cc@Ix%YQ3Pn3{Mx5gZK~ZLd4^TQ<;L|~ z)GP~ObeED?!KC5QT`EWdBwoU_201%)%5EUP-Y;KeS9>oEN;i>!ZfMfrF&$~M(sF@s zvD8zAi@#uY%n52b=|4nUQi~N-k51jDK5ebOHg*jvc@Kh;lI`U_H_|- zdiUN42W4Lih1gQOVbwPuAd4A}&*^gn*us6x$p4KlbLwpho8{&S!8Tvm_!E@i%=yM# zb|_DQ$08k-+Q`uhj9uFI{q7a`eWK$UJa(wR!MtNK_K|tO%#h&w0mz3CCek`++q{tm z3wpAw^+BJ_KeY{X572MCS`-xMQG6u8kFPIYnM7OP!WbHDEXD?75z#0^IoZn&=GtS? z($8%x==)o-EQ%{T@2B#piK4=g1s<6Fr^kw)5}7XljYjY{J}sb;9=0CNA2cLY0_Yru zSVP5ls$>*Ke23!_xL6s5K_cda<(XtbBp7hEx9#xqnvR~{Uzrb>{zF5YRs)ydODVm- zpUHo7|5~j0eH^9$UgJae`f3vG0xMB)SeziH$o_QhL#hJGQ6F6lnn33mbEB6}ZxQ(rv-yBUrN%?x9MweAf4G5W9V{qN3I!}`qwURYwHpFV?lGVAtjMLBlpYz z$mSp!hglh)MYf=R`l^UujbSvzDvtpEtlweD64F@C5t(R|cSo5AI)@Y7%gGnMLGq74r9@28QEI}c?`7^fBmgg$JIG(o#H~ZB8ceC} zrZQG~u_!8m<}nQCb37lh%JY{%{g6BeuOv{b@K>phYcT%u(mD6|?6H3~%21owGy2lG#&57Aep(+!ZDzQX5GuX)r?~IYB@- zPPJj!5hvwzj(k-z$gb3E>X2cpqxv6KoO*-Vg--NE;2x>q=!Z!mFqky2#nkYOv$6Jyp73=+8h!d zCj(zFu86hk{z_3sjFmEVpB7{SQGuj~r5{!-nQ(aI##k8!aLR-*>HJ7f>9698YWogs zj{de6T)W=m3`3vbO)afnp!Ck=Nz9WIk7LLw%K0~pi_?VjF5}DIFV2;MzL_!3MyWj* zur4CQT^4!qNN-3}a5rAa>H%f#><3^AeB(H38{DT0QBwY5`i2O>@51PU3UPXotYsM4 zs$d!h{&#~8i@V2&04E8X0!KdMh*~+VtpBQ7tB+%m9wTT)E{~;v*HqpEA(BK-QK z?C^J(;OBQ8KH-zWY!gLoUpDpcn|-y#vE@u8tFT0Wk4fvan_~@8ZM2#6rA>;yLtabf zMP@!xZ)Ih~F+&D$fPsm+5M#m64$b${z=)!p=2eV}GlqKM$Dzrp=C`ET-i?KJ;Y z@9O?ny(_{-2P4*U0)2F5{IGQh{_a+Atnfr)?P*#dLQ~F3F(he{A%Eh@!K3+%stxh= z1OxJCBeE5gWCr>B-PQE2zcUqp!$f11lDa+4G#4O^i=_`e;Px~u{4TrO*SFw--+hly z1N$FP=KQYQ%DbH&-G2?O)2^(BnWW*V0ywdf&V9=rvv#}BP@_X5jSa?v{CrVDO@aS0(ksdGMeS8 z9*sfc5Ui+Npo4;{0P+@LH~W)E0aurfVm?V9i?`a4rsUi)8=4&`F`s0fv*`&ynTa0> zOL&Nj+{&60e3yD$p8xM)qJ5NgkB;&lhOyBNO%_u!{U;X>W6X2 zkg7MwO02oe9Dz=_ob?HKOioo*jjNHA@DXUniR#jU1Xq*X<%fa+;a46LUQwuxvrXN& z7LS)hPs>i!UlOVo&x`xBEh?>Lais~mjWtftUY+H}X-2#e+0C60+RceY$1rukS7~70 zCGTz-G|6bosCY~({eZZw>TI63u9jxPkAi7mAvtbq(I_Hz5oatXPWBMNX_~1 zEH7~ihnbA*R-Y_Dq$j&#to_Mg-co&>yyOgcFKM9>YlmqwrUpU%S+nv;@y%tbEVd=h z?UI&g6$4b>kK(3TtRPk2xV-b*1ZP;j#c0p+`!lStRZhw1T;7DJ$9oN1jVCih=VX&F z=Ugf82+QQq5#Vax&?-aJ!r>dwR!|bEJ<~IV><8Jw8WWt|c})9xodHKc04D8)M#cnC z9V4^M?U*o3E>E+bipA}d9GJ2zxVFcH+j#DqXo8dHEbr~Pjg_&z^|i8Nd#O&f2TvRk zh|3bQvE)WuAl(HR$Jf6|byI(-GlrY3L?0b{TuZh(w+Sio6_MMu>`@@p92;G6vR;0X zmife*r0RI2m1u&S$WJ)T>ka>awwDiV`9aL#bY(pMfLilxuw?_ewj|Z~f_~?BKqp2a zy)eIpJ*==o2mE$|XV?)mBlo7?e}ZlKuY3m=IIQ!)$m^G*E6j@Fh#KsithaEl(N&&l z=g@+_##B1|K-(Ib`MFq}1oYzhX<3grU&1@}*b8faToYFQmtS2XuGZU;nC>1>f&tIe zWa1a&J06N#mDLJPNNgnkZ}KGDCfY#q?Wd@2xm!zvI^i`fxw`|=y7LRw-LZQ8*J$?h zxrxe@no{<{{Goz(^E;`<>4I@cl)!Xy`4uz3uSrbRtdaXYIvCv}wK#F-zHj`~d1gVxVmEjhhVEPtlmL|B=*D+4R%&Xyf|^cV}ARkbVD|WhV!!MwG_H zi9txZvq0yt32OPwF~gJCN<9tvQ9ZUU8j=0M~{`e)#v2rOwzj#?3ofu zcZ=kQwegA@L=WH0pRa#LxB0$zgcuTze9fJ5FVvdG%EvUwr;TTu6uKRI^LK0IN3$}7 zE!3lc4LJK^gWIUYu~b&3z+%b~Q}uJ}fLYm{yKZHd4T&BIt4x(Y ztc?*@?Awkd;_(1)PKkQW_r309H|~oC!#AO+9UMU*u19?vDUeUJDIJhVlg86X2XQS! z!*se3@q}m(sT^z7pzq)=@*fN$lL#L>+vke;T5~g&1MJ*pxPm42eZW6&3i(|X9yNDp zt+o_*3V$)9(ngsmxtL-INmHk~@QxDNei@D=*`EhzMPe{?EQcaplrPgP%Bzr!%F5=1 zh3W{Y$Iyc1Nqf1uW+r>pyLfB38Ho0|1GrTLxV1>qVDVm0)Zq>3Jnp+&)QWvLsGG1< zZ>U4=H`D^7mv{|)e)krNT`UHd#~0+;4wiu;7v1c>6jBwDYX<=au^*8J zrqF%@0f|@@{;#SW8?J8R0^^aG8U5xLu<1WOwh;9aJ%S^)<=RS{f3W?`wZjl4JWHyc7R}S58J2$UB}aK#63GkLG*)!XzNC8|I0KRY^g^;_=y7 zsO9EGpr@rayIGLpkupe%t?TP(DEdJuh6@6$ym7_an$8+8_dYP_1O5O59aqf6w4W>_ zdO^XgY3{I8Kk3M{uJ#)r9Guo>SsfSNj>pik0SNmsrl6_+zGZnr&WcIo^-xF+s91yD zazFWPWC&ad1L=miefaC*K))f;A}oKKOR(`BKMkAT{IW^=)+Bz84vs&2n1D}{^wOTo zJH@Xsk90kG~sX-Q#e@cxfh4T#EJqkyxMb6dA(K1Y)ThTkyTBjWD z9=^rm)#<)()n@CJ!7eUt`ZKE!#8$=8724%zy1u4FupBmmO}K>zE#+a>(@q8MN1Q2 z_@99+=dF)Q`iBnL|IiVYGX#E9Ai8;PK`7&9v*vDD3`oXBaRl3{O~uC70B~ z739IsgSWkdIu^gwGGnsGEc^(V$te`}@=ffNczOBbd)*BiL24?dq6OJZq?bPM=PM4z zjKGPzzr5aYZgY<5_wd7rmvH*!VIIqz$V}iEVy<`n)u^G$8LoE&TjJ!~z)6=_N1_>? z3t7+7)z6JkHv}pGu_}-NNez~18L)}EcZQo8=xbvWER~fK`w8@Eqr(N0Lz!<6t0;wK zodBNIF?5bv@pyPUnmh-I)TatRy8mbbGC(3B6MzX*t0i_ux)38~C7|oCCOO6n(k||- zup%%(Ws>y)P=Q0kI7x|qD6v;cO(Z{~aUR#{E#+V)9lBRb1p_hg!Zr0x*&0xdohg43 zfc9H2h?0bu5;d#X23V_K0-77BlahEyM$$$})&3nu&nEt^9NN;H=y-%l+<5WhF}o<1 zsI&9^S*X5XD5`>BrxBnHwAkWuuad_2r~RlNF7T?#Dq?gU8|IdAar>%-l-t-77B*W_ zQXH>^Q`YoMwhknm%CRC)B1kl=ZVdXejU)P#LOah!;L-NV@mfYz@7h3of}@$g<0^k} zUvWO9dcBgUMxUnM4#C58!h&Q;o?=%6^)cx8b2gm(!Q}#uFYc28RM zCsMkmDkV^)^7`-?P1UXvKTTX?o>4e!K;jc{d~t1G-6fr@^oLx?<&YFW;a4ciXdh*G zTf?77Wkyx;w(tdVjeM{`C>~xJLyTw?bBxKjqh&6SVh?&I!v#{=Ur_Ia^4KtnwKl`W z^Q7117(OhT)T!pze5xo-lK+zQl$Hc_2hrjAlTpybm<{!#r z`t@@s0WsNRlW=R?_O4J5T+F}DS9p?G?GhF*r zqq9nOC^oB9$jRxc^J!QA>>T}Y+q1>4@e+df3Uh{Y6hiwMU0ea}BwHkPC;OzqLC7)- z;!}(n+pk~1dt|>L&Z5l6DJpi_8;&R&6S7`!o8%_07W@HCUlOi2xPo4JBgm3f zmv0S77h0^}>@>rxm6x%ce#J2mgbSUemotvL$Z?d3WUHOgc95i)X7*}haRpBp`H?0W z1#>S4wsRVyArGTEgAod}7bh3nrZ@bza3#9uH)b(_PyFwnaTrs%;M(sAh3GmSP6mCK zwR*kti@%Ke-WOE`3*_Jr0bW0R)C_gXt5raz z)M7uQHufd71!W0s$5y`G$K1cm#?YB-BdqhR>V@|#; zAg{kbqhp2uLwDE-$I0bvw%OEn%QfpM$v12P!c?KLBAq~-JPUhN1iHRKZqX>nqG{8} zZcqRlV!9+!z*42;gaPO_;7)(jmqmg;&@?iXPpC_svSBQl=SSsMVK9F|EX^W$1p&p+ za>e30ydcU_c}{5Khf2~dyQ=7lG;v^DkxFmq0{P-W)Uwdx_*Z5- zMkB>$c7+^VrWn=A*G7VQmEaNe5;7i5gq*j;)A?NNLJwg%+FJm|?OyIE9$D|Et(e3D z8PEGL78P;=-TM*#)*#L$o3wQM@7Q-F`2Um!+N*jMtfA@zWXqy+cIl2}iBMhUYSlNh zb){>}|KjF#z zt{>`5{AnEzkiLJe#n?|k1?@tWhj&z~Ctq^D+KqIz1_Fv~+OcpIZpD8vvShYmUE(2c7?6dtLZqn+%ReMe zV6oG;+|2eLUtcjGBX52QD%gH_S&z&QrC<~dVCdR=UsZv{+1N1j@Ud1fj_y+_|(g4Efqce!?jrHre*$Ipf3xM|Lb zb}R&%mA3TV6P$aE7FJ4r()r1;1o{=G9O({~S9x>f<$Y6rjLpp|FRE^q99fEKKf%48 z>*hWNGB)OpX=6}j=GkR|0zXNq96i=cw-I2qmRW`_!+_{<>nmxHK z+u%HyGGH;r9RT#oxti`mQ$%bXJ{we-Yz1`N8i!zLRQAHC%;Ew{od?Y{^CG5Xh%5{LV<#GV_m zr`#7s#UlT|8HwKprfH;_Qgs(6I5Mfyi!Rek)m?BOt|s_26?Wz_j^IeRB6GZ9D(z*a!R3+Y6+`TGL>Kl0G?*g@P=%c+^K_V>m zjOv{rxWjsaAyWk1zLKFB={A}R-HGN#7a1Vbr|K8K`fO~jF9!3PuC3Q&lXDDhD1SL= zf0x|6Oeu=6^Le`RDbQ!4Sc##Y|M159=Pcl;J1j*djt6K5#UJZbqKlXf`(U4*wZN7^ z`vrMM#lJ({gZm`V>UT4KzZ^@i&U@Vt@U`YP@O?*z>9#Ul8&~^Yux55RC3R}?-b|e~ zbIBL|uo#MLa3sy#`UIX^rjKsuH;QTg-Kn?p@VSgmt4m>dz2?9iLd)@BXJsf~0BlmM zm*}|r^sEvm zn~L~%!p)rJmTKs}kWK~xCvF<$7dcRHmSceo!iP(sgO9!LGB=yZh4q)I6kXcOU zJzSmJep7HaKQxDlpfN0`TI@%m09Dvu=$EA%SW_kfq|Ci=?~#|7E5lxc^ZIqINmO?u zPj$KcEJ-za@?Z^i1v8(LN-(ugnrGR3;<#as$!DCAf#_}|UK$u0Ds26%Ym$j685YrK zZi4O{G)ut|7kw#m4Nw%O8eI=M&=RgR$J2E|qRv79Hr?lL3~4Tv%GZx(%Xl<$`-iF+ z)~1{zp=rrJK@b?xA~7ryx$UFXLt4UEVw-03@TpUJiQGL)e|7(;BsJ6yJTyFLlX9F$ zdZfSY4Rtez&}0Ffo0_k3)G6QzWuk;$R2i-GD)pWl$10yz=0>O3c`}Rye5Q{CQL$ZF zo5(odv00wM$pBh^#U9lEp#m8?QLD@;jr2E(8w)4y{uZJ)rS|2zOunrx%WDf0`Hm(SA-d!L0Hr`$zl>C!MPnV_ zg134bu%~HNEsgV~%C||C52G#FBk6aP=~mxkXb97fHh~+H(VgRXR}r@?Xf5KlF!|_~ zB5v>53$>|;_xD5(!MC5jE(?8NXAvL19YNC79{OkzpA3DvQ^n^vXaOJQetUYVDID;&5@?4XM7RQ?e{|Zb1$TGaW$@|d*m7N z)Gczn6?e$-PTVcW{p1%hK7UH}dk&3HQPmaWGk8{x&*6DFda>w(`T{n*C;;6?cwZ!7 ztKc4q>PcLV8VT_mi6uZl*spJikRj!ObUkbjvX{R>p# zWm3PRwYDboe8Ly{d%_=0)P{WtCG1bAk;H=9ro;lJIT1P>uDuU0?vwdQEtAA+&A$Oq zO9u!PF%D7L2><}*5|a@c9FyRF7n8eKGJin)Top>fcB93lL1KbZ;GqW7S`(j|%kFf$ zbiZR(<8_5Wq)M2 zSquRnZL=UmnJ#qXz{$3Q%g#shXKaNK}Mxq-vzh*ZqI7;n_-wT5BSNPnk62oyVE zsw~=ZJrY<6m18Nv7YL&qHNg!PO$>Fwc#%Wdyc>@n77Znz_Uxu4O`(cv7==wptB0*h|8*R91nx>OY&` z!tIQCrwk2+0;X_RVDcG1Ht&84dH!I6t8;8@X(*z^_kH$OFu@kE^aV4oKVh~~ImN;W zu2*jIaRU7#?tK8pv>cxk$o&9NO9u$5i{lpAlfhbAe+PV9)w%z^t8uTdl_kYVlqsBq zII(4Ck)e=)R}v?(lg1e+gG83DZ4pV=Xe4n51=_T zp{2W&*F9bzrF78+asTIB$+m1cq`#M+;of`B_kHKv^v;bd3cj*c6QNJb?mlRbfbrWsWSf+PFw8NtMcrF)sCjI1`s z!|Ak2I+Lf%$m~p+84v-BO{PVovTCVCBW*;osaU4BZY<0O7rAJXPUSS2Y5t{QRhr5) ze+dUQLRpr?OmoK_F|rHdZu00fjixirng~jz8BFCM8#E)*m{3fCXwt~k?b#Isp;_eB zX(r8Pa*f_mX)co^WA542G7hZ;X!B`-PV>lDjMk!3B~uyBY=@5|Ajb3p>S%4dXb~;e zX(3$+t8~J+8dVip&4N>D8I#kvF$*7Kf2ybojy3CsrTbk}Lw=pAsTQ`fIEk5cf@a;$ zaHbnZT+UdGddg5AA6aK>rDG5HH5d+5e8GARY-Z`23|e{8BTJXZOxJbK&e=HCzW*G$EL~qvVB;7V%m(mHMqcp10TcNxW3R}bJZiuVW z+fWiLtEL-zEmq+u!D7hPa1V}qJH10V$vejp!nR8P1p%Z&;8L@yMswR}#^Y8c0FgWC z-8!A3_b_>@O2b$_`#zoSpwps|1;=rn2YJ6vx6|EBYhEcB7Bv{1e`d-G=k{zzeqW^z zGHt24gwtBs8^%J6Q*NH059{m zkxGLi04`VOkLdITdK5DH{Rgh!c&J*V$MBH|XHc2bF8Y$-rkcKt(vZ$}r1S1wQPom1 zTYr_#3+Ts@dCg>zwEHi!1iYfC7Qs=L!?9nZuM3s^H`B`he;i+>Zy=lH*%elPN@DbM*&x&1LcE4ck16bQ+!U{><_Q)I72s0*T;!=0L9X%T-> z7yaBSalb&Sf6in04+(@{6`D)QPkjM1-=+LUr{9XwSspQy8FaDf?MAPQekZ!IQ}lmK zGslY3kd4KoqW=CK#RmcK2c4c5t%*}K?@1I`e@XEtAOlJNM1K|}{(}6GF|AD(y(k)) z=jm@S7J3Av#e#ZW^bfjUXy%_%>ri7)+{mDJc*!#Ff6L$`j=?0;Ewcd(IRrqeX3Qbw zX0px9_XRGt2@T)NV$hIu3g&1|MqTU_J;lAO7WcEVbgEpI?_7qPs<8!OWM_km%h{!~ z&Xa^fq3EkG$2-PlgOT=vr=lwGG^Q&r4@YGW5<+lHLCzQ0JGr8ar}KUM}L` z4qhShL%KQ9lj(KwD)=9J8Iy%Q9ecImV$2d^e_`#oygOWIR`PlQfpKENsM3jsper1g z0pENgV&tub(PECpst;w&m&nF5F}S$TYCUQ--lX$J5pWCgP*KxJ`;uk`;KvMKIN57~ z0HoLeP zf2|!i@#f*ixmGmJwX$*Mt=52=_l#b+=4GV-D194m7qJlp*|BG8+y)zitdTtC;++;C zW|wLC^GA&|+|IPHs(6N*VD#WU7%+G*Q&kDYjJUQSu@zwyN225Ftos8i`bP)-f-z?< z9pi^C-p>bg4)H-WgC))jnq6Jufa^ukf7x&GcSPsI92Nuf2}B@VFe1`jJtMJJmLQS8 zGig3yM6#kC;!b$INHa@H>SJtnvd)a@+{HKGOgMgL4Ar$LAB{PxQNm*)Y09sgkg%L!7VP%aJG!ojJanfe|S9x zDaKo+x@rPhOZEJGf_rtokufo?tSTk7Wupxxa9b?py;h*Vj%juY<6!c{H|TsTW1Kp4Nro?BjFOv0yyQ=Mlg>Buo6&+qW1_X} z$XdZ57}NX- zZgYl7-^uS53dT4!DPz{RH@39oTLgZeyg*@$P`1{lt2BN;Jh1o@t<^}U!(B#GtjiF^ z>;qPsl1532%efU3r>W93z|V*H!#aPEF$FpH?B48Or?D7(K(?VbBfM`$e<_*=8eIHw z{)A8him5Z(6GhGkg{lJ$qE>y1?-evZU8u9@?z`(6VqGoCj3E=mXMhxy9EeOI$vwcI z6*!;6PF0H}1ABd5=ll7L=$_7tx14C9kPD`cHeW+Hjhgk4$meN(7`E8CYsa?c#@!l! zVGN|ar{YH~$a8>vb*#t2fBvGi_9bi0g8PcK_EkiJaUv4WrenwCjcRzS8BE2VRw^X& z)JpD^%!ZZ~zM=C4e$w&^d4+@eQ8a+&?{)Z_{4JeSei}xtjYp1ZfBYR-GjTMEG2X@B zv+_RXkMbD0{1iF~Glll!ht@iVj@cs=cV&|qQB=`i`_2&t?qEvOkrViu^O3pA~(FmJBCNk(FhGz0JkH zR=dr@n0d?;}B{dV4CFM;hW3ZSvd@UR44kwdFJT1-AXnm;s z`+|VuK!RXcF@jxou6k69~=H3eys9KXk_G_Lu1@b8?O@AdGX$nf9!$N<%SsTWIKD2hje~f zp`v+YcQ?!$RTTxPBpo-59+4fk0bH>w4qdS+&O%>b05^}z%Nj)kWCX75QgnI{?y8hS z3;AD%T*@R2Km39+83vBWIy7Y}I)V}*&|sPwWQ%Z*_{Bz!=a^zwsES)xJRAViFk>X9bJ}$gaa(+^8LKPd6?&tu63!g;J?2K4qbcTCBIlLY4!?Kfg?XEwh2L zL|0}jRVZI5C?fhSqm8|jvQ}~6GNoErt_Fgn#ZO7_f213P^z*KNivo^W*$WXT3=$2ocL}v^etJO zUQ(+mn3tT$u_)+ccrBry61*1XBxS48g62WlhGLNKhsC|UrUb<=j3q9oM%}I`ZRi4& z9ZYpTxET13`i_TV834)bKU}MQVVR+P8B-R6e*mas+H#75FWxa?mHT38U)K6@MN{?^ z<(84kqwE7uBkIEp+YKdQR`*%Alh8_tY1yUk8;4U>K8P?yJ*!}fs>zpK-^lc5l`f(0 zkx5w2PB;jI)uu)yaV$kKNTw38q~VJQKkPwelk(@2nQvP-mQ8dRDY=3a z?;ur{Qp6W&_>Yw?qDe>aR!*cUr?zH+$^#EP<5Fvho zedOLZNcExC>Krxo)7F~cvg*S3cKp}of8Ocdm7~4=6w1*->n}J+*M|-sZ0o16{VW-d zN2od!vbnq3?e186juP(bvy?8ZX0du)tnMqU^kU^TVkP8$9RS_0KTB^IptlUt?V*5u zknRZi&(OPa^xl5DtDinFNFNFX9Dc98pYC~xKFJhtdYuo^XPHj(d9OpfpJ93of20Fy zjs{Ni$GxiiVId|>8>BA)SD>Ej8@hn?FXregr^yR670P+Ss~*nLg&aK{aP$q`hyCx! z{aUdRrv02$CAf3;W3(Q~J1x}YWA3%pJB=V=GZ1XP)XdV|+7NY977 zWry7_^wS@6^w%8yUF=rdYCw}@wIZ?>GZzB@@oE7O=o>l*I~m2yUKFQ9Cgdv*&>%2!>=5s3f4p`u#o7Q* zZX2Xi;JlxwxO;Q#KEpF}JbT32)KX+?56{o>6`?iS-84gVo$K6-}D)ob-;MW}Pf9IP9`Q}h7B5#my z1xZJBKcDpX^KF0+wVmO&3HsCohCTfD9KS2HM!j1&_GGWK!qU00org~q_H@Xk_R%D- z(^jEM%lJbeGr;f7@m&GU!*>txJ)uCE7q1`7@h5Y9-yq))KeDgUa{OS02A0T;62jE=dbo%RIf6L7Rs<5ASh6h0is+D;__c{V)eQ*=3JR(+&6O{ad z&>4Pgm=>H<5`#_!wX!q(f^L0zdFNl=iRh*ke>_5 z`1(@~IQVmp|0W&jU!k_gX+9#|KAQ zQTvBUud%Ic?IQ=b)|{vIL1lL6U=R>`olmWP5i_r`XQvJT5vV?o8jvUbK-!@iu-{5hdEf4RKfRt>N%%LbI~LSy52=eBbN z6~i_jrB&MIcR6LJN7*HeTvnvXXWK_5u5O`MhBN zk$5_%e>>+mPY^kmIakQ%T4z8$H#s-U=VoV%vm4K#bBBEHc3v-^9nNm~yv2D^t;h4E z^PLj@l=D5}sn)AO`P`xIlF!|0r+miLTf~zT1=u!&Ru6$aMWu}@EhJXynjxB;{|4D1 z`Ut7khy1%X5gB>2(P`*SnZ1ZTQ%}29ri^*$SO0#WiXpXIs=Gu1BJX<%-f43!R zf$fdtv)x8l*uG7bwijuk-A0S-DlN88p)2ifT4JxFDtiqrwXddS_O(=PucsROb>z1n zqFQ@|>g*?Jx&33b!rn(K?GMl@`;Ta~{YARU{x4eNU|Q=~MC%-WTJKm+0Y?jMaO|L~ z9SPd#I7XWsy>yM^eRQqk0jhUSIHv~ZT55E@hnk#sQM2=hv{~IuThzDFR`n@rQJYt%27H$Y#+5QbsO9u#{)Cb{z761TUEt3%%9FxOwF@FhoTvZkR?@W^SGMR1(X*;E~ zA#EW|Gf5X3$^eCuwh#>go0c%N5MO6rl2>Ntg_$>PaY02bZYYae5f|KwiVB!cB9S6u zTR{bJ2T^fBLB$185s~PkEG}W$f%Qe?*S@-(- zomT8hJAW0gkJQI{>znFhZgRj$Sf1mi!bvx7b3JV*Y%61Pv){^uWBqpQ%1kzysgLwp ziHzM;KhPIWS_5H6c*NtUty#Tx4QbQsisyT?i3Ari{Z@DtQ9IS=q-;Cwr24qJ+fHXF zi|gx}*EFvS$L-zqZ#1D40$px49kVw(30q;In}66Z3X#9n2lTH1Kb+L^Eom^`@K zN-RydF)MMIGmw`yvqK+q+!n#lRHzb~xRdcVI%$QPB9?Y`X2nz6(ure-QnuH!ZA&{3 z&3_RxO6_&}vT5y6h2pdA( zRBt+#uUNCjq zysVRm+i3%h0jv=52HAC5NqeFOd2%ufqgj}>(9`0BR9qq4a6IAhXA7dpVii`4v^6xo z*}c-lS_RW{^Hf2cE&^6yox+kyBREcqc3ngilDu>>%t$)QO<%1Ydsz@?W4-L2Lw|Lh zjBp8JLw@Nzg;_Lq!_JJG$a?n0me(J|#=Lc#6c$XK5(duag|uQZJHw1z$(-zKm^Op{ zpB2*_URr={QfTPAcDyQp3-D@%Q(xgB0~b=;JmCdyk`A~?60#E)k1G>hS7$ssX6{+B7Q3#pAgGJ1(PfJ4u8B;=-$Ny8n2*%_b`}_XEO#aGjQ%W6WR;wRPMcaUlp#$ z4Ycz3eFHZ!qu87~?Y&+Q@5lNo+>8&fvZnOHhphWNg|S zvj_5b?yLF!lP|?1d4D^;#Zfiy#mf}L*Kxma`3AjF)atx! zZ?B!U<6CS?x4v&OYQ??w)IhdSnTp#-ifyxCPzi~FZ%q<5-IE>);6Z#_p?urc&Ea(> zzN^qUMp(jQ%C7cE07vmXDQU-!^ZitQJ$^@t=*`*xH|V_vA;xpVKLAZZ;9GOSxWMuT-u&-l_gNRx;-NFL`Mu z$@F5X8Tb_=m9cv5ZD|(LMGX^b+{7sT2EPs9*LZ5eEKw{P)6NpVmz(#rf@(JL2fBk! z%DAZrmHd<>oyVT!f4L#)*4KtMdcRU|p zAN)tL=I6_p+z7hwUkbi$UB^0N$sSMs8!uMk1^kDiJ-5T%!`{Oe#hB<)>Pbca7cU2J z6-H^u9w!xd_hd}PH-gFW+OwP#OZthWR8hgqs*LAVIsLQKNfm-< zDnnuZ*eSY12AtxAs469^`uU16RTT@_>1)@TY6gv$=4++gltX>>%~iAX5T#~I1>ZhJ zdaLSy3aA?L3mscS;|zWuzB?AU4^qI zNto?ZCh>U2l-!_}lecQ*Eg3u0p5kUYJK)*zvCFEON=B&mi%K?{MZ0z5h7Vq4mIXtt zVuL6=^zus+2mAag6g>ICEbB?JsN>B^ zIvIJmW~4mu>Zyo`C1cO-wD;(-Tb-pR7ZkG~{zlLq6{S_()%a6ZkCOOstXTD+m`gMtAH8 zl^w*~6$dfD=^z$_4`N}c{2&$$;pDp@e{)ceCHZsaa>^uk{|${JSQhPQ9K`$_mXBaX zw6SLhO&VR9!)ev6{FlQSLpW;?3vxJjKY!M)$f0dNnt5g}e+!~HY#v5O^uj^BCfa!f z6$kvYR@{wlGTEMkl|#I{F&f=LYEsPa9K^y%8IMKE2eBv`sc6cfzk3kLh~aNFD_SeV zn!8zR?nj_094gBp8!FFX?=7er#x)W10NMq=HX1RHQr76RA#()#qLIK5t~=CP<$rGt z)&^^GY;T4-L;h! zx8aeHaTE_VX{u<%(CiFxa1Qs1cYp6Ia(p0Sj%cYNGZY9HLJ`hWt}LNs9O#e{9FFdg z6Gx*Xc#s+n;XBn258=@v{4j@~M9dr>51A3;06N8Cl_6QUuPIuz$mpqlk`@i)cR4&$ z{l{Zw75B}a>SwjZe?7LPB1T!OSzGCQZM3!WW9rOW^Ol#piz&e0Le1>Xl7B={Rk9t8 zlu3ZApBu(M@5W0xCa?14RKaS73uCf|6 zv#Y$dBB$omR`hfYsS|Q)KGP(nNuqm%qKbzU^agXgwaZV%nc8#)|{g8edXn5(bTvir`C7t3lt}KO=tMd5p`} z)TTmkxsTPjk?(~|aSuMq$y?wZ9H#{iazqvQ4II_*Av~<%;~aXn3m{*6?fUQ4Jqe zKU7Zv>c{FajX$OSDA0Gk?*smsszt+q3j1#LeL~{`1;5Sr8I21R{C3|#jSCcf*f*ka zfr20M-LG+hfQ62eJ^NSpy2=US7=-y zaeuXcp5Pi1hfkf)vU?rs{)Hqn&gb>HNVWjJ)^j8 zN+Op;ROpFOlub=z;IO9pg%~ys)e~BybEem5luh&hL}Qce<_S9UWU?BEQL=itaF_!P z_fBDmihIcKP+=8rGgQk<@RLfMIYz^+0u>~%r5i0{8)c;%;`02)3pja{9lDHtx#@WCKTt~t z2$O>I1(UvhXn)s3>*2<`Rq)hx(N-ub-Uyn$&&o_jw6Ay7F_yC$FmrAc5ZHIW|~8EW!xjm$DK{!wCc zsrBM_-Y+gz#-PB|wd_e>%OvtoudXS`%NST3)$u;9#PHGA132V008is5+=%tf;01 z2KV`uQ01n~KQq7;Q(RRGhO^*sFwW~Nck?K50F$eimoJ!Fdq%DPjF~5(kCyrtrB6^x z2MB7q>hqI~fKq>W9MyF`*ZY{&YWyuPB(#a01}G^NZD<;|W@X}lp-q9%k~Dp%rKBxM9|>tv+O#z- zZ2xm-R@#*%NLzpY_RhKY+;h)8=Rc3D*WUl)3q*94xJQ4`>HF>*+=k>I%SvnTSG%J=I)04-nLdI(99?{a4-rkfOjb*f4 z%wQR*)K!}{Zr%jm{MPdRkwQ9+32RJ?Z2+lfM~$qm=Z)+rW{>N63uj?|YsaRJt+AAT zyy@Nm2|<6sA+wNA>Ngr`UC?D_ezbEmucgv@=XhSr<@9`Kf7Y_KbXp;=pe1)`$Fb1m-G?6Drp(lf(pY;QXt$kW<(AViC3Nstt(6SVFBp|?T}L0U?6AqvsL8uHPy z5Cy1)zgC1ONVWWR8QiJKU2E5`UoU8M&I`H@-4>V5G|Wyu%%!Ajhipd8wzd!0yw)B2 z7^Z*h+fm)_OKX-TsG+s3LYAD|7NRR?HCsUy6skN{p(Z#)KVew5B@K2cL~E%fNX>L* z72F)16lxXJC}#_{k?!m>(`ld($hH)U2&&ODIeQ`wX@cs@dPq*5gBtA=3sRIiz?#Mk ztAKOsTH6j+TO&m4X#;DqQPAR9YYGCJ8fJe)_vG`MJX4`9LF!^p*BaJ#BM;5Y{6vVZ zb}rP73u-B#zp*twJC3&T#jl}jc|VZ3s9JG_ZV;px)(*a1hk(O}Z6TQ5b);W_SdDOZSYqML)%M*V`;{fMwqXh2YN>xaTr z#@MbP#c8)7uVvh&OC=)xLrs4zw+o65*;*c{V(kWnJ`$wc7+r1EHpyxk&KEXk zojG89JD;Qp+WFyF;p4SDUv(Na>Kwap-=v^rs42$CL^&t+xdltm<~dOE;Z6j=hi>kP zZQQ&iK%*!nlEu=Kg}h-;bnZeuZW(_t%`r*` z=?;R%%PY0(&*lm?MCe*ZA(N9swek+$?hI0nP>dEF?p4Sx=L7ImZ9fh`;tMhFYdze{ zkUT*XK^mvK5LJa-O0%K6f~Ed`7JCof%NpHR7AJ4BZ!B+)Yr~u??}waZ+O+f#{Ww>Z zar!9aq~wKg60%rth#sV$U?G1$S-w_oi*r-SqmBdKnNqaOuH)|sDyZEf>r z{e-jU5=c)+^v}`9g7mZW^V|TS+pxz%^l`d{gZvYiVk~8G@l~yTm+p2IfskLIUu0cA zDJV9-3+LH+ig%ty@v+Uau1j0zRP~qWGtB!K*P1&E=%+&T1Si`z`elDMAUZD_HvO!V zr+)=0AK>a4w#;DF$RD|mUpXFqen45o1I^1<+GnIP{)vyrVc}`uZ2`S#9YI&&U#xV>gk8`)HBY87} zG+^fo7N;Ij33sQjnhKtf%N%o9m;Xa8;MJBU{MmdFgg2cY$7H z{+FucRj?@Zy9hPBc6OP0eMef)Kq?~h_qe_JGQEsEl+{nz?!xTYp02E~(pQ-MHB_Wa zwB7+VClvYvXpD&7jY1isuW}^6PG3XDnYSP3nBSMz_|<=;In4-X#;>D!wX_kL5m=U> zD}SV%1ttHO{v=3$M1RUBqYw0Fj-h+NUH`&KIp08@EIXmMFfzi4U{ArWs3d8%KZHg~9)ctJ*)(clhU{ybDDuD51zHA|0YH(s@Sc_baRDo{B*F7rX@e%mTuc zn-sZI4bZ=GMn?pBIr;<_P>RdJolO5ZQ#&RDeoQ z47PC_g%DoHfXV}60jALc! z5e>5n?!2vgMZpz~Fu7PJXzh@mM{KBh-7e&_Na{E5+qV~#l|yPp{xwrN%qdosAA9cU ziokyZ)n}Xg2jdkcaTeoHZI!q@C{~Iqs<*`zfmh=q6fv%eS?9Tj)H`ec%o-#$iRPeK zBi14(;KkLeSw^y_fYN{z?G&Y%Cc12y`Gg^K#Fb(l+YE2ddH^snq;~qWD;cex9Q(P^&Sq9$ki=;AI%H;@&Yn`R* z$~BJfa5HN0tb5$x^h$%S>-*sOkmyAhD0)O6H-B@qj+Kbo!HBvMhEow>Dugp`@KAqz zha-FS^vOdeGH_Y_nam*YKwREBZ;leX_zHMrEg6+2u;G@t)2WKRtmArdOVuA4&}!=( zQA3DeJQ_HDovGQy$C(At_KO2Su}>Vt2E*bOI-f7((B_0h0+}5vhkV5UmJs12G!LVQ z5{uvTdiIjPWz7!lwcGU(t&q0M^xl72+j0JF;wZcM_Ub<_{ci;+Vi+~L+yUanX&0=% zFlg@Jo+q!+n=SCXQc0IXcb-VY!a_xiyvccG*YBB2aB}s zGzHI5=fEUgA1%_R#K0@$pDcgTiskqn!iiDV~Un$Q13Fq)&ni(DpuF z$+rIzwp_&X93>Xei`0zy=0qYMsXshLN1*H}YK_YC*F?|LZLHu?(8GU;_{+37`hxcf zs)>3wPLfx=Qh7w`k|P#MA|gOUzW@_05O?ACZdzD^sXc7zHlh+N<-upOOVyG`q0QyB zh2#UJP#0(vY6_R&4`%Oj83eN{4tvX$CF)_i*`aU1*F{=gf^QaJCJv9>4Fw`_xE~w$ zVs;F|j>B6YgV@I~I|+Zjw*ZWHu%`sR8q#UR^(wD3biUgD3VF}ekDa6J?(>vObbF&y zWYsO9F9o`NVK2Mv?!*@VV^kjt`#g>QBK_DT+)bZo^e=dv{r+Yw>@CvCBK;KhKZN~L z^sr}(uhAEK;YsRx=ZMgAjj@sZSp~=>sCZ-p+C! zPwXzzlc%6kG*P6dfVQXO3VS7Sq}%I>40)qNzV;!1Y^eU%!(PWl&m`cW-@G8TxBv?@ z(q%9?0m033Y{T#7X@`BBg1+}@nL>9clvAisq7@|b9Y_)iJy2rv!s3n-r}^Nnixen9 zy1!zvpr95IIx~MJT?f015cm-LbP;(gHb^&jR-Y*SxCom2;KxUB{A>8G;YW}gw)-Va zh_*x|kYgC0W&E!7T)Qd?E3ab6N204X0`ceQxzptHJacjZZ2B#5KJ^TC3VHX1D!JKa<7(`R_&9HBZ{;o)W-|emQ&VtsbX>l1^ z-<>RdCzf#W^fvxamhK;j;H0-Nv=`}nXZYs=vRM#U>6J!XD#zud%CU)BD8kZ*cfq1QQz!PLaynVx+^p>aTn=v`V-!mYeg0OqBPcgerK42~e=r2vkH zG1k#Pm%T=JVgu*%TcYjf=m%#KyCaFS6y`9D9<6`>w<-D|!Uq9X{~!2icfT*<2XR)U z_g&W5Q8d=fD?HOn7jPH>5`N?k2Rq0yH!7g&gje{So z6-IwncYvV zdWvFu;@UcT^$AX9UFYeY1Z%bGD5DjoxT!((L+!BQC^sc`UKj`iwv+UIcPxf|iK5uk z?XPPEpm!C;;1+L9S4~Yc{S1Yc)i!z`eDpLek1gX#y{qP|&@i>EwkSr!fp$+LV&%Imp{us5X0r4I>)Rj< zRx26~g#EFz;*JK9h7HT1gSr=pvb`hliFxo0s`ci9@-^X{261QkehtFHXH1$1_bz`p z?utx_6BjBazf1v6*C)e1{(?epM=sDBM1v_NMO==b1yJ&IucUco`d+%9z6YAeZ-YKb zpOZ9?On;t=lID@=m*`bV^T_m9@XIuhO#cv1V5WIw`aQ8g(ma9|9^q$Nj!Z8SOC`-C z(<{XqN%P2bm)It09+@5#`z6gI)3<+$5lQpN^qt}!N%P3`)8ZTQo-#KGgO-$_I$ANI zh$T)L7+1s^j%p-;X4#}+g+qOu%CTO;SLK4c0V3jZNoI^@xvYrwRT5WFt+UF-2yOMu zcCVM$yJxM7s`Z8vHOzIg(=JpTJFPBOR?`A^MfN+2yWL4>?tMIHJ1VCk*S$Xu+H8!o z*C1&%_4)XjDP)i30Nr98+ z5)l9Z(;$-(8XS{okuHC0PZL2DhToyULMfC|KoC$|72CqH+%yd}CWgd>q+VhTM!rpV zhwZ}N*bBxO`62uYHHwKJ`~m(V^@H&YEkdxBo$O}zo%6isOwP>r-(SB0Si=&-(c$6q z@>{jejXmL+>bh#|s0*s$yMgWS*!Dfm^-V!~C>+5fL5mF@X-0oT7<*CTM(X+wcOtQ% z2A1aXK(nQXX|AoV++C&EuzbqzB#uKu)Zp4O{R+>rJt0Edvq(JyQV52=%IOSt3->%`m*n54i4d&(fdPcZCrv z=w%o^Qy&uHnY@4Q2nBr%J^P6*Vg|O&(0kwZ?DzpgcVWA@#gHl#w=&3JC=CVK4AL0r zM-Ote;`kQBSfP66TZ`pBDv~=_L+woz3s=DyF@8dM#+r!j>(3}YCQ5L(lED>B^kW>8 z4EZ(z3Z@v8KnjkR!3;*zn8r0KnV)QjVd;D=8x0=T#D0GwJVR89)pnb&FzA;d{}ee3 zRp!0Pu+ov;-0YZe`EsaD$~l?x4mG~8(b0M0?^+v!k~Em7u5QpNWEiZ)bkHC3;Dv-4 zvQ;{SPZGB-!V?8K>ahZbetL7V>2yzeXr93!eKqubTl#B+kt3u(;_Cb{uFF7%#ir#?3=uPn8!cw7#pIgIK$E%kvh<$Bu2bq4ve0ST zOO%wQ!|j&CJ#^Cm@=b?9=`cNWjKT$vA4Hvbq)t4FovWBS#=?KApbJDUayj(}P)i30 zC$0jCTLJ(8S_G338XS|*HV>2Ueh8B=lqG*n{~4>;Cs6j(O4FdN5UL0wcoGza-nW}+ zvh8LQcGGwe`yjrBC&7ac;6sTM(Sx%vAK!1knPoq}-ai0f0(%TzHkxp^5pF z%LN~DnP-L4qExvFvOGrO7BvcobeRel$Q0$utux1`3!xnjd65K}C<0aQh~vrlb zSsVS$FVt$js6?oRNy6Lt5@p$j7K7HgGOD~_aL~W`38*}* zx1RlXgBb&_KbUo)1HKP!*kRDPqAw~y51M4_VstvNO?{VKkJKY=9=$>L^*2z1E%3ep zP)i30?%vMzBmn>bYLk(G9g~`sO@F3AF@%KJLN!7!wnzmVpb{*mUT`uwNd_h}FeAo#wKHWDVB`scGWRVO&GS7s@g?Pa+jN2^EfFhnwQcmTz_Bp{Hhn5 zENQ04lQE~9s%lQkkQl|{#Q5ba<7De*_WVn}X_COJXsJtdAXi-k3=3x5Tj3}?z*pfYqC-cDNW@saxxM9`%ojFSv~P2XwTG$}IG<|*iA2=S^Twg{2obo_9T2zqcv z#cA|1^fpz^JQbX!uvZPs5Z8mS_aZol0Tul?&(PnR;hg38A}3s~Reu${A)_5CFmQcS z#UL&)beOhQWH{F}YVi+jFCr$x3^AP0P21xUye$I{VwkX-xz1`{g-THnS1}@!>Rjhr zIW7*DOCldf4Y>+zY?9XAeL?d)9gwnsHHt_RYY9hrrywK6SNHlu=4eX%7Z)q=xl8T+TlQ9zj#$B3H4RGU+9utJ8}C$~s#a-H7>0TD-I=p<|?jeg)QO1m~E&*i2qRJ#WG_@s+gP*EiaPTy1=RitZSA(wv#V zR;U?4_-ltji}srm!=p*PD>VrCQ~Rdqu;55{J~YAL2(Qyo87bc_TQ98rV#}utghX#a zRXk0>NropmfSXPmM;=(KUl|NG8tdJqyzTu^614zprYARC;H=f`v29SH4wJQZvRDdD zD?Tjr;lsjeF967tM7KB7g_?13{TioVh~Vr=7d0`lC>QQJt6y(}wDgpyO~ozBwFHDe zdyvT-4)rXGg<7l2FIgdNPMHt7$y=0U+VWvk1pwM9S7+Mpn8`Kk~A(_q74L!c*vaqwxYBDD_zN8 zdQ$L}F|a*cVy?Dtw30uFrnwl87&tZ9xiA4Bt?@g(CfX0719HiL+Atf#3f_{tvj5(% zmCFUAGfVzX)Jc!g8Amy%e2Q1WuTIfm#)^+Z)f&rCPcJf(rr?Ta*neL4Vz{V29A8;& zoU(8`4m2XFDASL{2_fUWszgTREg$ef=IzZDT5UvndCiuYWN2CtYb~bZopC#(U%H2g z4$g4&j_=PwYk-&~h=vF-3;kSWIu?pp)ew+zlDqc9Vu9QjjuK62v589n4c&*djXPAB zX~vf>ESV0|1d^4P%buraUEX8W<&i#?m8mL*-WyMp!-aluvpHzZvQBK$c5R08mZ$j$ zZEC-3UrN|?=_zZ<3s9VnyYWLeUJ#GA-%e^{l5yWPoQ~uFj#g*@2YCrhzuqA=6KRR5 zSVPi%k4sJEe62QU*6JsdPG7hbg71tEi}Lv7Ah@HH|h{RdCL} z$3C9L;Ytua_bc<*_R$?N9T?<%7=s^yAFHf75ph*n^N56)blx1(x^N?6J&rvm63@kV zn;4Zts)_3fu%4Nb*8CQ8#XhKsr>B;bC#Ff5PJBaT%7Hgg% zK>dAb6J74w1wnHH|B~fG(_M3@89=>BBdvt3Qa+WGB(ngY>5+tr*BM4!B*`yEs%HN^qW~OpY=Vf4Ey6k$}K#RJD3+E4axBJ%CrhIW6 zzQi|n(eP#i(MtCX+^gqC!}F3emHJkNjJHq;QwX79-`KX-xgM&SymJ_%29JhH*V~3y z)LRTs8s2we-YsxY=nu-u^TkAa&q7pnyPvAdy0=GCD??aoXNJPn?eTtS-LN$0NvZ(L z^G_>MO_q!V}bB>F)4Ebm`d{f3Uyf+NYxwMPB$!>HWhGL zJ0l4+dJLK{c0%pB?;RBjg*cwf7pd}h)#1AONh-g@ggj0s#YU(=(W)!4$mHYJLsB}o z-xa1Sc#CWZ65gaxuP~Htz;|9-SOL}rMz84AYIRv4|R{M-rl{X6+WeP z*?{P+*s-@}TRyPjvYR&v&Tjkg$!ci=0&x(jpq0fZ z!q-lHNJtqbV)$PYx_o&(t%Ton^`=|jF26hlP3tybYy(@d|$eP&cnH2S&B(v@r|J`B=d=cOYbo%&B=2Xec58}o zPphF-AGe0~_*mw)VhIY?7EX_r2?hRfev8pHy@%_GTMzB<9b_p*ii^@;;zkW>V!z3ViX!MSBcf zS#Q0RY__kzo4=J3e>?X)MCsk;Sj~HbyB=_(pJ@dvxtfpYT(&io9w}{N_=J2MuM+!u zU4;uxEQZ>scmjA5$I7lh7*X-%GubZv%fEA*r@*qYSmQ+CDb3KY9|iz3ESVbVd7 z`SWcoS77M3C%Z}F2HEDV&R5jKfNuZT-PU%2oE|BUn)5E<5e6}Q@N><5jpYwf50ZZg zwKz<3qWo!#k@r{1=T#hHM*KgtjhHCp)Efs@8r{=es4)n48a7&I_+rMC^=*Ko{;@IL z&s;{ZLTTJ?gnsTVCCvtmwt`SHps0-8>44bvn#zI9?|n{qp&a5J$8q_XMx*MN^}?aJ)V$cm7fDuN8KZ zXsu(2=*@*@;q(5PE6bw8$a0oV!{*V;%P*1T@ey9WWoJx2Q+LEGctiZ~>^#plCa-ze z%+?>TQ^d4=ZoPb^uf3?h-;k%tSwtx%h0{N5xC1*ZvA~!#KP8-6>0po~7nMTize`cm zpnGYa1-KUAXxZlko(chwLRX$$`}{BQ5?gFOSjWlUY6h?IKQUBhKIH^uWC$1=#;eg=-u#aE$u^xLTwL z`y==a0;wMY&I%p?nT3S+OXJzs%^f1UWlUzZKWsH0C@;Q5`;TBN>r*K8iWuc$7ICrt z3t@kRIsYBc-x9xpV4&ft2#lfuNuJwwlS`31{5%v98FCF<{2#6ZP{Zla?8~44AvjKI+!^2uP6(*Q ziSYa(_5XJyj=&bq40b>}PYwWaD1ap$U>nCnmH;NAk9M{Dt%z$`6bu^e>NF# z`Vhd}5MX-=1swE6_N##QA2#6J7f@;gBT7bD2?Jnfc$!k{``oDXK>{sOg=O)_MwhfD_Xc&okfivFfsW#rOD`DIwo|%B} zfeDt#i{l23mKWe zR<>b`0|-(-jNu^n$4@2@IQkX_p<%hQ1qs{Cbq0E z>L1hef!!ZqHNQQ6z1)Z}8DN>1TcoavxZzW%0LL~YbW_aN#P+GAs2 zR=Z~-!vbIPqC?W$cTU|nm2|JJ-VD$F;SzfK$uj2%)ibp94<7pYf*|dq7ftx!LfmBJ zXv34lP0VN+okYf|iD|UE^cD53M}OvlGPiN1LDGISKtfYKrb~!*5miZ%l142qqMaqW zu$KL6zcef>;NsR-kEyi~bIH10_<;;~&m!rjxXS`i4+uy$PYtHrs8kPDH;Zk}Z^r4n z<^xCJw*MOn&n=d&+EdvqjfjpH{=QF>=iOaj0;7~3pn&I zae>t!M$%P+F@RC%6pEPlu~_K&{>k~{I4J-qVf6-^AAqRH{qFMoy2|AO(Np{*j0vdwzdoh)JQ;Fchq#(r6RJu|x zHXpfK)~z3_)Qk>7o^jv*z)9zY3G@(}W_BUhuvvD9i79Pgt@EGgj?r`twl;=o#HxMo(*a( z*76K}@W72{ngiSF20&+U(5#B`y-FL8qP@2peO^0nI&0+N_W|XKC25UTWDCD4H9bm* zr)p+W<}lWO!~}sLMaH-TnXrI`Rbccjz6=vIB70%9o5$4sHcbM&&Hc#J{L>c&xulgM z{SG)Hk`&CLKR`-3guft@^79^lVo}r;`flw&#mE%b;In%-rLUV}eLoOr*(7Kon7Bk? zY%0LN)-gVgjCrfm8;8K|NWdJyp}-QYUVD8k1C|~|*fhIH50h2R@IeEeSYwMjJ(tQu zzA&>5+w^;J?3hdj-Xw3>|1S6v=f_@FXSETu>KV#W`)N4!Td+rIw`2{-$+AX1fmtwN z8ErJRLmQOWF;;{^6GdT9oVG-Gvnih4k70?Yi`guFgez8i45DJ?=gu8hvd&oyNk$}8 zk&0O-yQzd4{x+e}kIph7V*NtQ*PneKI__ThW-7@c1W`MUzs<$q?&F+DB<^%TNiqIn z968lJnX_}6@@opO5-z^<*Qa&M7V%#mP1Y9{=4eOrPoBfAOh=d7@A27WqguN#$ZrV$ zdt}S6`#n0~U|>xNf5`9?K8eW_q)N#WDljl1AjqNc-dOhyt*XP@GHRzb%L5F&@jW-!FkatbU4@qZJEKj*_MXcE z+ z`sLi%>0=yw%!Sr)E&3k9zHAe2!_y}RXrdvSPV7MU@BGjsndLN3(kvK<3wCTg*?;W__49<-WuF+mr!M6qbKpcS)7b7M0< zAW~@=JeV$XK`HR8`}JGz6RdjttSlt_iKiU_cSZ7ge1d2Cjd$8x;K|wJ2;hdY(BWD}e;VIw~nWE($m?ejf-Uy~#&XT)w1QQ!tTB05?~Tz}qd(mdCNwZH{t@ zpR?R_^T7CvZ#%rkO#x-YIwyH^wkpOyjXPA;N9Lq=Cw>kL(C2{!WH)X>(#H%MH~DQx|b`NMrB zNc1cqd&jX*6;Ta4p6Ve%CJnGu{wrAnZhIy|3nx#Ku{2R}32PIfH&fL3SYe%d!Pitd zDULXt1ivZ1=AZzC*RkF*SUC%6^E1IkC^l8?S@vl&RTYD1dd)S+=t*XAq+TIbT+LLs zxy}EL``v!!X~*KqC%E-)0ncJn(qSsp5q9c zt4*(Ts+#d<%k%zJ$AS?_1`zvkC8rJ$;&&%;#mAX;V{)-nR#!v=;DL3#kc|7Es| zpn~bfs$d+juP5>llBJ2=mh4{Cpsx-h_%b5(9)E<>?w{>Ea?nPUk3%ghU^{%vWqI+d z0}xv`s`7${$rFmz9W^R&s_EW7j>S}WG`8`tAyJYqA?K2D9U7_m{uHJdlgtTG=(**Y zwFkb6rQpF)ft;m>F8?NYzQRt8@ggE*YH%__fcAmN+Fh_Gl}va7V_`na&byDll9;1k zn8Ra!SSh;`E@vj0g@4!jk^q6Tf_ShckL5QwcrA-5t9lc_d4A1rWUx;J+0j0?5Goi_ z4%>z03W~IL@IlDAEpm+uj+=OB!;6kJec*r3kAQUX+=7HrNwZSwu`741+*7M>Y_<8& z=}82-7~MW2PMSbj{+!1O3Z7WAtT+3lse$^Zo|~6>L`hTP2Dt$V+p&AmiC7eKyI zyNMC=qUR*nVfcveWTT^&(LPtmdXUT-$KL>p4pBmUlPysyJ0?sddWZu##2GLc+C}+3&E!{mr1eXncb|9DxL$9n>h^*k)GttNDaD7Eo*GhKy6c5_B;>_z?jW zGa4#w%pyOE_ar2~zBz9d+|c4B0)J(N$bBp7zim{}p?T#I=f({Ta1&x=R=&#c`Q!@4K<5t&ZzXP9PAOSB@o=-sdx3U&2y%@F`dE{QYH2%%(edNaKsd zEf}Mw*!7!tKf(&8X3r=`z+g%Y=IeGAZ;?Shuj(G&oJC;CGi5+~ zO^+*tkEmdFOYxt0=GV|OfINmnlrqILzecz-`;lm>= z7!R!o$CUw&dUquEN10Aql$5LrN4~-IFMMFn>g3J|a#c6n4@7ayf+cZ&D5WO8Pja{7v)qpW*x7ZdEG| z0|o}3uuE(Y3{0}k0-*iao`GNMOoZFh>9Gj1FkZ+QYpCx^9ln(i!~zRR3dqV<~VZ!3a!|SlThW<;EBhJ1n8g(rZ2Z5bEo-#|aqTX_%$+Iohx=ayLFuU~r%I~YZ|<<}Z8 zY?0f{oZq>(Rmnsxy8H7_t z(_-QH87yD?)~+has8Z3txcl}Z*mOnPcrVg!%s znK=vv%47VB-zFduAlf+zZ}f0V4j5-6>>>kN45R;;9Jc)s$Rms@-=i_RZ3(cZVc1jF zF^S6E8F$rx1bVwFswrSlNF@~2p21<4wuH5E9nxEg$NT2IXlMbj`7GaYHgQ0;E1g>p zYa||{>s38m?E1Ow;}#P2g6%)nj+Fj1N*7a`Fo`xPdS<PTwU9XZ3)AaUWegKa%@{yx-ZO8p zEHpr(S~qy&LiLceT2VjPwC(>nxcOkE)`Sc)4`6Fz#_y7rfTjkSRuu+e8X=K@V6eCy zLY7VWW`uTamUgkwIG7vk_gs)nUKrAj2=;W-pN;rSih7>ymnnA!=fnSUs;oiW?%n99 z_-TIn-`6e&r_+kEqs5>y%nOM5y=+Yvw)RxiAQ?lrOL!KHmhuy*+^cr-bv5(54JR?6 zFdq|J2BAe!bzD|D&f+jd7_Ex>=S#flj~$E_4r%ROtRm7NHvGUD5n|nNF%K&bAFjf` z1G%rI-y(?{H0h*tJ2>cjnRFAy=VE!y!wT%uMho+7ohh58rO$NQguhqdKmRNiaPSPX z@!CLzx57qI%d+=aU}Zb~q6VCv()+Uj`P}8qqI0)sQ!jf)_|AEDeI7ebQn$eSGb~)a z9kPXu(fRcGSO8_?r9MJ#nP(F8AX&_1GvpkFgJyjQ4^bIf5c;y!Vk@Ua+SW7V-q_*Bw!`P%@n{Fl!M zZ;9r|;;H(~^bB1z@96v-)oS034owA0+deH@kkx^@sFkY^s3nXLmA4MKw9{i(b>&C61 zL<@6BBgqWdN*jHnWQbfvHWQ>&Z5LI~${pl1m3I3zLZ96Tx!K8`F8Bxf2N2eODO8|1 z2(o9SJ8UVX;Y@^{*o0thH7qBW+`+$T0Gqq37f5m}x=eltXSq0|9@ebunzKKP`D&{1 zr!l+h6SvoxL~Z1Z>@)!emf&((QN2i zwLDm46e408vEPz|{u#?Wt5_XdNf%Kx#LvVZn3*&WJxIqf!jU30ULp^@(|21FcN6cP z?;l`b`UYve)9vkdmvYF?j#1ojO%TkmOW<+l!KxdhIw6|bL^sgrP7!3^TM-A52OH^G zacz)V>RK?+EK_fQB#B$3TWf2`8T!=AE?-MX(uJWlA_R7dq1;J0vyxdTJi^8&jC<{8 zIm_*a0r@*_I9P#Z0*p~Tq@}UFW_EK_?7T`?aOC;@yyGqMUI#8iQDS#D4cB}EFuue1 z9Dl|PZo*?c3R>jz=bzz&tp&4$eATT^95}Y=1`otbH!ES_n^eYqwGm z?#o|?taDNaDqP54EpmKX8ibgQuqP#BTMh60+4Mdsx_Sq9`&_S0%HuhF983~#!2^mW z1q(}zml4eEVbggeB(Oxx`W)2?Ye_^6-4}nKP>_3q3Ex6y>03KxOznJn%}Y6=xP{+c z5Y2vK2`mhwgy|e@ghF7_`FDnPPTobtw&%S`b7GG z=(3ud6Ga977Le|2pLzaf7C)NC`jqW`H1YAdKh6pEq-6f=;PamM^6SFkLy}9^ReRCf zN^Vhiot2&-wJLwrP92#sn7oDYxNh24?h4@pI6}D6)wa2x%xG0+Zo%=y#=3B9V{3cr zPN-O6lYR6Aoh%l#eY8e_A8ec&jXdW9FbWZVRgHKy`_StwbsbdnO@THZY1r^z7(|^% z0i@GLfrveqQ4lv-aNDbFor~Kgq^Dc&gLT%Q`m(i!N-~543_oL$Jp1>eXIHN9;Q1th z#G}YZa|TrBA6N(V!v-VLiY=|vXlp=78N?aR4+{vx&#AmAQ&LNlCbZwG0Yo&c~%<0jbrN!vV-Qj zpJHH&7SUOPalfQu^c+|N(Kkp?TaIRdI7*91X)IAOthuK=H1w!Viajo0ubw+Fdg7w8Ab!5^2jk&2(8 zb3s*~QowNOWs7N&rdL!kVLCB>Ysh#qKD`?%y3hYClDikVRf0dAF;GT^R0w&vJW7f! zoxQ3$A*ENO&IHc~MU}UL3RWm3%Hw)zUL~#J(Iq5X%HYQbkspFW0F1zILg^;%AxucT z)T-g`SM|-sX~q}@uo^fSz@?r27e`fRYy|B>T=RZd)4#cZRGadREY1Zbh47fR>WB(i zZNG}Xi=s|pC)tDB8c?TDD$cE?Tb%e*!%CFa zRd$tsEzwJYM_e+>8aZm0IOsmnx#`%!plB)S=zHECac2mkI;stc_mo`dzV zbox|6>WlOlUV-&ddY~x576#$P0nN(bI42|AV9u*5Njf=SvY`%kNFi|sE>>@Xz4~4a zliKF)H8?si!seNFzM{y$lh(TzE5xq^>;V7>A|uif2D>=HfcbR@1wF;*^*0}? zeH-dIBB}nItX9^i%%80FPfs~H-C!Gobp0uTfhmOwUcnjO!dkC+c6Mi;oA;e%`?oVU zg}X1we_BwgcKrVwd`AK}OQd=WO(NV6+FnAFRIA(e@2m9#!?r7WHXw##wVtfU&=m00nRu_0bQ{o{ zb(Gk2i(B@*2~vKgrRq7L!?J&9T}pj*>XeJm#TcCtBZ;9nf?lGpSsgY3VMI>Y@PDHr zZD+_qSn3?Wegn^xtGm4MLR}rC{AlV$^K1j``}qkug7T;wqD>_Hlb{$Z(L>y@;MW!xR>Vrq%&m1LUZB`wXd0=~|S{VEqorX`)A;AiX7`PIUOPlk0TMJO!9yOb)kQB-?dEDQ+Ji!WJJER zQk%pTn^w&57-5BqRxEY=$I8(q=c(fF(~S>i8AfCzysfm>P*x!&nW2xh|IDLh?VE%+ z12gQ61(>+zdmOs_mGb>>t^`rt;Zn4l-$q&LbZ@FF|AC7Jr;}d{aXbT!_LcW&Rn3L% z^2L;2xmO8$uzbnu8x_X)o>^#%j&Ga9k({oP5?~-B72(LW+^I0z61Z@&fd3EAO&$ol zT>jaDVMwrl5EB9t4Gb0*7VICytS0;ZZ@S11)K*7T$J!i*q#V#7|HFo1nr;Q>A4{W$ z+%XuM1P+I?Al`7#|wOWii-ZH{nudv?y=tOY%w zT$7gd$j3a#x2wK;{QYK{(f(WJ5O{wCrNYn~!C-MK3oU?si%{KcZ_{zGLX|y>C*86D zNK-^JVh5+|pgm)hwoYA0m#EqPt9Ji`Ri|0Zy+cb{&4azL6>r{rI$Kpfi@0L3xeHiS zbY@aB&g&Xq)JJ6rLQXbI9O0&<%a|hZ)>p7s(3eJcz8NNF0y*?nXoj~`odgJRR4TWb zII3YF1V&__gutksx3KkmV(E6E?MxH%CjF% zjd~sJ1y|^KIa_oI{;H+;pSAr2j;FFuc#>XS8LoH>v9cYZh!D+s@&rI|VS$2x6rdt} zJe2f)3n3NAALsU>5Z_sY7-klI10XUuCr=M5g0T7x)mGBU7be>7ph9@q*Lg1OAPN7_ zN0M^k2w}bHjJbiD20F*}x6Ap*j>Ri)J)Dda)7x^+0lOlgC0&#F>mPv!#RyPTNm#2c zqswN0oYlifx%^}LxuTMM7ugV9eFbm)gKT(J*pD?DCR`1R z)B>c?I^@d7j)QV4vMR)x?+Sa*#Zt$GfChZKpO5&H)b{&Y?qMxcJ2t8Cr+8sXyWiD9 z24~vQGw7Ymi3pa0aznY}BVRKlg1oM zP>U5Lv@ZBnifFcFQmv$myPZYcUcxQn#F@d z!Ji-O_FhXCZW9?mh*=Xy>{(6=>hFyEino*vpI}dzeQ&rcZs#{Old~XlJOqAUsGm6c zLVM`({Lk2JGZ%rHk$6@_>rTt-5&kj~>s|foqgDCEuYuLGI0y{8379R|2;vI#xmQqg z0wgCmjC90zcv0JsE8~j-+eF%cPxw+Uyih2F+1>+qP%<;{cAdl94)blL$Tv>|??S70 z({uxf*cCJ-+wY-C|8Y%`E;=f8=Z1o@g>`q2gu32LHp!~c|9hC=`M9o5#E1wBe$T-QA)rOX|7QP% zQ%IO`Ag8f%Uet~&UaV3)4^;a4-OME-lwoo8v(Rn_7ES8bYNtnu(*3`Q4biR6b zlljDZbdb{B_4T7i4Gk-s&)19bwNRka#H}^^VfuyjCvYT(8D30H1!%K4(+@>DE@kf& zMT=1!vmTB=kDvZC)KF!Q1%ci^_;G`cDdp$mwFk#F5>-|}+V*qfPqe{c}lUd{noMyU<>nJs0Cm6M` zI;b+z;%&D2pJNgOt$zYRD+WH;s%r(CD$# zt(IYEs#RPBDQB8vQKfI}wRI}7+H=Y{X{V-j7@Xgn*RFyAZ8FC4(iqW4GSC9${341< zdBe(+ug}9u+pM+?xLCJ{ZwDZtho1)^IOTgtMFLWK=RqocKuhev>$Y8Cd&V}$rpfRC zDK5(Wj+*#c%8D(ge%*wvqBicHgq;LOC69Y!!4+L2hf)qs^mQhvy)cTA=CfB%PH{&u z6CC5~CSVFvFVPR{rIYZoMLrkOP(?y;SXQowvR4;UHQ3ejxVx^U6dhJm)aC+jP1QT!*)wLK|gEtJJiVht5ln&R4ZA9ax0~l zr0lV)wxo%=a`xP3CPZzvzbbY*m}fw>P@|x*2bsVgI6ar$k{whQMoN5J!CJ$Nd5IEY zlh5p0>01TkyiX9{_qPjIMk6`QlMROTAuj7KN{d+k%xBT1QiRHd!AG{o8KkLLLb}WY z2E4zb|0#Zt9AiRf5Ye~jz%;V~} z@a;wLD8WVf(C3oMa}|k~pi4YGsmf_!D8pzlcZ|<&zkdwqXghi5#*!>-igqZA>{We- zH4oS}EQ!1m?h?FpNZ?f-5+i<->hcM_@wi3@Qebxtg2T-NsvR36S5^05uLz8?0ZaYt zfn)am4x2`XQ=Y$bou_7qF*tM7{eVJPSZrR=JxW3V-5{B~!T^EY1ST1y;Ni%$YfqT7}XryhqVELkq%RK4Fo`bN$dU@0e| zga$#;qSHvR%Gzhqf^*oiZXxkos?X&7tJN5Xy~WQJ1Qto)uCqUX{IMp9OKuT2f` z@Bty1(fDdlEh=MPSK3m8OI)}YB&cZuVD0Fr;QtX@XCAqXH0n`VAyI*WZU^Fk0Ky%K zux2QCJBFhq=LDZGAD?erJ#9VXcXx7UvsaSrf7otgd=?+9yT;;kIaro8amX}9E3{e2 z@M~8S7!>)GA=JsH1Wu**OAe9tiQ@VNV}Lx|lV&0p_)berpHuD##u^TM*lelFIA@6? zr)*}fqDwanOWF?~UbLYW2Lh8qu!;8deKc(|Ajl>b+>qL&xLieSd_9>nD(>lc(ITac zw<^#+t#>Mh#>sdbAWC-sx@?{@$vYK26<dxSdiB<`Z!S=&!8gc@W6tKq3fh*r($WT26Bh zpgWOZURFw8imxDrO(DV1frAG=wv<8f{oqv&6#Y4_OF+wfb>FYm_tr>tWWh*J4_Q2| zSXI$oe)WAX6>M?85$YAq601;ngbEQ(7gJe@vF~gvPrcf8pzW?0Zb;G66aqU`Mu`r~ zDh=?Vcq>o$PPHgp#QWHec5Cmr*-q;@)xR&!ATJ4F3i?2L;eImC27Ll2A4PsxKML2 zM5rbUe|3wu4LevAo2i=a8t6S>tMM^0k1`#4Qgj(G1=20@N~8BLyfsDY zfk$YNh-b0aBNF49)L*cA3V4UGmgYq}zvzq?c#%r;tb$l-$98}=#d=}JKW?A%05D;a z4{Q5^_s-V1T+=&~vgO!3%5LeF8SAagHVlD!4GcEY$r)}G7yO!oQ=CCfXWpP2I||e) z?5PWfrj4mKpdjh;2kC63xMjgKun(tO)e%lW&EZ=L_84ND9Kc&Eo#EVdt9yL? zWpHJ=VBEzxwgDJShs^THfHgu0f=j(OZjsiXh9k`XqIVDS6@jdBH3$+B*>JC%ju^CE zesu~W6^RrWjI{RU3t$~6v(BRG5nJZgB&xEW#28^-CMhFTV6vR-?&)*R#5Wv#tqQ$x z%K3;wcpCfaX<4Rgr9ay%#ngC1P}fTcVAjch;u@PDE+)rHZ^$OIJrJzB!Lp>^{XPbZ zP@#H|@_!&Bdd{U8_1`i~%YRE*22%g4nC}VlRm=>gN^%NlLdd?8mn`RXq;ynUN07K6 zII0XpEL4$uGUo7JX9fEUn|eONlk8Q&&;)-GLSXiOX-`T<2Gaf+QL>f%Dj6620^16r zYBt8&i1LwU>LIGpMm+IJ=xCZtZhBmLJB@4atSXK~yHH$EDV?yc_dxz^7dMwlCH_}s z&aWY+9W$1Vqr^H&_^SPnQA4K22a+K?7>CY$?O`nQ{a!~?y38eeAYnb<4 zcN^e zOb0$xvHe^Op!@#Dg5VmL-`UgzEF<7GtE$X7Zig_&8u4b)`5POrK`Hu+k~v?b?&q>m zN7t=GA%U9(mQN}OnTm7XU+rEP7p0__Bie|T2bAYOPcaupnjwkWSF-^NFU zvt4UOD#yjD!kY+r=>AtxT%5x3zN}Mb%rg~5L)mzay0qux^srX2SV^f9So@;AjZJheBoXEjwP;b_e~KVtE3_X+=@ zV;7M=SLuB{J5q$sPD)2w+?;OZzwgS2E{Yu!>hgVi#!)0?yksZ5r$A;Edb-kUtR%*| zMK9J4Hj(4m3(71bT7M>4ePU~*iJ0J#j2DgYt&fe-UmK541P-+WMk3X+vFQ?jZvz%V zzs4a*`pnKYNG7C(yKgdZt+)1(GDNMc7}lokfO^O3V8Ptji}j$mOb$SBENL<|>>-h5M+=T>ItcqF&PJ`k7;qI*3s z*Gg2jFp!cuMT?7Si|nidetvJ?cc=-deA+sE*1JPP3Z_@0_wqGj_T?+a^H_dDsjL-H zNzk?IXO7kbC=XEi41EvHgJWWb61^`6)RKW{s^n(7%)GqkC&SZ0XRqB4`pEK(R`SS9lRP30xj1{r*n zl6zq+^%80W1sQ6*7b0rPYS;^Z7XDnN^JUGf`~G7I{V})Yt(Gz3q{D*X<-V*m?69lBZfo=(0WFSFE zyCz5*)Zrkc{h=9Q2+8k7Ld@`Ws$tRY!)J0!?U#OBl4`B@L%Ea;_cH|E;#R^7(zXhf z3&1(x4C;Lrl(d%8g0AJ3d$y<9R*d=Klexe<xGa42Jgls6wmw*%}fE7wt8 zZ~qpdi^Wg7^ESvJK0Up>(rcSxR1yc?jG0_m;eVrM+nWueN}I6Un?Wk9z@^*$9yHE> zxOd^Br)uTfKis?e{n>yhV2mBO?(A!q)k4w6>z1#6x9)EbLjuSE%45MPK+?3PTq4C|n~+68r1o8%YQTcco*1uS9gvg*9zj8(6J#;(0FH~4=B6^%R=FBs%Pk8 z*ob>nOFu?|DtNq|ftF2yBBl5fyi#O2iBcV8U;aN{{Tc7lB(`_v=bpIkao05Itta^= z1n!HO+aJ_Uw+JHnCd}EN^s05$2=c`{(lT%9X$muiKV9Tk`%iS~OpnUP7^#jDbu+tV zzIFGnOcE+tr)1}SI{RJD8b?aK_Kpv9(grr#e}>DdQVTRkAmu1#)pLb$lqn|;=nX^- zR2lKr(&H5J{Vq?u2h2?qUoopF`GO+h$+NPb6TEJI`0p>&8mk`i)^d&(uV>uTJp6}e zQuL%FWdFGfl>ZxMlgh~f=`mE1I~YsWg|(G>os@b>^m+urqN0$@R=!`1xiwGO3H|T3 zU?+dtqCr$HQX&00f1zGSR$I0~%H4eGW$Nel&%4Rb=a&)$hM;xh|ipb^%w)NR60FDcN@+rU=g{Vp>9OLgCW>$4k8I+!QaV&oSlIto@idJ)``*p@b}?%{BJ|zRC%jdu&Wy~w zBv#)flD?~ERi_aHPi;NAsFcY}Z|QfL@zOVYQ+&teH;)RLL5GgxweM#DeDN&C$5>*M z0Rk(<+)_p4S!8h;gmj0{Tpq@E&r8p?xM?VseWXT_6fgUVcKH7GE?77lZ>%GwP7r`q}4K( z{FjJ0yxok6FQ>|mld^PI?~KI&fjOJ(UQxJ`iA6jRdwPGOs>;4JwWk5(<^l=h9@59^ zl3RKrmeYm3aD}4Z1oe_Jg2<#bi~mOrqEfA4?hZ(B!EL!MXwRO#DX#qmBf3rU+z6BQ z#8f>Siri`15isS>bpuU%V*_PQQ!xA#>IaIu0MvAJf*FEUNQ_TGmYgoYz`J&==sW94YoEc*-?)pVbp0Fafx`{ z+Yr>=j$5PMHv8Pkwqn+!RtR_et9hbuQIeikPk$4=I8qsXp(a7iAP zu;I%&a`WkAH!`E=tLz!BF#x9UR}9y;rG@FU)I=2PKs5pyHE9!eIY!;plxD~&*Cn~^ zwXrjJI0HEjCnHe*B&#$l99K!tV1Mx=(VP-Zy!w;)HHez^G} zv`qOr(75ep<49pd@u*wkbAe$0_|dLui8<_Kwt7vZ5b?powUWA)uP3tkUWKQHAZg%q z;(5YY8!fV6I;jQ98896iL;mig4slNu-sm6(;Lh`+fovYp=$I+O(f+Fs{W-IAc|U$D zNdx_NfU_linEhfs6u|&3FBJWJ&v6))1^N6P$pC#+Jz~~msaN`)LsuyJpqaAyGI7$14`WItP2tZE#<-DaKKnLY_(;Y3K-4=Q`_M^ur5_dd zQbrRl53`8*)Y+JJzhX_f9!dh}p~3~D)h99%MR8rrhAbx=8#?TGbV&JVZUJ$|gyr@l zP2%_dbd1<0Fb4kcYwyI%lf$~P zWeWQ^_vF6mgm5MZ^JVE3^N3q{F)-2P_`gY)m2a}hIenuib2VvjAJI68&4#ce`aA#9 z*|tAJp2+p<#;wiA4CJWWJ5nT;-p%?OcaU9)Hb^$Wqd|JY9CJLLH~23_hjvLz+HGg; z#|pwWDaIC!-_%0_qS`vp=YaGolurjx0(seX6nW(Cm|con|j(Y8S{3WaX^^#({!#fpkLtgHRjFFU++R&^Qu z*Ry@O1Rm!7w^lzdwJYCr1*_~PfyxbOnXu|tlcJx~Fnwc^F~A&unVrhv)A*h2xA(OQ zh;~d&t3W9!(zyZnqaUAJTpTyr?BDek+O22X#t9DT7FH*7LqxNiiuh-wjoitqs=uCq z4q9d-HcHVeX+OU#`*dF5XWfF&dN+fqf(@5@;*0hvIvu6i>CdXYCrD+IB8snL zxl=bQ$-x{kbz%k9yuIpsNG|Qc?c*W~hdspTrInqA(eA%M^coOY-Gqb14gAx=KY9Sn7@bLqb8JD9BELKKjU#1T8f6X=bJCHur(EruPonivI;^jO%0Z$u#G?gn~1;N4nh6+B9wGB3#O ztQG74BljPkcD)>@ek$A2A7;7*_3=n;f7_!cRWqN}2x;D_+=4#0)KZh#_bI)#RsP#= zA@eBtif`Js_=JVicb_k}Gi2g&#I>b1K#!mm(LyiF-&Pp9?)@liOSAXEH!E^|QCvMP zOt_*s5MnP8>(Us)uQfm^l5>f6tDF0=|7;TqeD`8Xvv~-n2!HlDj$lXH5&RLN^^wbn z@Jh>{m%yG|O&*H<$eR8u+#|k4nnkOAjqug&$F}ytw$!{rd~n0Zn607yaftBasAm}p$C^3UXkoJvFPT#??1P~+W)rXyps$` z089O*b8o22+Khalr^jx7@-j*=V)!cR_kb{={;b1ZML^o1Z;&eSg^eqb68n+XLc+qn zv0p3H9t=Ym?WK{1DvU*<2SuJ6N!#7%i*z<5$Qgy~8k5N?eM0?8GH{^GO82+{b@R6Pu&}qmV&Yso`r!5&-r0% zHH^7IA8_pP8j90IREE|4ymX3SK+|G4pII$#f|sJ zOxV<70V=Axs8PYKBl^Wchrvnvhv3Ls%lTN@xfib&z9qG{vk&~c7SP8+WlMjCcvl)) z&0`Kn>e}FOHk+DAYkgXOn%4iu+f&!mzQ6zLBWf#QDHdFNd!e$ct4KSSur*tDgNf;V zhGGcalgxN;M{o$lPGpT$?DziwZ9tO0tZ(+E7t@*^IfgHPBg4&V^forut$PnSSJi*< z`>E$K5D%oW&MpOx8xScsa2Oqtin$l#_1gVf-LPq-zttcFitR8}7VWwo39N+5F&!ms zkIdIfMiD73Ob@vaRH3TZn-;8#WDSITKZCRchxXELITy^!O!2I7yPg2*jN##)%nBE%a zmT?ZHB4%)2ppde(+JLobxbTOn_EDUWpT|Xkl0(UJ74kgEk-&2GtdiBUP#KqSxrjN; zbK`6ofh}PC7x$-TE^!4{Gogz-saZQ+t%mFDCE;mYs=!?JpUszWGh^N=Lqnl}DclyA ze9Di_yPrayKbYX^92;92iYW1Ym%SBCumvoB!OW|Vn5|SkVc`VVDmVBzf&Kw^-UDM= zj?W3?egIHQ2MDXU9opIf006QAlc6LWlLK8je+hh4)w%z^JIlSf8MX-l1`x&o5=bTi z!~lb!*?_<#P{QJ{2se|PWMpP;oCQc1tG2YZ)-DgbVC`m?wAN~CVG>OhyP@r)+S3lBd^M5~n>nC`mirk!hFQ_*2W ze~y@m&Wd0~q^qL3B4WjRqcI~LwGx52)oEfqX~s+=Wn#0(NChH2X5>gJ6HiqHyNp=M ztgh(o4#bV#KvdA^sHuo9nUqC1)}&15vujn$)OGKI6S zzP9GdnzeyW^JvBEG-4*b-O3~*=B8-Oe`H#0CA(|8lSXIEtUZ=AdV9@e?PmG8*ZyiX zq6w9pOw(^LjvBQwBhg*Ez2gQml2*yhsz@8brWilV#JWs9a{#NSTpLGMetI9S^hKLmrxZsVG@Ir! zdB*OjG@r?pws!AqnSj;;v<0+Kr_0D+h}NP~1yc#mY=@7;A;!!+>R4@iXfZ9(X%Srk zt8~G*8dVlp&4yEHIg{JGF#{iCe=4sGjW_H1W&1o-O#z*%s0OyOIf+`ef@bXwBi#cd zu3&P2A^1;ap%8hQ#=?WORdl6JD`_>8cjCTEbzmuN*&aEf7l4QrV6UZhrL=~E;HHS1 zsdRPT8{~4EB|WXl?Al~y5}nP-q?J@@V_vB_vMOE6qzXp_2Oes$b=L?+f3A)uqUnv} zbTi`89%`mdI@Qx=+a^1Vq?t&2s6`N{r>!>8HY09&C}gj-g6M&o8;s;)jkd#kYI>6v zA}bv=QyRSrd?n4^m?0uEnSx5!7CE;FC&fIVopuSc?PgkfX+)$rdj*r%+0kN)BNXJJ zeY8&O>}T?i$r6!R6!8#`e;bL;5b_NWQYQ3!5FSx!(>tWo^>i4YbOE;E~MM*G!qed{8f9u9f)J$u16e~>{ z9fyfieW|n=4+ukR^lGN5l1wHYjn#&tDWuNVLa25#?Y9B_IgjY`TV4KikLlmKr`2C+ z)^ykS15NQhvAZGOchrbw%w;ti-Gmc5%~T{A&FRNm%o%Q`TLhoC=97Rty*`;V`Vhcx zgm#UT;Du>Pfp+s*e;`!IG6=qj-mKFJx^1E^r4w|H(Wpvqh4MxzY%x+j5LczQp(NN= zO*Qn{tin-3g^;aAFOGXVy+b(3J0}prwo3m60i;6UQgbTDa@%OdVs<3}kvr+#I-R8V zF!?Hr!`MFiKArBMQ=*WCCUBhtdB0A#)7?yUuM`Z68_X^%e`$wvd!{3|uhIvZHdkK6 zX>IKF;~^#}H^yT?f?9IxP|wHd6Q%Sq z>SwBcMXBsZd)i2Y{-^Ti7En~_)5w45X4=f-X_*iZ?4P0gOX)s(0A(p5mkY~R&fh%r zIeJjQeIEWAe>eI%Oq`TVZ_jyn(PRwbXDF-Fy)?k21Ogg8#1wc%LF&7}ZZ03GG$aDx zQg!}_PG6u$A!8u0|N0FFt2BBHA8{j%%AE4hmjpLe^ktNWRHh@9bMNxXmZI7Et8`94 zKaR|6B?_e7cZnt76-BiPjR(`ZiP=O>~;ax1%yRp}ZCkeV4u`boG7V%Po_s^M?ZD zN9b^^M13xeGc^?Rod1;DAJemf+y6m++gr{_g$;ug(&0tGfuRQyTEK+-?ap9P7(Ab+GSd(%TNibm#n`WuXe z9sy}FuU-%RgYFla`KQ!6)Yuy{)94*uvd#N4e>jO@FiH2wYyd+J1CXj1b4aO`XtQ#C zfrlMJ!}qcnG$ft8Ihqrl9(IeK;$Bt@`&n5!RW8YOE+b9V_<}IHv);p{?9o~0DMF!8 z^wpQ*9TT#_XnVoaQ5ARw(-oJ7qjDJ%LTFq;&K1}@xx9pD@~nKSO4$ zykjeLd3xxyi(+c zRCByH-RI#e;eYI7Ocu^m^wp+^F-wSre>D^G?nt3o#p?tF#)*YvN+%kEZX+fGzWI2> z%vlSg#XOr;Kgyavo{6QSaB;ugdemsVQRfXJ;1=efIxREhPgrSyA2t0(qR$2eWIej_ zNvG}I$OBu@_l7L%NTye13?g%ynm5(&4(&R$d1rl7sQJ+D_U4_3wrp>0_HZ*=e>-mC zO(TtSjcA-}WaG?R>;X0B8GUfgOG*Jy`c~d1Vj~2y=^P(OPz7>}GN5 zxN9VS3%^yE<#rgUR^vO6e-1FYrd#Ze%ERxlivZ>-MpnWcrKXH7b9XYzv|y6koDtG@ z^1FqCF-}cMTlMXYEiJhgf!`-DP#7bWqqXTOjo%LsEWAW(HB%|0+iZ$nw{r3{Rh$O+`4E3t=MOTbAlL3)n*wV!7K0DSHuR;1_suFse{+9>hd<7r5K2HX zb!U1zk@G>Ja({!URiEN}1nytap4x_JcS|B|$^`KlAazO(M5d7B9^lUkoX=sW zvPF`Cy*{t={d`(bkHj*m=uvs&TOWx)g{?*cT0~fH80&jc z2$)P5G5cmNW<`!bUA4`VqC@|W^Aja-%C9lapFH3euT&Y+M)IP;ROo5NLLx`4=w8um zXj|bMI-ln!ZLg45IH(^518DAEhrh|+(n;l~Vbq#f;Xad-!{H-pBk= z8bz0%L?>Y-(SH2UUdPZeca-AJOd^duIi`*HF=nJjD--LKtwAJd!sGnC@~+L_nWyIO zvXXwGcE2!yUt^3L)4+9oN6Lz2(xz?MpUO)`{+Z6tioQcj7zs;cW!YeF_3$tGSE4rm z+C}2uw1#UPf5hK;EI)NX-8)f9t+;JTc@xSQEG`?lmW}iniG&$TNwYNCA1ePoFW>}_ z5ExeZ4;a9c$29(<&d-U0t_yA3U`&@+j=2^tMjzV$3;$K1z6d8yC;J3Zk&Y(A6Z=5= zJO4xH=NZGt`u~R?tNaog8F}Z>7_(C5tHgC)tZy`Xf8cbvAx1md&R*bQonKa{U>@1k z1G9Fjih@*u&gw)h0!a1v616Brr4FL;?UA`;j$}ISsGCMzf=6=hNQ4>P>g8merxF`1Ke%lCnl=qr+jW=Gu9O$bhV3%p#eROpIdS>&M>`)!GkWq;w%FOy))Y@jUFmAOhK z$`=ZXh(6nB&Nm8*mv>NE^=^7n{VGu>lBplgc|*gt{5 zSdvMzOWcXp+7v*0of6ckR9RneV^IjDDjSd_qlu%|5hS2>MFz>qua*mjGUXcOT3y+w ze_%**MMSK5lt%bHjMc={JeoRV;%7Hg-jUnd^XIkc-&()ZA5G+!$Cgh2(j}>-HJXBX z$&DO~fDlnFMi)RlB#k+gemG-1yfXYuI&0pr$4)N34M=F!g6-P zK^UwyHX?~*sS|@_G9FEs{)q6yUQ{+Ie=eE%w;D-*SJI06BUY!`0ip9IJe+SUe{-Ee zdtV}L93LZZhq(Q@2=ASOclfGP{HBXMfJ_-Vf&pTefI+<#S2b;!c!!ykD@gG!Qe`Pc ze36F#Sm`F3au{!=L|VDmm8EG}D$mlqEL|QBWofB*S(a)~sn1jm(p3);;wRKk-n~Oq zA8xJ6Qqur!sSYi#%71Uee{J3!f8L#0+A~1mEFG}_LPKSWr%JM2c1K7M>uer z-j${I4$xf#^noGzP&nuc_?!cD&qMS{rl8yBeuzHHbc)aUT;lyS(_k&J#ZMP?pYT>FJ=WfA~J^e@E`ui2dms zvh;&G0ay;uXKc`Nm-DcEdm>9e5lF{?^fQU%7f8-gP@n1^1>5l;{qioF1K?jvV0S;2 z4$*JJ1N6UV13&|0P=nMye=SSTouZk7mUz$eHa(D|9V`)0B@*flKGzUEANG|T^1d)Y zf6UTfv-EedcOF7#>0hU)EH9|d#)Yr>@NpsNa@A?&norHLa?gb`K3BQsJS-$F*QBUH zO_J3L$lAitsI{r3z}98FAj_AB>$JORhM-r*i?Y0QZ~ySqJ}HV%b(CvD8r69? zXKK0qd7m>J5Jy&dyM>_NeC=8LwL!c-$eZ_;amygL;;eI2EOTe`Y~d*iSpnLm&jz$~>U^T)~olxCvGs5i81_Rl$;gPxF-sN&!LWG(R>% z3(hHtL8pRR$!Y#_IH>2TmH1pCA*G%tc15+Xq-qSIbA^O*ukI0=r}^tcd_ElVK~kTy z8Y+D%%ioq+INU1Y z+Oev&pIqEpeU93Pl)2#pAwbN_DhpbjkI-ddM|Jz4vN)?;F`z6PR0248WtnniR#}7H z(s0P(-Oyg9ti|%xSWvOByq)pYus5qTe@>`Pe=cuxQ~_-B@bclf=lcOJrbnou!#*7^Z5aN`&UoVydKToDVq9s81@_IR~BR=_91t zAM)>dm2Ow*UX|`6dWq^(s#>`Eied7Ke+Fq7jgnRr7GMH=F`mP;sR+=Md7xpmRV9BE_lA&I4Q}#Oe+L~f2Re*v_~jI zA10k#@tDJ)NC8QAYpQOJ;Gg;`OIE>`-WlCty7o|$4e~gGb z0ZxKQLv9oY7XVRSXZ4z^Nz(kM;QKam2t7%p`8H)fFTcgV+(x-=Cb^;Vb1FaYRQZMc zZUZ`H0n5*e|2+r4Qc8x&U4Zj~jq_X{M4D-NjNTa+D=M- zcednUESgQS3}zW!9}%Ytwo*z)e>a5nN@?WZh}Y;7mq<{)?gDuvF>$hBVv)^++>9tu zJZos1oFdj?e+NX{M@}*!a};{%1IFvY@w;I1dvFLESNaqv-Urh@fOve0rqRuu+!to+4ayn?SQ>7)&X>^6tOG}-VROzgyWzN;K+_{FTob^=gyp96SgH+>; zP_6R>t#E#fRyzA>mGc3*()lA=?R=50a{i0zTuf_Ri)pPZK=2Pz^UisA!xyaW`6iVE1Jh4K(}o1mg7 z_(a7Az7R!3MMUcVd`Z@{w1xhD>AC0o&UfD5Ip=%qwfi3eaI9)qxc<^hH?4g~eXkX} z$WDL7>m&8CzWS#6n427Q5|-zMz zGKIO@tsPcN!bC0`4I2+LCnHz`8qU+IhZS7hbj0Qykl|r)Hf* z+)f*43}A(bH^{EjO4^e($di*<7|p`0g`O54q~Z$UhSw9m{%k=MS**fpk#-D?Z+0&- zu|~o4+&onf$BBRySgUa4lo6aDMY}E{3Q1l%8D=CM<)$yujy*q!ldw*9Po{smPDZ!{ zu|B_as=^!^yS_K$CbFJ=w&e{3u_15WX$p&`PYDBW;f1tfF+0PIT*;j5Z z4lgahHYqgpT|3?y!09+c;pjJc$iSJ@HcxoEo1_EIl7#HU*%Qh{*CiRxP8!%m&)I3- z>)L~ApG_@2>S|j_YOonwD$#$1b9u-6EGLmo+h@`bRzFjwda8su4^feJJ}bo(3=M2! z(hbT&f)$~5s#Ic-FGNoO7vOCSW1I!pqZPgRFvgfX3}aiu%48^FLelC*s$io}Zdd=* zPMhj78*r#hX;teQuvV{W?aC&DxJWG8jzsY~7OIGW)I^VJ^$iTt{e6F~6mQ#$4JaHw zWm*?Ykyx8XMuP0oT6-6D$ON$?Z|zQMHD1Kq+(d%uPVF)VnDUi&a?rb^gC`h^q9-(^ ztkDtgz&itYJKjao1Xn~noi?vw`PRubH>D?O-j2SH&ikj zH`3}2l6wqlUA$Ol>P*}$HK<2w)-4L5X*n6Vjh>;%AU-GLpT&Re3`0Jfbt9cODKErV zdvK>@!snT4rO6n?7p0YK$6agyp1Z!Qt-ZZiKff#`%*9veKaLYl-z6K|ovDOt#oG$A zio%*HZrPhDwfEp&(dMg6=xplk&R~bk3DYI?K{I%8FLH8lm}PZ5U}Vt3A>*`NF?%q7 z=kCk*pL{7E&D($R0N0u``tq50h)CLI!QR1YQ$Ky%DPE=^zJ^DH%h&0RqE@G7`}*v( z9p7YIy7hgNQ7i7Xrv|fy%2eFmUu>HNgGxvYd~1rZ>7Mjh0FUC^3gufiZw#+B@m+<+ zal#TF({{D*1#kf0my&kySYD;V{tp7!had97kW0LSLu7vtPl?O+;YSo3OSl=X{6yx8 zefVkd#%eJo9{>4-jm-mTcV~VS`~{uT=4KP|x|HkH^-1Nbky-jZe^UD7bA#!ZgWZ}GbTeuHNx%@W0;G2<-p2f2BFR8Y+({!Dk!Nf|d4 zp^|@*zGr`Xh4vK0U&TGY#NVizn`usQ$}#bGjt!D>X_xwYtf5D}sbPka|AChR?1TR- z*8F@KlN&+z{aeAerR!ivEZO79|KOEMyo~=+wC8rXJK1~qq8JxlN?#_&<_(m`}UVE04Vo5)=)QYwNE8S&ZoV9;bF=PfjXnPr5~^sRiLD1XZn?FO&;-(O$Q0sF1k8a=eYwFF5hF2i2i!aX>9n9Ian^0vn*w*qu4z9^sd5*QzXpR zX_I&&V@hsN%gI|c@|KLBX-{!8ogMV-`1oa2O(i2#`&lI$&7$4f3Bw1kGRuOYRmxTx;P^hj&dE@pI=(EOcpck`-fK411_r8)&uuEv zdW8?Ra!!V{8Rc{5$)gP*3>F|CY#Q>prXinq0DPpc!6AH>ZzR^p^A&_k8l&5`h069~ z{))X=*t8dm!h5keRK6EWhH=C_kiU7T$C3GS=5op;cmK7GqgWR0XdJ@A9F~t_MYOSJ z7)=^onZvQwt^Ak6@xwTA2#az!WjBA;tjM8lH=227K7Wg%Icyw3NA%1goD=QbkBUA1 zIVRTR6b_Z;kPVgRuU`P}jp&5Jd+wR)Rid*r$ zkZ}NyHEF77#L(;vac~X~ig$k>E^_=v#2nR9LuM!tE`%bSr(9V=$vDsA4kj_eikw##vXKv!zx3v@NiSKXpzxV{R}M{!S8eUQ}uHP z%_{DjJ=M=^i(fdnr6NXIt65v=dt0=%@@92Ht$F=x-Nh8(Z?R@}cS(ODs4CfxM#?0> z)h~|VU-#nG9Ftf1a;joCV~3}-&E?@5WzsO!IjREDiU)CVG#V=JiTZ0)u&b;_&F(61 zt;nf)wG};G!|ITnTFA7?sU^FS5l3{28zM%COZC-{_t0lggbX@jR4paluv$iU{+I;& z(GaSrQAbD2vIk*ABb9&tkkLhVSLW0T2J`98J($biB4M;7sqLVLmW{BejNuid<>6k_%jYf0%d=M5%@0+SLG=utRu`+QG`w0}qv5sc1`TgiBN{%Sp3v|K^`v?h zP(M;X)%dgOIf1@weAoGBs}>CdD(t(_cZ`1^Q^1ZBafr9_nU!ie<#QoL& z1%hix96t3Hmfb5+_dlF#V3~o=S1@~wb6>zfxn4M3|9AEO?FNS%1&pzZPfNfWjtavV zV~wAd#=zyIdJS_8T%pwBG4_h8>G_dJWcp{~XK1y|nMi*=u1SucS@ZJ^+&_jZrzLVp zM1`InL)r8+2KH&HUy5NfP(7_RI(cS|#@IC9AR4F1Zl0hsPbRBz7$vLw3Wqt+aPKIF zsJMsx4i#46Hbb?%3O}jDnd3CvDo{ZJTe{IQzEM`XAui8vyo@8p*rChVrwfD}DdoE} zpGpTe6!l}~+k27t7-w)C=qBA(?q5hhUdCbI3etUyirv8$|0)7%J*w0O1XVv~sU&9m z)?tosGv@j(z&u|J)xLhz_%6jE{w~z|FT{L*91Hvo7Wxwi`3JQezaBgM{|8V@2MF_% zQ9_g6fM|bLYwO|0yH)Vib@511@kS5@MNkmDOt;f*GJ^)Bl7ZbJsQ6gS;nH)y#vH%OvXX_=`c_M)UotQ*oK zE%9bsS}$l*aBDk}b$44*TdKKf=tVO1RPNE(*;#)NHny2H^`H4xM{5>rTYBrW#l(buXk=59e`jQxlJQSsn@Oz~ zzVl&zu_6WqCU0a{`dY@Jf8MyEAS+^+{l3PJlZgE$PWy~X{M>(!g_cyhW9W>ml_3+= z(_d(p%PhYwQ^WfzR@s5T{L){8|M2paKw)Y5%7KH45{f807{TZ$hEQ=(!dPBS2@D?c zE1|+ok$+}@E2g-r%7KKkxO9u$1 zr-P4^tb$U1d|T&LKc6M}$~VfxcI?D?G`Du#$dYB}vDm57m+hpjW98{QrX)>zEnnL= zk#tqvt0Zn&x3ZK+3yf`rEh%eDp>u(*ERf3Xvcv;MJIcB--jCA3ItFY7Mqxk;tM)(N zm2CNy4#+P*efN8v?|kR{&;Ojyue|%YYee)uVGFu{_~3&Fwmr}|peIfn>A}WmV`8YW zwJ~9(GGUz%E@d}HhxDXvv^HjjBPl%-FTV&8U)A#{D z2|;Rqzm>}-j62PwA!wDA9c~}a>Vrw6{cKjxWQ=TkZ`yYBWKtoopk=4@GkSYcPY<{6 z9XMqq9EBBxkNH&n`fk6 zU5SKY+q?C&E>F3&e6yK$jBHv@whv)pd(ujOoW_OQcP_Xc!Ygkv)24HqpnHPX(f7I< z&NsPFcSgEw+ei&0vAyN6AWyL6aDbN3GL;mn7PS5Up|?V{DlMn#00n4q75S(>Kz^#? zuayB(X%T;|f;)A&YyHNJ8wCx|d%>bZx5uP2O{<*`EB2&o`yEEj_Ll2xUSDi`7^duh z+hN1$N$NHLUmI*GlO+eY2j~V`$5zk;1+@lkGiLG6@s{*|tIlYmL@U6D&XAe zV9T+Y)(Fr>+QeFH7PNHMoPxln+G){$UD>QI&s3;GrB3$rBGcYsW}%st9SzXU?uDYb zpgsun*9Bv<<7hiy{1&>E_XC+rW-6}G9fB0o-pRKMP&YL%qAuzYbnji#JK7)?WzB&c zTSD8=Y;Vv8EyLE*mZK%Cw4yqYxpN=vjpl{1O#^|;z2WsknncYyV-_f(6iuIcmx<{oGjINfMHc9I#<_m{eXC4^e z%O~lAcD*-N_;@|bSDiwQHqS2HHzBAVImH|rEpcK`F<}YXIuAlXtm)J%kmo=Ty_TAt#(BKYp*x+z55n?d6L`ymWe{Y)S%%UIWmjTm%oTj8orwAIa zDA%qxoyj>6VdyD^EGCDU%DZ^GPo)eY8C4wXR>&#w0oKgeeg=TV7h>KQJl4&SJV&D{ zou&H`Rk_Td?m%}1Q@y<`_DARgtkHudaq>0?N3zygeSo?0Ly(h5TDB3OALXoamOczQ zgYrT+2`ttfpoi(lSjdlmm#$T2lJ18_r08K1TaF$UlxD#!?y=UlZ(^ySu0eg!~-+JnQlaL6L=B zxWLW}yz?TGk7Jc|T^^iQ)nA}b@!BUi*W8ywJr$s*m~30<7ukS+sJtB5^p{+o{$)@; zz|}QiTgjYba9$74r&&T1jfslN!;E_~A&WQ78k#Rcv>_c(8N9JM-JFi2zM6MUN*~om z^fQJwU>Ir5(Nl+!5#7O$p=~JN+&`itQu=eL4O%8^VWTsu zAzVlKESF6pMK)=FE6#(>G_Ex?(?)b>nYxe@26>C7XQ5g#j$tr)TyeWLl(kZz0VkWY znFeiHEw=H+c9dV{P&OIWnr)00HIX|!#iOOdHY&NNIo*|T;E=LmtvGSmv`t4F zah!}DZ7)(}8?$AxP@XQ4+nKRkHj=7OO|W;YA^6I~3FYR01F`oGxz-wBKxsJ}=FznT zE{W@wFKyLq!;ntVOvh$xpD_VIaNw_?PMyZufn3@#QwAzHBg6X?`n6e^en!6fj7rbZ z^C&}H@S$3mhiQ%?sFSjoshg@$W&-;+=ruM)Z)OCo zn>VLU^V^Jn5(_)pkD3{`So^$6SDEz`Bkgd06x1-I%G#OErHrg}JCvKGFYx-`njx=j zi9)}FP{V6yx0N+^CXE!NA~JuM%bPFKOW>ijan31D%#Q7;%=#tzJzo9_GSVEacS6lk zg}w}p5z%{)Cv4|xgIS$lO}bluj4(5P4aKXi4@pK~S%Pl*p*Ral z{t^ALN`FXy!Y88+tW2Fo^?%K%b*4jL?56&!T(F0_k7z66mpV2vaUnJ)m1>o03KK>x!L_}}z>WRC-26Q(IY6`&YwQ!Eq$ zcpU>O4~Ys4qXfny*>Dmg3&l^`aM}+Y=#}w*vlvqLfmPFv`>tLVY?)P5rOnK4KvatwRV)*=vl8xt zrF&Vz6?HJVs4qR?iZT_k66!nFp#!n9i@K9B9JorXRz-tYGjm%^5jOydNKKsS((WUF z4um>u|MVOrY2rpztP^-K*5e)3t=ndzD+j^{@w%yIx->4`cOhX22(ex?vnBA(tN{!Y zxg@HwL$;Ca8ivGx2*UZ8Zh`Z8G$M!nB3$B`IYJc?fhgN>4xq+BMYgY)nDOFSup*w7 z7(~0+sERhR38sPkvsU)>K_nF`2l^9#y#cXBysrv6ZAGrYImM%=R(OM4MT$n z8B!U2u(%>1w!2fepf(IH7~0}CUUNH~IV{g`aPOE~;E662c$n;-@is?=YA_IY0Qji2 z8TRhbY|eH^;mJG2U8>kA?#2ew=E^gh&1Fy>1jH^7B4+x0#Q&BN;UwhT;Vfc*lAppx zdd{DKW=F*O9mbHJOFE_gzFFIG{$8<!`f)vq@)K)5-@KAGdcFzbdYRH0r z*Dm(PA#qq02gMQ4#uRM&EhLnZ-=>L9#6ezD_0w71*341;j*ZiC zD0_i|VR` z_05IOdo&wfY zkwHU6ukt)Y=PRu*llM~1$ONVLT%k-n>J5*RUA>Gx?~nQ#yzH_E;vJPwP)(%4=c%jA z(+9`kZu)p#WyO)JO@#+1=%eu{ zHa`Y~FKX~E+nA?M9)WlaJ$~f84~Y0$E6aH@z9&ylUw}&Cc%GgC+MbOm?3MWOsMizf z_lEm@t^Jje{+eHH@VYK~E)EC%`lQri5*DbVRWLaL!A-J%ZNcx>DTjTGRNuQ)uh1!l zG79Aiw2~x+qDw-dhYD<7Slo5u)H=B9ZSof&y|QdFry$@7H4=A=4lYhY;3MqQCFCvJ zAl=+P^F-;#CD7alKYkR)zk=^7evTBw_K<`LQAbDuIfCW|#_xL1t!u)t@*0MGD7D?=ehG0u<19k@|owbQ^>n7CeQb&Mxk-B@@d^%<`_MXkKY#vuUF%H_%NU(lBYkIpg)yC_GcGpDck=qkBk+*I!4D@BUk7( zUio^QK{QTZZ}5%NH}dqYsJGfX3tErU(h{`3GgkP2b|hZJ)0_A|R`^g~2q(Qc*_x++ zy2L+|U^5k0>6S)YF54BP$+nT2WgDap+1^aI$#y60l5LFk%Ju*qm+f&n34;^q2n%jU z$dYZ29+fTsc1zHFQnoIHl8l2T9GYL0ZoSGr-Qse<)hP`5rlu8om7^Go8W}uOqpvA+ z77!wTdWTjPa4WAAfN?3~9je?>0*4BDg8;{)XshX;OJ1YS5N z^1N74QfT!a_BN7?sA4pUs8>XNa>-iI2ZJiAFsgvhuQQ-T6Y~NXi2ui#LBxi<2-S+# zlX~qWgbMyde|D&>9gZ>BU)3VPk_n)QD$Ue8+f1WPMKDXR| zktSuITkgL^UzUAtx&ICNmh5xOeY`PcpIh{W2X8R+Wy}3mRP5a6mir0unAFpM4a@J* zk^+uWWq36)H=}Un5EJVVQ(iCAbX3$9s1_*^p@!-Zvs8+=0=~+|-CdXwM-|SUepl>_ zZHVY~R8gFexxdmC;Kp`QtVa^-)F=c?sRbTHJ8KGJHZn^*R4#~EX`dV{9b6@)7mI)z zu)uzV>^tXq&zYQ=@4vr(1F(uEhNHv7=jFF*of~_?ZK&(2v7;7M!*hJg=8@&O zn&UMD>4C5X4+SkYd8iqGO=0YXu@kE6JKPRMQT0vD;l5@`kNVo$vaxcHVuSK2zZ2Uw z31O3K%k(Q;({hCfEY~FUKm;M>BE4L?TPkY}aiG2%0Aonjyf`q#Bg+;H(_UceX22V^ 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 Date: Thu, 26 Mar 2026 06:07:16 +0100 Subject: [PATCH 09/11] build: fixed build --- .../ApiDemos/gradle/wrapper/gradle-wrapper.properties | 4 ++-- Maps3DSamples/advanced/app/build.gradle.kts | 2 ++ .../example/advancedmaps3dsamples/common/Map3dViewModel.kt | 2 +- .../advanced/gradle/wrapper/gradle-wrapper.properties | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties b/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties index 6a38a8c..8e61ef1 100644 --- a/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties +++ b/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME 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..4146564 100644 --- a/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties +++ b/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4146564..f4d2442 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From fe4f7f4e539ca8b8356ef44292819ecf8e1b0ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 26 Mar 2026 06:08:07 +0100 Subject: [PATCH 10/11] build: unify gradle wrapper versions to 9.4.1 --- .../advanced/gradle/wrapper/gradle-wrapper.properties | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 4 ++-- snippets/gradle/wrapper/gradle-wrapper.properties | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties b/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties index 4146564..c518c17 100644 --- a/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties +++ b/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f4d2442..c518c17 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/snippets/gradle/wrapper/gradle-wrapper.properties b/snippets/gradle/wrapper/gradle-wrapper.properties index 6a38a8c..c518c17 100644 --- a/snippets/gradle/wrapper/gradle-wrapper.properties +++ b/snippets/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 4177d873ec446f468fe26f381a6fbdb493c8249f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 26 Mar 2026 06:13:07 +0100 Subject: [PATCH 11/11] build: remove gradle wrapper distribution sha --- Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties | 1 - Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties | 1 - gradle/wrapper/gradle-wrapper.properties | 1 - snippets/gradle/wrapper/gradle-wrapper.properties | 1 - 4 files changed, 4 deletions(-) diff --git a/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties b/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties index 8e61ef1..c61a118 100644 --- a/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties +++ b/Maps3DSamples/ApiDemos/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true diff --git a/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties b/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties index c518c17..b27721f 100644 --- a/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties +++ b/Maps3DSamples/advanced/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c518c17..b27721f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true diff --git a/snippets/gradle/wrapper/gradle-wrapper.properties b/snippets/gradle/wrapper/gradle-wrapper.properties index c518c17..b27721f 100644 --- a/snippets/gradle/wrapper/gradle-wrapper.properties +++ b/snippets/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb distributionUrl=https://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true