diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/LocalizationController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/LocalizationController.kt new file mode 100644 index 00000000..be865969 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/LocalizationController.kt @@ -0,0 +1,56 @@ +/* + * 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.maps3d.common + +import com.google.android.gms.maps3d.model.Map3DMode + +/** + * Pure Kotlin state machine controller for Maps Localization. + * + * Manages transitions between [MapLocalePreset] configurations and map rendering modes + * ([Map3DMode.HYBRID] vs [Map3DMode.ROADMAP]). + */ +class LocalizationController( + initialPreset: MapLocalePreset = MapLocalePreset.ENGLISH_US, +) { + private var state = LocalizationState( + selectedPreset = initialPreset, + ) + + /** Returns the current immutable [LocalizationState]. */ + fun getState(): LocalizationState = state + + /** + * Selects a new [MapLocalePreset] and updates language and region. + */ + fun selectPreset(preset: MapLocalePreset): LocalizationState { + state = state.copy( + selectedPreset = preset, + languageCode = preset.language, + regionCode = preset.region, + ) + return state + } + + /** + * Updates the 3D map rendering mode (`Map3DMode.HYBRID` or `Map3DMode.ROADMAP`). + */ + fun setMapMode(@Map3DMode mapMode: Int): LocalizationState { + state = state.copy(mapMode = mapMode) + return state + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/LocalizationData.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/LocalizationData.kt new file mode 100644 index 00000000..5725c163 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/LocalizationData.kt @@ -0,0 +1,131 @@ +/* + * 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.maps3d.common + +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.LocaleOptions +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import java.util.Locale + +private const val SYSTEM_DEFAULT_CODE = "default" + +val DEFAULT_LOCALIZATION_CAMERA: Camera = camera { + center = latLngAltitude { + latitude = 37.8080 + longitude = -122.4177 + altitude = 0.0 + } + tilt = 45.0 + heading = 340.0 + range = 18500.0 + roll = 0.0 +} + +/** + * Curated language and region presets for the Maps Localization sample. + */ +enum class MapLocalePreset( + val displayName: String, + val nativeLabel: String, + val language: String, + val region: String, +) { + ENGLISH_US( + displayName = "English (US)", + nativeLabel = "en-US", + language = "en", + region = "US", + ), + CHINESE_CHINA( + displayName = "Chinese (China)", + nativeLabel = "中文 (zh-CN)", + language = "zh", + region = "CN", + ), + FRENCH_FRANCE( + displayName = "French (France)", + nativeLabel = "Français (fr-FR)", + language = "fr", + region = "FR", + ), + JAPANESE_JAPAN( + displayName = "Japanese (Japan)", + nativeLabel = "日本語 (ja-JP)", + language = "ja", + region = "JP", + ), + SPANISH_SPAIN( + displayName = "Spanish (Spain)", + nativeLabel = "Español (es-ES)", + language = "es", + region = "ES", + ), + ARABIC_SAUDI_ARABIA( + displayName = "Arabic (Saudi Arabia)", + nativeLabel = "العربية (ar-SA)", + language = "ar", + region = "SA", + ), + GERMAN_GERMANY( + displayName = "German (Germany)", + nativeLabel = "Deutsch (de-DE)", + language = "de", + region = "DE", + ), + SYSTEM_DEFAULT( + displayName = "System Default", + nativeLabel = SYSTEM_DEFAULT_CODE, + language = Locale.getDefault().language, + region = Locale.getDefault().country, + ) +} + +/** + * Immutable UI state for the Maps Localization sample across Kotlin Views, Java Views, and Compose. + * + * @property selectedPreset Currently active [MapLocalePreset]. + * @property languageCode Active ISO-639 language code (`"en"`, `"zh"`, `"fr"`, etc.). + * @property regionCode Active ISO-3166-1 alpha-2 region code (`"US"`, `"CN"`, `"FR"`, etc.). + * @property mapMode Current [Map3DMode] (`HYBRID` or `ROADMAP` to display localized labels). + * @property camera Target [Camera] position. + */ +data class LocalizationState( + val selectedPreset: MapLocalePreset = MapLocalePreset.ENGLISH_US, + val languageCode: String = selectedPreset.language, + val regionCode: String = selectedPreset.region, + @param:Map3DMode val mapMode: Int = Map3DMode.HYBRID, + val camera: Camera = DEFAULT_LOCALIZATION_CAMERA, +) { + /** Formatted locale badge text (e.g. `"zh-CN"` or `"default (en_US)"`). */ + val localeBadgeText: String + get() = if (selectedPreset == MapLocalePreset.SYSTEM_DEFAULT) { + "default (${Locale.getDefault()})" + } else { + "$languageCode-$regionCode" + } + + /** + * Builds a [LocaleOptions] instance matching the current [languageCode] and [regionCode] + * for runtime locale updates via [com.google.android.gms.maps3d.GoogleMap3D.setLocale]. + */ + fun toLocaleOptions(): LocaleOptions = LocaleOptions().apply { + language = languageCode + region = regionCode + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MapsLocalizationViewModel.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MapsLocalizationViewModel.kt new file mode 100644 index 00000000..d1821932 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MapsLocalizationViewModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import com.google.android.gms.maps3d.model.Map3DMode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Shared Architecture ViewModel for Maps Localization across Kotlin Views, Java Views, and + * Jetpack Compose. + * + * Exposes immutable [LocalizationState] via [StateFlow] (for Kotlin/Compose) and [LiveData] + * (for Java Views via [asLiveData]). + */ +class MapsLocalizationViewModel( + initialPreset: MapLocalePreset = MapLocalePreset.ENGLISH_US, +) : ViewModel() { + + private val controller = LocalizationController(initialPreset) + + private val _uiState = MutableStateFlow(controller.getState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val localizationStateData: LiveData = _uiState.asLiveData() + + val currentState: LocalizationState + get() = _uiState.value + + /** Selects a [MapLocalePreset] and updates the language and region. */ + fun selectPreset(preset: MapLocalePreset) { + _uiState.value = controller.selectPreset(preset) + } + + /** Switches between [Map3DMode.HYBRID] and [Map3DMode.ROADMAP] to inspect localized labels. */ + fun setMapMode(@Map3DMode mapMode: Int) { + _uiState.value = controller.setMapMode(mapMode) + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_maps_localization.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_maps_localization.xml new file mode 100644 index 00000000..8f278738 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_maps_localization.xml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index 138b4de3..b31206e6 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Cloud Map Styling Roadmap Mode Field Of View + Maps Localization Coming soon! @@ -246,4 +247,11 @@ 3D Model Animation Mode: Synchronized (High CPU) Midpoint Jump (Low CPU) + + + 3D Map Localization Controls + Change Language & Region + %1$s [%2$s] + Applied %1$s [%2$s] + Location: San Francisco Bay, USA diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/LocalizationControllerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/LocalizationControllerTest.kt new file mode 100644 index 00000000..5780e950 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/LocalizationControllerTest.kt @@ -0,0 +1,84 @@ +/* + * 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.maps3d.common + +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.common.truth.Truth.assertThat +import java.util.Locale +import org.junit.Before +import org.junit.Test + +/** + * Tier 2 unit tests for [LocalizationController] and [MapLocalePreset]. + */ +class LocalizationControllerTest { + + private lateinit var controller: LocalizationController + + @Before + fun setUp() { + controller = LocalizationController() + } + + @Test + fun `getState defaults to English US in HYBRID mode`() { + val state = controller.getState() + + assertThat(state.selectedPreset).isEqualTo(MapLocalePreset.ENGLISH_US) + assertThat(state.languageCode).isEqualTo("en") + assertThat(state.regionCode).isEqualTo("US") + assertThat(state.localeBadgeText).isEqualTo("en-US") + assertThat(state.mapMode).isEqualTo(Map3DMode.HYBRID) + } + + @Test + fun `selectPreset updates language and region options`() { + val updated = controller.selectPreset(MapLocalePreset.CHINESE_CHINA) + + assertThat(updated.selectedPreset).isEqualTo(MapLocalePreset.CHINESE_CHINA) + assertThat(updated.languageCode).isEqualTo("zh") + assertThat(updated.regionCode).isEqualTo("CN") + assertThat(updated.localeBadgeText).isEqualTo("zh-CN") + + val localeOptions = updated.toLocaleOptions() + assertThat(localeOptions.language).isEqualTo("zh") + assertThat(localeOptions.region).isEqualTo("CN") + } + + @Test + fun `selectPreset SYSTEM_DEFAULT resolves default LocaleOptions`() { + controller.selectPreset(MapLocalePreset.FRENCH_FRANCE) + + val resetState = controller.selectPreset(MapLocalePreset.SYSTEM_DEFAULT) + val expectedDefaultLocale = Locale.getDefault() + + assertThat(resetState.selectedPreset).isEqualTo(MapLocalePreset.SYSTEM_DEFAULT) + assertThat(resetState.languageCode).isEqualTo(expectedDefaultLocale.language) + assertThat(resetState.regionCode).isEqualTo(expectedDefaultLocale.country) + assertThat(resetState.localeBadgeText).startsWith("default (") + + val localeOptions = resetState.toLocaleOptions() + assertThat(localeOptions.language).isEqualTo(expectedDefaultLocale.language) + assertThat(localeOptions.region).isEqualTo(expectedDefaultLocale.country) + } + + @Test + fun `setMapMode updates mapMode cleanly`() { + val updatedState = controller.setMapMode(Map3DMode.ROADMAP) + assertThat(updatedState.mapMode).isEqualTo(Map3DMode.ROADMAP) + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/MapsLocalizationViewModelTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/MapsLocalizationViewModelTest.kt new file mode 100644 index 00000000..73ebf6ce --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/MapsLocalizationViewModelTest.kt @@ -0,0 +1,79 @@ +/* + * 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.maps3d.common + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.android.gms.maps3d.model.Map3DMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * Tier 3 JVM Unit Tests for [MapsLocalizationViewModel]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MapsLocalizationViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + private lateinit var viewModel: MapsLocalizationViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + viewModel = MapsLocalizationViewModel() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun selectPreset_updatesStateFlowAndLiveData() = runTest { + val observedStates = mutableListOf() + viewModel.localizationStateData.observeForever { observedStates.add(it) } + + viewModel.selectPreset(MapLocalePreset.CHINESE_CHINA) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(MapLocalePreset.CHINESE_CHINA, state.selectedPreset) + assertEquals("zh", state.languageCode) + assertEquals("CN", state.regionCode) + assertEquals(MapLocalePreset.CHINESE_CHINA, observedStates.last().selectedPreset) + } + + @Test + fun setMapMode_dispatchesCleanly() = runTest { + viewModel.selectPreset(MapLocalePreset.GERMAN_GERMANY) + + viewModel.setMapMode(Map3DMode.ROADMAP) + assertEquals(Map3DMode.ROADMAP, viewModel.currentState.mapMode) + } +} diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/MapsLocalizationVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/MapsLocalizationVisualTest.java new file mode 100644 index 00000000..a1a34de8 --- /dev/null +++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/MapsLocalizationVisualTest.java @@ -0,0 +1,63 @@ +/* + * 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.maps3djava; + +import static org.junit.Assert.assertTrue; + +import android.content.Intent; +import android.graphics.Bitmap; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.Until; +import com.example.maps3djava.mapslocalization.MapsLocalizationActivity; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tier 4 Visual Regression Test for the Java Views {@link MapsLocalizationActivity} sample. + */ +@RunWith(AndroidJUnit4.class) +public class MapsLocalizationVisualTest extends BaseVisualTest { + + @Test + public void verifyMapsLocalizationRenders() { + Intent intent = new Intent(context, MapsLocalizationActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + + uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000); + waitForMapRendering(15); + + Bitmap screenshotBitmap = captureScreenshot("maps_localization_screenshot.png"); + + String prompt = + "Please act as a UI tester and analyze this screenshot.\n" + + "1. Confirm that a 3D map view is visible in Hybrid or Roadmap mode with place labels.\n" + + "2. Confirm that the bottom 3D Map Localization Controls card is visible (showing the language selection button and location label below it).\n" + + "\n" + + "If and ONLY IF you can clearly see the 3D map scene and the bottom Localization Controls card, reply with \"PASSED\".\n" + + "If you cannot see the 3D map scene or control card, reply with \"FAILED: 3D Map Localization scene or controls not visible\".\n" + + "Report what you see in detail."; + + String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey); + System.out.println("Gemini's analysis: " + geminiResponse); + + assertTrue( + "Visual verification failed. Gemini response: " + geminiResponse, + geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")); + } +} diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml index 7649c6cd..fb241e1e 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml @@ -200,6 +200,13 @@ android:exported="true" /> + + diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java index 7e4b9f0d..7efcefb7 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java @@ -51,6 +51,7 @@ import com.example.maps3djava.cloudstyling.CloudStylingActivity; import com.example.maps3djava.roadmapmode.RoadmapModeActivity; import com.example.maps3djava.fieldofview.FieldOfViewActivity; +import com.example.maps3djava.mapslocalization.MapsLocalizationActivity; import com.google.android.material.appbar.MaterialToolbar; import java.util.LinkedHashMap; @@ -81,6 +82,7 @@ public class MainActivity extends AppCompatActivity { put(R.string.feature_title_cloud_styling, CloudStylingActivity.class); put(R.string.feature_title_roadmap_mode, RoadmapModeActivity.class); put(R.string.feature_title_field_of_view, FieldOfViewActivity.class); + put(R.string.feature_title_maps_localization, MapsLocalizationActivity.class); }}; @Override diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mapslocalization/MapsLocalizationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mapslocalization/MapsLocalizationActivity.java new file mode 100644 index 00000000..50757f02 --- /dev/null +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mapslocalization/MapsLocalizationActivity.java @@ -0,0 +1,235 @@ +/* + * 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.maps3djava.mapslocalization; + +import android.os.Bundle; +import android.transition.TransitionManager; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.RadioGroup; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.lifecycle.ViewModelProvider; +import com.example.maps3d.common.LocalizationDataKt; +import com.example.maps3d.common.LocalizationState; +import com.example.maps3d.common.MapLocalePreset; +import com.example.maps3d.common.MapsLocalizationViewModel; +import com.example.maps3dcommon.R; +import com.example.maps3djava.sampleactivity.SampleBaseActivity; +import com.google.android.gms.maps3d.GoogleMap3D; +import com.google.android.gms.maps3d.Map3DView; +import com.google.android.gms.maps3d.model.Camera; +import com.google.android.gms.maps3d.model.Map3DMode; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import java.util.List; + +/** + * Java Views implementation of the 3D Map Localization sample (`language` and `region` via {@link + * com.google.android.gms.maps3d.model.LocaleOptions} and {@link GoogleMap3D#setLocale}). + * + *

Dynamically updates the map language and region in-place on the live {@link GoogleMap3D} + * instance without recreating {@link Map3DView} or reloading the map scene. + */ +public class MapsLocalizationActivity extends SampleBaseActivity { + + private static final String TAG = "MapsLocalizationActivity"; + + @Override + public Camera getInitialCamera() { + return LocalizationDataKt.getDEFAULT_LOCALIZATION_CAMERA(); + } + + @Override + public String getTAG() { + return TAG; + } + + private MapsLocalizationViewModel viewModel; + + private LinearLayout collapsibleContent; + private MaterialButton collapseToggleButton; + private MaterialButton changeLanguageButton; + private RadioGroup mapModeRadioGroup; + + private boolean isCollapsed = false; + private boolean isUpdatingUiFromState = false; + private MapLocalePreset activePreset = MapLocalePreset.ENGLISH_US; + private int activeMapMode = Map3DMode.HYBRID; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + viewModel = new ViewModelProvider(this).get(MapsLocalizationViewModel.class); + + ConstraintLayout rootContainer = findViewById(R.id.map_container); + View controlPanel = + LayoutInflater.from(this) + .inflate(R.layout.control_panel_maps_localization, rootContainer, false); + rootContainer.addView(controlPanel); + + ViewCompat.setOnApplyWindowInsetsListener( + controlPanel, + (view, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + ConstraintLayout.LayoutParams layoutParams = + (ConstraintLayout.LayoutParams) view.getLayoutParams(); + int margin12dp = (int) (12 * getResources().getDisplayMetrics().density); + layoutParams.bottomMargin = insets.bottom + margin12dp; + view.setLayoutParams(layoutParams); + return windowInsets; + }); + + bindControlPanelViews(controlPanel); + setupControlListeners(); + observeViewModel(); + } + + + @Override + public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { + super.onMap3DViewReady(googleMap3D); + LocalizationState state = viewModel.getCurrentState(); + activePreset = state.getSelectedPreset(); + activeMapMode = state.getMapMode(); + googleMap3D.setMapMode(state.getMapMode()); + googleMap3D.setLocale(state.toLocaleOptions()); + } + + private void bindControlPanelViews(View panel) { + collapsibleContent = panel.findViewById(R.id.card_content); + collapseToggleButton = panel.findViewById(R.id.btn_collapse); + changeLanguageButton = panel.findViewById(R.id.btn_change_language); + mapModeRadioGroup = panel.findViewById(R.id.rg_localization_map_mode); + + panel.findViewById(R.id.card_header).setOnClickListener(v -> togglePanelCollapse()); + collapseToggleButton.setOnClickListener(v -> togglePanelCollapse()); + } + + private void setupControlListeners() { + changeLanguageButton.setOnClickListener(v -> showLanguageSelectionDialog()); + + mapModeRadioGroup.setOnCheckedChangeListener( + (group, checkedId) -> { + if (isUpdatingUiFromState) { + return; + } + int targetMode = + (checkedId == R.id.rb_mode_roadmap) + ? Map3DMode.ROADMAP + : Map3DMode.HYBRID; + viewModel.setMapMode(targetMode); + }); + } + + private void showLanguageSelectionDialog() { + List presets = MapLocalePreset.getEntries(); + String[] labels = new String[presets.size()]; + for (int i = 0; i < presets.size(); i++) { + MapLocalePreset preset = presets.get(i); + labels[i] = preset.getDisplayName() + " — " + preset.getNativeLabel(); + } + + MapLocalePreset currentPreset = viewModel.getCurrentState().getSelectedPreset(); + int currentIndex = Math.max(0, presets.indexOf(currentPreset)); + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.change_language_button) + .setSingleChoiceItems( + labels, + currentIndex, + (dialog, which) -> { + MapLocalePreset chosenPreset = presets.get(which); + dialog.dismiss(); + if (chosenPreset != currentPreset) { + viewModel.selectPreset(chosenPreset); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void observeViewModel() { + viewModel.getLocalizationStateData() + .observe( + this, + state -> { + renderUiState(state); + GoogleMap3D map = this.googleMap3D; + if (map != null) { + applyStateToMap(map, state); + } + }); + } + + private void renderUiState(LocalizationState state) { + isUpdatingUiFromState = true; + try { + changeLanguageButton.setText( + getString( + R.string.language_button_format, + state.getSelectedPreset().getDisplayName(), + state.getLocaleBadgeText())); + + int expectedRadioId = + (state.getMapMode() == Map3DMode.ROADMAP) + ? R.id.rb_mode_roadmap + : R.id.rb_mode_hybrid; + if (mapModeRadioGroup.getCheckedRadioButtonId() != expectedRadioId) { + mapModeRadioGroup.check(expectedRadioId); + } + } finally { + isUpdatingUiFromState = false; + } + } + + private void applyStateToMap(GoogleMap3D map, LocalizationState state) { + if (state.getMapMode() != activeMapMode) { + activeMapMode = state.getMapMode(); + map.setMapMode(state.getMapMode()); + } + + if (state.getSelectedPreset() != activePreset) { + activePreset = state.getSelectedPreset(); + map.setLocale(state.toLocaleOptions()); + Toast.makeText( + this, + getString( + R.string.localization_applied_format, + state.getSelectedPreset().getDisplayName(), + state.getLocaleBadgeText()), + Toast.LENGTH_SHORT) + .show(); + } + } + + private void togglePanelCollapse() { + ConstraintLayout rootContainer = findViewById(R.id.map_container); + TransitionManager.beginDelayedTransition(rootContainer); + isCollapsed = !isCollapsed; + collapsibleContent.setVisibility(isCollapsed ? View.GONE : View.VISIBLE); + collapseToggleButton.setIconResource( + isCollapsed ? R.drawable.expand_less_24px : R.drawable.expand_more_24px); + } +} diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/MapsLocalizationVisualTest.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/MapsLocalizationVisualTest.kt new file mode 100644 index 00000000..86b6b378 --- /dev/null +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/MapsLocalizationVisualTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3dkotlin + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.example.maps3dkotlin.mapslocalization.MapsLocalizationActivity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tier 4 Visual Regression Test for the Kotlin Views [MapsLocalizationActivity] sample. + */ +@RunWith(AndroidJUnit4::class) +class MapsLocalizationVisualTest : BaseVisualTest() { + + @Test + fun verifyMapsLocalizationRenders() { + runBlocking { + val intent = Intent(context, MapsLocalizationActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + waitForMapRendering(15) + + val screenshotBitmap = captureScreenshot("maps_localization_screenshot.png") + + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible in Hybrid or Roadmap mode with labels. + 2. Confirm that the bottom 3D Map Localization Controls card is visible + (showing the language selection button and location label below it). + + If and ONLY IF you can clearly see the 3D map scene and the bottom Localization + Controls card, reply with "PASSED". + If you cannot see the 3D map scene or control card, reply with + "FAILED: 3D Map Localization scene or controls not visible". + Report what you see in detail. + """.trimIndent() + + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } +} diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml b/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml index 5de5d795..f4762c49 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml @@ -203,6 +203,13 @@ android:exported="true" /> + + diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt index 00fd104b..6a43c0da 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt @@ -67,6 +67,7 @@ import com.example.maps3dkotlin.datavisualization.DataVisualizationActivity import com.example.maps3dkotlin.cloudstyling.CloudStylingActivity import com.example.maps3dkotlin.roadmapmode.RoadmapModeActivity import com.example.maps3dkotlin.fieldofview.FieldOfViewActivity +import com.example.maps3dkotlin.mapslocalization.MapsLocalizationActivity import com.example.maps3dkotlin.theme.Maps3DSamplesTheme import kotlinx.coroutines.launch @@ -114,6 +115,7 @@ class MainActivity : ComponentActivity() { Sample(R.string.feature_title_cloud_styling, CloudStylingActivity::class.java), Sample(R.string.feature_title_roadmap_mode, RoadmapModeActivity::class.java), Sample(R.string.feature_title_field_of_view, FieldOfViewActivity::class.java), + Sample(R.string.feature_title_maps_localization, MapsLocalizationActivity::class.java), ) @OptIn(ExperimentalMaterial3Api::class) diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mapslocalization/MapsLocalizationActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mapslocalization/MapsLocalizationActivity.kt new file mode 100644 index 00000000..1868a905 --- /dev/null +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mapslocalization/MapsLocalizationActivity.kt @@ -0,0 +1,219 @@ +/* + * 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.maps3dkotlin.mapslocalization + +import android.os.Bundle +import android.transition.TransitionManager +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import android.widget.RadioGroup +import android.widget.Toast +import androidx.activity.viewModels +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.example.maps3d.common.DEFAULT_LOCALIZATION_CAMERA +import com.example.maps3d.common.LocalizationState +import com.example.maps3d.common.MapLocalePreset +import com.example.maps3d.common.MapsLocalizationViewModel +import com.example.maps3dcommon.R +import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch + +/** + * Demonstrates 3D Map Localization (`language` and `region` via + * [com.google.android.gms.maps3d.model.LocaleOptions] and [GoogleMap3D.setLocale]). + * + * Dynamically updates the map language and region in-place on the live [GoogleMap3D] instance + * without recreating `Map3DView` or reloading the map scene. + */ +class MapsLocalizationActivity : SampleBaseActivity() { + + override val TAG = "MapsLocalizationActivity" + override val initialCamera: Camera = DEFAULT_LOCALIZATION_CAMERA + + private val viewModel: MapsLocalizationViewModel by viewModels() + + private lateinit var collapsibleContent: LinearLayout + private lateinit var collapseToggleButton: MaterialButton + private lateinit var changeLanguageButton: MaterialButton + private lateinit var mapModeRadioGroup: RadioGroup + + private var isCollapsed = false + private var isUpdatingUiFromState = false + private var activePreset: MapLocalePreset = MapLocalePreset.ENGLISH_US + private var activeMapMode: Int = Map3DMode.HYBRID + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val rootContainer = findViewById(R.id.map_container) + val controlPanel = LayoutInflater.from(this).inflate( + R.layout.control_panel_maps_localization, + rootContainer, + false, + ) + rootContainer.addView(controlPanel) + + ViewCompat.setOnApplyWindowInsetsListener(controlPanel) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val layoutParams = view.layoutParams as ConstraintLayout.LayoutParams + val margin12dp = (12 * resources.displayMetrics.density).toInt() + layoutParams.bottomMargin = insets.bottom + margin12dp + view.layoutParams = layoutParams + windowInsets + } + + bindControlPanelViews(controlPanel) + setupControlListeners() + observeViewModel() + } + + override fun onMapReady(googleMap3D: GoogleMap3D) { + super.onMapReady(googleMap3D) + val state = viewModel.uiState.value + activePreset = state.selectedPreset + activeMapMode = state.mapMode + googleMap3D.setMapMode(state.mapMode) + googleMap3D.setLocale(state.toLocaleOptions()) + } + + private fun bindControlPanelViews(panel: View) { + collapsibleContent = panel.findViewById(R.id.card_content) + collapseToggleButton = panel.findViewById(R.id.btn_collapse) + changeLanguageButton = panel.findViewById(R.id.btn_change_language) + mapModeRadioGroup = panel.findViewById(R.id.rg_localization_map_mode) + + panel.findViewById(R.id.card_header).setOnClickListener { + togglePanelCollapse() + } + collapseToggleButton.setOnClickListener { + togglePanelCollapse() + } + } + + private fun setupControlListeners() { + changeLanguageButton.setOnClickListener { + showLanguageSelectionDialog() + } + + mapModeRadioGroup.setOnCheckedChangeListener { _, checkedId -> + if (isUpdatingUiFromState) return@setOnCheckedChangeListener + val targetMode = if (checkedId == R.id.rb_mode_roadmap) { + Map3DMode.ROADMAP + } else { + Map3DMode.HYBRID + } + viewModel.setMapMode(targetMode) + } + } + + private fun showLanguageSelectionDialog() { + val presets = MapLocalePreset.entries + val labels = presets.map { "${it.displayName} — ${it.nativeLabel}" }.toTypedArray() + val currentState = viewModel.uiState.value + val currentIndex = presets.indexOf(currentState.selectedPreset).coerceAtLeast(0) + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.change_language_button) + .setSingleChoiceItems(labels, currentIndex) { dialog, which -> + val chosenPreset = presets[which] + dialog.dismiss() + if (chosenPreset != currentState.selectedPreset) { + viewModel.selectPreset(chosenPreset) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun observeViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + renderUiState(state) + val map = googleMap3D + if (map != null) { + applyStateToMap(map, state) + } + } + } + } + } + + private fun renderUiState(state: LocalizationState) { + isUpdatingUiFromState = true + try { + changeLanguageButton.text = getString( + R.string.language_button_format, + state.selectedPreset.displayName, + state.localeBadgeText, + ) + + val expectedRadioId = if (state.mapMode == Map3DMode.ROADMAP) { + R.id.rb_mode_roadmap + } else { + R.id.rb_mode_hybrid + } + if (mapModeRadioGroup.checkedRadioButtonId != expectedRadioId) { + mapModeRadioGroup.check(expectedRadioId) + } + } finally { + isUpdatingUiFromState = false + } + } + + private fun applyStateToMap(map: GoogleMap3D, state: LocalizationState) { + if (state.mapMode != activeMapMode) { + activeMapMode = state.mapMode + map.setMapMode(state.mapMode) + } + + if (state.selectedPreset != activePreset) { + activePreset = state.selectedPreset + map.setLocale(state.toLocaleOptions()) + Toast.makeText( + this, + getString( + R.string.localization_applied_format, + state.selectedPreset.displayName, + state.localeBadgeText, + ), + Toast.LENGTH_SHORT, + ).show() + } + } + + private fun togglePanelCollapse() { + val rootContainer = findViewById(R.id.map_container) + TransitionManager.beginDelayedTransition(rootContainer) + isCollapsed = !isCollapsed + collapsibleContent.visibility = if (isCollapsed) View.GONE else View.VISIBLE + collapseToggleButton.setIconResource( + if (isCollapsed) R.drawable.expand_less_24px else R.drawable.expand_more_24px, + ) + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml index 1fdee10d..e22af608 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml @@ -64,6 +64,7 @@ + diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt index 5e475d91..a6dc6679 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt @@ -46,6 +46,7 @@ import com.example.composedemos.fieldofview.FieldOfViewActivity import com.example.composedemos.flightsimulator.FlightSimulatorActivity import com.example.composedemos.hellomap.HelloMapActivity import com.example.composedemos.mapinteractions.MapInteractionsActivity +import com.example.composedemos.mapslocalization.MapsLocalizationActivity import com.example.composedemos.markers.MarkersActivity import com.example.composedemos.models.ModelsActivity import com.example.composedemos.pathfollowing.PathFollowingActivity @@ -207,6 +208,11 @@ fun CatalogScreen() { context.startActivity(Intent(context, FieldOfViewActivity::class.java)) } } + item { + SampleItem("Maps Localization") { + context.startActivity(Intent(context, MapsLocalizationActivity::class.java)) + } + } } } diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapslocalization/MapsLocalizationActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapslocalization/MapsLocalizationActivity.kt new file mode 100644 index 00000000..1bd3f654 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapslocalization/MapsLocalizationActivity.kt @@ -0,0 +1,355 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.composedemos.mapslocalization + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.maps3d.common.LocalizationState +import com.example.maps3d.common.MapLocalePreset +import com.example.maps3d.common.MapsLocalizationViewModel +import com.example.maps3dcommon.R +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.maps.android.compose3d.GoogleMap3D + +/** + * Jetpack Compose implementation of the 3D Maps Localization sample. + * + * Uses the shared [MapsLocalizationViewModel] from `:Maps3DSamples:ApiDemos:common` and + * dynamically updates the map language and region in-place on the live [GoogleMap3D] instance + * via [GoogleMap3D.setLocale] without recreating the map view or reloading the scene. + */ +class MapsLocalizationActivity : ComponentActivity() { + + private val viewModel: MapsLocalizationViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + MaterialTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + MapsLocalizationScreen( + state = uiState, + onSelectPreset = { chosenPreset -> + if (chosenPreset != uiState.selectedPreset) { + viewModel.selectPreset(chosenPreset) + Toast.makeText( + this@MapsLocalizationActivity, + getString( + R.string.localization_applied_format, + chosenPreset.displayName, + viewModel.currentState.localeBadgeText, + ), + Toast.LENGTH_SHORT, + ).show() + } + }, + onSelectMapMode = viewModel::setMapMode, + onNavigateUp = ::finish, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MapsLocalizationScreen( + state: LocalizationState, + onSelectPreset: (MapLocalePreset) -> Unit, + onSelectMapMode: (Int) -> Unit, + onNavigateUp: () -> Unit, +) { + var googleMap3DInstance by remember { mutableStateOf(null) } + var isPanelExpanded by remember { mutableStateOf(true) } + + LaunchedEffect(googleMap3DInstance, state.selectedPreset) { + googleMap3DInstance?.setLocale(state.toLocaleOptions()) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.feature_title_maps_localization)) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), + ), + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + ) { + GoogleMap3D( + camera = state.camera, + mapMode = state.mapMode, + modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + googleMap3DInstance = map + map.setLocale(state.toLocaleOptions()) + }, + ) + + LocalizationControlCard( + state = state, + isExpanded = isPanelExpanded, + onToggleExpanded = { isPanelExpanded = !isPanelExpanded }, + onSelectPreset = onSelectPreset, + onSelectMapMode = onSelectMapMode, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(12.dp), + ) + } + } +} + +@Composable +private fun LocalizationControlCard( + state: LocalizationState, + isExpanded: Boolean, + onToggleExpanded: () -> Unit, + onSelectPreset: (MapLocalePreset) -> Unit, + onSelectMapMode: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + var showLanguageDialog by remember { mutableStateOf(false) } + + if (showLanguageDialog) { + AlertDialog( + onDismissRequest = { showLanguageDialog = false }, + title = { Text(text = stringResource(R.string.change_language_button)) }, + text = { + Column { + MapLocalePreset.entries.forEach { preset -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showLanguageDialog = false + onSelectPreset(preset) + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = state.selectedPreset == preset, + onClick = { + showLanguageDialog = false + onSelectPreset(preset) + }, + ) + Text( + text = "${preset.displayName} — ${preset.nativeLabel}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showLanguageDialog = false }) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } + + ElevatedCard( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggleExpanded) + .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.localization_controls_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onToggleExpanded, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = if (isExpanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.Default.KeyboardArrowUp + }, + contentDescription = stringResource(R.string.collapse_controls), + ) + } + } + + AnimatedVisibility(visible = isExpanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 10.dp), + ) { + OutlinedButton( + onClick = { showLanguageDialog = true }, + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + ) { + Text( + text = stringResource( + R.string.language_button_format, + state.selectedPreset.displayName, + state.localeBadgeText, + ), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + ) + } + + Text( + text = stringResource(R.string.localization_location), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, bottom = 2.dp), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .weight(1f) + .height(36.dp) + .clickable { onSelectMapMode(Map3DMode.HYBRID) }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = state.mapMode == Map3DMode.HYBRID, + onClick = { onSelectMapMode(Map3DMode.HYBRID) }, + ) + Text( + text = stringResource(R.string.hybrid), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Row( + modifier = Modifier + .weight(1f) + .height(36.dp) + .clickable { onSelectMapMode(Map3DMode.ROADMAP) }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = state.mapMode == Map3DMode.ROADMAP, + onClick = { onSelectMapMode(Map3DMode.ROADMAP) }, + ) + Text( + text = stringResource(R.string.roadmap), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } + } +} diff --git a/snippets/java-app/src/main/java/com/example/snippets/java/SnippetRegistry.java b/snippets/java-app/src/main/java/com/example/snippets/java/SnippetRegistry.java index ef38592e..db72ee40 100644 --- a/snippets/java-app/src/main/java/com/example/snippets/java/SnippetRegistry.java +++ b/snippets/java-app/src/main/java/com/example/snippets/java/SnippetRegistry.java @@ -55,6 +55,7 @@ public static void clearTrackedItems() { private static final List> snippetClasses = Arrays.asList( MapInitSnippets.class, + MapLocalizationSnippets.class, CameraControlSnippets.class, MarkerSnippets.class, PolygonSnippets.class, diff --git a/snippets/java-app/src/main/java/com/example/snippets/java/TrackedMap3D.java b/snippets/java-app/src/main/java/com/example/snippets/java/TrackedMap3D.java index 3a685dd3..5a8a8a80 100644 --- a/snippets/java-app/src/main/java/com/example/snippets/java/TrackedMap3D.java +++ b/snippets/java-app/src/main/java/com/example/snippets/java/TrackedMap3D.java @@ -26,6 +26,7 @@ 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.LocaleOptions; import com.google.android.gms.maps3d.model.Marker; import com.google.android.gms.maps3d.model.MarkerOptions; import com.google.android.gms.maps3d.model.Model; @@ -122,4 +123,8 @@ public void setCameraRestriction(CameraRestriction restriction) { public CameraRestriction getCameraRestriction() { return delegate.getCameraRestriction(); } + + public void setLocale(LocaleOptions options) { + delegate.setLocale(options); + } } diff --git a/snippets/java-app/src/main/java/com/example/snippets/java/snippets/MapLocalizationSnippets.java b/snippets/java-app/src/main/java/com/example/snippets/java/snippets/MapLocalizationSnippets.java new file mode 100644 index 00000000..8fc3d20d --- /dev/null +++ b/snippets/java-app/src/main/java/com/example/snippets/java/snippets/MapLocalizationSnippets.java @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.snippets.java.snippets; + +import com.example.snippets.java.TrackedMap3D; +import com.example.snippets.java.annotations.SnippetGroup; +import com.example.snippets.java.annotations.SnippetItem; +import com.google.android.gms.maps3d.model.LocaleOptions; +import java.util.Locale; + +@SnippetGroup( + title = "Map Localization", + description = + "Snippets demonstrating 3D map language and region localization using LocaleOptions and GoogleMap3D.setLocale.") +public class MapLocalizationSnippets { + + private final TrackedMap3D map; + + public MapLocalizationSnippets(TrackedMap3D map) { + this.map = map; + } + + /** Localizes 3D map labels to Simplified Chinese (`zh`) and China (`CN`) region. */ + @SuppressWarnings("unused") + @SnippetItem( + title = "1. Chinese (zh-CN)", + description = "Applies language='zh' and region='CN' via LocaleOptions and setLocale.") + public void setChineseLocalization() { + // [START maps_android_3d_localization_init_java] + LocaleOptions localeOptions = new LocaleOptions(); + localeOptions.setLanguage("zh"); + localeOptions.setRegion("CN"); + map.setLocale(localeOptions); + // [END maps_android_3d_localization_init_java] + } + + /** Localizes 3D map labels to French (`fr`) and France (`FR`) region. */ + @SuppressWarnings("unused") + @SnippetItem( + title = "2. French (fr-FR)", + description = "Applies language='fr' and region='FR' via LocaleOptions and setLocale.") + public void setFrenchLocalization() { + // [START maps_android_3d_localization_runtime_java] + LocaleOptions localeOptions = new LocaleOptions(); + localeOptions.setLanguage("fr"); + localeOptions.setRegion("FR"); + map.setLocale(localeOptions); + // [END maps_android_3d_localization_runtime_java] + } + + /** Localizes 3D map labels to Japanese (`ja`) and Japan (`JP`) region. */ + @SuppressWarnings("unused") + @SnippetItem( + title = "3. Japanese (ja-JP)", + description = "Applies language='ja' and region='JP' via LocaleOptions and setLocale.") + public void setJapaneseLocalization() { + // [START maps_android_3d_localization_japanese_java] + LocaleOptions localeOptions = new LocaleOptions(); + localeOptions.setLanguage("ja"); + localeOptions.setRegion("JP"); + map.setLocale(localeOptions); + // [END maps_android_3d_localization_japanese_java] + } + + /** Resets the map language and region back to the Android device's system default locale. */ + @SuppressWarnings("unused") + @SnippetItem( + title = "4. Reset System Default", + description = "Resets language and region to the device's system default locale.") + public void resetToSystemDefaultLocale() { + // [START maps_android_3d_localization_default_java] + Locale defaultLocale = Locale.getDefault(); + LocaleOptions defaultLocaleOptions = new LocaleOptions(); + defaultLocaleOptions.setLanguage(defaultLocale.getLanguage()); + defaultLocaleOptions.setRegion(defaultLocale.getCountry()); + map.setLocale(defaultLocaleOptions); + // [END maps_android_3d_localization_default_java] + } +} diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/SnippetRegistry.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/SnippetRegistry.kt index e81504dd..aeb462c7 100644 --- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/SnippetRegistry.kt +++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/SnippetRegistry.kt @@ -21,6 +21,7 @@ import com.example.snippets.kotlin.annotations.SnippetGroup import com.example.snippets.kotlin.annotations.SnippetItem import com.example.snippets.kotlin.snippets.CameraControlSnippets import com.example.snippets.kotlin.snippets.MapInitSnippets +import com.example.snippets.kotlin.snippets.MapLocalizationSnippets import com.example.snippets.kotlin.snippets.MarkerSnippets import com.example.snippets.kotlin.snippets.ModelSnippets import com.example.snippets.kotlin.snippets.PlaceSnippets @@ -70,6 +71,7 @@ object SnippetRegistry { private val snippetClasses = listOf( MapInitSnippets::class.java, + MapLocalizationSnippets::class.java, CameraControlSnippets::class.java, MarkerSnippets::class.java, PolygonSnippets::class.java, diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt index d7637e0c..14c7e388 100644 --- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt +++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt @@ -10,6 +10,7 @@ import com.google.android.gms.maps3d.model.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.LocaleOptions import com.google.android.gms.maps3d.model.Marker import com.google.android.gms.maps3d.model.MarkerOptions import com.google.android.gms.maps3d.model.Model @@ -71,4 +72,5 @@ class TrackedMap3D( fun setOnMapSteadyListener(listener: OnMapSteadyListener?) = delegate.setOnMapSteadyListener(listener) fun setCameraRestriction(restriction: CameraRestriction?) = delegate.setCameraRestriction(restriction) fun getCameraRestriction() = delegate.getCameraRestriction() + fun setLocale(options: LocaleOptions) = delegate.setLocale(options) } diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MapLocalizationSnippets.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MapLocalizationSnippets.kt new file mode 100644 index 00000000..159e43b2 --- /dev/null +++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MapLocalizationSnippets.kt @@ -0,0 +1,103 @@ +/* + * 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.snippets.kotlin.snippets + +import com.example.snippets.kotlin.TrackedMap3D +import com.example.snippets.kotlin.annotations.SnippetGroup +import com.example.snippets.kotlin.annotations.SnippetItem +import com.google.android.gms.maps3d.model.LocaleOptions +import java.util.Locale + +@SnippetGroup( + title = "Map Localization", + description = "Snippets demonstrating 3D map language and region localization " + + "using LocaleOptions and GoogleMap3D.setLocale.", +) +class MapLocalizationSnippets(private val map: TrackedMap3D) { + /** + * Localizes 3D map labels to Simplified Chinese (`zh`) and China (`CN`) region. + */ + @Suppress("unused") + @SnippetItem( + title = "1. Chinese (zh-CN)", + description = "Applies language='zh' and region='CN' via LocaleOptions and setLocale.", + ) + fun setChineseLocalization() { + // [START maps_android_3d_localization_init_kt] + val localeOptions = LocaleOptions().apply { + language = "zh" + region = "CN" + } + map.setLocale(localeOptions) + // [END maps_android_3d_localization_init_kt] + } + + /** + * Localizes 3D map labels to French (`fr`) and France (`FR`) region. + */ + @Suppress("unused") + @SnippetItem( + title = "2. French (fr-FR)", + description = "Applies language='fr' and region='FR' via LocaleOptions and setLocale.", + ) + fun setFrenchLocalization() { + // [START maps_android_3d_localization_runtime_kt] + val localeOptions = LocaleOptions().apply { + language = "fr" + region = "FR" + } + map.setLocale(localeOptions) + // [END maps_android_3d_localization_runtime_kt] + } + + /** + * Localizes 3D map labels to Japanese (`ja`) and Japan (`JP`) region. + */ + @Suppress("unused") + @SnippetItem( + title = "3. Japanese (ja-JP)", + description = "Applies language='ja' and region='JP' via LocaleOptions and setLocale.", + ) + fun setJapaneseLocalization() { + // [START maps_android_3d_localization_japanese_kt] + val localeOptions = LocaleOptions().apply { + language = "ja" + region = "JP" + } + map.setLocale(localeOptions) + // [END maps_android_3d_localization_japanese_kt] + } + + /** + * Resets the map language and region back to the Android device's system default locale. + */ + @Suppress("unused") + @SnippetItem( + title = "4. Reset System Default", + description = "Resets language and region to the device's system default locale.", + ) + fun resetToSystemDefaultLocale() { + // [START maps_android_3d_localization_default_kt] + val defaultLocale = Locale.getDefault() + val defaultLocaleOptions = LocaleOptions().apply { + language = defaultLocale.language + region = defaultLocale.country + } + map.setLocale(defaultLocaleOptions) + // [END maps_android_3d_localization_default_kt] + } +}